diff --git a/apps/web-console/app.js b/apps/web-console/app.js
index 1422d68..83b9df1 100755
--- a/apps/web-console/app.js
+++ b/apps/web-console/app.js
@@ -4860,7 +4860,18 @@ function modeOperationParameters(operation) {
return Object.entries(parameters);
}
-function formatModeOperationValue(name, value) {
+function formatModeOperationValue(name, value, capability = "") {
+ if (name === "level" && ["seat.set_driver_ventilation_native", "seat.set_driver_heating_native"].includes(capability)) {
+ return { 1: "高档", 2: "低档" }[value] || formatConfigValue(name, value);
+ }
+ if (capability === "seat.set_driver_massage_native") {
+ const labels = {
+ action: { mode: "切换按摩模式", strength: "调节按摩力度", off: "关闭" },
+ mode: { wave: "波浪", pulse: "脉冲", stretch: "舒展", slack: "舒缓", relax: "放松" },
+ strength: { 1: "轻柔", 2: "中等", 3: "较强" },
+ };
+ if (labels[name]?.[value]) return labels[name][value];
+ }
const valueLabels = {
on: "开启",
off: "关闭",
@@ -4887,7 +4898,7 @@ function modeOperationView(operation) {
const parameters = modeOperationParameters(operation)
.map(([name, value]) => {
const label = CONFIG_PARAMETER_LABELS[name] || name;
- return `${label}:${formatModeOperationValue(name, value)}`;
+ return `${label}:${formatModeOperationValue(name, value, capability)}`;
})
.join(",");
return {
@@ -4926,7 +4937,50 @@ function modeTimelineStepView(step, action) {
};
}
+function modeScheduleDurationLabel(durationMs) {
+ const minutes = Math.floor(durationMs / 60000);
+ const seconds = Number(((durationMs % 60000) / 1000).toFixed(3));
+ return minutes ? `${minutes}分${seconds ? `${seconds}秒` : ""}` : `${seconds}秒`;
+}
+
+function modeScheduleView(mode) {
+ const schedule = mode?.schedule;
+ if (
+ schedule?.schema !== "cognitive-agent-mode-schedule/v1"
+ || !Array.isArray(schedule.phases) || !schedule.phases.length
+ || !Number.isInteger(schedule.repeat_count)
+ || schedule.repeat_count < 1 || schedule.repeat_count > 20
+ || schedule.phases.some((phase) => !Number.isFinite(phase?.duration_ms)
+ || phase.duration_ms <= 0 || !Array.isArray(phase.operations))
+ ) return null;
+ let offsetMs = 0;
+ const phases = schedule.phases.map((phase, index) => {
+ const view = {
+ name: String(phase.name || `阶段 ${index + 1}`),
+ timing: `+${modeScheduleDurationLabel(offsetMs)} 开始 · 持续 ${modeScheduleDurationLabel(phase.duration_ms)}`,
+ actions: phase.operations.map(modeOperationView),
+ };
+ offsetMs += phase.duration_ms;
+ return view;
+ });
+ return {
+ phases,
+ summary: `${phases.length} 个阶段 · 重复 ${schedule.repeat_count} 次 · 总时长 ${modeScheduleDurationLabel(offsetMs * schedule.repeat_count)}`,
+ stopHint: "执行后可输入“停止动态模式”提前结束,已执行状态不自动回滚;时间为每轮的相对时间。",
+ };
+}
+
function modeExecutionActionViews(mode) {
+ const schedule = modeScheduleView(mode);
+ if (schedule) {
+ // Runtime operations may contain a flattened projection. Prefer phases so
+ // repeated device targets remain distinct and are not listed twice.
+ return schedule.phases.flatMap((phase, index) => phase.actions.map((action) => ({
+ ...action,
+ phase: `阶段 ${index + 1}:${phase.name}`,
+ timing: phase.timing,
+ })));
+ }
const operations = Array.isArray(mode?.operations)
? mode.operations.map(modeOperationView)
: [];
@@ -4947,6 +5001,57 @@ function modeExecutionActionViews(mode) {
return [...operations, ...timelineSteps];
}
+function modeScheduleTrialMatches(draft, payload) {
+ if (!draft?.schedule) return true;
+ if (!modeScheduleView(draft) || !modeScheduleView(payload)) return false;
+ const signature = (schedule) => JSON.stringify([
+ schedule.schema, schedule.repeat_count,
+ schedule.phases.map((phase) => [
+ phase.id, phase.name, phase.duration_ms,
+ phase.operations.map((operation) => [
+ operation.domain, operation.capability,
+ operation.target_scope || operation.target?.instance || operation.target?.zone,
+ modeOperationParameters(operation).sort(([left], [right]) => left.localeCompare(right)),
+ ]),
+ ]),
+ ]);
+ const execution = payload.schedule_execution;
+ const totalDurationMs = draft.schedule.phases.reduce((total, phase) => total + phase.duration_ms, 0)
+ * draft.schedule.repeat_count;
+ return signature(draft.schedule) === signature(payload.schedule)
+ && execution?.status === "completed" && execution.completed === true
+ && execution.virtual_elapsed_ms === totalDurationMs;
+}
+
+function modeScheduleExecutionView(result) {
+ const execution = result?.schedule_execution;
+ if (!execution || typeof execution !== "object") return null;
+ const status = String(execution.status || "");
+ if (status === "running" && result.status === "running") {
+ return {
+ state: "processing", badge: "已启动", title: "动态时间轴已启动",
+ detail: "后续动作将按阶段执行;可输入“停止动态模式”提前结束。已执行的设备状态不会自动回滚。",
+ };
+ }
+ if (["stopped", "cancelled"].includes(status)) {
+ return {
+ state: "succeeded", badge: "已停止", title: "动态时间轴已停止",
+ detail: "后续阶段不再执行;已经执行的设备状态不会自动回滚。",
+ };
+ }
+ if (status === "completed" && execution.completed === true) {
+ const simulated = execution.virtual_elapsed_ms != null;
+ return {
+ state: "succeeded", badge: simulated ? "模拟通过" : "已结束",
+ title: simulated ? "动态时间轴模拟试运行通过" : "动态时间轴已结束",
+ detail: simulated
+ ? "已使用虚拟时钟校验全部阶段;未按真实时间等待,也未向车辆下发控制。"
+ : "已处理全部计划阶段,请以车辆反馈确认各设备的实际状态。",
+ };
+ }
+ return null;
+}
+
function isUserModeProfile(mode) {
return mode?.origin === "user" || mode?.deletable === true;
}
@@ -4954,6 +5059,7 @@ function isUserModeProfile(mode) {
function isDynamicModeProfile(mode) {
const behavior = String(mode?.behavior || "").trim().toLowerCase();
if (["cyclic", "dynamic"].includes(behavior)) return true;
+ if (modeScheduleView(mode)) return true;
if (behavior === "static") return false;
if (mode?.timeline && typeof mode.timeline === "object") return true;
return LEGACY_DYNAMIC_MODE_IDS.has(String(mode?.id || ""));
@@ -5034,6 +5140,34 @@ function createModeGalleryCard(mode) {
button.append(iconBox, copy);
shell.append(button);
+ const schedule = modeScheduleView(mode);
+ if (schedule) {
+ meta.textContent = schedule.summary;
+ const details = document.createElement("details");
+ details.className = "mode-gallery-schedule-details";
+ const summary = document.createElement("summary");
+ summary.textContent = "查看分阶段执行动作";
+ const list = document.createElement("ol");
+ schedule.phases.forEach((phase) => {
+ const item = document.createElement("li");
+ const title = document.createElement("strong");
+ title.textContent = phase.name;
+ const timing = document.createElement("small");
+ timing.textContent = phase.timing;
+ item.append(title, timing);
+ phase.actions.forEach((action) => {
+ const row = document.createElement("p");
+ row.textContent = `${action.target} · ${action.action}:${action.parameters}`;
+ item.append(row);
+ });
+ list.append(item);
+ });
+ const hint = document.createElement("p");
+ hint.textContent = schedule.stopHint;
+ details.append(summary, list, hint);
+ shell.append(details);
+ }
+
const editButton = document.createElement("button");
editButton.className = "mode-gallery-edit";
editButton.type = "button";
@@ -5998,12 +6132,19 @@ function ensureModeActionPopover() {
function renderModeActionPopover(mode) {
const popover = ensureModeActionPopover();
const actions = modeExecutionActionViews(mode);
+ const schedule = modeScheduleView(mode);
$("[data-mode-action-title]", popover).textContent = mode?.name || mode?.id || "功能模式";
$("[data-mode-action-count]", popover).textContent = actions.length
? `${actions.length} 步`
: `${Number(mode?.operation_count || 0)} 项`;
const list = $("[data-mode-action-list]", popover);
list.replaceChildren();
+ if (schedule) {
+ const summary = document.createElement("li");
+ summary.className = "mode-schedule-popover-summary";
+ summary.textContent = `${schedule.summary}。${schedule.stopHint}`;
+ list.append(summary);
+ }
if (!actions.length) {
const empty = document.createElement("li");
empty.className = "mode-chip-action-empty";
@@ -6020,6 +6161,12 @@ function renderModeActionPopover(mode) {
copy.className = "mode-chip-action-copy";
const actionName = document.createElement("strong");
actionName.textContent = action.action;
+ if (action.phase) {
+ const phase = document.createElement("small");
+ phase.className = "mode-schedule-popover-phase";
+ phase.textContent = `${action.phase} · ${action.timing}`;
+ copy.append(phase);
+ }
const metadata = document.createElement("small");
const device = document.createElement("span");
device.innerHTML = "设备";
@@ -6189,8 +6336,17 @@ function populateModeChips() {
button.append(runIcon);
}
const timeline = mode.timeline;
+ const schedule = modeScheduleView(mode);
let cycleBadge = $(".mode-cycle-badge", button);
- if (["infinite", "until_stopped_or_timeout"].includes(timeline?.repeat)) {
+ if (schedule) {
+ if (!cycleBadge) {
+ cycleBadge = document.createElement("small");
+ cycleBadge.className = "mode-cycle-badge";
+ button.append(cycleBadge);
+ }
+ cycleBadge.textContent = schedule.summary;
+ button.dataset.modeBehavior = "dynamic";
+ } else if (["infinite", "until_stopped_or_timeout"].includes(timeline?.repeat)) {
if (!cycleBadge) {
cycleBadge = document.createElement("small");
cycleBadge.className = "mode-cycle-badge";
@@ -6222,7 +6378,7 @@ function populateModeChips() {
: `${phase.name} 目标到位`;
}).join(" → ")
: "";
- button.title = [mode.description || mode.name, phaseSummary, "悬浮或聚焦查看全部执行动作"]
+ button.title = [mode.description || mode.name, schedule?.summary || phaseSummary, "悬浮或聚焦查看全部执行动作"]
.filter(Boolean)
.join("\n");
});
@@ -6336,7 +6492,7 @@ function setModeExecutionState(modeProfileId, modeState = "idle") {
$$(".mode-chips button[data-mode-profile-id], #modeGallery button[data-mode-profile-id]").forEach((button) => {
const active = button.dataset.modeProfileId === modeProfileId && modeState !== "idle";
button.classList.toggle("running", active && modeState === "running");
- button.classList.toggle("succeeded", active && modeState === "succeeded");
+ button.classList.toggle("succeeded", active && ["succeeded", "started", "ended", "stopped"].includes(modeState));
button.classList.toggle("failed", active && modeState === "failed");
button.dataset.modeState = active ? modeState : "idle";
button.setAttribute("aria-pressed", active ? "true" : "false");
@@ -6345,6 +6501,12 @@ function setModeExecutionState(modeProfileId, modeState = "idle") {
if (stateLabel) {
stateLabel.textContent = active && modeState === "running"
? "执行中"
+ : active && modeState === "started"
+ ? "已启动"
+ : active && modeState === "ended"
+ ? "已结束"
+ : active && modeState === "stopped"
+ ? "已停止"
: active && modeState === "succeeded"
? "已完成"
: active && modeState === "failed"
@@ -6352,7 +6514,7 @@ function setModeExecutionState(modeProfileId, modeState = "idle") {
: "点击执行";
}
});
- if (modeProfileId && ["succeeded", "failed"].includes(modeState)) {
+ if (modeProfileId && ["succeeded", "failed", "started", "ended", "stopped"].includes(modeState)) {
state.modeRunTimer = window.setTimeout(() => setModeExecutionState(null), 2800);
}
}
@@ -6407,14 +6569,19 @@ async function executeSavedModeProfile(mode) {
);
if (!payload) throw new Error("模式卡片未返回执行结果");
renderResult(payload);
+ const scheduleRunning = modeScheduleExecutionView(payload)?.state === "processing";
const completed = ["succeeded", "partially_succeeded"].includes(payload.status);
$("#utterance").value = mode.name || mode.id;
- $("#resultTitle").textContent = completed
+ $("#resultTitle").textContent = scheduleRunning
+ ? `${mode.name}时间轴已启动`
+ : completed
? `${mode.name}执行完成`
: `${mode.name}执行未完成`;
- setModeExecutionState(mode.id, completed ? "succeeded" : "failed");
+ setModeExecutionState(mode.id, scheduleRunning ? "started" : completed ? "succeeded" : "failed");
if (state.executionMode === "hardware") await synchronizePhysicalSeatState();
- showToast(completed
+ showToast(scheduleRunning
+ ? "时间轴已启动,可输入“停止动态模式”提前结束"
+ : completed
? `${mode.name}已直接执行保存的控制指令流`
: `${mode.name}未完成,请查看执行结果`);
} catch (error) {
@@ -7489,6 +7656,9 @@ const CONFIG_DOMAIN_META = {
const CONFIG_CAPABILITY_LABELS = {
"seat.set_ventilation": "设置座椅通风",
"seat.set_heating": "设置座椅加热",
+ "seat.set_driver_ventilation_native": "设置主驾座椅通风",
+ "seat.set_driver_heating_native": "设置主驾座椅加热",
+ "seat.set_driver_massage_native": "设置主驾座椅按摩",
"seat.set_longitudinal": "调节座椅前后位置",
"seat.set_longitudinal_position": "调节座椅前后位置",
"seat.set_cushion_tilt": "调节座椅高度",
@@ -7519,6 +7689,8 @@ const CONFIG_CAPABILITY_LABELS = {
"ambient_light.set_illumination": "设置氛围灯颜色与亮度",
"ambient_light.set_color": "设置氛围灯颜色",
"ambient_light.set_brightness": "设置氛围灯亮度",
+ "ambient_light.set_brightness_level": "设置氛围灯亮度",
+ "ambient_light.set_color_index": "设置氛围灯色号",
"ambient_light.set_mode": "设置氛围灯模式",
"roof.set_transmittance": "设置天幕透光率",
"roof.set_shade_position": "设置遮阳帘位置",
@@ -7527,6 +7699,10 @@ const CONFIG_CAPABILITY_LABELS = {
};
const CONFIG_PARAMETER_LABELS = {
+ action: "动作",
+ strength: "力度",
+ color_index: "色号",
+ brightness_level: "亮度档位",
power: "电源",
state: "状态",
level: "档位",
@@ -7813,6 +7989,24 @@ function renderExecution(result) {
const summary = $("#executionSummary");
const list = $("#resultList");
list.replaceChildren();
+ const scheduleExecution = modeScheduleExecutionView(result);
+ if (scheduleExecution) {
+ summary.className = `execution-summary ${scheduleExecution.state === "processing" ? "partial" : scheduleExecution.state}`;
+ const icon = document.createElement("i");
+ icon.dataset.lucide = scheduleExecution.state === "processing" ? "timer" : "circle-check";
+ const copy = document.createElement("div");
+ const title = document.createElement("strong");
+ title.textContent = scheduleExecution.title;
+ const detail = document.createElement("span");
+ detail.textContent = scheduleExecution.detail;
+ copy.append(title, detail);
+ summary.replaceChildren(icon, copy);
+ $("#resultNavMeta").textContent = scheduleExecution.badge;
+ $("#resultPanelState").textContent = scheduleExecution.badge;
+ $("#resultPanelState").className = `workspace-panel-state ${scheduleExecution.state}`;
+ refreshIcons();
+ return;
+ }
const report = result.execution_report;
if (!report) {
summary.className = "execution-summary failed";
@@ -8084,6 +8278,7 @@ window.addEventListener("cognitive-agent:extension-vehicle-result", (event) => {
function renderResult(result) {
state.result = result;
+ const scheduleExecution = modeScheduleExecutionView(result);
const reportMode = result.execution_report?.spec?.mode;
const feedbackSource = result.execution_report?.metadata?.labels?.seat_feedback;
const submittedUnverified = executionVerificationStatus(result) === "submitted_unverified";
@@ -8118,7 +8313,9 @@ function renderResult(result) {
};
const peripheralRoute = String(result.orchestration_route || "")
.startsWith("nfc_peripheral");
- $("#resultTitle").textContent = peripheralRoute && executionSucceeded
+ $("#resultTitle").textContent = scheduleExecution
+ ? `${result.mode_profile?.name ? `${result.mode_profile.name} · ` : ""}${scheduleExecution.title}`
+ : peripheralRoute && executionSucceeded
? `${result.peripheral?.name || "第三方外设"} · ${result.peripheral?.action || "动作已执行"}`
: partiallySucceeded
? `${domains.length} 域场景部分完成`
@@ -8142,7 +8339,7 @@ function renderResult(result) {
$("#requestMeta").textContent += ` / reasoning:${result.reasoning_effort_used}`;
}
setRunStatus(
- partiallySucceeded || submittedUnverified ? "partial" : executionSucceeded ? "succeeded" : "failed",
+ scheduleExecution?.state || (partiallySucceeded || submittedUnverified ? "partial" : executionSucceeded ? "succeeded" : "failed"),
submittedUnverified ? "SUBMITTED_UNVERIFIED" : result.status.toUpperCase(),
);
renderDecision(result);
@@ -8722,12 +8919,17 @@ window.CognitiveAgentConsole = {
&& expectedSignatures.every((signature, index) => signature === actualSignatures[index]));
if (payload.status !== "succeeded"
|| (expectedOperationCount > 0 && actualOperationCount < expectedOperationCount)
- || !sameActions) {
+ || !sameActions || !modeScheduleTrialMatches(draft, payload)) {
throw new Error("试运行未完整覆盖草稿动作,请重新生成模式预览。");
}
return payload;
},
revealModeProfile,
+ updateScheduleExecutionStatus(modeProfileId, status) {
+ if (!modeProfileId || (state.pendingModeProfileId && state.pendingModeProfileId !== modeProfileId)) return;
+ const terminal = { completed: "ended", failed: "failed", cancelled: "stopped", stopped: "stopped", idle: "idle" }[status];
+ if (terminal) setModeExecutionState(modeProfileId, terminal);
+ },
notify(message) {
showToast(String(message));
},
@@ -8750,9 +8952,11 @@ window.addEventListener("cognitive-agent:request-complete", (event) => {
if (!modeProfileId) return;
state.pendingModeProfileId = null;
const result = event.detail?.result;
+ const scheduleRunning = !event.detail?.error
+ && modeScheduleExecutionView(result)?.state === "processing";
const succeeded = !event.detail?.error
&& ["succeeded", "partially_succeeded"].includes(String(result?.status || ""));
- setModeExecutionState(modeProfileId, succeeded ? "succeeded" : "failed");
+ setModeExecutionState(modeProfileId, scheduleRunning ? "started" : succeeded ? "succeeded" : "failed");
});
document.addEventListener("DOMContentLoaded", async () => {
diff --git a/apps/web-console/assistant-chat.css b/apps/web-console/assistant-chat.css
index 0ac9079..7045259 100644
--- a/apps/web-console/assistant-chat.css
+++ b/apps/web-console/assistant-chat.css
@@ -551,6 +551,111 @@
padding: 9px 10px 8px;
}
+.assistant-mode-schedule-summary,
+.assistant-mode-schedule-hint {
+ margin: 0;
+ padding: 9px 12px;
+ color: var(--assistant-muted, #64748b);
+ font-size: 10px;
+ line-height: 1.5;
+ overflow-wrap: anywhere;
+}
+
+.assistant-mode-schedule-summary {
+ color: var(--status-info-foreground, #3764d9);
+ background: var(--status-info-soft, #eef3ff);
+ font-weight: 750;
+}
+
+.assistant-mode-schedule-phase + .assistant-mode-schedule-phase,
+.assistant-mode-schedule-hint {
+ border-top: 1px solid var(--assistant-border, #e5eaf1);
+}
+
+.assistant-mode-schedule-phase > header {
+ display: grid;
+ gap: 3px;
+ padding: 10px 12px 5px;
+ color: var(--assistant-heading, #334155);
+ font-size: 11px;
+ overflow-wrap: anywhere;
+}
+
+.assistant-mode-schedule-phase > header small {
+ color: var(--assistant-muted, #64748b);
+ font-size: 9px;
+ font-weight: 500;
+ line-height: 1.5;
+}
+
+.assistant-mode-draft-scheduled .assistant-mode-draft-row {
+ grid-template-columns: 18px minmax(0, 1fr) minmax(0, 1fr);
+ gap: 5px;
+}
+
+.assistant-mode-draft-scheduled .assistant-mode-draft-row > strong {
+ white-space: normal;
+ overflow-wrap: anywhere;
+ text-align: right;
+}
+
+.mode-gallery-card-shell:has(> .mode-gallery-schedule-details) > .mode-gallery-card {
+ height: auto;
+}
+
+.mode-gallery-schedule-details {
+ padding: 9px 12px;
+ color: var(--foreground, #334155);
+ background: var(--card, #fff);
+ border: 1px solid var(--border, #e5eaf1);
+ border-radius: 10px;
+ font-size: 11px;
+ line-height: 1.6;
+ overflow-wrap: anywhere;
+}
+
+.mode-gallery-schedule-details > summary {
+ cursor: pointer;
+ color: var(--primary, #3764d9);
+ font-weight: 700;
+}
+
+.mode-gallery-schedule-details ol {
+ margin: 10px 0;
+ padding-left: 19px;
+}
+
+.mode-gallery-schedule-details li + li {
+ margin-top: 10px;
+}
+
+.mode-gallery-schedule-details li > strong,
+.mode-gallery-schedule-details li > small {
+ display: block;
+}
+
+.mode-gallery-schedule-details small,
+.mode-gallery-schedule-details > p {
+ color: var(--muted-foreground, #64748b);
+}
+
+.mode-gallery-schedule-details p {
+ margin: 4px 0;
+}
+
+.mode-chip-action-popover li.mode-schedule-popover-summary {
+ display: block;
+ color: var(--status-info-foreground, #3764d9);
+ background: var(--status-info-soft, #eef3ff);
+ font-size: 11px;
+ line-height: 1.6;
+}
+
+.mode-schedule-popover-phase {
+ color: var(--primary, #3764d9);
+ line-height: 1.5;
+}
+
.assistant-mode-draft-actions[hidden] {
display: none;
}
diff --git a/apps/web-console/assistant-chat.js b/apps/web-console/assistant-chat.js
index 1840b4f..f64c260 100644
--- a/apps/web-console/assistant-chat.js
+++ b/apps/web-console/assistant-chat.js
@@ -11,6 +11,7 @@
let pendingMessage = null;
let latestControlResult = null;
let activeModeDraftContext = null;
+ let activeSchedulePoll = null;
let initialized = false;
const $ = (selector, root = document) => root.querySelector(selector);
@@ -542,7 +543,7 @@
const consumed = new Set();
const rows = [];
const targetZone = (action) => String(
- action?.target?.instance || action?.target?.zone || action?.target || "",
+ action?.target?.instance || action?.target?.zone || action?.target_scope || action?.target || "",
);
const parameters = (action) => (
action?.parameters && typeof action.parameters === "object" ? action.parameters : {}
@@ -613,6 +614,10 @@
const capability = String(action.capability || "");
const domain = String(action.domain || "");
const zone = targetZone(action);
+ const zoneLabel = {
+ driver: "主驾", passenger: "副驾", front_left: "主驾", front_right: "副驾",
+ front_row: "前排", rear_row: "后排", rear_left: "左后", rear_right: "右后",
+ }[zone] || "";
const params = parameters(action);
let label = capability || domain || "座舱设备";
let value = "已配置";
@@ -683,7 +688,7 @@
icon = "armchair";
order = instructionOrder("座椅", "副驾", "主驾");
} else if (capability === "climate.set_temperature") {
- label = "空调温度";
+ label = `${zoneLabel}空调温度`;
value = `${params.temperature_c ?? "--"}℃`;
icon = "thermometer";
order = instructionOrder("空调", "温度");
@@ -701,13 +706,14 @@
: capability === "climate.set_cycle_mode" ? "空调循环"
: capability === "climate.set_control_mode" ? "空调控制模式"
: capability === "climate.set_outlet_mode" ? "空调出风模式" : "空调";
+ label = `${zoneLabel}${label}`;
value = params.level != null ? `${params.level}档`
: params.mode != null ? (climateModeLabels[capability]?.[String(params.mode)] || String(params.mode))
: motionLabel(params.state);
icon = "fan";
order = instructionOrder("空调", "风量");
} else if (capability === "interior_light.set_reading_power") {
- label = "阅读灯";
+ label = `${zoneLabel}阅读灯`;
value = motionLabel(params.state);
icon = "book-open";
order = instructionOrder("阅读灯", "灯光");
@@ -734,6 +740,63 @@
return rows.sort((left, right) => left.order - right.order);
}
+ function modeDraftScheduleView(draft) {
+ const schedule = draft?.schedule;
+ if (
+ schedule?.schema !== "cognitive-agent-mode-schedule/v1"
+ || !Array.isArray(schedule.phases) || !schedule.phases.length
+ || !Number.isInteger(schedule.repeat_count)
+ || schedule.repeat_count < 1 || schedule.repeat_count > 20
+ || schedule.phases.some((phase) => !Number.isFinite(phase?.duration_ms)
+ || phase.duration_ms <= 0 || !Array.isArray(phase.operations))
+ ) return null;
+ const durationLabel = (durationMs) => {
+ const minutes = Math.floor(durationMs / 60000);
+ const seconds = Number(((durationMs % 60000) / 1000).toFixed(3));
+ return minutes ? `${minutes}分${seconds ? `${seconds}秒` : ""}` : `${seconds}秒`;
+ };
+ let offsetMs = 0;
+ const phases = schedule.phases.map((phase, index) => {
+ // Each phase is rendered separately, without instruction-based grouping:
+ // the same seat or temperature can have a different target later on.
+ const rows = modeDraftRows({ instruction: "" }, phase.operations.map((operation) => ({
+ ...operation,
+ target: operation.target || (operation.domain === "seat"
+ ? { instance: operation.target_scope }
+ : { zone: operation.target_scope }),
+ })));
+ const view = {
+ id: String(phase.id || `phase-${index + 1}`),
+ name: String(phase.name || `阶段 ${index + 1}`),
+ timing: `+${durationLabel(offsetMs)} 开始 · 持续 ${durationLabel(phase.duration_ms)}`,
+ rows,
+ };
+ offsetMs += phase.duration_ms;
+ return view;
+ });
+ return {
+ phases,
+ operationCount: schedule.phases.reduce((count, phase) => count + phase.operations.length, 0),
+ summary: `${phases.length} 个阶段 · 重复 ${schedule.repeat_count} 次 · 总时长 ${durationLabel(offsetMs * schedule.repeat_count)}`,
+ stopHint: "执行后可输入“停止动态模式”提前结束,已执行状态不自动回滚。各阶段按顺序执行,时间为每轮的相对时间。",
+ };
+ }
+
+ function appendModeDraftRows(container, rows) {
+ rows.forEach((item) => {
+ const row = document.createElement("div");
+ row.className = "assistant-mode-draft-row";
+ const icon = document.createElement("i");
+ icon.dataset.lucide = item.icon;
+ const label = document.createElement("span");
+ label.textContent = item.label;
+ const value = document.createElement("strong");
+ value.textContent = item.value;
+ row.append(icon, label, value);
+ container.append(row);
+ });
+ }
+
async function saveModeProfileDraft(message, card, draft, buttons, status) {
buttons.forEach((button) => { button.disabled = true; });
card.classList.add("is-busy");
@@ -849,23 +912,39 @@
headingCopy.append(eyebrow, title);
const badge = document.createElement("span");
badge.className = "assistant-mode-draft-badge";
- badge.textContent = `${Number(draft.operation_count) || 0} 项动作`;
+ const scheduleView = modeDraftScheduleView(draft);
+ badge.textContent = `${scheduleView?.operationCount ?? (Number(draft.operation_count) || 0)} 项动作`;
heading.append(headingCopy, badge);
const list = document.createElement("div");
list.className = "assistant-mode-draft-list";
- modeDraftRows(draft, actions).forEach((item) => {
- const row = document.createElement("div");
- row.className = "assistant-mode-draft-row";
- const icon = document.createElement("i");
- icon.dataset.lucide = item.icon;
- const label = document.createElement("span");
- label.textContent = item.label;
- const value = document.createElement("strong");
- value.textContent = item.value;
- row.append(icon, label, value);
- list.append(row);
- });
+ if (scheduleView) {
+ card.classList.add("assistant-mode-draft-scheduled");
+ const summary = document.createElement("p");
+ summary.className = "assistant-mode-schedule-summary";
+ summary.textContent = scheduleView.summary;
+ list.append(summary);
+ scheduleView.phases.forEach((phase, index) => {
+ const section = document.createElement("section");
+ section.className = "assistant-mode-schedule-phase";
+ section.dataset.modeSchedulePhase = phase.id;
+ const phaseHeading = document.createElement("header");
+ const name = document.createElement("strong");
+ name.textContent = `${index + 1}. ${phase.name}`;
+ const timing = document.createElement("small");
+ timing.textContent = phase.timing;
+ phaseHeading.append(name, timing);
+ section.append(phaseHeading);
+ appendModeDraftRows(section, phase.rows);
+ list.append(section);
+ });
+ const hint = document.createElement("p");
+ hint.className = "assistant-mode-schedule-hint";
+ hint.textContent = scheduleView.stopHint;
+ list.append(hint);
+ } else {
+ appendModeDraftRows(list, modeDraftRows(draft, actions));
+ }
const controls = document.createElement("div");
controls.className = "assistant-mode-draft-actions";
@@ -965,6 +1044,90 @@
}
}
+ function stopScheduleStatusPoll() {
+ if (!activeSchedulePoll) return;
+ window.clearTimeout(activeSchedulePoll.timer);
+ activeSchedulePoll.controller?.abort();
+ activeSchedulePoll = null;
+ }
+
+ function startScheduleStatusPoll(payload, message) {
+ stopScheduleStatusPoll();
+ const vehicleId = String(payload?.vehicle_id || payload?.vehicle?.id
+ || payload?.mode_profile?.vehicle_id || $("#vehicleSelect")?.value || "");
+ if (!vehicleId) return;
+ const poll = {
+ vehicleId, conversationId: conversationId(), message,
+ sessionId: String(payload?.schedule_execution?.session_id || ""),
+ runId: String(payload?.schedule_execution?.run_id || ""),
+ modeProfileId: String(payload?.mode_profile?.id || ""),
+ timer: null, controller: null,
+ };
+ activeSchedulePoll = poll;
+ const isCurrent = () => activeSchedulePoll === poll
+ && conversationId() === poll.conversationId
+ && (!$("#vehicleSelect")?.value || $("#vehicleSelect").value === poll.vehicleId);
+ const update = async () => {
+ if (!isCurrent()) {
+ if (activeSchedulePoll === poll) stopScheduleStatusPoll();
+ return;
+ }
+ poll.controller = new AbortController();
+ try {
+ const response = await window.CognitiveAgentNetwork.request(
+ `/api/modes/schedule/status?vehicle_id=${encodeURIComponent(poll.vehicleId)}`,
+ { method: "GET", signal: poll.controller.signal, timeoutMs: 8000, retries: 0 },
+ );
+ if (!isCurrent()) return;
+ if (response.status === 401) {
+ setActionStatus(message, "登录状态已失效,无法继续查询时间轴状态;请重新登录查看。", "failed");
+ stopScheduleStatusPoll();
+ return;
+ }
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const status = await response.json();
+ if (!isCurrent()) return;
+ if (status.vehicle_id && String(status.vehicle_id) !== poll.vehicleId) {
+ throw new Error("时间轴状态车型不匹配");
+ }
+ if ((poll.runId && status.run_id && String(status.run_id) !== poll.runId)
+ || (poll.sessionId && status.session_id && String(status.session_id) !== poll.sessionId)) {
+ setActionStatus(message, "当前时间轴已被新的运行任务替换,请查看最新模式消息。", "working");
+ stopScheduleStatusPoll();
+ return;
+ }
+ const terminal = ["completed", "failed", "cancelled", "stopped", "idle"].includes(status.status);
+ if (status.status === "running") {
+ const phase = Number.isInteger(status.phase_index) && status.phase_index >= 0
+ ? ` · 阶段 ${status.phase_index + 1}` : "";
+ const repeat = Number.isInteger(status.repeat_index) && status.repeat_index >= 0
+ ? ` · 第 ${status.repeat_index + 1} 轮` : "";
+ setActionStatus(message, `动态时间轴执行中${repeat}${phase}。可输入“停止动态模式”;已执行状态不自动回滚。`, "working");
+ } else if (terminal) {
+ const stopped = ["cancelled", "stopped"].includes(status.status);
+ const failed = status.status === "failed";
+ const copy = failed
+ ? `动态时间轴执行失败:${status.error || "某个阶段未通过执行校验"}。后续阶段不再执行,已执行状态不自动回滚。`
+ : stopped
+ ? "动态时间轴已停止,后续阶段不再执行;已执行状态不自动回滚。"
+ : status.status === "completed"
+ ? "动态时间轴已结束,全部计划阶段已处理;请以车辆反馈确认设备实际状态。"
+ : "当前没有正在运行的动态时间轴。";
+ setActionStatus(message, copy, failed ? "failed" : "success");
+ window.CognitiveAgentConsole?.updateScheduleExecutionStatus?.(poll.modeProfileId, status.status);
+ stopScheduleStatusPoll();
+ return;
+ }
+ } catch (error) {
+ if (!isCurrent() || poll.controller?.signal.aborted) return;
+ setActionStatus(message, "暂时无法获取时间轴状态,正在重新查询;需要停止时可输入“停止动态模式”。", "working");
+ } finally {
+ if (isCurrent()) poll.timer = window.setTimeout(update, 2000);
+ }
+ };
+ poll.timer = window.setTimeout(update, 2000);
+ }
+
function applyAssistantEvent(event, message) {
if (event.type === "answer.delta") {
setMessageContent(message, `${message.content}${event.delta || ""}`);
@@ -985,14 +1148,20 @@
if (event.type === "action.completed" || event.type === "work.completed") {
latestControlResult = event.payload || latestControlResult;
const completed = event.success !== false;
+ const scheduleRunning = completed
+ && event.payload?.orchestration_route === "mode_schedule_execute"
+ && (event.payload?.status === "running" || event.payload?.schedule_execution?.status === "running");
const defaultText = event.type === "work.completed"
? (completed ? "AI助手已完成工作" : "AI助手未能完成工作")
: (completed ? "座舱控制已完成" : "座舱控制未完成");
setActionStatus(
message,
- event.display_text || defaultText,
- completed ? "success" : "failed",
+ scheduleRunning
+ ? "已启动动态时间轴;后续动作将分阶段执行。可输入“停止动态模式”提前结束,已执行的设备状态不会自动回滚。"
+ : event.display_text || defaultText,
+ scheduleRunning ? "working" : completed ? "success" : "failed",
);
+ if (scheduleRunning) startScheduleStatusPoll(event.payload, message);
if (event.payload) {
window.CognitiveAgentConsole?.applySyncedResult?.(event.payload);
if (
@@ -1238,11 +1407,17 @@
document.addEventListener("click", (event) => {
const target = event.target instanceof Element ? event.target : null;
if (!target?.closest("#newConversation")) return;
+ stopScheduleStatusPoll();
sessionStorage.removeItem(STORAGE_KEY);
clearActiveModeDraftContext();
sessionStorage.setItem(CONVERSATION_STORAGE_KEY, createConversationId());
}, true);
+ document.addEventListener("change", (event) => {
+ if (event.target?.id === "vehicleSelect") stopScheduleStatusPoll();
+ });
+ window.addEventListener("pagehide", stopScheduleStatusPoll);
+
document.addEventListener("DOMContentLoaded", initialize);
window.CognitiveAgentAssistant = {
submit: submitAssistant,
diff --git a/apps/web-console/index.html b/apps/web-console/index.html
index 637fe52..a3bf0d4 100755
--- a/apps/web-console/index.html
+++ b/apps/web-console/index.html
@@ -13,20 +13,20 @@
-
+
-
+
-
+
diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js
index 741c426..2732239 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-v278";
+const CACHE_VERSION = "cognitive-agent-pad-v279";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
@@ -6,7 +6,7 @@ const APP_SHELL = [
"/index.html",
"/styles.css?v=dual-seat-23",
"/network.js?v=android-pad-2",
- "/app.js?v=dual-seat-131",
+ "/app.js?v=dual-seat-132",
"/gesture-control.js?v=android-pad-9",
"/pwa.js?v=android-pad-3",
"/console-v2.css?v=delivery-state-66",
@@ -16,8 +16,8 @@ const APP_SHELL = [
"/speech-input.css?v=sensevoice-7",
"/speech-input.js?v=sensevoice-13",
"/speech-audio-worklet.js?v=sensevoice-5",
- "/assistant-chat.css?v=codex-assistant-27",
- "/assistant-chat.js?v=codex-assistant-27",
+ "/assistant-chat.css?v=codex-assistant-28",
+ "/assistant-chat.js?v=codex-assistant-28",
"/energy-monitor.css?v=energy-monitor-4",
"/energy-monitor.js?v=energy-monitor-4",
"/assets/pets/self-extension-cat.png?v=3",
diff --git a/config/modes/vehicles/denza_n7/leisure_lounger.yaml b/config/modes/vehicles/denza_n7/leisure_lounger.yaml
index a4079e2..5e45c2e 100644
--- a/config/modes/vehicles/denza_n7/leisure_lounger.yaml
+++ b/config/modes/vehicles/denza_n7/leisure_lounger.yaml
@@ -2,12 +2,13 @@ id: leisure_lounger
name: 休闲躺椅模式
aliases:
- 休闲躺椅
-description: 腾势 N7 副驾休闲躺椅姿态,并联动当前已接入的空调、天幕和氛围灯能力。
+description: 腾势 N7 主驾放松按摩与副驾休闲躺椅姿态,并联动当前已接入的空调、天幕和氛围灯能力;驻车使用,不改变主驾驾驶姿态。
icon: sofa
scope:
vehicle_ids:
- denza_n7
operations:
+ - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用已接入的主驾放松按摩,让主副驾按各自能力联动休闲;主驾没有绝对位置反馈,不自动移动其座椅。}
- domain: seat
capability: seat.set_longitudinal_position
target_scope: passenger
diff --git a/config/modes/vehicles/denza_n7/meditation_space.yaml b/config/modes/vehicles/denza_n7/meditation_space.yaml
index b1d4eb5..1bbc62c 100644
--- a/config/modes/vehicles/denza_n7/meditation_space.yaml
+++ b/config/modes/vehicles/denza_n7/meditation_space.yaml
@@ -3,12 +3,13 @@ name: 冥想空间模式
aliases:
- 冥想空间
- 冥想
-description: 腾势 N7 冥想空间姿态,并联动当前已接入的恒温、低风、内循环、遮阳帘和冥想氛围灯能力。
+description: 腾势 N7 主驾放松按摩与副驾冥想空间姿态,并联动当前已接入的恒温、低风、内循环、遮阳帘和冥想氛围灯能力;驻车使用,不改变主驾驾驶姿态。
icon: leaf
scope:
vehicle_ids:
- denza_n7
operations:
+ - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用当前工程已接入的主驾放松按摩辅助驻车冥想;不假定副驾具有按摩能力,不移动主驾座椅。}
- domain: seat
capability: seat.set_longitudinal_position
target_scope: passenger
diff --git a/config/modes/vehicles/denza_n7/nap.yaml b/config/modes/vehicles/denza_n7/nap.yaml
index f21b476..4e4de9f 100644
--- a/config/modes/vehicles/denza_n7/nap.yaml
+++ b/config/modes/vehicles/denza_n7/nap.yaml
@@ -2,12 +2,13 @@ id: nap
name: 小憩模式
aliases:
- 小憩
-description: 腾势 N7 副驾小憩姿态,并联动当前已接入的静音恒温、遮阳帘和氛围灯关闭能力。
+description: 腾势 N7 主驾低档座椅通风与副驾小憩姿态,并联动当前已接入的静音恒温、遮阳帘和氛围灯关闭能力;驻车使用,不改变主驾驾驶姿态。
icon: moon
scope:
vehicle_ids:
- denza_n7
operations:
+ - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 主驾使用低档座椅通风保持驻车静坐舒适,不自动移动主驾或开启按摩。}
- domain: seat
capability: seat.set_longitudinal_position
target_scope: passenger
diff --git a/config/modes/vehicles/denza_n7/office.yaml b/config/modes/vehicles/denza_n7/office.yaml
index b492850..422be86 100644
--- a/config/modes/vehicles/denza_n7/office.yaml
+++ b/config/modes/vehicles/denza_n7/office.yaml
@@ -2,12 +2,14 @@ id: office
name: 办公模式
aliases:
- 办公
-description: 腾势 N7 副驾办公姿态,并联动当前已接入的恒温、中低风、内循环、遮阳帘和冷白氛围灯能力。
+description: 腾势 N7 主副驾低档座椅通风与副驾办公姿态,并联动当前已接入的恒温、中低风、内循环、遮阳帘和冷白氛围灯能力;不改变主驾驾驶姿态。
icon: briefcase
scope:
vehicle_ids:
- denza_n7
operations:
+ - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾原生 CAN 低档通风,改善驻车办公时久坐闷热;不移动主驾座椅。}
+ - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾独立接口低档通风,与办公支撑姿态联动。}
- domain: seat
capability: seat.set_longitudinal_position
target_scope: passenger
diff --git a/config/scene_templates/boarding_warmup.yaml b/config/scene_templates/boarding_warmup.yaml
new file mode 100644
index 0000000..a30fcc6
--- /dev/null
+++ b/config/scene_templates/boarding_warmup.yaml
@@ -0,0 +1,26 @@
+id: boarding-warmup
+name: 上车预热模式
+aliases: [上车预热的功能模式, 上车预热功能模式, 上车暖舱模式, 分阶段预热模式, 动态预热模式, 空调预热模式]
+description: 分阶段上车预热;前2分钟以28℃和4档风量快速暖舱,再用24℃和1档风量舒适保持5分钟,并分别编排主副驾加热。总计7分钟,不移动座椅位置;时间轴结束后保持最后设置,不承诺无人值守预约启动。
+icon: thermometer
+schedule:
+ schema: cognitive-agent-mode-schedule/v1
+ repeat_count: 1
+ phases:
+ - id: warmup
+ name: 快速暖舱
+ duration_ms: 120000
+ operations:
+ - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: 'on'}, reason: 开启空调。}
+ - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 28}, reason: 初始预热目标。}
+ - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 4}, reason: 初始阶段较高风量。}
+ - {domain: seat, capability: seat.set_driver_heating_native, target_scope: driver, operation: set, parameters: {state: 'on', level: 2}, reason: 主驾低档加热接口2为低档。}
+ - {domain: seat, capability: seat.set_heating, target_scope: passenger, operation: set, parameters: {state: 'on', level: 2}, reason: 副驾使用独立接口2档加热。}
+ - id: comfort
+ name: 舒适恒温
+ duration_ms: 300000
+ operations:
+ - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 预热结束后降低目标温度。}
+ - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 降低风噪。}
+ - {domain: seat, capability: seat.set_driver_heating_native, target_scope: driver, operation: set, parameters: {state: 'on', level: 2}, reason: 主驾维持低档。}
+ - {domain: seat, capability: seat.set_heating, target_scope: passenger, operation: set, parameters: {state: 'on', level: 1}, reason: 副驾降低为1档。}
diff --git a/config/scene_templates/camping.yaml b/config/scene_templates/camping.yaml
index fcbcdd3..a11d09f 100644
--- a/config/scene_templates/camping.yaml
+++ b/config/scene_templates/camping.yaml
@@ -1,9 +1,13 @@
id: camping
name: 露营模式
aliases: [户外露营模式, 营地模式]
-description: 使用低风外循环、打开遮阳帘并启用露营氛围;不自动开窗。
+description: 为驻车露营组合主副驾低档通风、副驾靠背和腿托支撑、低风外循环、开阔天幕与露营氛围;不移动主驾座椅,不自动开窗。
icon: tent-tree
operations:
+ - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾原生 CAN 的低档通风,减少驻车久坐闷热。}
+ - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾独立接口的低档通风。}
+ - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 为驻车露营提供适度舒展的副驾靠背支撑。}
+ - {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 35}, reason: 使用适度副驾腿托支撑,不采用极限躺平姿态。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 保持座舱温度。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用舒适中性温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 使用安静低风量。}
diff --git a/config/scene_templates/cinema.yaml b/config/scene_templates/cinema.yaml
index 565f709..6c2e74c 100644
--- a/config/scene_templates/cinema.yaml
+++ b/config/scene_templates/cinema.yaml
@@ -1,9 +1,13 @@
id: cinema
name: 观影模式
aliases: [影院模式, 家庭影院模式, 电影模式, 看电影模式]
-description: 关闭直射光和车内主照明,使用低风恒温与影院氛围灯;不包含屏幕或音响控制。
+description: 为驻车观影组合主驾放松按摩、副驾舒展靠背和腿托、低档通风、遮光与低风恒温;不改变主驾驾驶姿态,不包含屏幕或音响控制。
icon: film
operations:
+ - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用主驾已接入的放松按摩,缓解驻车观影时的久坐不适;不移动主驾座椅。}
+ - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾低档通风减少久坐闷热。}
+ - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 55}, reason: 为驻车观影提供适度后仰的副驾靠背支撑。}
+ - {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 为驻车观影提供副驾腿部支撑。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 维持观影期间的座舱恒温。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用舒适中性温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 使用低风量降低噪声。}
diff --git a/config/scene_templates/quiet_night.yaml b/config/scene_templates/quiet_night.yaml
index bfb3af1..e88b24c 100644
--- a/config/scene_templates/quiet_night.yaml
+++ b/config/scene_templates/quiet_night.yaml
@@ -3,9 +3,11 @@ name: 夜间静享模式
aliases:
- 夜间模式
- 静夜模式
-description: 使用低亮冷色氛围、关闭主照明与阅读灯,并以低风恒温保持安静。
+description: 使用主副驾低档座椅通风、低亮冷色氛围、关闭主照明与阅读灯,并以低风恒温保持安静;不改变座椅姿态。
icon: moon-star
operations:
+ - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾低档通风,降低夜间久坐闷热且不启动姿态调整或按摩。}
+ - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 副驾使用低档通风,保持座椅当前姿态。}
- {domain: ambient_light, capability: ambient_light.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启低亮氛围灯。}
- {domain: ambient_light, capability: ambient_light.set_illumination, target_scope: vehicle, operation: set, parameters: {color_hex: '#102040', brightness_pct: 10}, reason: 使用低亮深蓝氛围。}
- {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "off"}, reason: 关闭室内主照明。}
diff --git a/config/scene_templates/reading.yaml b/config/scene_templates/reading.yaml
index 50b10a5..c511090 100644
--- a/config/scene_templates/reading.yaml
+++ b/config/scene_templates/reading.yaml
@@ -4,9 +4,13 @@ aliases:
- 阅读空间模式
- 看书模式
- 读书模式
-description: 为座舱阅读提供暖色照明、低风恒温和适度遮光。
+description: 为驻车阅读提供主副驾低档座椅通风、副驾靠背与腿托支撑、暖色照明、低风恒温和适度遮光;不改变主驾驾驶姿态。
icon: book-open
operations:
+ - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 主驾采用已接入原生 CAN 的低档通风,减少久坐闷热;不移动主驾座椅。}
+ - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 副驾使用独立接口的低档通风。}
+ - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 22}, reason: 使用偏直立的副驾阅读靠背支撑,避免大幅躺倒。}
+ - {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 21}, reason: 副驾腿托适度支撑腿部,供驻车阅读预览使用。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 保持阅读期间的座舱恒温。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用舒适中性温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 使用安静低风量。}
diff --git a/config/scene_templates/reception.yaml b/config/scene_templates/reception.yaml
index e322a1c..74aaeaa 100644
--- a/config/scene_templates/reception.yaml
+++ b/config/scene_templates/reception.yaml
@@ -1,9 +1,11 @@
id: reception
name: 接待模式
aliases: [贵宾模式, 迎宾接待模式]
-description: 使用柔和暖光、车内照明、恒温和半开遮阳帘营造接待环境。
+description: 使用主副驾低档座椅通风、柔和暖光、车内照明、恒温和半开遮阳帘营造接待环境;不自动移动有人入座的座椅。
icon: handshake
operations:
+ - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾低档通风营造舒适接待环境,保持当前座椅位置。}
+ - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 副驾使用低档通风,不在迎宾入座时自动调整姿态。}
- {domain: ambient_light, capability: ambient_light.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启氛围灯。}
- {domain: ambient_light, capability: ambient_light.set_illumination, target_scope: vehicle, operation: set, parameters: {color_hex: '#FFE2B8', brightness_pct: 50}, reason: 使用柔和暖色氛围光。}
- {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启室内主照明。}
diff --git a/config/scene_templates/romantic.yaml b/config/scene_templates/romantic.yaml
index f9f56df..5a5658c 100644
--- a/config/scene_templates/romantic.yaml
+++ b/config/scene_templates/romantic.yaml
@@ -1,9 +1,11 @@
id: romantic
name: 浪漫模式
aliases: [约会模式, 浪漫氛围模式]
-description: 使用浪漫氛围灯并关闭车内主照明与阅读灯,同时关闭遮阳帘。
+description: 使用主副驾低档座椅通风与浪漫氛围灯,关闭车内主照明、阅读灯和遮阳帘;保持当前座椅姿态。
icon: heart
operations:
+ - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾低档通风减少久坐闷热,不调整驾驶姿态。}
+ - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾低档通风,保持用户当前的座椅姿态。}
- {domain: ambient_light, capability: ambient_light.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启氛围灯。}
- {domain: ambient_light, capability: ambient_light.set_mode, target_scope: vehicle, operation: set, parameters: {mode: romantic}, reason: 使用工程已接入的浪漫氛围。}
- {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "off"}, reason: 关闭室内主照明。}
diff --git a/config/scene_templates/seat_dynamic.yaml b/config/scene_templates/seat_dynamic.yaml
new file mode 100644
index 0000000..0be451c
--- /dev/null
+++ b/config/scene_templates/seat_dynamic.yaml
@@ -0,0 +1,23 @@
+id: seat-dynamic
+name: 座椅动态舒缓模式
+aliases: [座椅动态模式, 座椅的动态模式, 动态座椅模式, 座椅循环模式, 座椅交替放松模式, 动态放松模式]
+description: 驻车使用的有界双座舒缓时间轴;主驾在放松和舒缓按摩间切换,副驾靠背与腿托小幅交替,30秒一个阶段、重复5次,总计5分钟。不自动移动主驾位置;停止后不再进入后续阶段。
+icon: activity
+schedule:
+ schema: cognitive-agent-mode-schedule/v1
+ repeat_count: 5
+ phases:
+ - id: relax
+ name: 舒展支撑
+ duration_ms: 30000
+ operations:
+ - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 主驾使用原生放松按摩,不改变驾驶姿态。}
+ - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 驻车时副驾适度舒展。}
+ - {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 25}, reason: 适度支撑腿部。}
+ - id: support
+ name: 轻柔回落
+ duration_ms: 30000
+ operations:
+ - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: slack}, reason: 主驾切换舒缓按摩。}
+ - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 40}, reason: 副驾小幅回到支撑姿态。}
+ - {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 20}, reason: 腿托小幅回落,不使用无界轴运动。}
diff --git a/config/scene_templates/summer_cooling.yaml b/config/scene_templates/summer_cooling.yaml
index ca1f60f..5324658 100644
--- a/config/scene_templates/summer_cooling.yaml
+++ b/config/scene_templates/summer_cooling.yaml
@@ -1,9 +1,10 @@
id: summer-cooling
name: 夏季清凉模式
aliases: [清凉模式, 酷夏模式]
-description: 使用内循环、适中冷风、关闭遮阳帘并开启当前已接入的副驾座椅通风。
+description: 使用内循环、适中冷风、关闭遮阳帘并分别开启主驾低档和副驾中档座椅通风;不改变座椅姿态。
icon: snowflake
operations:
+ - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾原生 CAN 的低档通风;该接口 2 为低档,不与副驾档位混用。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启空调。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 23}, reason: 设置清凉但不过低的温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 3}, reason: 使用适中风量快速降温。}
diff --git a/config/scene_templates/winter_warmth.yaml b/config/scene_templates/winter_warmth.yaml
index f7e0472..df9dfef 100644
--- a/config/scene_templates/winter_warmth.yaml
+++ b/config/scene_templates/winter_warmth.yaml
@@ -1,9 +1,10 @@
id: winter-warmth
name: 冬季温暖模式
aliases: [暖冬模式, 冬日模式]
-description: 使用内循环、暖风、关闭遮阳帘并开启当前已接入的副驾座椅加热。
+description: 使用内循环、暖风、关闭遮阳帘并分别开启主驾低档和副驾中档座椅加热;不改变座椅姿态。
icon: flame
operations:
+ - {domain: seat, capability: seat.set_driver_heating_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用已接入的主驾原生 CAN 低档加热;该接口 2 为低档,不与副驾档位混用。}
- {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启空调。}
- {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 26}, reason: 设置温暖舒适温度。}
- {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 2}, reason: 使用中低风量。}
diff --git a/docs/mode-schedules.md b/docs/mode-schedules.md
new file mode 100644
index 0000000..2fad154
--- /dev/null
+++ b/docs/mode-schedules.md
@@ -0,0 +1,46 @@
+# 功能模式的座椅联动与时间轴
+
+## 行为
+
+新建或重新构建功能模式时,未指定座位默认只编排副驾座椅;明确“主驾”时只编排主驾座椅;只有明确要求主驾和副驾时才组合双座。该规则同时覆盖已登记模板、工程预设复制、未登记模型组合,以及时间轴每一个阶段。其他设备的独立区域不改变座椅选择,例如“主驾空调”并不代表选择主驾座椅。
+
+模板保留主副驾能力作为可选来源。构建时服务端先按所选座位投影,再逐动作校验,不能把副驾靠背、腿托的位置能力重标为主驾。模型规划的能力目录也按所选座位收窄,越界方案整体拒绝。已经保存的用户模式及其后续参数编辑仍以原有配置为准,不因本次默认规则变更静默删除座椅动作。
+
+常用阅读、观影、露营、夜间静享、接待、浪漫、冬季温暖、夏季清凉模板,以及办公、小憩、冥想、休闲躺椅预设,包含本工程支持的主驾和副驾舒适动作。主驾优先使用加热、通风、按摩,不凭空增加没有协议支持的位置动作。除明确座椅场景外,不随意改变主驾坐姿。
+
+已有用户保存的配置优先,不被模板升级静默覆盖。要采用新版组合,可删除不再需要的旧自定义副本后重新构建,或明确修改已有模式。除霜、新风等专项模式不强行增加无关的座椅动作。
+
+“构建一个座椅的动态模式”和“构建一个上车预热的功能模式”命中本地模板,无需模型推理。其他带时间变化的需求由结构化场景编排产生分阶段计划,每个阶段再次经过能力、参数、语义、安全和 CAN 编译校验;任意阶段失败则整份草稿拒绝,不生成静态替代品。
+
+## 模板默认值
+
+- 座椅动态舒缓:30 秒舒缓、30 秒支撑,重复 5 次,总计 5 分钟。默认副驾靠背与腿托小幅改变;明确主驾时改用主驾支持的按摩切换,不移动主驾姿态。仅应在车辆安全停稳后使用。
+- 上车预热:升温阶段 2 分钟,空调 28℃、风量 4;舒适保持阶段 5 分钟,空调 24℃、风量 1,同时编排所选座位的加热(默认副驾)。总计 7 分钟。
+
+预览卡片展示每阶段的目标与时长,默认值属于可审核的建议。生成和试运行都不下发实际车辆控制;保存后进入模式才开始计时。“上车预热”目前是模式名称,不表示已接入自动开门触发。
+
+## 存储与执行
+
+`cognitive-agent-mode-schedule/v1` 保存 `repeat_count` 与有序 `phases`。每阶段有唯一 ID、名称、`duration_ms` 和绝对目标 `operations`。同一能力在不同阶段的不同值不能合并或去重。
+
+约束:2–8 阶段;每阶段 1–12 项动作,1 秒–15 分钟;1–20 轮,含重复的总时长不超过 1 小时。禁止无限循环、相对累加动作和未经校验的底层指令。
+
+真实执行由服务端计时。每阶段重新编译并检查权限/硬件租约,任意阶段失败即停止后续阶段。同车只运行一条时间轴,新时间轴替换旧时间轴;同车阶段回调互斥。不同车辆独立。
+
+输入“停止动态模式”可取消后续阶段。已下发的目标不自动回滚,当前已进入执行器的操作可能继续完成。模拟停止不会改变真实任务。服务关闭时取消时间轴,重启不会自动续跑;网页关闭不会隐式修改服务端已启动任务。
+
+`GET /api/modes/schedule/status?vehicle_id=denza_n7` 只读返回运行 ID、会话、阶段和终态,不暴露底层指令或 CAN 数据。助手区分已启动、模拟完成、真实完成、失败和取消,不把刚启动显示为全部完成。
+
+## 验证
+
+核心时钟/并发/取消:`tests/unit/test_mode_schedule.py`。
+
+真实能力链创建、试运行、保存、重载、执行与防篡改:`tests/integration/test_mode_schedule_flow.py`。
+
+座椅模板覆盖:`tests/integration/test_registered_scene_seat_coverage.py`。
+
+新建座位选择、模型目录约束、非座椅分区隔离、保存绑定及越界拒绝:`tests/integration/test_mode_creation_seat_policy.py`。词汇与路由:`tests/unit/test_mode_creation_seat_policy.py`、`tests/unit/test_mode_creation_seat_routing.py`。
+
+时间轴卡片与状态轮询:`tests/unit/mode_schedule_frontend.test.cjs`;隔离服务的真实浏览器:`tests/e2e/test_mode_schedule_browser.py`。
+
+自动化测试使用模拟执行、临时用户目录并阻断硬件访问,不代表完成实车 CAN 验收。
diff --git a/src/cognitive_agent/agent/prompts/codex_cockpit_assistant.md b/src/cognitive_agent/agent/prompts/codex_cockpit_assistant.md
index d3a909c..3671dd9 100644
--- a/src/cognitive_agent/agent/prompts/codex_cockpit_assistant.md
+++ b/src/cognitive_agent/agent/prompts/codex_cockpit_assistant.md
@@ -8,6 +8,7 @@
- 对目录中的原子设备控制,调用工具时同时提交结构化 `plan`:只能逐字使用本回合能力目录中 `assistant_callable=true` 的 capability、target scope 和参数契约。对于目录中已有的座舱模式或当前 NFC 卡已授权的红外外设,省略 `plan`,由应用按动态注册表解析。结构化计划只是候选数据,应用会再次执行原始文本证据校验、Schema 校验和安全校验;本地确定性解析与计划重叠时,以本地结果为准。
- 用户仅给出一个尚未登记的功能模式名称时,应结合该场景的通常含义,从本回合能力目录中选择少量、明确且相互协调的 `assistant_callable=true` 原子能力,提交一个结构化 `plan`。每项动作必须是可重复的绝对 `set` 状态,不得使用相对增减、时长动作、动态/循环模式、保存或应用操作;同一能力与目标不得重复。若任何动作或参数有歧义,就不要提交残缺计划。不得引用目录外能力,不得生成原始 CAN,不得声称已经保存或真实执行;应用会把整组计划原子校验为 dry-run 动作预览,用户确认后才会保存。已有模式名称不要重新推测动作,应省略 `plan` 复用工程配置。
- 用户明确要求控制车辆,或直接表达当前座舱不适并期待处理时,必须调用该工具;不要只给建议,也不要要求用户先选择“问答/车控”模式。
+- 新建功能模式时,座椅默认只编排副驾;用户明确指定主驾时只编排主驾,只有明确要求两座时才同时编排。该规则适用于每个动态阶段,以本回合 `seat_scope_policy` 为准。不得把副驾姿态能力替换目标后冒充主驾能力,不得把后排或未知座位映射成前排。空调分区、车窗等非座椅设备的主副驾限定不改变座椅选择。单纯执行或修改已保存模式时,应保留其已保存的座位范围,不要重新套用新建默认值。
- “车上有点热”“有点热”“太热了”等当前不适,在没有否定、假设或纯知识提问时,直接调用一次工具;具体规范动作由服务端决定。
- “如果车上有点热应该怎么办”“为什么暴晒后车里会热”“车上有点热,但不要调整设备”等是假设、知识问题或否定请求,不得执行。
- 用户说“不要下发”“不执行”“只解析”“仅预览”或同义表达时,不得调用车辆工具。应直接用自然语言给出解析结果,保留用户明确说出的设备、目标位置、方向、数值、单位、时长和动作顺序,并明确说明未向车辆下发。
diff --git a/src/cognitive_agent/application/assistant_capabilities.py b/src/cognitive_agent/application/assistant_capabilities.py
index ff25cd0..c796add 100644
--- a/src/cognitive_agent/application/assistant_capabilities.py
+++ b/src/cognitive_agent/application/assistant_capabilities.py
@@ -615,6 +615,19 @@ def _mode_detail_answer(
if isinstance(item, Mapping)
]
name = _safe_text(mode.get("name"), limit=80)
+ schedule = mode.get("schedule")
+ if isinstance(schedule, Mapping) and isinstance(schedule.get("phases"), list):
+ phases = schedule["phases"]
+ details = ";".join(
+ f"第{index}阶段({phase['name']},保持{phase['duration_ms'] // 1000}秒):"
+ + "、".join(_mode_operation_text(op) for op in phase["operations"])
+ for index, phase in enumerate(phases, 1)
+ )
+ return CapabilityAnswer(
+ f"“{name}”按时间轴执行,重复{schedule['repeat_count']}次。{details}。"
+ "停止动态模式可取消后续阶段,已下发目标保持。本次仅查询,未下发车辆控制。",
+ "specific_mode_detail",
+ )
if not operations:
return CapabilityAnswer(
f"已找到“{name}”,但当前模式目录没有返回可核验的动作明细;本次仅查询,未下发车辆控制。",
@@ -2257,6 +2270,7 @@ class AssistantCapabilityCatalog:
"origin": _safe_text(raw.get("origin"), limit=32),
"operation_ids": operation_ids,
"operations": operations,
+ **({"schedule": raw["schedule"]} if raw.get("schedule") else {}),
"assistant_callable": callable_mode,
"all_operations_have_native_binding": bool(operation_ids)
and all(
@@ -2290,6 +2304,11 @@ class AssistantCapabilityCatalog:
requests = (
raw_plan.get("requests", []) if isinstance(raw_plan, Mapping) else []
)
+ schedule = raw.get("schedule")
+ if isinstance(schedule, Mapping):
+ requests = [operation for phase in schedule.get("phases", [])
+ if isinstance(phase, Mapping)
+ for operation in phase.get("operations", [])]
operation_ids = [
_safe_text(item.get("capability"), limit=120)
for item in requests
@@ -2327,6 +2346,7 @@ class AssistantCapabilityCatalog:
# The assistant consumes this only after an exact name/alias
# match and the vehicle service validates it again.
"plan": dict(raw_plan) if callable_template else None,
+ **({"schedule": schedule} if isinstance(schedule, Mapping) else {}),
}
)
return templates, "ready"
diff --git a/src/cognitive_agent/application/assistant_plan_guard.py b/src/cognitive_agent/application/assistant_plan_guard.py
index 6c7dca8..d257f83 100644
--- a/src/cognitive_agent/application/assistant_plan_guard.py
+++ b/src/cognitive_agent/application/assistant_plan_guard.py
@@ -25,8 +25,6 @@ from collections.abc import Set as AbstractSet
from dataclasses import dataclass
from typing import Any
-from cognitive_agent.application.semantic_ir import resolve_scene_seat_scopes
-
MAX_PLAN_BYTES = 24 * 1024
MAX_PLAN_REQUESTS = 12
MAX_PLAN_PARAMETERS = 12
@@ -1159,10 +1157,13 @@ def guard_assistant_plan(
parser_claims.update(_intent_claims(intent))
local_keys = _resolved_keys(trusted_local or {})
non_execution = bool(_NON_EXECUTION_PATTERN.search(text))
- scene_seat_scopes = (
- resolve_scene_seat_scopes(text if scene_scope_utterance is None else scene_scope_utterance)
- if allow_scene_composition else None
- )
+ # An explicit empty internal scope is reserved for already validated saved
+ # schedules/draft revisions. New scenes use passenger when no seat is named.
+ scene_seat_scopes = None
+ if allow_scene_composition and scene_scope_utterance != "":
+ from cognitive_agent.application.mode_creation_seat_policy import creation_seat_scopes
+
+ scene_seat_scopes = creation_seat_scopes(text if scene_scope_utterance is None else scene_scope_utterance)
accepted: list[dict[str, Any]] = []
rejected: list[dict[str, str]] = []
diff --git a/src/cognitive_agent/application/codex_assistant.py b/src/cognitive_agent/application/codex_assistant.py
index b2ef03c..ff5543a 100644
--- a/src/cognitive_agent/application/codex_assistant.py
+++ b/src/cognitive_agent/application/codex_assistant.py
@@ -20,19 +20,25 @@ from typing import Protocol
from cognitive_agent.application.mode_profile_intent import (
is_mode_profile_query,
+ mode_creation_source_name,
normalize_mode_profile_name,
parse_mode_profile_create,
parse_mode_profile_delete,
parse_mode_profile_update,
)
+from cognitive_agent.application.mode_schedule import ModeSchedule
+from cognitive_agent.application.mode_schedule_service import public_schedule_status
from cognitive_agent.application.scene_composer import (
SCENE_COMPOSER_INSTRUCTIONS,
SCENE_PLANNER_VERSION,
+ SCHEDULE_COMPOSER_INSTRUCTIONS,
SceneCompositionError,
build_scene_output_schema,
normalize_scene_output,
+ scene_context_for_mode_creation,
)
from cognitive_agent.application.scene_templates import normalize_scene_template_name
+from cognitive_agent.application.schedule_intent import is_schedule_stop, needs_mode_schedule
LOGGER = logging.getLogger(__name__)
INTERNAL_SCENE_BUILDER_MARKER = "根据该场景名称使用当前可控原子能力"
@@ -374,6 +380,9 @@ def _catalog_exact_mode_match_count(
}:
matched_ids.add(str(raw_mode.get("id") or ""))
matched_ids.discard("")
+ subject = mode_creation_source_name(requested_name)
+ if not matched_ids and normalize_mode_profile_name(subject) != needle:
+ return _catalog_exact_mode_match_count(catalog, subject)
return len(matched_ids)
@@ -475,6 +484,10 @@ def _catalog_exact_scene_template_plan(
template_id = str(raw_template.get("id") or "").strip()
if template_id:
matches[template_id] = raw_template
+ if not matches:
+ subject = mode_creation_source_name(requested_name)
+ if normalize_scene_template_name(subject) != needle:
+ return _catalog_exact_scene_template_plan(catalog, subject)
if len(matches) != 1:
return None
template = next(iter(matches.values()))
@@ -499,7 +512,12 @@ def _catalog_exact_scene_template_plan(
def _vehicle_result_succeeded(result: Mapping[str, object]) -> bool:
"""Only a terminal, fully successful service result completes a tool call."""
- return str(result.get("status") or "").lower() == "succeeded"
+ schedule = result.get("schedule_execution")
+ return str(result.get("status") or "").lower() == "succeeded" or (
+ result.get("status") == "running"
+ and result.get("orchestration_route") == "mode_schedule_execute"
+ and isinstance(schedule, Mapping) and schedule.get("status") == "running"
+ )
def _mode_profile_result_succeeded(result: Mapping[str, object]) -> bool:
@@ -594,6 +612,10 @@ def _vehicle_action_state(result: Mapping[str, object]) -> tuple[str, bool, str]
if not _vehicle_result_succeeded(result):
return "failed", False, "车辆控制未完成"
+ if result.get("orchestration_route") == "mode_schedule_stop":
+ return ("simulated" if result.get("execution_mode") != "hardware" else "cancelled"), True, "动态模式停止请求已处理"
+ if result.get("status") == "running":
+ return "running", True, "时间轴已启动,尚未完成所有阶段"
execution_mode = str(result.get("execution_mode") or "").lower()
if execution_mode == "dry_run":
return "simulated", True, "车辆控制模拟编排已完成"
@@ -621,6 +643,8 @@ def _authorize_vehicle_request(
message=message,
)
normalized = "".join(str(message).lower().split())
+ if is_schedule_stop(message):
+ return _VehicleAuthorization(message, "explicit_schedule_stop")
mode_profile_create = _mode_profile_create_parts(message)
mode_profile_update = _mode_profile_update_parts(message)
mode_profile_delete = parse_mode_profile_delete(message)
@@ -899,6 +923,9 @@ class CodexAssistantService:
"explicit_mode_profile_draft_update",
}
mode_parts = _mode_profile_create_parts(message) if mode_profile_preview else None
+ if mode_parts:
+ scene_context = scene_context_for_mode_creation(scene_context, message)
+ model_context = scene_context_for_mode_creation(model_context, message)
exact_mode_matches = (
_catalog_exact_mode_match_count(catalog, mode_parts[0])
if mode_parts
@@ -909,10 +936,11 @@ class CodexAssistantService:
if mode_parts and not mode_parts[1] and exact_mode_matches == 0
else None
)
+ dynamic_scene_request = bool(mode_parts and needs_mode_schedule(message))
direct_mode_profile_preview = bool(
mode_parts
and (
- mode_parts[1]
+ (mode_parts[1] and not dynamic_scene_request)
# Ambiguous or unavailable mode catalogs fail closed in the
# local builder. Only a known-zero match is safe to hand to
# semantic scene composition.
@@ -929,7 +957,7 @@ class CodexAssistantService:
}.get(authorization.reason)
scene_composition_preview = bool(
mode_parts
- and not mode_parts[1]
+ and (not mode_parts[1] or dynamic_scene_request)
and exact_mode_matches == 0
and scene_template_plan is None
and scene_context.get("composition_revision")
@@ -941,6 +969,7 @@ class CodexAssistantService:
# vehicle service still limits it to dry-run and requires confirmation.
direct_vehicle_control = (
fast_comfort_control
+ or authorization.reason == "explicit_schedule_stop"
or exact_quick_control
or saved_mode_execution_id is not None
or direct_mode_profile_preview
@@ -1248,11 +1277,17 @@ class CodexAssistantService:
revision = str(scene_context.get("composition_revision") or "")
if not revision:
raise _ScenePlannerFallback("scene capability revision is unavailable")
+ scope_policy = scene_context.get("seat_scope_policy")
+ selected_scopes = (
+ scope_policy.get("selected_scopes", [])
+ if isinstance(scope_policy, Mapping) else []
+ )
+ scope_key = ",".join(sorted(str(scope) for scope in selected_scopes))
return (
vehicle_id,
revision,
normalize_mode_profile_name(mode_name),
- SCENE_PLANNER_VERSION,
+ f"{SCENE_PLANNER_VERSION}:seats={scope_key}",
)
def _begin_scene_plan(
@@ -1493,7 +1528,8 @@ class CodexAssistantService:
raise _ScenePlannerFallback("structured turns are not supported")
planning_started_at = time.monotonic()
- output_schema = build_scene_output_schema(scene_context)
+ dynamic = needs_mode_schedule(message)
+ output_schema = build_scene_output_schema(scene_context, dynamic=dynamic)
context_bytes = len(
json.dumps(
dict(scene_context),
@@ -1522,7 +1558,7 @@ class CodexAssistantService:
try:
try:
thread_id = self.client.start_thread(
- developer_instructions=SCENE_COMPOSER_INSTRUCTIONS,
+ developer_instructions=SCHEDULE_COMPOSER_INSTRUCTIONS if dynamic else SCENE_COMPOSER_INSTRUCTIONS,
ephemeral=True,
replace_developer_instructions=True,
timeout=self._remaining_scene_time(deadline),
@@ -1652,7 +1688,7 @@ class CodexAssistantService:
raise SceneCompositionError("scene composer output was not valid JSON") from exc
if not isinstance(raw_output, Mapping):
raise SceneCompositionError("scene composer output must be an object")
- normalized = normalize_scene_output(raw_output, scene_context)
+ normalized = normalize_scene_output(raw_output, scene_context, dynamic=dynamic)
finished_at = time.monotonic()
LOGGER.info(
"Scene Composer model completed context_bytes=%d schema_bytes=%d "
@@ -1887,7 +1923,9 @@ class CodexAssistantService:
"success": successful,
"state": action_state,
"display_text": (
- "功能模式预览已生成"
+ completion_text
+ if action_state == "running" or result.get("orchestration_route") == "mode_schedule_stop"
+ else "功能模式预览已生成"
if mode_profile_preview and successful
else "功能模式预览生成失败"
if mode_profile_preview
@@ -2047,6 +2085,13 @@ class CodexAssistantService:
if isinstance(draft, Mapping)
else "新功能模式"
)
+ if isinstance(draft, Mapping) and isinstance(draft.get("schedule"), dict):
+ schedule = ModeSchedule.from_dict(draft["schedule"])
+ return (f"已为你生成“{name}”的分阶段时间轴:{len(schedule.phases)}个阶段,"
+ f"每轮{schedule.operation_count}项动作,重复{schedule.repeat_count}次,"
+ f"总时长{schedule.total_duration_ms // 1000}秒。"
+ "每个阶段的设备目标和持续时间见卡片。当前未保存或执行;确认后保存,进入模式才开始计时。"
+ "执行中可输入“停止动态模式”取消后续阶段,已下发目标不会自动回滚。")
if (
isinstance(draft, Mapping)
and draft.get("generation_source") == "draft_revision"
@@ -2251,6 +2296,18 @@ class CodexAssistantService:
result: Mapping[str, object],
successful: bool,
) -> str:
+ if result.get("orchestration_route") == "mode_schedule_stop":
+ if result.get("execution_mode") == "hardware" and (result.get("schedule_execution") or {}).get("status") != "cancelled":
+ return "当前没有正在执行的动态时间轴。"
+ return ("已停止动态模式后续阶段。已下发的设备目标保持,不自动回滚。"
+ if result.get("execution_mode") == "hardware"
+ else "已模拟停止动态模式,未改变正在运行的车辆任务。")
+ if result.get("orchestration_route") == "mode_schedule_execute":
+ if not successful:
+ return "时间轴未能完成,后续阶段已停止。请检查车辆状态后重试。"
+ return ("动态模式时间轴已启动,将按卡片中的时长逐阶段调节。尚未完成所有阶段;输入“停止动态模式”可取消后续阶段。"
+ if result.get("status") == "running"
+ else "已按虚拟时钟模拟验证动态模式的全部阶段,未向实际车辆下发操作。")
if reason in {"exact_quick_control", "explicit_saved_mode_execute"}:
instruction = re.sub(
r"\s+",
@@ -3011,7 +3068,7 @@ class CodexAssistantService:
if isinstance(intent_ir, dict):
spec = intent_ir.get("spec")
if isinstance(spec, dict) and isinstance(spec.get("actions"), list):
- for item in spec["actions"][:12]:
+ for item in spec["actions"][:96 if result.get("schedule") else 12]:
if not isinstance(item, dict):
continue
actions.append(
@@ -3125,6 +3182,22 @@ class CodexAssistantService:
)
if key in mode_profile_draft
}
+ for key in ("mode_profile_draft", "mode_profile"):
+ source = result.get(key)
+ if isinstance(source, dict) and isinstance(source.get("schedule"), dict):
+ try:
+ safe[key]["schedule"] = ModeSchedule.from_dict(source["schedule"]).to_dict()
+ except ValueError:
+ pass
+ if isinstance(result.get("schedule"), dict):
+ try:
+ safe["schedule"] = ModeSchedule.from_dict(result["schedule"]).to_dict()
+ except ValueError:
+ pass
+ if isinstance(result.get("schedule_execution"), dict):
+ safe["schedule_execution"] = public_schedule_status(result["schedule_execution"])
+ if isinstance(result.get("vehicle"), dict):
+ safe["vehicle"] = {"id": str(result["vehicle"].get("id") or "")[:128]}
failure = result.get("mode_creation_failure")
if isinstance(failure, Mapping):
reason, next_step = CodexAssistantService._mode_profile_failure_feedback(
diff --git a/src/cognitive_agent/application/mode_creation_seat_policy.py b/src/cognitive_agent/application/mode_creation_seat_policy.py
new file mode 100644
index 0000000..ccfd228
--- /dev/null
+++ b/src/cognitive_agent/application/mode_creation_seat_policy.py
@@ -0,0 +1,76 @@
+"""Seat selection for new mode drafts, not for replaying saved user modes."""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Mapping, Sequence
+from copy import deepcopy
+from typing import Any
+
+from cognitive_agent.application.mode_schedule import ModeSchedule
+from cognitive_agent.application.semantic_ir import resolve_scene_seat_scopes
+
+
+class ModeCreationSeatPolicyError(ValueError):
+ """A source mode cannot satisfy the selected seat without substitution."""
+
+
+def creation_seat_scopes(text: str) -> frozenset[str]:
+ """Default to passenger; HVAC/window zone names do not select a seat."""
+ normalized = text.replace("主副驾", "主驾和副驾")
+ # Do not let the generic 驾驶席/驾驶位 aliases inside 副驾驶席/位
+ # accidentally select the driver as well as the passenger.
+ normalized = re.sub(r"副驾驶(?:席|位|座(?!椅))?", "副驾", normalized)
+ if re.search(r"(?:未知|不明|不存在的?|任意)(?:一个)?(?:座位|座椅)|第[四五六七八九0-9]+排座", normalized):
+ return frozenset({"unsupported"})
+ normalized = re.sub(r"(?:前排)?两(?:个|张)?(?:座椅|座位|座)|双座(?:椅)?", "主驾和副驾", normalized)
+ excluded = set()
+ for scope, aliases in (("driver", r"主驾(?:驶)?|驾驶位|驾驶席|司机位"),
+ ("passenger", r"副驾(?:驶)?|乘客位")):
+ pattern = re.compile(
+ rf"(?:不要|别|不用|无需|不需要|不涉及|不包含|排除)"
+ rf"(?:再|去|动|移动|调节|调整|控制|联动|编排|使用|添加|包含)*"
+ rf"(?:{aliases})(?:座椅|座位)?|"
+ rf"(?:{aliases})(?:座椅|座位)?(?:不动|不要动|不用动|不联动|不参与|不编排)"
+ )
+ if pattern.search(normalized):
+ excluded.add(scope)
+ normalized = pattern.sub("", normalized)
+ selected = resolve_scene_seat_scopes(normalized) or frozenset({"passenger"})
+ return frozenset(selected - excluded)
+
+
+def normalized_seat_scope(scope: object) -> str:
+ value = str(scope or "")
+ return {"front_left": "driver", "front_right": "passenger"}.get(value, value)
+
+
+def project_seed_operations(
+ operations: Sequence[Mapping[str, Any]], scopes: frozenset[str],
+) -> list[dict[str, Any]]:
+ """Choose from trusted seat variants. Never retarget a passenger capability."""
+ if "unsupported" in scopes:
+ raise ModeCreationSeatPolicyError("当前功能模式仅支持明确的主驾或副驾座椅,不会替换为其他座位。")
+ seats = [op for op in operations if op.get("domain") == "seat"]
+ if any(normalized_seat_scope(op.get("target_scope")) not in {"driver", "passenger"} for op in seats):
+ raise ModeCreationSeatPolicyError("模式包含无法验证的座位范围,请重新生成。")
+ projected = [deepcopy(dict(op)) for op in operations
+ if op.get("domain") != "seat" or normalized_seat_scope(op.get("target_scope")) in scopes]
+ if seats and not any(op.get("domain") == "seat" for op in projected):
+ target = "主驾" if scopes == {"driver"} else "副驾" if scopes == {"passenger"} else "所选座位"
+ raise ModeCreationSeatPolicyError(f"该模式没有支持{target}的座椅动作,不会把另一座位的能力冒充为{target}能力。")
+ return projected
+
+
+def project_seed_plan(plan: Mapping[str, Any], scopes: frozenset[str]) -> dict[str, Any]:
+ projected = deepcopy(dict(plan))
+ projected["requests"] = project_seed_operations(plan.get("requests", []), scopes)
+ projected["detected_domains"] = list(dict.fromkeys(op["domain"] for op in projected["requests"]))
+ return projected
+
+
+def project_seed_schedule(schedule: ModeSchedule, scopes: frozenset[str]) -> ModeSchedule:
+ document = schedule.to_dict()
+ for phase in document["phases"]:
+ phase["operations"] = project_seed_operations(phase["operations"], scopes)
+ return ModeSchedule.from_dict(document)
diff --git a/src/cognitive_agent/application/mode_profile.py b/src/cognitive_agent/application/mode_profile.py
index f2ca53e..1796a8f 100644
--- a/src/cognitive_agent/application/mode_profile.py
+++ b/src/cognitive_agent/application/mode_profile.py
@@ -12,6 +12,8 @@ from typing import Any
import yaml
+from cognitive_agent.application.mode_schedule import ModeSchedule
+
_MATCH_FORMATTING = re.compile(r"[\s()()]+")
_NON_ASCII_SLUG = re.compile(r"[^a-z0-9]+")
_MODE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
@@ -450,6 +452,7 @@ class ModeProfile:
user_defined: bool = False
instruction: str = ""
command_flow: dict[str, Any] | None = None
+ schedule: ModeSchedule | None = None
@classmethod
def from_yaml(cls, path: Path, profile_root: Path | None = None) -> "ModeProfile":
@@ -486,10 +489,12 @@ class ModeProfile:
user_defined=_is_user_profile_path(path, profile_root),
instruction=_clean_instruction(document.get("instruction", "")),
command_flow=_clean_command_flow(document.get("command_flow")),
+ schedule=(ModeSchedule.from_dict(document["schedule"])
+ if document.get("schedule") is not None else None),
)
def public_dict(self) -> dict[str, Any]:
- is_cyclic = self.timeline is not None or any(
+ is_cyclic = self.schedule is not None or self.timeline is not None or any(
operation.capability == "seat.start_dynamic_mode"
for operation in self.operations
)
@@ -514,6 +519,9 @@ class ModeProfile:
}
if self.timeline is not None:
result["timeline"] = self.timeline.public_dict()
+ if self.schedule is not None:
+ result["schedule"] = self.schedule.to_dict()
+ result["operation_count"] = self.schedule.operation_count
return result
@@ -656,6 +664,7 @@ class ModeProfileRegistry:
},
"operations": [operation.to_yaml() for operation in profile.operations],
"timeline": profile.timeline.to_yaml() if profile.timeline is not None else None,
+ "schedule": profile.schedule.to_dict() if profile.schedule is not None else None,
"instruction": profile.instruction,
"command_flow": profile.command_flow,
}
@@ -688,6 +697,7 @@ class ModeProfileRegistry:
instruction: str = "",
command_flow: dict[str, Any] | None = None,
timeline: ModeTimeline | None = None,
+ schedule: ModeSchedule | None = None,
expected_revision: str | None = None,
create_only: bool = False,
) -> ModeProfile:
@@ -725,6 +735,8 @@ class ModeProfileRegistry:
document["command_flow"] = clean_command_flow
if timeline is not None:
document["timeline"] = timeline.to_yaml()
+ if schedule is not None:
+ document["schedule"] = schedule.to_dict()
path = (
self.user_root / "vehicles" / vehicle_id / "user" / f"{profile_id}.yaml"
if vehicle_id
@@ -994,6 +1006,8 @@ def _mode_profile_document(
}
if profile.timeline is not None:
document["timeline"] = profile.timeline.to_yaml()
+ if profile.schedule is not None:
+ document["schedule"] = profile.schedule.to_dict()
if profile.instruction:
document["instruction"] = profile.instruction
if profile.command_flow is not None:
diff --git a/src/cognitive_agent/application/mode_profile_intent.py b/src/cognitive_agent/application/mode_profile_intent.py
index 3befe36..7919332 100644
--- a/src/cognitive_agent/application/mode_profile_intent.py
+++ b/src/cognitive_agent/application/mode_profile_intent.py
@@ -9,12 +9,37 @@ _MODE_NAME = r"(?P[^,。,.!!::\s“”\"'「」『』《》]{1,64}?
_QUOTED_MODE_NAME = (
r"(?P[“\"'「『《]?)" + _MODE_NAME + r"(?P[”\"'」』》]?)"
)
+_CREATION_DUAL_SEAT_NAMES = (
+ r"主驾(?:和|与|及)副驾|主副驾|双座(?:椅)?|(?:前排)?两(?:个|张)?(?:座椅|座位|座)"
+)
+_CREATION_SEAT_NAMES = (
+ rf"{_CREATION_DUAL_SEAT_NAMES}|"
+ r"副驾驶席|副驾驶位|副驾驶|副驾|乘客座椅|乘客席|"
+ r"主驾驶席|主驾驶位|主驾驶|主驾|驾驶席|驾驶位"
+)
+_CREATION_SEAT_PREFIX = re.compile(
+ rf"^(?:为|给|针对|供)?(?P{_CREATION_SEAT_NAMES})"
+ r"(?:(?:座椅|座位|座席)?(?:专属|专用))?的?"
+)
+_CREATION_SEAT_CLAUSE = re.compile(
+ rf"^(?:仅|只)?(?:为|给|针对|适用于|用于|面向|联动|编排)"
+ rf"(?P{_CREATION_SEAT_NAMES})(?:座椅|座位|座席)?"
+ r"(?:使用|联动|编排|的能力|的动作)?$"
+)
+_CREATION_SEAT_GENERIC_SCENE = re.compile(
+ r"^(?:座椅|座位|座席)的?(?P"
+ r"阅读|看书|读书|办公|观影|看电影|电影|露营)模式$"
+)
+_CREATION_SEAT_FUNCTION_SUBJECT = re.compile(
+ r"^(?:舒适|舒服|舒缓|放松|动态|循环|通风|加热|按摩|靠背|腿托|坐垫).{0,32}模式$"
+)
_MODE_PROFILE_CREATE_HEAD = re.compile(
r"^\s*"
r"(?:(?:请问(?:可以|能不能|能否|可不可以|能)帮我|"
r"(?:可以|能不能|能否|可不可以|能)帮我|"
r"我(?:想要|想|希望|需要|打算|准备)(?:让你)?|"
r"想要|想|希望|需要|打算|准备|请你?|麻烦你?|帮我|帮忙|给我|先)\s*){0,6}"
+ rf"(?:(?:为|给|针对)(?P{_CREATION_SEAT_NAMES})(?:座椅|座位|座席)?\s*)?"
r"(?P构建|创建|新建|新增|生成|添加)\s*"
r"(?:一下\s*)?(?:一个|个|一套|套)?\s*"
r"(?P命名为|名称为|取名为|名叫|名为|叫做)?\s*"
@@ -35,7 +60,7 @@ _MODE_PROFILE_PURPOSE_WRAPPERS = (
r"(?P.+?)的(?:功能|场景|自定义)?模式$"
),
re.compile(
- r"^(?P(?:座椅|座位|乘坐|坐着).+?)的(?:功能|场景|自定义)?模式$"
+ r"^(?P(?:座椅|座位|乘坐|坐着|上车|空调).+?)的(?:功能|场景|自定义)?模式$"
),
)
_MODE_PROFILE_PURPOSE_INVALID = frozenset(
@@ -70,6 +95,7 @@ _MODE_PROFILE_CREATE_CAPABILITY_CLAUSE = re.compile(
)
_MODE_PROFILE_CREATE_ASSISTED_REQUEST = re.compile(
r"^(?:请问)?(?:可以|能不能|能否|可不可以|能)帮我(?:先)?"
+ rf"(?:(?:为|给|针对)(?:{_CREATION_SEAT_NAMES})(?:座椅|座位|座席)?)?"
r"(?:构建|创建|新建|新增|生成|添加)"
)
_MODE_PROFILE_CREATE_SUFFIX_UNIT = re.compile(
@@ -146,6 +172,10 @@ _MODE_PROFILE_UPDATE_NEGATION = re.compile(
r"不要|别(?:再)?|不用|无需|不必|禁止|不许|不得|"
r"取消(?:修改|调整|更改|更新|变更|操作)|不执行|不下发|不保存"
)
+_MODE_PROFILE_UPDATE_PREFIX_NEGATION = re.compile(
+ r"^(?:请|麻烦)?(?:先|暂时)?(?:不要|别|不用|无需|不必|禁止|不许|不得)"
+ r"(?:把|将|修改|调整|更改|更新|变更)"
+)
_MODE_POSSESSIVE_BOUNDARY = r"(?:中的|里的|内的|之中的|的|中|里|内)"
_MODE_MEMBER_BOUNDARY = rf"(?:{_MODE_POSSESSIVE_BOUNDARY}|[,,::])"
_PREFIX_UPDATE = re.compile(
@@ -173,7 +203,9 @@ MODE_PROFILE_CHANGE_PREDICATE = re.compile(
r"设置|设为|设成|设到|设至|"
r"打开|开启|启动|关闭|关掉|关上|全关|停止|停用|切换|进入|退出|"
r"移动|前移|后退|前进|升高|降低|抬高|放低|降下|升起|调直|放倒|"
- r"复位|半开|开到|关到|拉开|拉上|上锁|解锁"
+ r"复位|半开|开到|关到|拉开|拉上|上锁|解锁|"
+ r"(?:持续|保持)(?:时间)?(?:[0-9]+(?:\.[0-9]+)?|[零一二两三四五六七八九十]+)(?:分钟|秒钟|秒|s)|"
+ r"(?:重复|循环)(?:[0-9]+(?:\.[0-9]+)?|[零一二两三四五六七八九十]+)次"
)
MODE_PROFILE_QUERY_CUE = re.compile(
r"什么|哪些|多少|几(?:度|档|级|个|项)?|是否|是不是|"
@@ -233,6 +265,41 @@ def normalize_mode_profile_name(value: object) -> str:
return compact_mode_profile_text(value).strip("“”\"'「」『』《》").casefold()
+def _creation_seat_label(value: str) -> str:
+ if re.fullmatch(_CREATION_DUAL_SEAT_NAMES, value):
+ return "主副驾"
+ return "副驾" if value.startswith(("副", "乘客")) else "主驾"
+
+
+def mode_creation_source_name(value: object) -> str:
+ """Remove only an explicit front-seat qualifier for source lookup.
+
+ A scoped draft keeps its distinct display name. This helper only allows its
+ existing scene seed to be located; it never authorizes any seat operation or
+ rewrites an unsupported rear/unknown target into a front seat.
+ """
+
+ name = compact_mode_profile_text(value).strip("“”\"'「」『』《》")
+ match = _CREATION_SEAT_PREFIX.match(name)
+ if match is None:
+ return name
+ subject = name[match.end():]
+ if not subject or subject == "模式" or not subject.endswith("模式"):
+ return name
+ # A seat location before a generic activity is a scope wrapper. Keep the
+ # actual device topic in 座椅舒适/座椅动态 rather than dropping every 座椅.
+ generic_scene = _CREATION_SEAT_GENERIC_SCENE.fullmatch(subject)
+ if generic_scene is not None:
+ subject = f"{generic_scene.group('scene')}模式"
+ elif (
+ match.group("seat").endswith(("座椅", "座位", "座"))
+ and _CREATION_SEAT_FUNCTION_SUBJECT.fullmatch(subject)
+ ):
+ subject = f"座椅{subject}"
+ inferred = _infer_mode_profile_purpose_name(subject)
+ return inferred[0] if inferred is not None else subject
+
+
def _infer_mode_profile_purpose_name(value: object) -> tuple[str, str] | None:
"""Turn one bounded purpose description into a concise mode name.
@@ -448,12 +515,24 @@ def parse_mode_profile_create(value: object) -> tuple[str, str] | None:
# Keep explicit user names byte-for-byte while giving inferred scenes a
# concise, reusable name (for example 看书模式 instead of
# 适合看书的功能模式).
- if not str(match.group("naming") or "") and not opening:
+ explicit_name = bool(str(match.group("naming") or "") or opening)
+ if not explicit_name:
inferred_purpose = _infer_mode_profile_purpose_name(name)
if inferred_purpose is not None:
name = inferred_purpose[0]
+ seat_prefix = _CREATION_SEAT_PREFIX.match(name)
+ if seat_prefix is not None and mode_creation_source_name(name) != name:
+ name = _creation_seat_label(seat_prefix.group("seat")) + mode_creation_source_name(name)
+ if match.group("seat") and seat_prefix is None:
+ name = _creation_seat_label(match.group("seat")) + name
if normalize_mode_profile_name(name) in _GENERIC_MODE_PROFILE_CREATE_NAMES:
return None
+ name_seat = _CREATION_SEAT_PREFIX.match(name)
+ if (
+ match.group("seat") and name_seat is not None
+ and _creation_seat_label(match.group("seat")) != _creation_seat_label(name_seat.group("seat"))
+ ):
+ return None
raw_match_end = raw_ends[match.end() - 1]
if "\n" in raw[:raw_match_end] or "\r" in raw[:raw_match_end]:
@@ -498,6 +577,19 @@ def parse_mode_profile_create(value: object) -> tuple[str, str] | None:
return name, ""
instruction = _strip_mode_profile_create_suffix(instruction)
+ seat_clause = _CREATION_SEAT_CLAUSE.fullmatch(compact_mode_profile_text(instruction))
+ if seat_clause is not None:
+ # A seat-only clause selects the draft, not an extra actuator command.
+ # Real device settings in a following clause remain untouched below.
+ name_seat = _CREATION_SEAT_PREFIX.match(name)
+ if (
+ name_seat is not None
+ and _creation_seat_label(name_seat.group("seat")) != _creation_seat_label(seat_clause.group("seat"))
+ ):
+ return None
+ if not explicit_name and name_seat is None:
+ name = _creation_seat_label(seat_clause.group("seat")) + name
+ return name, ""
if (
not instruction
or len(instruction) > 800
@@ -637,6 +729,7 @@ def parse_mode_profile_update(
text = compact_mode_profile_text(value)
if (
not text
+ or _MODE_PROFILE_UPDATE_PREFIX_NEGATION.search(text)
or _MODE_PROFILE_CREATE_REFERENCE_CLAUSE.search(text)
or _MODE_PROFILE_CREATE_UNSAFE_PREFIX.search(text)
):
diff --git a/src/cognitive_agent/application/mode_schedule.py b/src/cognitive_agent/application/mode_schedule.py
new file mode 100644
index 0000000..93c4c16
--- /dev/null
+++ b/src/cognitive_agent/application/mode_schedule.py
@@ -0,0 +1,429 @@
+"""Bounded, cancellable timelines for validated absolute cockpit operations.
+
+This module validates the *schedule* contract, not vehicle authorization. Callers
+must validate every phase against the current registry and execute it through the
+ordinary compiler/safety path. It deliberately has no CAN or ModeProfile imports.
+Durations are holds after one phase dispatch, never actuator motion durations.
+Cancelling prevents future phases; it does not roll back an already applied state.
+"""
+
+from __future__ import annotations
+
+import math
+import re
+import time
+from collections.abc import Callable
+from copy import deepcopy
+from dataclasses import dataclass, field
+from threading import Event, Lock, RLock, Thread, current_thread
+from typing import Any
+from uuid import uuid4
+
+from cognitive_agent.application.assistant_plan_guard import SCENE_COMPOSABLE_CAPABILITIES
+
+MODE_SCHEDULE_SCHEMA = "cognitive-agent-mode-schedule/v1"
+MAX_SCHEDULE_DURATION_MS = 60 * 60 * 1000
+_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
+_FORBIDDEN_PARAMETERS = frozenset(
+ {"duration", "duration_ms", "seconds", "delta", "relative", "relative_pct",
+ "direction", "toggle", "repeat", "repeat_count", "axis"}
+)
+_FAILURE_STATUSES = frozenset(
+ {"failed", "error", "cancelled", "canceled", "rejected", "blocked", "aborted"}
+)
+_SUCCESS_STATUSES = frozenset({"succeeded", "success", "ok", "completed"})
+
+
+def _object(value: Any, fields: set[str], required: set[str], label: str) -> dict[str, Any]:
+ if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
+ raise ValueError(f"{label} must be an object with string keys")
+ if unknown := set(value) - fields:
+ raise ValueError(f"{label} contains unknown fields: {', '.join(sorted(unknown))}")
+ if missing := required - set(value):
+ raise ValueError(f"{label} is missing fields: {', '.join(sorted(missing))}")
+ return value
+
+
+def _text(value: Any, label: str, maximum: int, *, allow_empty: bool = False) -> str:
+ if not isinstance(value, str) or len(value) > maximum:
+ raise ValueError(f"{label} must be a string of at most {maximum} characters")
+ if not allow_empty and not value.strip():
+ raise ValueError(f"{label} must not be empty")
+ if "\x00" in value:
+ raise ValueError(f"{label} contains a null character")
+ return value.strip()
+
+
+def _integer(value: Any, minimum: int, maximum: int, label: str) -> int:
+ if type(value) is not int or not minimum <= value <= maximum:
+ raise ValueError(f"{label} must be an integer between {minimum} and {maximum}")
+ return value
+
+
+def _operation(value: Any) -> dict[str, Any]:
+ required = {"domain", "capability", "target_scope", "parameters"}
+ document = _object(value, required | {"operation", "reason"}, required, "schedule operation")
+ domain = _text(document["domain"], "operation domain", 64)
+ capability = _text(document["capability"], "operation capability", 128)
+ if capability not in SCENE_COMPOSABLE_CAPABILITIES or not capability.startswith(domain + "."):
+ raise ValueError("schedule capability must be a stable, scene-composable capability")
+ if document.get("operation", "set") != "set":
+ raise ValueError("schedule operations must use absolute set operations")
+ parameters = document["parameters"]
+ if not isinstance(parameters, dict) or not 1 <= len(parameters) <= 12:
+ raise ValueError("schedule operation parameters must contain 1 to 12 scalar fields")
+ for key, parameter in parameters.items():
+ _text(key, "parameter name", 64)
+ if key.casefold() in _FORBIDDEN_PARAMETERS:
+ raise ValueError("schedule operations cannot contain relative, motion or timing parameters")
+ if parameter is None or type(parameter) not in (str, int, float, bool):
+ raise ValueError("schedule parameter values must be non-null JSON scalars")
+ if type(parameter) is float and not math.isfinite(parameter):
+ raise ValueError("schedule numeric parameters must be finite")
+ if isinstance(parameter, str):
+ _text(parameter, "parameter value", 256)
+ if parameter.casefold() in {"toggle", "increase", "decrease", "relative"}:
+ raise ValueError("schedule operations cannot request relative state changes")
+ return {
+ "domain": domain,
+ "capability": capability,
+ "target_scope": _text(document["target_scope"], "operation target_scope", 128),
+ "operation": "set",
+ "parameters": deepcopy(parameters),
+ "reason": _text(document.get("reason", ""), "operation reason", 1000, allow_empty=True),
+ }
+
+
+def _operation_key(operation: dict[str, Any]) -> tuple[str, str]:
+ target = operation["target_scope"]
+ if operation["domain"] == "seat":
+ target = {"front_left": "driver", "front_right": "passenger"}.get(target, target)
+ return operation["capability"], target
+
+
+@dataclass(frozen=True)
+class ModeSchedulePhase:
+ id: str
+ name: str
+ duration_ms: int
+ operations: tuple[dict[str, Any], ...]
+
+ def __post_init__(self) -> None:
+ phase_id = _text(self.id, "schedule phase id", 64)
+ if not _ID_PATTERN.fullmatch(phase_id):
+ raise ValueError("schedule phase id must contain lowercase letters, numbers, '_' or '-'")
+ object.__setattr__(self, "id", phase_id)
+ object.__setattr__(self, "name", _text(self.name, "schedule phase name", 128))
+ _integer(self.duration_ms, 1000, 15 * 60 * 1000, "schedule phase duration_ms")
+ if not isinstance(self.operations, (list, tuple)) or not 1 <= len(self.operations) <= 12:
+ raise ValueError("schedule phase must contain 1 to 12 operations")
+ operations = tuple(_operation(operation) for operation in self.operations)
+ keys = [_operation_key(operation) for operation in operations]
+ if len(set(keys)) != len(keys):
+ raise ValueError("schedule phase contains duplicate capability/target operations")
+ object.__setattr__(self, "operations", operations)
+
+ @classmethod
+ def from_dict(cls, document: dict[str, Any]) -> ModeSchedulePhase:
+ fields = {"id", "name", "duration_ms", "operations"}
+ value = _object(document, fields, fields, "schedule phase")
+ if not isinstance(value["operations"], list):
+ raise ValueError("schedule phase operations must be an array") # noqa: TRY004
+ return cls(value["id"], value["name"], value["duration_ms"], tuple(value["operations"]))
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "id": self.id,
+ "name": self.name,
+ "duration_ms": self.duration_ms,
+ "operations": deepcopy(list(self.operations)),
+ }
+
+
+@dataclass(frozen=True)
+class ModeSchedule:
+ phases: tuple[ModeSchedulePhase, ...]
+ repeat_count: int = 1
+ schema: str = MODE_SCHEDULE_SCHEMA
+
+ def __post_init__(self) -> None:
+ if self.schema != MODE_SCHEDULE_SCHEMA:
+ raise ValueError("unsupported mode schedule schema")
+ _integer(self.repeat_count, 1, 20, "schedule repeat_count")
+ if not isinstance(self.phases, (list, tuple)) or not 2 <= len(self.phases) <= 8:
+ raise ValueError("schedule must contain 2 to 8 phases")
+ if any(not isinstance(phase, ModeSchedulePhase) for phase in self.phases):
+ raise ValueError("schedule phases must be ModeSchedulePhase objects")
+ object.__setattr__(self, "phases", tuple(self.phases))
+ if len({phase.id for phase in self.phases}) != len(self.phases):
+ raise ValueError("schedule phase ids must be unique")
+ if self.total_duration_ms > MAX_SCHEDULE_DURATION_MS:
+ raise ValueError("schedule total duration including repetitions must not exceed 60 minutes")
+
+ @property
+ def total_duration_ms(self) -> int:
+ return sum(phase.duration_ms for phase in self.phases) * self.repeat_count
+
+ @property
+ def operation_count(self) -> int:
+ """Number of operations in one full repetition."""
+ return sum(len(phase.operations) for phase in self.phases)
+
+ @classmethod
+ def from_dict(cls, document: dict[str, Any]) -> ModeSchedule:
+ fields = {"schema", "repeat_count", "phases"}
+ value = _object(document, fields, fields, "schedule")
+ if not isinstance(value["phases"], list):
+ raise ValueError("schedule phases must be an array") # noqa: TRY004
+ return cls(
+ phases=tuple(ModeSchedulePhase.from_dict(phase) for phase in value["phases"]),
+ repeat_count=value["repeat_count"],
+ schema=value["schema"],
+ )
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "schema": self.schema,
+ "repeat_count": self.repeat_count,
+ "phases": [phase.to_dict() for phase in self.phases],
+ }
+
+
+ExecutePhase = Callable[[ModeSchedulePhase], dict[str, Any]]
+WaitFunction = Callable[[Event, float], bool]
+
+
+def _result_error(result: Any) -> str | None:
+ if not isinstance(result, dict):
+ return "phase executor did not return an object with an explicit success result"
+ status = result.get("status")
+ if result.get("success") is False or result.get("error") or status in _FAILURE_STATUSES:
+ return str(result.get("error") or result.get("message") or "phase execution failed")[:1000]
+ if result.get("success") is True or status in _SUCCESS_STATUSES:
+ return None
+ return str(result.get("error") or "phase executor did not confirm successful execution")[:1000]
+
+
+@dataclass
+class _Run:
+ vehicle_id: str
+ session_id: str
+ schedule: ModeSchedule
+ started_at: float
+ run_id: str = field(default_factory=lambda: uuid4().hex)
+ cancelled: Event = field(default_factory=Event)
+ thread: Thread | None = None
+ status: str = "running"
+ phase_index: int = -1
+ repeat_index: int = 0
+ executed_phases: int = 0
+ error: str | None = None
+ cancel_reason: str | None = None
+ finished_at: float | None = None
+
+
+class ModeScheduleRunner:
+ """At most one active timeline and one phase callback per vehicle.
+
+ A replacement cancels its predecessor immediately and waits for any already
+ running phase callback to return before dispatching. Callbacks must themselves
+ be bounded and cancellation never implies undoing a previously sent operation.
+ ``wait(event, seconds)`` may be injected for deterministic tests; like Event.wait
+ it returns true on cancellation. No callback runs while the state lock is held.
+ """
+
+ def __init__(
+ self,
+ *,
+ clock: Callable[[], float] = time.monotonic,
+ wait: WaitFunction | None = None,
+ ) -> None:
+ self._clock = clock
+ self._wait = wait or (lambda event, seconds: event.wait(seconds))
+ self._lock = RLock()
+ self._runs: dict[str, _Run] = {}
+ self._dispatch_locks: dict[str, Lock] = {}
+ self._workers: dict[str, _Run] = {}
+ self._closed = False
+
+ def _snapshot(self, run: _Run) -> dict[str, Any]:
+ end = run.finished_at if run.finished_at is not None else self._clock()
+ return {
+ "run_id": run.run_id,
+ "vehicle_id": run.vehicle_id,
+ "session_id": run.session_id,
+ "status": run.status,
+ "phase_index": run.phase_index,
+ "repeat_index": run.repeat_index,
+ "executed_phases": run.executed_phases,
+ "phase_count": len(run.schedule.phases),
+ "repeat_count": run.schedule.repeat_count,
+ "elapsed_ms": max(0, round((end - run.started_at) * 1000)),
+ "total_duration_ms": run.schedule.total_duration_ms,
+ "completed": run.status == "completed",
+ "error": run.error,
+ "cancel_reason": run.cancel_reason,
+ }
+
+ def status(self, vehicle_id: str) -> dict[str, Any]:
+ with self._lock:
+ run = self._runs.get(vehicle_id)
+ if run is not None:
+ return self._snapshot(run)
+ return {
+ "vehicle_id": vehicle_id, "status": "idle", "phase_index": -1,
+ "repeat_index": 0, "completed": False, "error": None,
+ }
+
+ def _cancel(self, run: _Run, reason: str) -> None:
+ if run.status == "running":
+ run.cancelled.set()
+ run.status = "cancelled"
+ run.cancel_reason = reason
+ run.finished_at = self._clock()
+
+ def cancel(self, vehicle_id: str, *, session_id: str | None = None) -> dict[str, Any]:
+ """Cancel a vehicle timeline, optionally only if it belongs to this session."""
+ with self._lock:
+ run = self._runs.get(vehicle_id)
+ if run is not None and (session_id is None or session_id == run.session_id):
+ self._cancel(run, "cancelled")
+ return self.status(vehicle_id)
+
+ def start(
+ self,
+ vehicle_id: str,
+ schedule: ModeSchedule,
+ execute_phase: ExecutePhase,
+ *,
+ session_id: str = "",
+ ) -> dict[str, Any]:
+ vehicle_id = _text(vehicle_id, "vehicle_id", 128)
+ session_id = _text(session_id, "session_id", 256, allow_empty=True)
+ if not isinstance(schedule, ModeSchedule) or not callable(execute_phase):
+ raise ValueError("start requires a ModeSchedule and callable phase executor") # noqa: TRY004
+ # Snapshot/revalidate to prevent caller-owned parameter dictionaries from
+ # altering the executable contract after the runner has accepted it.
+ accepted = ModeSchedule.from_dict(schedule.to_dict())
+ with self._lock:
+ if self._closed:
+ raise RuntimeError("mode schedule runner is closed")
+ previous = self._runs.get(vehicle_id)
+ if previous is not None:
+ self._cancel(previous, "replaced")
+ dispatch_lock = self._dispatch_locks.setdefault(vehicle_id, Lock())
+ run = _Run(vehicle_id, session_id, accepted, self._clock())
+ self._runs[vehicle_id] = run
+ self._workers[run.run_id] = run
+ run.thread = Thread(
+ target=self._run, args=(run, dispatch_lock, execute_phase),
+ name=f"mode-schedule-{run.run_id[:8]}", daemon=True,
+ )
+ try:
+ run.thread.start()
+ except RuntimeError:
+ run.status = "failed"
+ run.error = "schedule worker could not start"
+ run.finished_at = self._clock()
+ self._workers.pop(run.run_id, None)
+ raise
+ return self._snapshot(run)
+
+ def _run(self, run: _Run, dispatch_lock: Lock, execute_phase: ExecutePhase) -> None:
+ try:
+ for repeat_index in range(run.schedule.repeat_count):
+ for phase_index, phase in enumerate(run.schedule.phases):
+ # A cancelled replacement must not wait indefinitely behind
+ # an unrelated in-flight callback just to observe cancellation.
+ while not dispatch_lock.acquire(timeout=0.05):
+ if run.cancelled.is_set():
+ return
+ try:
+ with self._lock:
+ if run.cancelled.is_set():
+ return
+ run.repeat_index = repeat_index
+ run.phase_index = phase_index
+ # Isolate executor mutations from subsequent repetitions.
+ result = execute_phase(ModeSchedulePhase.from_dict(phase.to_dict()))
+ finally:
+ dispatch_lock.release()
+ with self._lock:
+ if run.cancelled.is_set():
+ return
+ if error := _result_error(result):
+ run.status = "failed"
+ run.error = error
+ run.finished_at = self._clock()
+ return
+ run.executed_phases += 1
+ if self._wait(run.cancelled, phase.duration_ms / 1000):
+ with self._lock:
+ self._cancel(run, "cancelled")
+ return
+ if run.cancelled.is_set():
+ return
+ with self._lock:
+ if not run.cancelled.is_set():
+ run.status = "completed"
+ run.finished_at = self._clock()
+ except Exception as exc: # noqa: BLE001 - any executor failure must stop later phases
+ with self._lock:
+ if not run.cancelled.is_set():
+ run.status = "failed"
+ run.error = f"{type(exc).__name__}: {exc}"[:1000]
+ run.finished_at = self._clock()
+ finally:
+ with self._lock:
+ self._workers.pop(run.run_id, None)
+
+ @staticmethod
+ def simulate(schedule: ModeSchedule, execute_phase: ExecutePhase) -> dict[str, Any]:
+ """Validate/execute phases synchronously with virtual holds and no thread.
+
+ The supplied callback must select the ordinary *dry-run* execution path;
+ this method never changes an executor's hardware policy implicitly.
+ """
+ if not isinstance(schedule, ModeSchedule) or not callable(execute_phase):
+ raise ValueError("simulate requires a ModeSchedule and callable phase executor") # noqa: TRY004
+ schedule = ModeSchedule.from_dict(schedule.to_dict())
+ report: dict[str, Any] = {
+ "status": "completed", "completed": True, "error": None,
+ "execution_mode": "dry_run", "virtual_elapsed_ms": 0,
+ "total_duration_ms": schedule.total_duration_ms,
+ "phase_index": -1, "repeat_index": 0, "executed_phases": 0,
+ "phase_results": [],
+ }
+ for repeat_index in range(schedule.repeat_count):
+ for phase_index, phase in enumerate(schedule.phases):
+ report.update(phase_index=phase_index, repeat_index=repeat_index)
+ try:
+ result = execute_phase(ModeSchedulePhase.from_dict(phase.to_dict()))
+ error = _result_error(result)
+ except Exception as exc: # noqa: BLE001 - simulation uses the same fail-closed policy
+ result = None
+ error = f"{type(exc).__name__}: {exc}"[:1000]
+ report["phase_results"].append({
+ "phase_id": phase.id, "phase_index": phase_index,
+ "repeat_index": repeat_index, "duration_ms": phase.duration_ms,
+ "virtual_start_ms": report["virtual_elapsed_ms"], "result": result,
+ })
+ if error:
+ report.update(status="failed", completed=False, error=error)
+ return report
+ report["executed_phases"] += 1
+ report["virtual_elapsed_ms"] += phase.duration_ms
+ return report
+
+ def close(self, *, timeout: float = 2.0) -> bool:
+ """Cancel all waits; return whether bounded, already-running callbacks exited."""
+ with self._lock:
+ self._closed = True
+ workers = list(self._workers.values())
+ for run in workers:
+ self._cancel(run, "closed")
+ deadline = time.monotonic() + max(0, timeout)
+ for run in workers:
+ if run.thread is not None and run.thread is not current_thread():
+ run.thread.join(max(0, deadline - time.monotonic()))
+ return all(run.thread is None or not run.thread.is_alive() for run in workers)
diff --git a/src/cognitive_agent/application/mode_schedule_edit.py b/src/cognitive_agent/application/mode_schedule_edit.py
new file mode 100644
index 0000000..4b65ae8
--- /dev/null
+++ b/src/cognitive_agent/application/mode_schedule_edit.py
@@ -0,0 +1,314 @@
+"""Phase-aware natural-language edits, always revalidated before persistence."""
+
+from __future__ import annotations
+
+import json
+import re
+from collections.abc import Callable
+from copy import deepcopy
+from typing import Any
+
+from cognitive_agent.application.mode_schedule import ModeSchedule
+
+_NUMBER = r"[0-9]+(?:\.[0-9]+)?|[零一二两三四五六七八九十]+"
+_PHASE = re.compile(r"第(?P[0-9]+|[一二两三四五六七八九十]+)(?:个)?(?:阶段|步)")
+_ALL_PHASES = re.compile(r"(?:全部|所有|每个|每一|各个|各)(?:的)?阶段")
+_HOLD = re.compile(
+ rf"(?:持续时间|保持时间|持续|保持|时长|^(?:把|将|的)?(?=调整为|调整到|设置为|设置成|设为|改为|改成|变成|变为))\s*"
+ rf"(?:调整为|调整到|设置为|设置成|设为|改为|改成|变成|变为)?\s*"
+ rf"(?P{_NUMBER})\s*(?P分钟|秒钟|秒|minutes?|mins?|min|seconds?|secs?|s)"
+)
+_REPEAT = re.compile(
+ rf"(?:重复|循环)(?:次数)?\s*(?:调整为|调整到|设置为|设为|改为|改成)?\s*"
+ rf"(?P{_NUMBER})\s*次"
+)
+
+
+def _number(value: str) -> float:
+ if re.fullmatch(r"[0-9]+(?:\.[0-9]+)?", value):
+ return float(value)
+ digits = {char: index for index, char in enumerate("零一二三四五六七八九")}
+ if value == "两":
+ return 2
+ if "十" in value:
+ tens, ones = value.split("十", 1)
+ if (tens and tens not in digits) or (ones and ones not in digits):
+ raise ValueError("请使用有效的整数编号或次数。")
+ return (digits[tens] if tens else 1) * 10 + (digits[ones] if ones else 0)
+ if value in digits:
+ return digits[value]
+ raise ValueError("请使用明确的阶段编号、持续时间或重复次数。")
+
+
+def edit_schedule(
+ schedule: ModeSchedule,
+ change_text: str,
+ *,
+ parse_operations: Callable[[str], list[dict[str, Any]]],
+ target_is_explicit: Callable[[str, str], bool],
+) -> ModeSchedule:
+ """Edit one/all phases; an unscoped differing trajectory needs clarification."""
+ text = change_text.strip()
+ if not text:
+ raise ValueError("请说明要修改的阶段、动作、时长或重复次数。")
+ if re.search(r"(?:秒钟|分钟|秒|s)(?:之后|以后|后|再)", text):
+ raise ValueError(
+ "阶段时间表示动作执行后的保持时长,请明确阶段动作和持续时间,不使用延时后执行的表述。"
+ )
+ source = schedule.to_dict()
+ selected: set[int] | None = None
+ phase_matches = list(_PHASE.finditer(text))
+ all_match = _ALL_PHASES.search(text)
+ if all_match:
+ if phase_matches:
+ raise ValueError("请分别修改单个阶段或全部阶段,不要混用阶段范围。")
+ selected = set(range(len(schedule.phases)))
+ text = _ALL_PHASES.sub("", text)
+ elif phase_matches:
+ indexes = {int(_number(match.group("number"))) - 1 for match in phase_matches}
+ if len(indexes) != 1 or any(
+ index < 0 or index >= len(schedule.phases) for index in indexes
+ ):
+ raise ValueError(f"当前只有{len(schedule.phases)}个阶段,请明确一次修改哪一个阶段。")
+ selected = indexes
+ text = _PHASE.sub("", text)
+ else:
+ named = {index for index, phase in enumerate(schedule.phases) if phase.name in text}
+ if len(named) > 1:
+ raise ValueError("请一次修改一个阶段,或明确指定全部阶段。")
+ if named:
+ selected = named
+ phase_name = schedule.phases[next(iter(named))].name
+ text = text.replace(phase_name + "阶段", "").replace(phase_name, "")
+
+ repeats = list(_REPEAT.finditer(text))
+ if repeats:
+ if len(repeats) != 1:
+ raise ValueError("请提供一个明确的整条时间轴重复次数。")
+ count = _number(repeats[0].group("number"))
+ if count != int(count):
+ raise ValueError("重复次数必须为整数。")
+ source["repeat_count"] = int(count)
+ text = _REPEAT.sub("", text)
+
+ holds = list(_HOLD.finditer(text))
+ if holds:
+ if len(holds) != 1 or selected is None:
+ raise ValueError("修改持续时间需要明确第几个阶段或全部阶段。")
+ seconds = _number(holds[0].group("number"))
+ if holds[0].group("unit") in {"分钟", "minute", "minutes", "min", "mins"}:
+ seconds *= 60
+ milliseconds = seconds * 1000
+ if milliseconds != int(milliseconds):
+ raise ValueError("阶段持续时间最多精确到毫秒。")
+ for index in selected:
+ source["phases"][index]["duration_ms"] = int(milliseconds)
+ text = _HOLD.sub("", text)
+
+ if re.search(rf"(?:{_NUMBER})\s*(?:分钟|秒钟|秒|minutes?|mins?|seconds?|secs?|s)", text):
+ raise ValueError("动态阶段只支持明确绝对设备目标;时长请单独表述为阶段保持时间,不支持按秒运动。")
+
+ text = re.sub(r"改为|改成|变更为|换成|变成|变为", "调整为", text)
+ text = re.sub(
+ r"^(?:把|将|的|中|里|阶段)+|(?:然后|并且|同时|并|,|,|;|;|。|\s)+$", "", text
+ ).strip("的中里,,;;。 ")
+ changes = parse_operations(text) if text else []
+ if not changes and not holds and not repeats:
+ raise ValueError("没有识别到可执行的阶段调整,请说明设备目标、阶段时长或重复次数。")
+ for raw_change in changes:
+ change = deepcopy(raw_change)
+ indexes = selected if selected is not None else set(range(len(schedule.phases)))
+ candidates = [
+ (index, operation)
+ for index in indexes
+ for operation in source["phases"][index]["operations"]
+ if operation["domain"] == change["domain"]
+ and operation["capability"] == change["capability"]
+ ]
+ if not target_is_explicit(text, change["target_scope"]):
+ targets = {operation["target_scope"] for _, operation in candidates}
+ if len(targets) > 1:
+ raise ValueError("该阶段的设备有多个区域,请明确主驾、副驾或具体目标区域。")
+ if targets:
+ change["target_scope"] = next(iter(targets))
+ candidates = [
+ (index, operation)
+ for index, operation in candidates
+ if operation["target_scope"] == change["target_scope"]
+ ]
+ if selected is None:
+ values = {
+ json.dumps(operation["parameters"], sort_keys=True, ensure_ascii=False)
+ for _, operation in candidates
+ }
+ if len(values) > 1:
+ raise ValueError("该设备在多个阶段使用不同目标,请明确第几个阶段,或说“全部阶段”。")
+ if not candidates:
+ raise ValueError("新增设备动作需要明确放入第几个阶段,或明确全部阶段。")
+ indexes = {index for index, _ in candidates}
+ key = (change["capability"], change["target_scope"])
+ for index in indexes:
+ operations = source["phases"][index]["operations"]
+ found = False
+ for position, operation in enumerate(operations):
+ if (operation["capability"], operation["target_scope"]) == key:
+ operations[position] = {
+ **deepcopy(operation),
+ "parameters": deepcopy(change["parameters"]),
+ }
+ found = True
+ if not found:
+ operations.append(deepcopy(change))
+ return ModeSchedule.from_dict(source)
+
+
+def revise_schedule(
+ service,
+ schedule: ModeSchedule,
+ *,
+ change_text: str,
+ utterance: str,
+ name: str,
+ conversation_id: str,
+ vehicle_profile,
+ context: dict[str, Any],
+ control_transport: str,
+ source_draft=None,
+ source_profile=None,
+) -> dict[str, Any]:
+ """Parse changed atoms locally, then recompile every phase before success."""
+
+ def parse_changes(text):
+ if any(
+ status != "complete"
+ for _clause, _parsed, status in service._local_clause_results(text, context)
+ ):
+ raise ValueError("阶段调整说明不完整,请使用设备和明确目标值;未修改原时间轴。")
+ result = service._process_once(
+ text,
+ "local",
+ conversation_id,
+ "dry_run",
+ None,
+ vehicle_profile.id,
+ control_transport=control_transport,
+ allow_fuzzy_mode_profile_match=False,
+ )
+ if (
+ result.get("status") != "succeeded"
+ or result.get("deferred_requests")
+ or result.get("unresolved_clauses")
+ or result.get("supplement_required")
+ or any(issue.get("severity") == "error" for issue in result.get("validation", []))
+ ):
+ raise ValueError("阶段动作未通过完整的设备能力或安全校验。")
+ operations = service._absolute_mode_operations(service._preview_mode_operations(result))
+ if not operations:
+ raise ValueError("未识别到可复用的绝对设备目标。")
+ return operations
+
+ revised = edit_schedule(
+ schedule,
+ change_text,
+ parse_operations=parse_changes,
+ target_is_explicit=service._mode_update_target_is_explicit,
+ )
+ preview = service._preview_mode_schedule(
+ revised,
+ name=name,
+ utterance=utterance,
+ conversation_id=conversation_id,
+ vehicle_profile=vehicle_profile,
+ control_transport=control_transport,
+ generation_source="draft_revision" if source_draft else "registered_mode_profile",
+ source_draft=source_draft,
+ source_profile=source_profile,
+ )
+ if preview.get("status") == "succeeded" and source_draft:
+ draft = preview["mode_profile_draft"]
+ draft["revises_source_request_id"] = source_draft["source_request_id"]
+ for key in ("source_template_id", "source_template_revision"):
+ if key in source_draft:
+ draft[key] = source_draft[key]
+ service._persist(preview)
+ return preview
+
+
+def update_saved_schedule(
+ service,
+ profile,
+ *,
+ change_text: str,
+ utterance: str,
+ conversation_id: str,
+ vehicle_profile,
+ context: dict[str, Any],
+ result: dict[str, Any],
+) -> dict[str, Any]:
+ try:
+ preview = revise_schedule(
+ service,
+ profile.schedule,
+ change_text=change_text,
+ utterance=utterance,
+ name=profile.name,
+ conversation_id=conversation_id,
+ vehicle_profile=vehicle_profile,
+ context=context,
+ control_transport="wired_can",
+ source_profile=profile,
+ )
+ if preview.get("status") != "succeeded":
+ return preview
+ draft = preview["mode_profile_draft"]
+ saved = service.save_mode_profile_card(
+ profile.id,
+ draft["name"],
+ draft["instruction"],
+ draft["source_request_id"],
+ vehicle_profile.id,
+ )
+ updated = service.mode_profiles.get_profile(profile.id, vehicle_profile.id)
+ report = service._mode_profile_control_report(
+ result["request_id"],
+ "update_mode_profile",
+ updated,
+ f"已更新“{profile.name}”的阶段时间轴;未向车辆下发控制。",
+ {"can_command_emitted": False, "phase_count": len(updated.schedule.phases)},
+ )
+ result.update(
+ status="succeeded",
+ execution_mode="dry_run",
+ hardware_execution_allowed=False,
+ orchestration_route="mode_profile_update",
+ provider_used="mode_schedule_editor",
+ mode_profile=saved["mode_profile"],
+ matched_modes=[saved["mode_profile"]],
+ schedule=updated.schedule.to_dict(),
+ execution_report=report,
+ validation=service.contracts.validate(report),
+ mode_profile_changes=[
+ deepcopy(op) for phase in updated.schedule.phases for op in phase.operations
+ ],
+ decision_summary=[
+ f"已更新“{profile.name}”的阶段时间轴,并完整重新校验全部阶段;未下发车辆控制。"
+ ],
+ )
+ except ValueError as exc:
+ result.update(
+ status="needs_confirmation",
+ execution_mode="dry_run",
+ hardware_execution_allowed=False,
+ orchestration_route="mode_profile_update",
+ validation=[
+ {
+ "severity": "error",
+ "code": "MODE_SCHEDULE_UPDATE_REJECTED",
+ "path": "schedule",
+ "message": str(exc),
+ }
+ ],
+ decision_summary=[f"未修改“{profile.name}”的时间轴:{exc}"],
+ )
+ service._persist(result)
+ return result
diff --git a/src/cognitive_agent/application/mode_schedule_service.py b/src/cognitive_agent/application/mode_schedule_service.py
new file mode 100644
index 0000000..0f98cd5
--- /dev/null
+++ b/src/cognitive_agent/application/mode_schedule_service.py
@@ -0,0 +1,238 @@
+"""Service integration for server-owned, fully validated phase schedules."""
+from __future__ import annotations
+
+import uuid
+from copy import deepcopy
+from datetime import UTC, datetime
+from typing import Any
+
+from cognitive_agent.application.mode_schedule import ModeSchedule
+
+
+def public_schedule_status(state: dict[str, Any]) -> dict[str, Any]:
+ """Expose lifecycle evidence, never internal errors, commands or CAN frames."""
+ keys = ("run_id", "vehicle_id", "session_id", "status", "phase_index", "repeat_index",
+ "executed_phases", "phase_count", "repeat_count", "elapsed_ms", "total_duration_ms",
+ "virtual_elapsed_ms", "completed", "cancel_reason")
+ public = {key: value for key in keys if isinstance((value := state.get(key)), (str, int, bool))}
+ if state.get("error"):
+ public["error"] = "阶段执行未通过校验或未返回成功结果,后续阶段已停止。"
+ return public
+
+
+def phase_plan(phase) -> dict[str, Any]:
+ operations = phase.operations
+ return {
+ "detected_domains": list(dict.fromkeys(op["domain"] for op in operations)),
+ "requests": [{**{key: op[key] for key in ("domain", "capability", "target_scope")},
+ "operation": "set", "duration_ms": None,
+ "parameters": [{"name": key, "value": value} for key, value in op["parameters"].items()],
+ "reason": op.get("reason") or "已校验时间轴阶段。"} for op in operations],
+ "ambiguities": [], "confidence": 1.0,
+ "decision_summary": ["按阶段保留动作顺序与持续时间。"],
+ }
+
+
+class ModeScheduleServiceMixin:
+ def _validated_schedule_artifact(self, result: dict[str, Any]) -> ModeSchedule | None:
+ draft = result.get("mode_profile_draft") or {}
+ raw = result.get("schedule")
+ if raw is None and draft.get("schedule") is None:
+ return None
+ schedule = ModeSchedule.from_dict(raw)
+ if draft.get("schedule") != schedule.to_dict() or draft.get("operation_count") != schedule.operation_count:
+ raise ValueError("时间轴草稿结构和服务端证据不一致,请重新生成。")
+ actions = (result.get("intent_ir") or {}).get("spec", {}).get("actions", [])
+ if len(actions) != schedule.operation_count:
+ raise ValueError("时间轴草稿动作证据不完整,请重新生成。")
+ offset = 0
+ for phase in schedule.phases:
+ phase_actions = actions[offset:offset + len(phase.operations)]
+ plan = phase_plan(phase)
+ if (self._scene_plan_signatures(plan) != self._scene_action_signatures(phase_actions)
+ or not self._scene_parameters_preserved(plan, phase_actions)):
+ raise ValueError("时间轴阶段目标与校验结果不一致,请重新生成。")
+ offset += len(phase.operations)
+ return schedule
+
+ def _preview_mode_schedule(
+ self, schedule: ModeSchedule, *, name: str, utterance: str,
+ conversation_id: str, vehicle_profile, control_transport: str,
+ generation_source: str, source_profile=None, source_template=None,
+ source_draft: dict[str, Any] | None = None,
+ scene_scope_utterance: str | None = None,
+ ) -> dict[str, Any]:
+ results = []
+ for phase in schedule.phases:
+ plan = phase_plan(phase)
+ preview = self._process_once(
+ f"进入{name}", "local", conversation_id, "dry_run", None,
+ vehicle_profile.id, control_transport=control_transport,
+ assistant_plan=plan, allow_scene_composition=True,
+ allow_fuzzy_mode_profile_match=False,
+ # An existing-name binding is not evidence that a newly
+ # supplied model plan came from the saved profile. New builds
+ # pass the original request even when reusing a saved name.
+ scene_scope_utterance=(scene_scope_utterance if scene_scope_utterance is not None
+ else "" if source_draft or source_profile else utterance),
+ )
+ actions = (preview.get("intent_ir") or {}).get("spec", {}).get("actions", [])
+ complete = (preview.get("status") == "succeeded"
+ and self._scene_plan_signatures(plan) == self._scene_action_signatures(actions)
+ and self._scene_parameters_preserved(plan, actions))
+ if not complete:
+ preview["status"] = "validation_failed"
+ self._scrub_failed_scene_preview(preview)
+ preview["mode_creation_failure"] = {
+ "code": "MODE_SCHEDULE_PHASE_INVALID",
+ "reason": f"时间轴阶段“{phase.name}”未完整通过设备能力和安全校验",
+ "next_step": "请调整该阶段的设备目标;没有保存或执行任何阶段",
+ }
+ preview["validation"] = [*preview.get("validation", []), {
+ "severity": "error", "code": "MODE_SCHEDULE_PHASE_INVALID",
+ "path": f"schedule.phases.{phase.id}",
+ "message": preview["mode_creation_failure"]["reason"],
+ }]
+ self._persist(preview)
+ return preview
+ results.append(preview)
+ result = deepcopy(results[0])
+ request_id = f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}"
+ instruction = f"进入{name}"
+ result.update(request_id=request_id, utterance=instruction, status="succeeded",
+ execution_mode="dry_run", hardware_execution_allowed=False,
+ orchestration_route="mode_profile_create_preview")
+ actions = []
+ for phase, preview in zip(schedule.phases, results, strict=True):
+ for original in preview["intent_ir"]["spec"]["actions"]:
+ action = deepcopy(original)
+ if "id" in action:
+ action["id"] = f"{phase.id}-{action['id']}"
+ actions.append(action)
+ result["intent_ir"]["spec"]["actions"] = actions
+ for field in ("function_configs", "command_plans"):
+ result[field] = {f"{phase.id}-{key}": deepcopy(value)
+ for phase, preview in zip(schedule.phases, results, strict=True)
+ for key, value in preview[field].items()}
+ result["function_config"] = None
+ result["command_plan"] = None
+ result["schedule"] = schedule.to_dict()
+ result["schedule_phase_previews"] = [
+ {"phase_id": phase.id, "source_request_id": preview["request_id"],
+ "status": preview["status"], "duration_ms": phase.duration_ms}
+ for phase, preview in zip(schedule.phases, results, strict=True)
+ ]
+ binding = ({"existing_mode_id": source_profile.id,
+ "existing_mode_revision": self.mode_profiles.profile_revision(source_profile)}
+ if source_profile is not None else {})
+ if source_draft:
+ binding = {key: source_draft[key] for key in ("existing_mode_id", "existing_mode_revision") if key in source_draft}
+ result["mode_profile_draft"] = {
+ "name": name, "instruction": instruction, "source_request_id": request_id,
+ "vehicle_id": vehicle_profile.id, "operation_count": schedule.operation_count,
+ "domains": sorted({op["domain"] for phase in schedule.phases for op in phase.operations}),
+ "generation_source": generation_source, "schedule": schedule.to_dict(),
+ "draft_revision": int((source_draft or {}).get("draft_revision", 0)) + 1,
+ **binding,
+ **({"source_template_id": source_template.id, "source_template_revision": source_template.revision}
+ if source_template is not None else {}),
+ }
+ result["mode_creation"] = {"requested_utterance": utterance, "source_utterance": instruction,
+ "source_status": "succeeded", "operation_count": schedule.operation_count}
+ result["decision_summary"] = [
+ f"已逐阶段验证“{name}”:{len(schedule.phases)}个阶段,重复{schedule.repeat_count}次,总计{schedule.total_duration_ms // 1000}秒。",
+ "当前仅生成草稿;保存后进入模式才开始计时执行。停止会取消后续阶段,已应用的设备状态保持。",
+ ]
+ self._persist(result)
+ return result
+
+ def _run_mode_schedule(
+ self, profile, *, utterance: str, conversation_id: str | None,
+ execution_mode: str, approval_token: str | None, vehicle_id: str,
+ simulation_fault: str, hardware_client_id: str | None,
+ hardware_access_bypass: bool, control_transport: str,
+ ) -> dict[str, Any]:
+ schedule = ModeSchedule.from_dict(profile.schedule.to_dict())
+ requested, supported = self._explicit_mode_seat_scopes(profile, utterance)
+ if requested and not requested.issubset(supported):
+ raise ValueError("该动态模式不支持指定座位,不会替换或扩大控制目标。")
+ if requested and requested != supported:
+ document = schedule.to_dict()
+ for phase in document["phases"]:
+ phase["operations"] = [op for op in phase["operations"]
+ if op["domain"] != "seat" or op["target_scope"] in requested]
+ # An empty phase is not silently removed: that would alter timing.
+ schedule = ModeSchedule.from_dict(document)
+ conversation_id = conversation_id or f"conv-{uuid.uuid4().hex}"
+ vehicle = self.vehicle_profiles.get(vehicle_id)
+ # Revalidate the entire saved schedule before the first write. No stale
+ # cached command bytes or client-authored phase plans are replayed.
+ preview = self._preview_mode_schedule(
+ schedule, name=profile.name, utterance="已保存时间轴复核",
+ conversation_id=conversation_id, vehicle_profile=vehicle,
+ control_transport=control_transport, generation_source="registered_mode_profile",
+ source_profile=profile,
+ )
+ if preview["status"] != "succeeded":
+ return preview
+
+ def execute_phase(phase):
+ context = self._context_for_vehicle(self.conversations.snapshot(conversation_id), vehicle)
+ parsed, _, _ = self._interpret(
+ "执行已验证的时间轴阶段", "local", context, vehicle,
+ assistant_plan=phase_plan(phase), allow_scene_composition=True,
+ scene_scope_utterance="",
+ )
+ return self.process(
+ "执行已验证的时间轴阶段", provider="local", conversation_id=conversation_id,
+ execution_mode=execution_mode, approval_token=approval_token,
+ vehicle_id=vehicle_id, simulation_fault=simulation_fault,
+ hardware_client_id=hardware_client_id, hardware_access_bypass=hardware_access_bypass,
+ control_transport=control_transport, _direct_can_batch=([], parsed),
+ )
+
+ if execution_mode == "hardware":
+ # Authentication/bench ownership is checked now and again for each
+ # phase, so a later lease loss stops further device commands.
+ if not hardware_access_bypass and control_transport != "wireless_mqtt" and not self._driver_only_vehicle_execution_active(control_transport):
+ self.hardware_access.ensure_authorized(hardware_client_id)
+ execution = self.mode_schedule_runner.start(vehicle_id, schedule, execute_phase, session_id=conversation_id)
+ else:
+ execution = self.mode_schedule_runner.simulate(schedule, execute_phase)
+ result = deepcopy(preview)
+ result.pop("mode_profile_draft", None)
+ result.update(
+ status="running" if execution["status"] == "running" else "succeeded" if execution.get("completed") else "failed",
+ utterance=utterance, execution_mode=execution_mode,
+ hardware_execution_allowed=execution_mode == "hardware",
+ orchestration_route="mode_schedule_execute", schedule_execution=execution,
+ mode_profile=profile.public_dict(), matched_modes=[profile.public_dict()],
+ )
+ result["decision_summary"] = [
+ f"已启动“{profile.name}”时间轴。" if execution["status"] == "running"
+ else f"“{profile.name}”已按虚拟时钟验证全部阶段。" if execution.get("completed")
+ else "时间轴执行失败,后续阶段已停止。",
+ "停止动态模式可取消后续阶段;已下发的设备目标不会自动回滚。",
+ ]
+ self._persist(result)
+ return result
+
+ def _cancel_mode_schedule(self, vehicle_id: str, conversation_id: str | None, utterance: str,
+ execution_mode: str) -> dict[str, Any]:
+ state = (self.mode_schedule_runner.cancel(vehicle_id) if execution_mode == "hardware"
+ else {"status": "simulated", "completed": True, "vehicle_id": vehicle_id})
+ result = {
+ "request_id": f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}",
+ "status": "succeeded", "execution_mode": execution_mode, "hardware_execution_allowed": False,
+ "vehicle": {"id": vehicle_id},
+ "utterance": utterance, "conversation_id": conversation_id or f"conv-{uuid.uuid4().hex}",
+ "orchestration_route": "mode_schedule_stop", "schedule_execution": state,
+ "decision_summary": [
+ "已模拟停止动态模式,未改变正在运行的车辆任务。" if execution_mode != "hardware"
+ else "已停止动态模式后续阶段。已下发目标保持,不自动回滚。" if state.get("status") == "cancelled"
+ else "当前没有正在执行的动态时间轴。"
+ ],
+ "validation": [], "trace_events": [],
+ }
+ self._persist(result)
+ return result
diff --git a/src/cognitive_agent/application/scene_composer.py b/src/cognitive_agent/application/scene_composer.py
index adc430e..7685401 100644
--- a/src/cognitive_agent/application/scene_composer.py
+++ b/src/cognitive_agent/application/scene_composer.py
@@ -5,6 +5,7 @@ from __future__ import annotations
import json
import math
from collections.abc import Mapping, Sequence
+from copy import deepcopy
from itertools import combinations
from typing import Any
@@ -13,8 +14,13 @@ from cognitive_agent.application.assistant_plan_guard import (
MAX_PLAN_PARAMETERS,
MAX_PLAN_REQUESTS,
)
+from cognitive_agent.application.mode_creation_seat_policy import (
+ creation_seat_scopes,
+ normalized_seat_scope,
+)
+from cognitive_agent.application.mode_schedule import ModeSchedule
-SCENE_PLANNER_VERSION = "n7web-scene-composer/v3"
+SCENE_PLANNER_VERSION = "n7web-scene-composer/v5"
_MAX_OUTPUT_SCHEMA_BYTES = 64 * 1024
_JSON_PARAMETER_TYPES = frozenset({"boolean", "integer", "number", "string"})
@@ -50,8 +56,14 @@ the complete live registry, parameters, compiler, safety policy, and dry-run res
The primary user need must be represented in the actions. For sitting comfort,
seating relaxation or rest, consider supported seat functions before auxiliary
climate and lights; climate and lights alone do not fulfil a seating request.
-For an unqualified front-seat comfort scene, consider both driver and passenger
-using each seat's own listed capabilities. Respect an explicitly named seat.
+Mode creation defaults to the passenger seat only. Include driver seat actions
+only when the user explicitly selects the driver; in that case omit passenger
+seat actions. Include both seats only when explicitly requested. Respect seat
+exclusions. Seat selection does not exclude unrelated cabin devices or confuse
+a driver/passenger HVAC zone with a request to move that seat. Never reinterpret
+rear or unknown seats as either front seat. The seat_scope_policy in the context
+is authoritative. Use each selected seat's own listed capabilities; never
+retarget a passenger capability to the driver or vice versa.
Driver heating/ventilation native levels use 2 for low and 1 for high; passenger
levels use 1 for low. Driver massage supports action=mode with one listed mode,
action=strength with strength, or action=off. Do not mix these action parameters.
@@ -62,11 +74,59 @@ is unavailable, return an ambiguity rather than an unrelated cabin-only scene.
Return only the JSON object required by the output schema.
"""
+SCHEDULE_COMPOSER_INSTRUCTIONS = SCENE_COMPOSER_INSTRUCTIONS + """
+This request requires a bounded temporal mode, not one static final state.
+The static root shape above is replaced by the phase schema below. Capability
+uniqueness and the 12-action limit apply within each phase, not across phases.
+The ban on timed actions means motor-duration commands, not bounded phase holds.
+Return an empty root requests array and a schedule containing 2-8 ordered phases.
+Each phase has id, name, duration_ms and requests using the same capability contract.
+Hold a phase for its duration, then execute the next phase; repeat_count is 1-20.
+Every phase lasts 1 second to 15 minutes; total duration including repeats <= 1 hour.
+Use a finite, conservative timeline. Boarding warmup should first warm the cabin
+then reduce temperature/fan for comfort. Seat dynamic modes must apply the same
+selected seat scope in every phase: default passenger absolute targets, or driver
+comfort states if the user selected the driver; never invent driver axes or add
+the other seat merely to make the timeline dynamic.
+Respect explicit user temperatures, times, repetitions, seat scopes and exclusions.
+Do not collapse successive values of the same device into a single final value.
+No unattended calendar scheduling or wakeup is supported. Stop cancels future phases;
+already applied states remain. If the requested timing is unsupported, give ambiguity.
+"""
+
class SceneCompositionError(ValueError):
"""Raised when a model scene candidate cannot enter the local safety chain."""
+def scene_context_for_mode_creation(
+ context: Mapping[str, object], message: str,
+) -> dict[str, object]:
+ """Constrain model seat choices without hiding unrelated cabin capabilities."""
+
+ projected = deepcopy(dict(context))
+ scopes = creation_seat_scopes(message)
+ capabilities = []
+ for capability in projected.get("capabilities", []):
+ if not isinstance(capability, dict):
+ continue
+ if capability.get("domain") != "seat":
+ capabilities.append(capability)
+ continue
+ selected_targets = [target for target in capability.get("target_scopes", [])
+ if normalized_seat_scope(target) in scopes]
+ if selected_targets:
+ capability["target_scopes"] = selected_targets
+ capabilities.append(capability)
+ projected["capabilities"] = capabilities
+ projected["seat_scope_policy"] = {
+ "selected_scopes": sorted(scopes),
+ "default_scope": "passenger",
+ "applies_to": "seat capabilities in every phase",
+ }
+ return projected
+
+
def _is_relative_parameter(parameter: Mapping[str, object]) -> bool:
"""Return whether a parameter describes a transient or relative movement."""
@@ -229,7 +289,7 @@ def _capability_schema_branch(capability: Mapping[str, object]) -> dict[str, obj
}
-def build_scene_output_schema(context: Mapping[str, object]) -> dict[str, object]:
+def build_scene_output_schema(context: Mapping[str, object], *, dynamic: bool = False) -> dict[str, object]:
"""Build a compact strict schema from the projected scene capability data."""
raw_capabilities = context.get("capabilities", [])
@@ -267,6 +327,27 @@ def build_scene_output_schema(context: Mapping[str, object]) -> dict[str, object
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
},
}
+ if dynamic:
+ phase_requests = schema["properties"]["requests"]
+ schema["properties"]["requests"] = {"type": "array", "maxItems": 0, "items": {"type": "string"}}
+ schema["required"].append("schedule")
+ schema["properties"]["schedule"] = {
+ "anyOf": [{"type": "null"}, {
+ "type": "object", "additionalProperties": False,
+ "required": ["repeat_count", "phases"],
+ "properties": {
+ "repeat_count": {"type": "integer", "minimum": 1, "maximum": 20},
+ "phases": {"type": "array", "minItems": 2, "maxItems": 8,
+ "items": {"type": "object", "additionalProperties": False,
+ "required": ["id", "name", "duration_ms", "requests"],
+ "properties": {
+ "id": {"type": "string"}, "name": {"type": "string"},
+ "duration_ms": {"type": "integer", "minimum": 1000, "maximum": 900000},
+ "requests": phase_requests,
+ }}},
+ },
+ }],
+ }
encoded_schema = json.dumps(
schema,
ensure_ascii=False,
@@ -280,9 +361,41 @@ def build_scene_output_schema(context: Mapping[str, object]) -> dict[str, object
def normalize_scene_output(
value: Mapping[str, Any],
context: Mapping[str, object],
+ *, dynamic: bool = False,
) -> dict[str, Any]:
"""Expand the lean model response into the existing guarded plan contract."""
+ if dynamic:
+ if not isinstance(value, Mapping) or set(value) != {"requests", "ambiguities", "confidence", "schedule"}:
+ raise SceneCompositionError("dynamic scene response must include a schedule")
+ if value["requests"]:
+ raise SceneCompositionError("dynamic scene must not mix static root requests")
+ base = normalize_scene_output({key: value[key] for key in ("requests", "ambiguities", "confidence")}, context)
+ raw_schedule = value["schedule"]
+ if raw_schedule is None:
+ return base
+ if not isinstance(raw_schedule, Mapping) or set(raw_schedule) != {"repeat_count", "phases"}:
+ raise SceneCompositionError("invalid dynamic scene schedule")
+ phases = []
+ if not isinstance(raw_schedule["phases"], list):
+ raise SceneCompositionError("dynamic phases must be an array")
+ for phase in raw_schedule["phases"]:
+ if not isinstance(phase, Mapping) or set(phase) != {"id", "name", "duration_ms", "requests"}:
+ raise SceneCompositionError("invalid dynamic phase")
+ normalized = normalize_scene_output({"requests": phase["requests"], "ambiguities": [], "confidence": value["confidence"]}, context)
+ operations = [{"domain": op["domain"], "capability": op["capability"],
+ "target_scope": op["target_scope"], "operation": "set",
+ "parameters": {p["name"]: p["value"] for p in op["parameters"]},
+ "reason": "经过时间轴编排的候选状态。"} for op in normalized["requests"]]
+ phases.append({"id": phase["id"], "name": phase["name"], "duration_ms": phase["duration_ms"], "operations": operations})
+ try:
+ schedule = ModeSchedule.from_dict({"schema": "cognitive-agent-mode-schedule/v1", "repeat_count": raw_schedule["repeat_count"], "phases": phases})
+ except (TypeError, ValueError) as exc:
+ raise SceneCompositionError(str(exc)) from exc
+ first = normalize_scene_output({"requests": raw_schedule["phases"][0]["requests"], "ambiguities": value["ambiguities"], "confidence": value["confidence"]}, context)
+ first["schedule"] = schedule.to_dict()
+ return first
+
if not isinstance(value, Mapping) or set(value) != {
"requests",
"ambiguities",
diff --git a/src/cognitive_agent/application/scene_templates.py b/src/cognitive_agent/application/scene_templates.py
index 36bae32..d5c0e8b 100644
--- a/src/cognitive_agent/application/scene_templates.py
+++ b/src/cognitive_agent/application/scene_templates.py
@@ -24,11 +24,13 @@ from cognitive_agent.application.assistant_plan_guard import (
SCENE_COMPOSABLE_CAPABILITIES,
)
from cognitive_agent.application.mode_profile import ModeOperation
+from cognitive_agent.application.mode_profile_intent import mode_creation_source_name
+from cognitive_agent.application.mode_schedule import ModeSchedule
_TEMPLATE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
_SURROUNDING_PUNCTUATION = " \t\r\n,。,.!!??;;::'\"‘’“”《》<>【】[]()()"
_DOCUMENT_FIELDS = frozenset(
- {"id", "name", "aliases", "description", "icon", "operations"}
+ {"id", "name", "aliases", "description", "icon", "operations", "schedule"}
)
@@ -66,6 +68,7 @@ class SceneTemplate:
operations: tuple[ModeOperation, ...]
source: str
revision: str
+ schedule: ModeSchedule | None = None
@classmethod
def from_yaml(cls, path: Path) -> "SceneTemplate":
@@ -100,7 +103,13 @@ class SceneTemplate:
alias_keys.add(key)
aliases.append(alias)
+ schedule = (ModeSchedule.from_dict(document["schedule"])
+ if document.get("schedule") is not None else None)
raw_operations = document.get("operations")
+ if schedule is not None:
+ if raw_operations:
+ raise ValueError(f"{path} cannot mix static operations and a schedule")
+ raw_operations = list(schedule.phases[0].operations)
if not isinstance(raw_operations, list) or not raw_operations:
raise ValueError(f"{path} must contain at least one operation")
if len(raw_operations) > MAX_PLAN_REQUESTS:
@@ -155,6 +164,8 @@ class SceneTemplate:
),
),
}
+ if schedule is not None:
+ semantic_document["schedule"] = schedule.to_dict()
canonical = json.dumps(
semantic_document,
ensure_ascii=False,
@@ -171,6 +182,7 @@ class SceneTemplate:
operations=tuple(operations),
source=str(path),
revision=revision,
+ schedule=schedule,
)
def plan(self) -> dict[str, Any]:
@@ -179,7 +191,7 @@ class SceneTemplate:
request = operation.to_request(self.name)
request["duration_ms"] = None
requests.append(request)
- return {
+ plan = {
"detected_domains": list(
dict.fromkeys(operation.domain for operation in self.operations)
),
@@ -195,6 +207,9 @@ class SceneTemplate:
"template_revision": self.revision,
},
}
+ if self.schedule is not None:
+ plan["schedule"] = self.schedule.to_dict()
+ return plan
def public_dict(self, *, include_plan: bool = False) -> dict[str, Any]:
result: dict[str, Any] = {
@@ -204,13 +219,16 @@ class SceneTemplate:
"description": self.description,
"icon": self.icon,
"domains": list(dict.fromkeys(item.domain for item in self.operations)),
- "operation_count": len(self.operations),
+ "operation_count": self.schedule.operation_count if self.schedule else len(self.operations),
"template_revision": self.revision,
"origin": "builtin_scene_template",
"read_only": True,
}
if include_plan:
result["plan"] = self.plan()
+ if self.schedule is not None:
+ result["schedule"] = self.schedule.to_dict()
+ result["domains"] = sorted({op["domain"] for phase in self.schedule.phases for op in phase.operations})
return result
@@ -257,7 +275,11 @@ class SceneTemplateRegistry:
def resolve_exact_name(self, value: object) -> SceneTemplate | None:
key = normalize_scene_template_name(value)
- return self._by_name.get(key) if key else None
+ exact = self._by_name.get(key) if key else None
+ if exact is not None:
+ return exact
+ subject = normalize_scene_template_name(mode_creation_source_name(value))
+ return self._by_name.get(subject) if subject and subject != key else None
def get_template(self, template_id: str) -> SceneTemplate:
try:
diff --git a/src/cognitive_agent/application/schedule_intent.py b/src/cognitive_agent/application/schedule_intent.py
new file mode 100644
index 0000000..7d50ce0
--- /dev/null
+++ b/src/cognitive_agent/application/schedule_intent.py
@@ -0,0 +1,25 @@
+"""Narrow detection of temporal scene needs; never authorizes execution."""
+from __future__ import annotations
+
+import re
+
+_TEMPORAL = re.compile(
+ r"动态|循环|分阶段|随时间|交替|轮流|每隔|预热|预冷|"
+ r"(?:秒|分钟|分|小时)(?:钟)?(?:后|再)|持续.{0,8}(?:秒|分钟|小时)"
+)
+_STOP = re.compile(
+ r"^(?:请|帮我|立即|马上|先)?(?:停止|停下|结束|取消|关闭)"
+ r"(?:当前|正在执行的|正在运行的|这个|该)?(?:座椅|空调|功能)?(?:动态|循环|分阶段|时间轴|定时)"
+ r"(?:座椅|空调|功能)?(?:模式|编排|任务)?[。!!\s]*$"
+)
+
+
+def needs_mode_schedule(text: str) -> bool:
+ # HVAC recirculation is a stable device enum, not a timed repetition.
+ normalized = re.sub(r"内外循环|内循环|外循环|自动循环|新风循环|循环风", "空气循环状态", text)
+ normalized = normalized.replace("空气循环状态", "空气状态")
+ return bool(_TEMPORAL.search(normalized))
+
+
+def is_schedule_stop(text: str) -> bool:
+ return bool(_STOP.fullmatch(text.strip()))
diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py
index 72d702a..ef01468 100755
--- a/src/cognitive_agent/application/seat_service.py
+++ b/src/cognitive_agent/application/seat_service.py
@@ -47,17 +47,27 @@ from cognitive_agent.application.direct_can_control import ( # noqa: E402
build_direct_can_intent,
)
from cognitive_agent.application.domain_runtime import ConfiguredDomainRuntime # noqa: E402
+from cognitive_agent.application.mode_creation_seat_policy import ( # noqa: E402
+ ModeCreationSeatPolicyError,
+ creation_seat_scopes,
+ project_seed_operations,
+ project_seed_plan,
+ project_seed_schedule,
+)
from cognitive_agent.application.mode_profile import ( # noqa: E402
ModeProfile,
ModeProfileRegistry,
)
from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
is_mode_profile_query,
+ mode_creation_source_name,
normalize_mode_profile_name,
parse_mode_profile_create,
parse_mode_profile_delete,
parse_mode_profile_update,
)
+from cognitive_agent.application.mode_schedule import ModeSchedule, ModeScheduleRunner # noqa: E402
+from cognitive_agent.application.mode_schedule_service import ModeScheduleServiceMixin # noqa: E402
from cognitive_agent.application.model_plan_adapter import ( # noqa: E402
APPLY_PRESET_CAPABILITY,
DYNAMIC_MODE_STOP_CAPABILITY,
@@ -90,6 +100,10 @@ from cognitive_agent.application.native_can_runtime import ( # noqa: E402
NativeCanRuntime,
)
from cognitive_agent.application.scene_templates import SceneTemplateRegistry # noqa: E402
+from cognitive_agent.application.schedule_intent import ( # noqa: E402
+ is_schedule_stop,
+ needs_mode_schedule,
+)
from cognitive_agent.application.seat_calibration import position_pct_to_angle # noqa: E402
from cognitive_agent.application.semantic_ir import ( # noqa: E402
apply_language_safety_guards,
@@ -331,8 +345,9 @@ DYNAMIC_MODE_WORDS = (
)
-class SeatDemoService:
+class SeatDemoService(ModeScheduleServiceMixin):
def __init__(self, repository_root: Path = REPOSITORY_ROOT):
+ self.mode_schedule_runner = ModeScheduleRunner()
self.root = repository_root
self.domain_root = repository_root / "domains" / "seat"
self.contracts = ContractValidator(repository_root)
@@ -663,7 +678,7 @@ class SeatDemoService:
resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id
profile = self.mode_profiles.get_profile(mode_id, resolved_vehicle_id)
- if not profile.command_flow or not profile.command_flow.get("plans"):
+ if profile.schedule is None and (not profile.command_flow or not profile.command_flow.get("plans")):
raise ValueError("该模式尚未生成可直接执行的控制指令流。")
result = self.process(
profile.instruction or f"进入{profile.name}",
@@ -678,6 +693,8 @@ class SeatDemoService:
control_transport=control_transport,
_direct_mode_profile_id=profile.id,
)
+ if profile.schedule is not None:
+ return result
public_profile = profile.public_dict()
result["provider_used"] = "saved_mode_command_flow"
result["utterance"] = profile.name
@@ -796,7 +813,7 @@ class SeatDemoService:
):
raise ValueError("功能模式草稿未通过安全校验,请重新生成。")
- return {
+ trial = {
"request_id": clean_request_id,
"status": "succeeded",
"execution_mode": "dry_run",
@@ -809,6 +826,14 @@ class SeatDemoService:
"validation": deepcopy(validation),
"trial_source": "server_validated_preview_artifact",
}
+ schedule = self._validated_schedule_artifact(source_result)
+ if schedule is not None:
+ trial["schedule"] = schedule.to_dict()
+ trial["schedule_execution"] = self.mode_schedule_runner.simulate(
+ schedule, lambda phase: {"status": "succeeded", "phase_id": phase.id,
+ "operations": deepcopy(list(phase.operations))},
+ )
+ return trial
def _persist_mode_profile_card(
self,
@@ -1007,6 +1032,11 @@ class SeatDemoService:
# generation workflow.
"execution_steps": [],
}
+ schedule = self._validated_schedule_artifact(source_result)
+ if profile is not None and profile.schedule is not None and schedule is None:
+ raise ValueError("动态模式更新必须保留完整时间轴,请按阶段重新生成预览。")
+ if schedule is not None:
+ operations = [deepcopy(op) for phase in schedule.phases for op in phase.operations]
saved = self.mode_profiles.save_mode(
clean_name,
operations,
@@ -1022,6 +1052,7 @@ class SeatDemoService:
instruction=clean_instruction,
command_flow=command_flow,
timeline=profile.timeline if profile is not None else None,
+ schedule=schedule,
expected_revision=draft_existing_mode_revision or None,
create_only=not bool(clean_mode_id),
)
@@ -1220,6 +1251,7 @@ class SeatDemoService:
self._vehicle_mqtt_transport.start()
def close(self) -> None:
+ self.mode_schedule_runner.close()
if self._vehicle_state_monitor is not None:
self._vehicle_state_monitor.close()
self._vehicle_mqtt_transport.close()
@@ -2076,6 +2108,32 @@ class SeatDemoService:
or mode_profile_query
):
execution_mode = "dry_run"
+ schedule_requested = "模式" in utterance or _direct_mode_profile_id or is_schedule_stop(utterance)
+ schedule_vehicle_id = vehicle_id or (self.vehicle_profiles.default().id if schedule_requested else "")
+ if execution_mode not in {"dry_run", "simulated_hardware", "hardware"}:
+ raise ValueError(f"unsupported execution mode: {execution_mode}")
+ if is_schedule_stop(utterance):
+ return self._cancel_mode_schedule(schedule_vehicle_id, conversation_id, utterance, execution_mode)
+ schedule_profile = (self.mode_profiles.get_profile(_direct_mode_profile_id, schedule_vehicle_id)
+ if _direct_mode_profile_id else None)
+ if schedule_requested and schedule_profile is None and not (draft_update or mode_delete_name or mode_profile_query or parse_mode_profile_create(utterance)):
+ candidates = self.mode_profiles.match_text(utterance, vehicle_id=schedule_vehicle_id)
+ if len(candidates) == 1 and candidates[0].schedule is not None:
+ aliases = "|".join(re.escape(name) for name in (candidates[0].name, *candidates[0].aliases))
+ if re.fullmatch(
+ r"(?:(?:请|麻烦|帮我|帮忙|给我|我想|我要|想要)\s*){0,4}"
+ r"(?:(?:主驾|副驾|驾驶位|副驾驶|主驾驶)(?:座椅)?\s*)?"
+ r"(?:进入|启动|执行|开启|启用|运行|打开|调用|应用|使用|切换到)\s*"
+ rf"(?:{aliases})(?:吧|一下)?[。!!\s]*", utterance.strip(),
+ ):
+ schedule_profile = candidates[0]
+ if schedule_profile is not None and schedule_profile.schedule is not None:
+ return self._run_mode_schedule(
+ schedule_profile, utterance=utterance, conversation_id=conversation_id,
+ execution_mode=execution_mode, approval_token=approval_token, vehicle_id=schedule_vehicle_id,
+ simulation_fault=simulation_fault, hardware_client_id=hardware_client_id,
+ hardware_access_bypass=hardware_access_bypass, control_transport=control_transport,
+ )
if execution_mode != "hardware":
return self._process_once(
utterance,
@@ -2196,6 +2254,7 @@ class SeatDemoService:
allow_fuzzy_mode_profile_match: bool = True,
allow_scene_composition: bool = False,
scene_scope_utterance: str | None = None,
+ mode_creation_scopes: frozenset[str] | None = None,
mode_profile_draft_context: dict[str, Any] | None = None,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
@@ -2454,6 +2513,8 @@ class SeatDemoService:
mode_requests = self.mode_profiles.requests_for_profiles(
[direct_mode_profile]
)
+ if mode_creation_scopes is not None:
+ mode_requests = project_seed_operations(mode_requests, mode_creation_scopes)
detected_domains = list(
dict.fromkeys(
str(item.get("domain") or "")
@@ -3687,6 +3748,16 @@ class SeatDemoService:
) -> dict[str, Any]:
if not matched_modes:
return parsed
+ if any(mode.schedule is not None for mode in matched_modes):
+ rejected = deepcopy(parsed)
+ for key in ("actions", "domain_actions", "native_can_actions", "preset_actions",
+ "preset_events", "deferred_requests", "model_requests"):
+ rejected[key] = []
+ rejected["ambiguities"] = [{
+ "field": "mode_profile",
+ "question": "动态模式必须通过时间轴执行,请独立输入“进入模式名称”;不会将各阶段压平执行。",
+ }]
+ return rejected
if self._contains_seat_preset_request(parsed):
return parsed
if len(matched_modes) > 1:
@@ -3767,6 +3838,19 @@ class SeatDemoService:
return parsed
mode_requests = self.mode_profiles.requests_for_profiles(matched_modes)
+ selected_seat_scope = self._explicit_mode_seat_selection(
+ matched_modes[0], utterance,
+ )
+ if selected_seat_scope is not None:
+ # A cabin profile can carry independent comfort operations for both
+ # front seats. An explicit single-seat invocation selects only that
+ # seat; it must not authorize its sibling or mutate the stored mode.
+ mode_requests = [
+ request for request in mode_requests
+ if request["domain"] != "seat"
+ or self._normalized_mode_seat_scope(request["target_scope"])
+ in selected_seat_scope
+ ]
detected_domains = []
for request in mode_requests:
if request["domain"] not in detected_domains:
@@ -3787,6 +3871,24 @@ class SeatDemoService:
context,
vehicle_profile,
)
+ # Native-only comfort capabilities are represented as deferred items by
+ # the generic adapter. A verified stored mode must materialize them too;
+ # otherwise a successful named-mode run silently drops its driver seat.
+ mode_native_actions = []
+ for item in mode_parsed.get("deferred_requests", []):
+ if item.get("execution_backend") != "native_can":
+ continue
+ action = {
+ "domain": item["domain"],
+ "capability": item["capability"],
+ "target": item["target_scope"],
+ "parameters": deepcopy(item.get("parameters", {})),
+ "operation": item.get("desired_change", "set"),
+ "reason": item.get("reason", ""),
+ "execution_backend": "native_can",
+ }
+ if self.native_can_runtime.supports(action):
+ mode_native_actions.append(action)
parsed_has_explicit_request = bool(
parsed.get("actions")
or parsed.get("domain_actions")
@@ -3803,6 +3905,10 @@ class SeatDemoService:
mode_parsed.get("domain_actions", []),
parsed.get("domain_actions", []),
)
+ merged["native_can_actions"] = self._merge_control_actions(
+ mode_native_actions,
+ parsed.get("native_can_actions", []),
+ )
for key in (
"preset_actions",
"preset_events",
@@ -3834,13 +3940,35 @@ class SeatDemoService:
)
return merged
- @staticmethod
+ @classmethod
def _explicit_mode_target_conflict(
+ cls,
profile: ModeProfile,
utterance: str,
) -> tuple[str, tuple[str, ...]] | None:
"""Reject a named mode when its persisted seat scope contradicts text."""
+ requested, normalized_scopes = cls._explicit_mode_seat_scopes(profile, utterance)
+ if requested is None or requested == normalized_scopes:
+ return None
+ if cls._explicit_mode_seat_selection(profile, utterance) is not None:
+ return None
+ return ",".join(sorted(requested)), tuple(sorted(normalized_scopes))
+
+ @staticmethod
+ def _normalized_mode_seat_scope(scope: str) -> str:
+ return (
+ "driver" if scope == "front_left" else
+ "passenger" if scope == "front_right" else
+ "rear" if scope in {"rear", "rear_left", "rear_right"} else scope
+ )
+
+ @classmethod
+ def _explicit_mode_seat_scopes(
+ cls,
+ profile: ModeProfile,
+ utterance: str,
+ ) -> tuple[frozenset[str] | None, set[str]]:
aliases = (profile.id, profile.name, *profile.aliases)
mode_clause = next(
(
@@ -3856,23 +3984,34 @@ class SeatDemoService:
utterance,
)
requested = resolve_explicit_seat_scopes(
- mode_clause,
+ mode_clause.replace("主副驾", "主驾和副驾"),
assume_seat_context=True,
)
- if requested is None:
- return None
-
normalized_scopes = {
- "driver" if operation.target_scope == "front_left" else
- "passenger" if operation.target_scope == "front_right" else
- "rear" if operation.target_scope in {"rear", "rear_left", "rear_right"} else
- operation.target_scope
+ cls._normalized_mode_seat_scope(operation.target_scope)
for operation in profile.operations
if operation.domain == "seat"
}
- if requested == normalized_scopes:
+ return requested, normalized_scopes
+
+ @classmethod
+ def _explicit_mode_seat_selection(
+ cls,
+ profile: ModeProfile,
+ utterance: str,
+ ) -> frozenset[str] | None:
+ """Scope a dual-seat mode invocation, never mode creation or editing."""
+ requested, supported = cls._explicit_mode_seat_scopes(profile, utterance)
+ if (
+ requested is None
+ or len(requested) != 1
+ or supported != {"driver", "passenger"}
+ or not requested.issubset(supported)
+ or cls.is_mode_profile_configuration_request(utterance)
+ or not re.search(r"进入|开启|启动|执行|启用|运行|切换|打开|调用|应用|使用", utterance)
+ ):
return None
- return ",".join(sorted(requested)), tuple(sorted(normalized_scopes))
+ return requested
@staticmethod
def _apply_implicit_mode_target_policy(
@@ -4205,7 +4344,9 @@ class SeatDemoService:
for issue in validation
):
raise ValueError("待保存功能模式草稿没有通过完整安全校验。")
- operations = self._preview_mode_operations(source_result)
+ schedule = self._validated_schedule_artifact(source_result)
+ operations = ([deepcopy(op) for phase in schedule.phases for op in phase.operations]
+ if schedule is not None else self._preview_mode_operations(source_result))
operation_count = draft.get("operation_count")
if (
not isinstance(operation_count, int)
@@ -4335,6 +4476,16 @@ class SeatDemoService:
vehicle_id=vehicle_profile.id,
)
)
+ if source_draft.get("schedule") is not None:
+ from cognitive_agent.application.mode_schedule_edit import revise_schedule
+
+ return revise_schedule(
+ self, ModeSchedule.from_dict(source_draft["schedule"]),
+ change_text=change_text, utterance=utterance, name=mode_name,
+ conversation_id=conversation_id, vehicle_profile=vehicle_profile,
+ context=context, control_transport=control_transport,
+ source_draft=source_draft,
+ )
base_operations = self._absolute_mode_operations(source_operations)
if not change_text:
raise ValueError("请说明要调整的设备和明确目标值。")
@@ -4684,6 +4835,14 @@ class SeatDemoService:
if profile is None:
self._persist(result)
return result
+ if profile.schedule is not None:
+ from cognitive_agent.application.mode_schedule_edit import update_saved_schedule
+
+ return update_saved_schedule(
+ self, profile, change_text=change_text, utterance=utterance,
+ conversation_id=conversation_id, vehicle_profile=vehicle_profile,
+ context=context, result=result,
+ )
if not change_text:
result["status"] = "needs_confirmation"
result["validation"] = [
@@ -5351,6 +5510,25 @@ class SeatDemoService:
result["scene_config"] = None
result["execution_report"] = None
+ def _mode_creation_seat_failure(
+ self, utterance: str, conversation_id: str, vehicle: VehicleProfile, reason: str,
+ ) -> dict[str, Any]:
+ result = {
+ "request_id": f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}",
+ "utterance": utterance, "conversation_id": conversation_id,
+ "vehicle": vehicle.public_dict(), "status": "validation_failed",
+ "execution_mode": "dry_run", "hardware_execution_allowed": False,
+ "orchestration_route": "mode_profile_create_preview", "trace_events": [],
+ "validation": [{"severity": "error", "code": "MODE_SEAT_SCOPE_UNAVAILABLE",
+ "path": "mode_profile_draft.seat_scope", "message": reason}],
+ "mode_creation_failure": {"code": "MODE_SEAT_SCOPE_UNAVAILABLE", "reason": reason,
+ "next_step": "请选择该模式实际支持的座位,或改用当前座位支持的舒适功能"},
+ "decision_summary": [reason, "没有保存模式或下发控制。"],
+ }
+ self._scrub_failed_scene_preview(result)
+ self._persist(result)
+ return result
+
def _process_mode_profile_create(
self,
utterance: str,
@@ -5385,6 +5563,14 @@ class SeatDemoService:
):
raise ValueError("功能模式名称与创建请求不一致。")
requested_instruction = create_parts[1] if create_parts is not None else ""
+ selected_seats = creation_seat_scopes(utterance) if source_draft is None else None
+ if selected_seats is not None and "unsupported" in selected_seats and re.search(
+ r"座椅|座位|靠背|椅背|腿托|脚托|坐垫|坐盆|按摩|零重力", utterance
+ ):
+ return self._mode_creation_seat_failure(
+ utterance, conversation_id, vehicle_profile,
+ "当前模式只支持明确的主驾或副驾座椅,不会将未支持的座位替换为其他座位。",
+ )
raw_plan_metadata = (
assistant_plan.get("metadata")
if isinstance(assistant_plan, dict)
@@ -5411,6 +5597,7 @@ class SeatDemoService:
or requested_instruction
or assistant_plan.get("requests")
!= candidate_template.plan().get("requests")
+ or assistant_plan.get("schedule") != candidate_template.plan().get("schedule")
):
raise ValueError("内置常用模式模板已更新,请重新提交。")
scene_template = candidate_template
@@ -5441,6 +5628,10 @@ class SeatDemoService:
if not requested_instruction and source_draft is None
else None
)
+ if source_profile is None and source_draft is None and not requested_instruction and scene_template is None:
+ source_name = mode_creation_source_name(mode_name)
+ if source_name != mode_name:
+ source_profile = self.mode_profiles.resolve_exact_name(source_name, vehicle_id=vehicle_profile.id)
draft_mode_name = (
str(source_draft.get("name") or mode_name).strip()
if isinstance(source_draft, dict)
@@ -5456,7 +5647,7 @@ class SeatDemoService:
else "registered_mode_profile"
if source_profile is not None
else "codex_scene_composition"
- if assistant_plan is not None and not requested_instruction
+ if assistant_plan is not None and (not requested_instruction or needs_mode_schedule(utterance))
else "explicit_scene_requirements"
)
# A name-only request may already describe a repository-backed scene.
@@ -5465,7 +5656,63 @@ class SeatDemoService:
# being parsed as a second control clause on the assistant's local fast
# path, turning a valid nine-action mode into an incomplete draft.
source_utterance = requested_instruction or f"进入{draft_mode_name}"
+ if requested_instruction and selected_seats is not None and len(selected_seats) == 1:
+ # Qualify only explicit seat-device clauses, never an HVAC/window
+ # zone or unrelated control. General one-shot controls keep their
+ # existing target policy; this is scoped to new mode construction.
+ label = "主驾" if selected_seats == {"driver"} else "副驾"
+ instruction_clauses = self._split_intent_clauses(requested_instruction)
+ qualified_clauses = [
+ f"{label}{clause}" if (
+ re.search(r"座椅|座位|靠背|椅背|腿托|脚托|坐垫|坐盆|按摩", clause)
+ and resolve_explicit_seat_scopes(clause, assume_seat_context=True) is None
+ ) else clause
+ for clause in instruction_clauses
+ ]
+ # Preserve the user's punctuation and wording unless a seat really
+ # needs qualification; explicit instructions need no rewriting.
+ if qualified_clauses != instruction_clauses:
+ source_utterance = ",".join(qualified_clauses)
+ raw_schedule = (assistant_plan.get("schedule") if isinstance(assistant_plan, dict) else None)
+ if source_profile is not None and source_profile.schedule is not None:
+ raw_schedule = source_profile.schedule.to_dict()
+ if raw_schedule is not None:
+ if isinstance(assistant_plan, dict) and assistant_plan.get("ambiguities"):
+ raise ValueError("动态需求仍有未解决的问题,不能生成部分成功的时间轴,请补充或调整需求。")
+ if source_profile is None and source_draft is None and scene_template is None and not needs_mode_schedule(utterance):
+ raise ValueError("用户未请求动态或分阶段模式,不能擅自生成定时任务。")
+ schedule = ModeSchedule.from_dict(raw_schedule)
+ if selected_seats is not None and (source_profile is not None or scene_template is not None):
+ try:
+ schedule = project_seed_schedule(schedule, selected_seats)
+ except ModeCreationSeatPolicyError as exc:
+ return self._mode_creation_seat_failure(utterance, conversation_id, vehicle_profile, str(exc))
+ return self._preview_mode_schedule(
+ schedule, name=draft_mode_name, utterance=utterance,
+ conversation_id=conversation_id, vehicle_profile=vehicle_profile,
+ control_transport=control_transport, generation_source=generation_source,
+ source_profile=existing_profile, source_template=scene_template, source_draft=source_draft,
+ scene_scope_utterance="" if source_draft is not None else utterance,
+ )
+ if needs_mode_schedule(utterance) and scene_template is None and not (source_profile is not None and source_profile.timeline):
+ result = {
+ "request_id": f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}",
+ "utterance": utterance, "conversation_id": conversation_id,
+ "vehicle": vehicle_profile.public_dict(), "validation": [], "trace_events": [],
+ "status": "validation_failed", "execution_mode": "dry_run", "hardware_execution_allowed": False,
+ "orchestration_route": "mode_profile_create_preview",
+ }
+ result["mode_creation_failure"] = {
+ "code": "MODE_SCHEDULE_REQUIRED",
+ "reason": "该需求包含时间变化,但没有生成完整的分阶段时间轴",
+ "next_step": "请重试,或补充每个阶段的设备目标和持续时间;未保存或执行静态替代动作",
+ }
+ self._scrub_failed_scene_preview(result)
+ self._persist(result)
+ return result
try:
+ if scene_template is not None and selected_seats is not None:
+ assistant_plan = project_seed_plan(assistant_plan, selected_seats)
source_result = self._process_once(
source_utterance,
provider,
@@ -5478,6 +5725,7 @@ class SeatDemoService:
direct_mode_profile_id=(
source_profile.id if source_profile is not None else None
),
+ mode_creation_scopes=selected_seats,
assistant_plan=assistant_plan,
# ``source_utterance`` is synthesized for a name-only, unknown
# scene. It must not fall back to substring matching (for example
@@ -5493,8 +5741,10 @@ class SeatDemoService:
# already passed the ordinary text/target authorization path.
# Its old display name cannot veto an explicitly requested new
# seat. This override is internal, never accepted from payloads.
- scene_scope_utterance="" if source_draft is not None else None,
+ scene_scope_utterance="" if source_draft is not None else utterance,
)
+ except ModeCreationSeatPolicyError as exc:
+ return self._mode_creation_seat_failure(utterance, conversation_id, vehicle_profile, str(exc))
except NativeCanCompileError:
# Repository modes and model-composed scenes share the same native
# compiler. A stale mode parameter must become a bounded failed
diff --git a/src/cognitive_agent/application/semantic_ir.py b/src/cognitive_agent/application/semantic_ir.py
index 3288d35..37771af 100644
--- a/src/cognitive_agent/application/semantic_ir.py
+++ b/src/cognitive_agent/application/semantic_ir.py
@@ -8,7 +8,6 @@ from collections.abc import Mapping
from copy import deepcopy
from typing import Any
-
CLAUSE_SEPARATOR = re.compile(r"[,,;;。!?!?\n]+")
NEGATED_OPERATION = re.compile(
r"(?:不要|别|不想|无需|不必|禁止)"
@@ -414,13 +413,17 @@ def apply_language_safety_guards(
]
result["confidence"] = min(float(result.get("confidence", 0)), 0.8)
- explicit_seat_scopes = (
- resolve_scene_seat_scopes(
+ if scene_composition:
+ # Reuse exactly the selection already applied to the model schema and
+ # trusted seed. A second, older scope parser must not reject aliases
+ # such as 前排两座 or widen 副驾驶席 to both seats after validation.
+ from cognitive_agent.application.mode_creation_seat_policy import creation_seat_scopes
+
+ explicit_seat_scopes = (None if scene_scope_utterance == "" else creation_seat_scopes(
utterance if scene_scope_utterance is None else scene_scope_utterance
- )
- if scene_composition
- else resolve_explicit_seat_scopes(utterance)
- )
+ ))
+ else:
+ explicit_seat_scopes = resolve_explicit_seat_scopes(utterance)
if scene_composition and not any(
isinstance(item, dict)
and (
diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py
index 0918f36..d5a9153 100755
--- a/src/cognitive_agent/server.py
+++ b/src/cognitive_agent/server.py
@@ -47,6 +47,7 @@ from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
normalize_mode_profile_name,
parse_mode_profile_update,
)
+from cognitive_agent.application.mode_schedule_service import public_schedule_status # noqa: E402
from cognitive_agent.application.seat_service import SeatDemoService # noqa: E402
from cognitive_agent.application.self_extension import ( # noqa: E402
ExtensionIntegrationError,
@@ -304,6 +305,14 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if path == "/api/runtime":
self._json(self.service.runtime_info())
return
+ if path == "/api/modes/schedule/status":
+ query = parse_qs(parsed_url.query)
+ try:
+ vehicle = self.service.vehicle_profiles.get(str(query.get("vehicle_id", [""])[0]) or None)
+ self._json(public_schedule_status(self.service.mode_schedule_runner.status(vehicle.id)))
+ except ValueError as exc:
+ self._json({"error": "invalid_request", "message": str(exc)}, HTTPStatus.BAD_REQUEST)
+ return
if path == "/api/vehicle-display-sync":
self._json(self.vehicle_display_sync.snapshot(self._extension_health()))
return
diff --git a/tests/e2e/mode_schedule_browser.cjs b/tests/e2e/mode_schedule_browser.cjs
new file mode 100644
index 0000000..927f729
--- /dev/null
+++ b/tests/e2e/mode_schedule_browser.cjs
@@ -0,0 +1,256 @@
+"use strict";
+
+const assert = require("node:assert/strict");
+const path = require("node:path");
+const { chromium } = require("playwright");
+
+const [baseURL, evidenceDir] = process.argv.slice(2);
+
+async function main() {
+ const browser = await chromium.launch({
+ headless: true,
+ ...(process.env.N7WEB_BROWSER_CHANNEL ? { channel: process.env.N7WEB_BROWSER_CHANNEL }
+ : process.platform === "win32" ? { channel: "msedge" } : {}),
+ });
+ const context = await browser.newContext({ viewport: { width: 1600, height: 1000 }, serviceWorkers: "block" });
+ const page = await context.newPage();
+ const errors = [];
+ const posted = [];
+ const responses = [];
+ let mockedExecution = null;
+ let statusReads = 0;
+ page.on("pageerror", (error) => errors.push(error.message));
+ page.on("console", (message) => {
+ if (message.type() === "error") errors.push(`${message.text()} ${message.location().url || ""}`);
+ });
+ await context.route("**/api/**", async (route) => {
+ const request = route.request();
+ const pathname = new URL(request.url()).pathname;
+ if (["/api/hardware/seat-state", "/api/hardware/vehicle-state"].includes(pathname)) {
+ await route.fulfill({ json: { status: "unavailable", hardware_execution_allowed: false } });
+ return;
+ }
+ if (request.method() !== "GET") posted.push({ pathname, payload: request.postDataJSON() });
+ if (mockedExecution && pathname === "/api/assistant/stream") {
+ await route.fulfill({ contentType: "application/x-ndjson", body: [
+ { type: "action.completed", success: true, payload: mockedExecution },
+ { type: "answer.completed", answer: "动态时间轴已启动,后续阶段会按计划执行。" },
+ ].map((event) => JSON.stringify(event)).join("\n") + "\n" });
+ return;
+ }
+ if (mockedExecution && pathname === "/api/modes/schedule/status") {
+ statusReads += 1;
+ assert.equal(new URL(request.url()).searchParams.get("vehicle_id"), "denza_n7");
+ await route.fulfill({ json: {
+ ...mockedExecution.schedule_execution, vehicle_id: "denza_n7",
+ phase_index: 1, repeat_index: 0, completed: false,
+ status: statusReads === 1 ? "running" : "failed",
+ error: statusReads === 1 ? null : "QA阶段模拟失败",
+ } });
+ return;
+ }
+ if (["/api/assistant/stream", "/api/mode-profile-drafts/trial", "/api/mode-profiles"].includes(pathname)) {
+ // Chromium can discard completed streaming response bodies from CDP.
+ // Fetch the real isolated endpoint and retain its exact response bytes.
+ const response = await route.fetch();
+ const text = await response.text();
+ responses.push({ pathname, text, status: response.status() });
+ await route.fulfill({ response, body: text });
+ return;
+ }
+ await route.continue();
+ });
+ const waitIdle = () => page.waitForFunction(() => !window.CognitiveAgentAssistant.isPending());
+ const responseFor = (pathname) => page.waitForResponse((response) => new URL(response.url()).pathname === pathname);
+ try {
+ await page.goto(baseURL, { waitUntil: "domcontentloaded" });
+ await page.waitForFunction(() => document.body.classList.contains("assistant-chat-enabled")
+ && document.querySelectorAll(".mode-chips button").length > 3);
+ assert.equal(await page.title(), "511工程-智能座舱");
+ assert.equal(new URL(page.url()).origin, baseURL);
+ assert.match(await page.locator("body").innerText(), /整车孪生界面/);
+ if (!await page.locator('[data-execution-mode="dry_run"]').isVisible()) {
+ await page.locator('#v2SidebarToggle').click();
+ }
+ await page.locator('[data-execution-mode="dry_run"]').click();
+ assert.equal(await page.evaluate(() => window.CognitiveAgentConsole.getExecutionMode()), "dry_run");
+
+ // Exercise the same real assistant endpoint and rendered cards for both
+ // implicit passenger selection and an explicit driver request. These
+ // previews stay unsaved, so the timeline save/trial tests remain isolated.
+ for (const [index, testCase] of [
+ { text: "构建一个阅读模式", seat: "passenger", label: "副驾", excluded: "主驾" },
+ { text: "构建一个主驾阅读模式", seat: "driver", label: "主驾", excluded: "副驾" },
+ { text: "构建一个主驾座椅动态模式", seat: "driver", label: "主驾", excluded: "副驾" },
+ ].entries()) {
+ const responsePromise = responseFor("/api/assistant/stream");
+ await page.locator("#utterance").fill(testCase.text);
+ await page.locator("#utterance").press("Enter");
+ const response = await responsePromise;
+ assert.equal(response.status(), 200);
+ const events = responses.filter((item) => item.pathname === "/api/assistant/stream").at(-1)
+ .text.trim().split("\n").map((line) => JSON.parse(line));
+ await waitIdle();
+ const payload = events.find((event) => event.payload?.mode_profile_draft)?.payload;
+ assert.ok(payload, `Missing selected-seat draft for ${testCase.text}: ${JSON.stringify(events)}`);
+ const draft = payload.mode_profile_draft;
+ const actions = draft.schedule ? draft.schedule.phases.flatMap((phase) => phase.operations) : payload.actions;
+ const seats = actions.filter((action) => action.capability?.startsWith("seat."))
+ .map((action) => action.target_scope || action.target?.instance || action.target?.zone);
+ assert.ok(seats.length, `Missing selected-seat actions for ${testCase.text}`);
+ assert.deepEqual([...new Set(seats)], [testCase.seat]);
+ const card = page.locator(`[data-assistant-mode-draft="${draft.source_request_id}"]`);
+ await card.waitFor({ state: "visible" });
+ const actionText = await card.locator(".assistant-mode-draft-row").allTextContents();
+ assert.ok(actionText.some((text) => text.includes(testCase.label)), actionText.join("\n"));
+ assert.ok(actionText.every((text) => !text.includes(testCase.excluded)), actionText.join("\n"));
+ await card.scrollIntoViewIfNeeded();
+ await page.screenshot({ path: path.join(evidenceDir, `seat-scope-preview-${index + 1}.png`) });
+ }
+
+ const savedModes = [];
+ for (const [index, text] of ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"].entries()) {
+ const responsePromise = responseFor("/api/assistant/stream");
+ await page.locator("#utterance").fill(text);
+ await page.locator("#utterance").press("Enter");
+ const response = await responsePromise;
+ assert.equal(response.status(), 200);
+ const events = responses.filter((item) => item.pathname === "/api/assistant/stream").at(-1).text.trim().split("\n").map((line) => JSON.parse(line));
+ await waitIdle();
+ const draftEvent = events.find((event) => event.payload?.mode_profile_draft);
+ assert.ok(draftEvent, `Missing schedule draft for ${text}: ${JSON.stringify(events)}`);
+ const draft = draftEvent.payload.mode_profile_draft;
+ assert.equal(draft.schedule?.schema, "cognitive-agent-mode-schedule/v1");
+ assert.ok(draft.schedule.phases.length >= 2);
+ const card = page.locator(`[data-assistant-mode-draft="${draft.source_request_id}"]`);
+ assert.equal(await card.locator(".assistant-mode-schedule-phase").count(), draft.schedule.phases.length);
+ assert.equal(await card.locator(".assistant-mode-draft-row").count(), draft.operation_count);
+ assert.match(await card.innerText(), /重复 \d+ 次 · 总时长/);
+ assert.match(await card.innerText(), /停止动态模式/);
+ if (index === 0) {
+ assert.match(await card.innerText(), /副驾/);
+ assert.doesNotMatch(await card.innerText(), /主驾/);
+ } else {
+ const temperatures = draft.schedule.phases.flatMap((phase) => phase.operations)
+ .filter((operation) => operation.capability === "climate.set_temperature")
+ .map((operation) => operation.parameters.temperature_c);
+ assert.ok(new Set(temperatures).size >= 2, "preheat must change temperature over time");
+ for (const value of new Set(temperatures)) assert.ok((await card.innerText()).includes(`${value}℃`));
+ }
+ await card.scrollIntoViewIfNeeded();
+ await page.screenshot({ path: path.join(evidenceDir, `schedule-preview-${index + 1}.png`) });
+
+ const trialPromise = responseFor("/api/mode-profile-drafts/trial");
+ await card.locator(".assistant-mode-draft-trial").click();
+ const trialResponse = await trialPromise;
+ const trial = JSON.parse(responses.filter((item) => item.pathname === "/api/mode-profile-drafts/trial").at(-1).text);
+ assert.equal(trialResponse.status(), 200, JSON.stringify(trial));
+ assert.equal(trial.status, "succeeded", JSON.stringify(trial));
+ assert.equal(trial.schedule_execution?.status, "completed");
+ await page.waitForFunction((requestId) => {
+ const status = document.querySelector(`[data-assistant-mode-draft="${requestId}"] .assistant-mode-draft-status`);
+ return status?.classList.contains("success") || status?.classList.contains("failed");
+ }, draft.source_request_id);
+ assert.match(await card.locator(".assistant-mode-draft-status").innerText(), /试运行通过/);
+
+ const savePromise = responseFor("/api/mode-profiles");
+ await card.locator(".assistant-mode-draft-save").click();
+ const saveResponse = await savePromise;
+ const saved = JSON.parse(responses.filter((item) => item.pathname === "/api/mode-profiles").at(-1).text);
+ assert.ok([200, 201].includes(saveResponse.status()), JSON.stringify(saved));
+ assert.ok(saved.mode_profile?.id);
+ savedModes.push(saved.mode_profile);
+ await page.locator(".assistant-mode-created").filter({ hasText: saved.mode_profile.name }).waitFor({ state: "visible" });
+ const button = page.locator(`.mode-chips button[data-mode-profile-id="${saved.mode_profile.id}"]`);
+ await button.waitFor({ state: "visible" });
+ // Saving reveals the card with smooth scrolling. A tooltip correctly
+ // closes while its rail is still moving, so wait for that motion to end.
+ await page.evaluate(() => new Promise((resolve) => {
+ const rail = document.querySelector('.mode-chips');
+ let previous = rail.scrollLeft;
+ let stable = 0;
+ const sample = () => {
+ const current = rail.scrollLeft;
+ stable = Math.abs(current - previous) < 0.1 ? stable + 1 : 0;
+ previous = current;
+ if (stable >= 12) resolve();
+ else requestAnimationFrame(sample);
+ };
+ requestAnimationFrame(sample);
+ }));
+ await page.mouse.move(1, 1);
+ const buttonBox = await button.boundingBox();
+ const railBox = await page.locator('.mode-chips').boundingBox();
+ const visibleLeft = Math.max(buttonBox.x, railBox.x);
+ const visibleRight = Math.min(buttonBox.x + buttonBox.width, railBox.x + railBox.width);
+ assert.ok(visibleRight > visibleLeft, 'saved card is visible in the mode rail');
+ // A native mouse move does not request another smooth scroll, unlike
+ // locator.hover() which scrolls the target to the center first.
+ await page.mouse.move((visibleLeft + visibleRight) / 2, buttonBox.y + buttonBox.height / 2);
+ const tooltip = page.locator("#modeChipActionPopover");
+ await tooltip.waitFor({ state: "visible" });
+ assert.match(await tooltip.innerText(), /重复 \d+ 次 · 总时长/);
+ assert.equal(await tooltip.locator(".mode-schedule-popover-phase").count(), draft.operation_count);
+ for (const phase of draft.schedule.phases) assert.ok((await tooltip.innerText()).includes(phase.name));
+ await tooltip.screenshot({ path: path.join(evidenceDir, `schedule-tooltip-${index + 1}.png`), timeout: 4000 });
+ await page.mouse.move(1, 1);
+ }
+
+ await page.locator("#modeGalleryToggle").click();
+ for (const mode of savedModes) {
+ const shell = page.locator(`#modeGalleryGroups .mode-gallery-card-shell`).filter({ has: page.locator(`button[data-mode-profile-id="${mode.id}"]`) });
+ const details = shell.locator(".mode-gallery-schedule-details");
+ await details.locator("summary").click();
+ assert.match(await details.innerText(), /开始 · 持续/);
+ assert.match(await details.innerText(), /不自动回滚/);
+ }
+ await page.locator("#modeGalleryGroups").evaluate((element) => { element.scrollTop = 0; });
+ await page.screenshot({ path: path.join(evidenceDir, "schedule-management-desktop.png") });
+ await page.locator("#modeGalleryClose").click();
+ await page.setViewportSize({ width: 720, height: 1000 });
+ if (await page.locator('#v2SidebarToggle').getAttribute('aria-expanded') === 'true') {
+ await page.locator('#v2SidebarToggle').click();
+ }
+ await page.locator(".assistant-mode-draft-scheduled").last().scrollIntoViewIfNeeded();
+ await page.screenshot({ path: path.join(evidenceDir, "schedule-preview-mobile.png") });
+ assert.equal(await page.locator("body").evaluate((body) => body.scrollWidth > window.innerWidth + 1), false);
+
+ // The backend sequence above is real and strictly dry-run. Separately
+ // verify the asynchronous UI contract with a synthetic running/failed
+ // response; it must never launch an actual timed hardware execution.
+ await page.setViewportSize({ width: 1600, height: 1000 });
+ const mode = savedModes[1];
+ mockedExecution = {
+ request_id: "browser-running-qa", status: "running", execution_mode: "dry_run",
+ orchestration_route: "mode_schedule_execute", vehicle_id: "denza_n7", mode_profile: mode,
+ schedule_execution: { status: "running", run_id: "qa-run", session_id: "qa-session" },
+ };
+ const messagesBefore = await page.locator('#assistantMessages [data-message-role]').count();
+ await page.locator(`.mode-chips button[data-mode-profile-id="${mode.id}"]`).click();
+ await waitIdle();
+ await page.waitForFunction((id) => document.querySelector(`.mode-chips button[data-mode-profile-id="${id}"]`)?.dataset.modeState === "started", mode.id);
+ const actionStatus = page.locator('.assistant-action-status').last();
+ assert.match(await actionStatus.innerText(), /已启动动态时间轴/);
+ await page.waitForFunction(() => Array.from(document.querySelectorAll('.assistant-action-status')).at(-1)?.textContent.includes('阶段 2'));
+ await page.waitForFunction(() => Array.from(document.querySelectorAll('.assistant-action-status')).at(-1)?.textContent.includes('QA阶段模拟失败'));
+ assert.equal(await actionStatus.getAttribute('class'), 'assistant-action-status failed');
+ assert.equal(statusReads, 2);
+ assert.equal(await page.locator('#assistantMessages [data-message-role]').count(), messagesBefore + 2);
+ await page.screenshot({ path: path.join(evidenceDir, "schedule-background-failure.png") });
+ assert.deepEqual(errors, []);
+ assert.ok(posted.filter((request) => request.pathname === "/api/assistant/stream").every((request) => request.payload.execution_mode === "dry_run"));
+ assert.equal(posted.filter((request) => request.pathname === "/api/mode-profile-drafts/trial").length, 2);
+ assert.equal(posted.filter((request) => request.pathname === "/api/mode-profiles").length, 2);
+ console.log(`PASS ${baseURL}: default passenger and explicit driver static/dynamic previews -> both exact user phrases -> phased preview -> virtual trial -> save -> top tooltip -> management; desktop 1600x1000 and 720x1000; no JS errors; no hardware requests forwarded`);
+ console.log(`Evidence: ${evidenceDir}`);
+ } catch (error) {
+ await page.screenshot({ path: path.join(evidenceDir, "schedule-browser-failure.png") });
+ console.error(`Page text: ${(await page.locator("body").innerText()).slice(-5000)}`);
+ throw error;
+ } finally {
+ await context.close();
+ await browser.close();
+ }
+}
+
+main().catch((error) => { console.error(error); process.exitCode = 1; });
diff --git a/tests/e2e/test_mode_schedule_browser.py b/tests/e2e/test_mode_schedule_browser.py
new file mode 100644
index 0000000..2530226
--- /dev/null
+++ b/tests/e2e/test_mode_schedule_browser.py
@@ -0,0 +1,109 @@
+"""Full browser -> deterministic assistant -> schedule trial/save, without hardware."""
+
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+import threading
+from http import HTTPStatus
+from http.server import ThreadingHTTPServer
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+from urllib.parse import urlparse
+
+import pytest
+
+from cognitive_agent.application.codex_assistant import CodexAssistantService
+from cognitive_agent.application.seat_service import SeatDemoService
+from cognitive_agent.auth import AuthConfig, AuthRuntime
+from cognitive_agent.server import REPOSITORY_ROOT, SeatDemoHandler
+
+
+def test_mode_schedule_real_browser_preview_trial_and_save(tmp_path: Path) -> None:
+ node = shutil.which("node")
+ if not node:
+ runtime = Path.home() / ".cache" / "codex-runtimes" / "codex-primary-runtime" / "dependencies" / "node" / "bin"
+ node = next((str(path) for path in (runtime / "node.exe", runtime / "node") if path.is_file()), None)
+ if not node:
+ pytest.skip("Node.js is required for the schedule browser regression")
+ env = dict(os.environ)
+ modules = Path(node).resolve().parent.parent / "node_modules"
+ if modules.is_dir():
+ env["NODE_PATH"] = os.pathsep.join(value for value in (str(modules), env.get("NODE_PATH")) if value)
+ probe = subprocess.run([node, "-e", "require.resolve('playwright')"], env=env, capture_output=True, encoding="utf-8", check=False)
+ if probe.returncode:
+ pytest.skip("Playwright is required for the schedule browser regression")
+
+ with patch.dict(os.environ, {
+ "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
+ "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
+ "COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false",
+ "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false",
+ "COGNITIVE_AGENT_EXECUTION_MODE": "dry_run",
+ "COGNITIVE_AGENT_MODE_PROFILE_ROOT": str(tmp_path / "user-modes"),
+ }):
+ service = SeatDemoService(REPOSITORY_ROOT)
+ service.artifact_root = tmp_path / "artifacts"
+ # A failed deterministic route must fail visibly, never launch a model or
+ # make network calls to an external assistant during the browser test.
+ client = MagicMock()
+ client.available = False
+ client.status = "test-offline"
+ assistant = CodexAssistantService(client, fast_implicit_controls=True)
+ forbidden = []
+
+ class BrowserHandler(SeatDemoHandler):
+ auth = AuthRuntime(AuthConfig(False, "", "", b"", 3600, 5, 300, 120, False))
+
+ def do_GET(self) -> None: # noqa: N802
+ path = urlparse(self.path).path
+ if path in {"/api/hardware/seat-state", "/api/hardware/vehicle-state"}:
+ # Both legacy GET paths can touch physical adapters or firmware.
+ self._json({"status": "unavailable", "hardware_execution_allowed": False})
+ return
+ if path == "/api/health":
+ self._json({"status": "ok", "assistant": {"status": "ready", "available": True}})
+ return
+ super().do_GET()
+
+ def do_POST(self) -> None: # noqa: N802
+ path = urlparse(self.path).path
+ if path == "/api/extension/display-sync":
+ self._json({"status": "ok"})
+ return
+ if path not in {"/api/assistant/stream", "/api/mode-profile-drafts/trial", "/api/mode-profiles"}:
+ forbidden.append(path)
+ self._json({"error": "browser_test_write_forbidden"}, HTTPStatus.FORBIDDEN)
+ return
+ super().do_POST()
+
+ def _read_json_body(self, **kwargs):
+ payload = super()._read_json_body(**kwargs)
+ if payload.get("execution_mode", "dry_run") != "dry_run":
+ forbidden.append(f"execution_mode:{payload.get('execution_mode')}")
+ raise ValueError("Browser regression permits dry_run only")
+ return payload
+
+ def log_message(self, *_args: object) -> None:
+ pass
+
+ BrowserHandler.service = service
+ BrowserHandler.assistant = assistant
+ server = ThreadingHTTPServer(("127.0.0.1", 0), BrowserHandler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ completed = subprocess.run([
+ node, str(Path(__file__).with_name("mode_schedule_browser.cjs")),
+ f"http://127.0.0.1:{server.server_port}", str(tmp_path),
+ ], env=env, capture_output=True, encoding="utf-8", timeout=180, check=False)
+ assert completed.returncode == 0, completed.stdout + completed.stderr
+ assert not forbidden, forbidden
+ assert len(list((tmp_path / "user-modes").rglob("*.yaml"))) == 2
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=3)
+ assistant.close()
+ service.close()
diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py
index 256b714..99b8f4b 100755
--- a/tests/e2e/test_seat_web_api.py
+++ b/tests/e2e/test_seat_web_api.py
@@ -621,7 +621,7 @@ operations:
def test_static_console_and_runtime_endpoint(self) -> None:
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
html = response.read().decode("utf-8")
- with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-131", timeout=3) as response:
+ with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-132", timeout=3) as response:
app_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3
@@ -656,7 +656,7 @@ operations:
) as response:
extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
with self.opener.open(
- f"{self.base_url}/assistant-chat.js?v=codex-assistant-27", timeout=3
+ f"{self.base_url}/assistant-chat.js?v=codex-assistant-28", timeout=3
) as response:
assistant_script = response.read().decode("utf-8")
with self.opener.open(
@@ -697,7 +697,7 @@ operations:
self.assertIn("CMD 0x431 · FB 0x41A · CH1", html)
self.assertIn("CMD 0x431 · FB 0x388 · CH1", html)
self.assertNotIn("透明车体 · 实体设备", html)
- self.assertIn('src="/app.js?v=dual-seat-131"', html)
+ self.assertIn('src="/app.js?v=dual-seat-132"', html)
self.assertIn('href="/styles.css?v=dual-seat-23"', html)
self.assertIn('src="/gesture-control.js?v=android-pad-9"', html)
self.assertNotIn('class="vehicle-system-hud"', html)
@@ -718,8 +718,8 @@ operations:
self.assertNotIn("滚动查看更多", html)
self.assertIn('aria-label="发送消息"', html)
self.assertIn('id="assistantTranscript"', html)
- self.assertIn('href="/assistant-chat.css?v=codex-assistant-27"', html)
- self.assertIn('src="/assistant-chat.js?v=codex-assistant-27"', html)
+ self.assertIn('href="/assistant-chat.css?v=codex-assistant-28"', html)
+ self.assertIn('src="/assistant-chat.js?v=codex-assistant-28"', html)
self.assertIn('href="/energy-monitor.css?v=energy-monitor-4"', html)
self.assertIn('src="/energy-monitor.js?v=energy-monitor-4"', html)
self.assertNotIn('"admin"', console_script)
@@ -992,14 +992,14 @@ operations:
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
self.assertIn('"cognitive-agent:module-change"', app_script)
- self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v278"', service_worker)
+ self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v279"', service_worker)
self.assertIn('/console-v2.css?v=delivery-state-66', service_worker)
self.assertIn('/console-v2.js?v=delivery-state-62', service_worker)
- self.assertIn('/assistant-chat.css?v=codex-assistant-27', service_worker)
- self.assertIn('/assistant-chat.js?v=codex-assistant-27', service_worker)
+ self.assertIn('/assistant-chat.css?v=codex-assistant-28', service_worker)
+ self.assertIn('/assistant-chat.js?v=codex-assistant-28', service_worker)
self.assertIn('/energy-monitor.css?v=energy-monitor-4', service_worker)
self.assertIn('/energy-monitor.js?v=energy-monitor-4', service_worker)
- self.assertIn('"/app.js?v=dual-seat-131"', service_worker)
+ self.assertIn('"/app.js?v=dual-seat-132"', service_worker)
self.assertIn('"/speech-input.js?v=sensevoice-13"', service_worker)
self.assertIn('"/speech-input.css?v=sensevoice-7"', service_worker)
self.assertIn('"/speech-audio-worklet.js?v=sensevoice-5"', service_worker)
@@ -1456,7 +1456,7 @@ operations:
) as response:
extension_script = response.read().decode("utf-8")
with self.opener.open(
- f"{self.base_url}/app.js?v=dual-seat-131", timeout=3
+ f"{self.base_url}/app.js?v=dual-seat-132", timeout=3
) as response:
app_script = response.read().decode("utf-8")
diff --git a/tests/integration/test_assistant_capability_execution_matrix.py b/tests/integration/test_assistant_capability_execution_matrix.py
index b6e43c1..937a9f1 100644
--- a/tests/integration/test_assistant_capability_execution_matrix.py
+++ b/tests/integration/test_assistant_capability_execution_matrix.py
@@ -715,9 +715,16 @@ def test_every_catalog_mode_rejects_incompatible_explicit_seat_scopes(
for mode_index, mode in enumerate(modes):
assert isinstance(mode, Mapping)
+ profile = vehicle_service.mode_profiles.get_profile(str(mode["id"]), "denza_n7")
+ has_driver = any(
+ operation.domain == "seat" and operation.target_scope in {"driver", "front_left"}
+ for operation in profile.operations
+ )
for scope_index, scope in enumerate(
("主驾", "驾驶员", "后排", "二排", "前排", "所有座椅", "全车座椅")
):
+ if has_driver and scope in {"主驾", "驾驶员"}:
+ continue # Valid dual-seat subsets have a separate exact-action matrix.
message = f"请让{scope}进入{mode['name']}"
result = vehicle_service.preview_native_can(
message,
@@ -754,7 +761,7 @@ def test_every_catalog_mode_rejects_incompatible_explicit_seat_scopes(
def test_preview_and_process_share_mode_target_safety_gate(
vehicle_service: SeatDemoService,
) -> None:
- message = "主驾开启小憩模式"
+ message = "主驾开启零重力模式"
direct = vehicle_service.process(
message,
provider="local",
diff --git a/tests/integration/test_custom_mode_creation_matrix.py b/tests/integration/test_custom_mode_creation_matrix.py
index 9ca9d2d..bc4db1a 100644
--- a/tests/integration/test_custom_mode_creation_matrix.py
+++ b/tests/integration/test_custom_mode_creation_matrix.py
@@ -754,7 +754,11 @@ def test_incomplete_ambiguous_or_unsupported_scene_never_creates_partial_draft(
assert result["status"] != "succeeded"
assert result.get("mode_profile_draft") is None
- assert result.get("orchestration_route") != "mode_profile_create_preview"
+ # Seat policy can reject on the creation route before compiling any action.
+ assert not (result.get("intent_ir") or {}).get("spec", {}).get("actions")
+ assert not result.get("actions")
+ assert result.get("command_plan") is None
+ assert not result.get("command_plans")
assert result.get("validation")
failure = result.get("mode_creation_failure")
assert isinstance(failure, dict)
diff --git a/tests/integration/test_mode_creation_seat_policy.py b/tests/integration/test_mode_creation_seat_policy.py
new file mode 100644
index 0000000..f46b62b
--- /dev/null
+++ b/tests/integration/test_mode_creation_seat_policy.py
@@ -0,0 +1,471 @@
+"""Creation selects one seat by default without remapping incompatible actuators."""
+
+from __future__ import annotations
+
+import json
+import queue
+from copy import deepcopy
+from unittest.mock import MagicMock, patch
+
+import pytest
+from jsonschema import Draft202012Validator
+
+from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
+from cognitive_agent.application.codex_assistant import ActiveCodexTurn, CodexAssistantService
+from cognitive_agent.application.mode_profile import ModeProfileRegistry
+from cognitive_agent.application.scene_composer import normalize_scene_output
+from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
+
+
+@pytest.fixture
+def policy_service(tmp_path, monkeypatch):
+ for variable in (
+ "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED",
+ "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED",
+ "COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED",
+ "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED",
+ ):
+ monkeypatch.setenv(variable, "false")
+ service = SeatDemoService(REPOSITORY_ROOT)
+ service.artifact_root = tmp_path / "artifacts"
+ user_root = tmp_path / "user-modes"
+ service.mode_profiles = ModeProfileRegistry(
+ REPOSITORY_ROOT / "config" / "modes", user_root=user_root,
+ )
+ try:
+ with (
+ patch.object(service.hardware_execution, "reserve", side_effect=AssertionError("physical CAN forbidden")) as reserve,
+ patch.object(service._vehicle_mqtt_transport, "bus", side_effect=AssertionError("physical MQTT forbidden")) as mqtt,
+ ):
+ yield service, AssistantCapabilityCatalog(service), user_root
+ reserve.assert_not_called()
+ mqtt.assert_not_called()
+ finally:
+ service.close()
+
+
+def _actions(result):
+ return (result.get("intent_ir") or {}).get("spec", {}).get("actions", [])
+
+
+def _seat_scopes(result):
+ return {
+ action["target"]["instance"] for action in _actions(result)
+ if action["domain"] == "seat"
+ }
+
+
+def _operation_key(operation):
+ target = operation.get("target_scope")
+ if target is None:
+ raw_target = operation.get("target") or {}
+ target = raw_target.get("instance") or raw_target.get("zone")
+ return (
+ operation["capability"], target,
+ json.dumps(operation["parameters"], ensure_ascii=False, sort_keys=True),
+ )
+
+
+def _preview(service, message, *, plan=None, draft=None):
+ return service.preview_native_can(
+ message, provider="local", conversation_id="conv-seat-policy",
+ execution_mode="hardware", vehicle_id="denza_n7",
+ assistant_plan=plan, mode_profile_draft_context=draft,
+ )
+
+
+def _assert_created(result, scopes):
+ assert result["status"] == "succeeded", result.get("validation")
+ assert result.get("mode_profile_draft"), result
+ assert result["execution_mode"] == "dry_run"
+ assert result["hardware_execution_allowed"] is False
+ assert _seat_scopes(result) == set(scopes)
+ schedule = result.get("schedule")
+ if schedule:
+ for phase in schedule["phases"]:
+ assert {
+ op["target_scope"] for op in phase["operations"] if op["domain"] == "seat"
+ } <= set(scopes)
+ assert result["mode_profile_draft"]["schedule"] == schedule
+
+
+def _assistant_preview(service, catalog, message, *, client=None):
+ if client is None:
+ client = MagicMock()
+ client.available = False
+ assistant = CodexAssistantService(client)
+ snapshot = catalog.snapshot(vehicle_id="denza_n7")
+ fast = catalog.fast_answer(message, snapshot)
+ results, events = [], []
+
+ def execute(instruction, progress, assistant_plan=None):
+ result = _preview(service, instruction, plan=assistant_plan)
+ results.append(result)
+ return result
+
+ response = assistant.stream(
+ message, conversation_id="conv-seat-policy", principal="seat-policy-test", history=[],
+ authoritative_answer=fast.answer if fast else None,
+ emit=lambda event: events.append(event) or True,
+ execute_control=execute, capability_snapshot=snapshot,
+ capability_context=catalog.compact_context(snapshot),
+ scene_capability_context=catalog.scene_composition_context(snapshot),
+ control_terms=catalog.control_terms(snapshot),
+ )
+ assert len(results) == 1, (response, results)
+ return response, results[0], client
+
+
+@pytest.mark.parametrize("name", [
+ "阅读模式", "观影模式", "办公模式", "座椅舒适模式", "放松模式", "座椅动态模式", "上车预热模式",
+])
+@pytest.mark.parametrize(("prefix", "scopes"), [
+ ("", {"passenger"}), ("主驾", {"driver"}), ("主驾和副驾", {"driver", "passenger"}),
+])
+def test_registered_modes_use_selected_seat_through_assistant_fast_path(policy_service, name, prefix, scopes):
+ service, catalog, user_root = policy_service
+ response, preview, client = _assistant_preview(service, catalog, f"构建一个{prefix}{name}")
+ assert response["success"] is True, (response, preview)
+ _assert_created(preview, scopes)
+ client.start_thread.assert_not_called()
+ client.start_turn.assert_not_called()
+ client.start_structured_turn.assert_not_called()
+ assert not list(user_root.rglob("*.yaml"))
+
+
+@pytest.mark.parametrize(("message", "scopes"), [
+ ("构建一个两个座椅的阅读模式", {"driver", "passenger"}),
+ ("构建一个两张座椅的阅读模式", {"driver", "passenger"}),
+ ("构建一个两座椅的阅读模式", {"driver", "passenger"}),
+ ("构建一个前排两座的阅读模式", {"driver", "passenger"}),
+ ("构建一个副驾驶席阅读模式", {"passenger"}),
+ ("构建一个副驾驶位阅读模式", {"passenger"}),
+ ("构建一个副驾驶座椅阅读模式", {"passenger"}),
+ ("构建一个两个座椅的动态模式", {"driver", "passenger"}),
+ ("构建一个两张座椅的动态模式", {"driver", "passenger"}),
+ ("构建一个两座椅的动态模式", {"driver", "passenger"}),
+ ("构建一个前排两座的动态模式", {"driver", "passenger"}),
+ ("构建一个副驾驶座椅动态模式", {"passenger"}),
+])
+def test_registered_seat_aliases_select_correct_targets_without_model(policy_service, message, scopes):
+ service, catalog, _ = policy_service
+ response, preview, client = _assistant_preview(service, catalog, message)
+ assert response["success"] is True, (
+ response["answer"], preview.get("validation"), preview.get("mode_profile_draft"),
+ )
+ _assert_created(preview, scopes)
+ assert response["route"] == "scene_template"
+ client.start_structured_turn.assert_not_called()
+ client.start_turn.assert_not_called()
+
+
+@pytest.mark.parametrize("template_id", [
+ "reading", "cinema", "camping", "quiet-night", "reception", "romantic",
+ "winter-warmth", "summer-cooling", "long-trip-comfort", "relaxation", "seat-comfort",
+ "seat-dynamic", "boarding-warmup",
+])
+@pytest.mark.parametrize(("prefix", "scope"), [("", "passenger"), ("主驾", "driver")])
+def test_trusted_template_selection_keeps_nonseat_devices_and_native_parameters(policy_service, template_id, prefix, scope):
+ service, _, _ = policy_service
+ template = service.scene_templates.get_template(template_id)
+ original = deepcopy(template.plan())
+ result = _preview(service, f"构建一个{prefix}{template.name}", plan=template.plan())
+ _assert_created(result, {scope})
+ raw_operations = (
+ [op for phase in original["schedule"]["phases"] for op in phase["operations"]]
+ if original.get("schedule") else [op.to_yaml() for op in template.operations]
+ )
+ expected = [
+ _operation_key(op) for op in raw_operations
+ if op["domain"] != "seat" or op["target_scope"] == scope
+ ]
+ assert sorted(map(_operation_key, _actions(result))) == sorted(expected)
+ assert template.plan() == original, "projecting a request must not mutate the registered template"
+
+
+def _model_output(scopes, *, dynamic=False):
+ requests = []
+ if "driver" in scopes:
+ requests.append({
+ "capability": "seat.set_driver_massage_native", "target_scope": "driver",
+ "parameters": {"action": "mode", "mode": "relax"},
+ })
+ if "passenger" in scopes:
+ requests.append({
+ "capability": "seat.set_backrest_recline", "target_scope": "passenger",
+ "parameters": {"position_pct": 45},
+ })
+ requests.append({
+ "capability": "climate.set_temperature", "target_scope": "vehicle",
+ "parameters": {"temperature_c": 24},
+ })
+ if not dynamic:
+ return {"requests": requests, "ambiguities": [], "confidence": 0.95}
+ return {
+ "requests": [], "ambiguities": [], "confidence": 0.95,
+ "schedule": {
+ "repeat_count": 2,
+ "phases": [
+ {"id": "relax", "name": "舒缓", "duration_ms": 30000, "requests": deepcopy(requests)},
+ {"id": "support", "name": "支撑", "duration_ms": 60000, "requests": deepcopy(requests)},
+ ],
+ },
+ }
+
+
+@pytest.mark.parametrize("dynamic", [False, True])
+@pytest.mark.parametrize(("prefix", "scopes"), [
+ ("", {"passenger"}), ("主驾", {"driver"}), ("主驾和副驾", {"driver", "passenger"}),
+])
+def test_unregistered_model_plan_preserves_correct_explicit_or_default_targets(policy_service, dynamic, prefix, scopes):
+ service, catalog, _ = policy_service
+ plan = normalize_scene_output(
+ _model_output(scopes, dynamic=dynamic), catalog.scene_composition_context(), dynamic=dynamic,
+ )
+ dynamic_label = "动态" if dynamic else ""
+ result = _preview(service, f"构建一个{prefix}星河释压{dynamic_label}模式", plan=plan)
+ _assert_created(result, scopes)
+ assert result["mode_profile_draft"]["generation_source"] == "codex_scene_composition"
+
+
+@pytest.mark.parametrize("dynamic", [False, True])
+@pytest.mark.parametrize("prefix", ["", "主驾", "副驾"])
+def test_unregistered_model_cannot_invent_cross_seat_actions(policy_service, dynamic, prefix):
+ service, catalog, user_root = policy_service
+ plan = normalize_scene_output(
+ _model_output({"driver", "passenger"}, dynamic=dynamic),
+ catalog.scene_composition_context(), dynamic=dynamic,
+ )
+ try:
+ result = _preview(service, f"构建一个{prefix}星河释压{'动态' if dynamic else ''}模式", plan=plan)
+ except ValueError:
+ pass
+ else:
+ assert result["status"] != "succeeded"
+ assert not result.get("mode_profile_draft")
+ assert not _actions(result)
+ assert result["hardware_execution_allowed"] is False
+ assert not list(user_root.rglob("*.yaml"))
+
+
+@pytest.mark.parametrize(("prefix", "requested_scope", "unexpected_scope"), [
+ ("", "passenger", "driver"), ("主驾", "driver", "passenger"),
+])
+def test_unexpected_seat_only_in_later_dynamic_phase_rejects_entire_draft(
+ policy_service, prefix, requested_scope, unexpected_scope,
+):
+ service, catalog, user_root = policy_service
+ output = _model_output({requested_scope}, dynamic=True)
+ output["schedule"]["phases"][1]["requests"] = _model_output({unexpected_scope})["requests"]
+ plan = normalize_scene_output(output, catalog.scene_composition_context(), dynamic=True)
+ try:
+ result = _preview(service, f"构建一个{prefix}星河释压动态模式", plan=plan)
+ except ValueError:
+ pass
+ else:
+ assert result["status"] != "succeeded"
+ assert not result.get("mode_profile_draft")
+ assert not _actions(result)
+ assert not result.get("command_plans")
+ assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
+ assert not list(user_root.rglob("*.yaml"))
+
+
+@pytest.mark.parametrize("message", [
+ "构建一个办公模式,空调先24度持续30秒,然后24度持续60秒,循环2次",
+ "构建一个办公模式,主驾座椅分两个阶段动态舒缓",
+])
+def test_existing_mode_binding_cannot_authorize_cross_seat_model_schedule(policy_service, message):
+ service, catalog, user_root = policy_service
+ original = service.mode_profiles.get_profile("office", "denza_n7")
+ revision = service.mode_profiles.profile_revision(original)
+ plan = normalize_scene_output(
+ _model_output({"driver", "passenger"}, dynamic=True),
+ catalog.scene_composition_context(), dynamic=True,
+ )
+ try:
+ result = _preview(service, message, plan=plan)
+ except ValueError:
+ pass
+ else:
+ assert result["status"] != "succeeded", "binding an existing mode is not authorization for model-added seats"
+ assert not result.get("mode_profile_draft")
+ assert not _actions(result)
+ assert not result.get("command_plans")
+ assert service.mode_profiles.profile_revision(service.mode_profiles.get_profile("office", "denza_n7")) == revision
+ assert not list(user_root.rglob("*.yaml"))
+
+
+@pytest.mark.parametrize(("message", "scope"), [
+ ("构建一个办公模式,空调先24度持续30秒,然后24度持续60秒,循环2次", "passenger"),
+ ("构建一个办公模式,主驾座椅分两个阶段动态舒缓", "driver"),
+])
+def test_existing_mode_binding_keeps_valid_selected_seat_schedule_saveable(policy_service, message, scope):
+ service, catalog, _ = policy_service
+ plan = normalize_scene_output(
+ _model_output({scope}, dynamic=True), catalog.scene_composition_context(), dynamic=True,
+ )
+ result = _preview(service, message, plan=plan)
+ _assert_created(result, {scope})
+ draft = result["mode_profile_draft"]
+ assert draft["existing_mode_id"] == "office"
+ assert draft["existing_mode_revision"]
+ saved = service.save_mode_profile_card(
+ draft["existing_mode_id"], draft["name"], draft["instruction"],
+ draft["source_request_id"], draft["vehicle_id"],
+ )
+ assert saved["status"] == "succeeded"
+ assert saved["mode_profile"]["schedule"] == result["schedule"]
+ executed = service.execute_mode_profile_card(
+ saved["mode_profile"]["id"], execution_mode="dry_run", vehicle_id="denza_n7",
+ )
+ assert executed["status"] == "succeeded"
+ assert _seat_scopes(executed) == {scope}
+
+
+@pytest.mark.parametrize("dynamic", [False, True])
+def test_default_seat_preference_does_not_force_seat_actions_into_nonseat_scene(policy_service, dynamic):
+ service, catalog, _ = policy_service
+ plan = normalize_scene_output(
+ _model_output(set(), dynamic=dynamic), catalog.scene_composition_context(), dynamic=dynamic,
+ )
+ result = _preview(service, f"构建一个星河恒温{'动态' if dynamic else ''}模式", plan=plan)
+ _assert_created(result, set())
+ assert all(action["domain"] == "climate" for action in _actions(result))
+
+
+def test_explicit_atomic_mode_requirements_default_unqualified_seat_to_passenger(policy_service):
+ service, _, _ = policy_service
+ result = _preview(service, "构建一个自定义支撑模式,座椅靠背调整到45%,空调24度")
+ _assert_created(result, {"passenger"})
+ assert {_operation_key(action) for action in _actions(result)} == {
+ ("seat.set_backrest_recline", "passenger", '{"position_pct": 45}'),
+ # Only the seat default changes; the existing local HVAC zone stays driver.
+ ("climate.set_temperature", "driver", '{"temperature_c": 24}'),
+ }
+
+
+@pytest.mark.parametrize("dynamic", [False, True])
+def test_passenger_position_capability_is_not_retargeted_to_driver(policy_service, dynamic):
+ service, catalog, _ = policy_service
+ output = _model_output({"passenger"}, dynamic=dynamic)
+ requests = output["schedule"]["phases"][0]["requests"] if dynamic else output["requests"]
+ requests[0]["target_scope"] = "driver"
+ try:
+ plan = normalize_scene_output(output, catalog.scene_composition_context(), dynamic=dynamic)
+ result = _preview(service, f"构建一个主驾星河释压{'动态' if dynamic else ''}模式", plan=plan)
+ except ValueError:
+ return
+ assert result["status"] != "succeeded"
+ assert not result.get("mode_profile_draft")
+ assert not _actions(result)
+
+
+@pytest.mark.parametrize(("message", "scopes"), [
+ ("构建一个主驾空调与座椅舒适模式", {"passenger"}),
+ ("构建一个主驾阅读灯与座椅舒适模式", {"passenger"}),
+ ("构建一个副驾空调与主驾座椅舒适模式", {"driver"}),
+ ("构建一个主驾阅读灯与副驾座椅舒适模式", {"passenger"}),
+])
+def test_other_device_zones_do_not_select_or_broaden_seat_scope(policy_service, message, scopes):
+ service, catalog, _ = policy_service
+ plan = normalize_scene_output(_model_output(scopes), catalog.scene_composition_context())
+ result = _preview(service, message, plan=plan)
+ _assert_created(result, scopes)
+
+
+@pytest.mark.parametrize(("template_id", "prefix", "scope"), [
+ ("reading", "", "passenger"), ("reading", "主驾", "driver"),
+ ("seat-dynamic", "", "passenger"), ("seat-dynamic", "主驾", "driver"),
+ ("boarding-warmup", "", "passenger"), ("boarding-warmup", "主驾", "driver"),
+])
+def test_save_reload_and_simulated_execution_preserve_selected_seat(policy_service, template_id, prefix, scope):
+ service, _, user_root = policy_service
+ template = service.scene_templates.get_template(template_id)
+ preview = _preview(service, f"构建一个{prefix}{template.name}", plan=template.plan())
+ _assert_created(preview, {scope})
+ draft = preview["mode_profile_draft"]
+ saved = service.create_mode_profile_card(
+ draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"],
+ )
+ assert saved["status"] == "succeeded", saved
+ service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root)
+ executed = service.execute_mode_profile_card(
+ saved["mode_profile"]["id"], conversation_id="conv-seat-policy-replay",
+ execution_mode="dry_run", vehicle_id="denza_n7",
+ )
+ assert executed["status"] == "succeeded", executed.get("validation")
+ assert executed["hardware_execution_allowed"] is False
+ assert _seat_scopes(executed) == {scope}
+ assert sorted(map(_operation_key, _actions(executed))) == sorted(map(_operation_key, _actions(preview)))
+ if preview.get("schedule"):
+ assert executed["schedule"] == preview["schedule"]
+ assert executed["schedule_execution"]["completed"] is True
+ assert all(
+ _seat_scopes(phase["result"]) <= {scope}
+ for phase in executed["schedule_execution"]["phase_results"]
+ )
+
+
+@pytest.mark.parametrize(("initial_scope", "prefix"), [("driver", "主驾"), ("passenger", "")])
+def test_unrelated_draft_edit_retains_original_seat_and_explicit_edit_may_extend_it(policy_service, initial_scope, prefix):
+ service, catalog, _ = policy_service
+ name = f"{prefix}星河释压模式"
+ built = _preview(
+ service, f"构建一个{name}",
+ plan=normalize_scene_output(_model_output({initial_scope}), catalog.scene_composition_context()),
+ )
+ _assert_created(built, {initial_scope})
+ draft = built["mode_profile_draft"]
+ revised = _preview(
+ service, f"把{name}的空调温度调整到26度",
+ draft={key: draft[key] for key in ("name", "source_request_id", "vehicle_id")},
+ )
+ _assert_created(revised, {initial_scope})
+ original_seat_ops = {_operation_key(a) for a in _actions(built) if a["domain"] == "seat"}
+ assert {_operation_key(a) for a in _actions(revised) if a["domain"] == "seat"} == original_seat_ops
+ added = "副驾座椅通风调整到1档" if initial_scope == "driver" else "主驾座椅通风调整到低档"
+ draft = revised["mode_profile_draft"]
+ extended = _preview(
+ service, f"把{name}的{added}",
+ draft={key: draft[key] for key in ("name", "source_request_id", "vehicle_id")},
+ )
+ _assert_created(extended, {"driver", "passenger"})
+ assert {_operation_key(a) for a in _actions(extended) if a["domain"] == "seat"} >= original_seat_ops
+
+
+def _structured_client(output):
+ client = MagicMock()
+ client.available = True
+ client.status = "ready"
+ client.start_thread.return_value = "thread-seat-policy"
+ client.start_turn.side_effect = AssertionError("scene fell back to unconstrained agent")
+
+ def start(thread_id, text, *, application_context, output_schema, timeout=None):
+ Draft202012Validator(output_schema).validate(output)
+ turn = ActiveCodexTurn(thread_id=thread_id, turn_id="turn-seat-policy", events=queue.Queue())
+ turn.events.put({"method": "item/completed", "params": {"item": {
+ "id": "answer", "type": "agentMessage", "text": json.dumps(output, ensure_ascii=False),
+ }}})
+ turn.events.put({"method": "turn/completed", "params": {"turn": {
+ "id": turn.turn_id, "status": "completed", "items": [], "error": None,
+ }}})
+ return turn
+
+ client.start_structured_turn.side_effect = start
+ return client
+
+
+@pytest.mark.parametrize(("prefix", "scopes"), [("", {"passenger"}), ("主驾", {"driver"})])
+@pytest.mark.parametrize("dynamic", [False, True])
+def test_unregistered_assistant_structured_path_uses_seat_policy(policy_service, prefix, scopes, dynamic):
+ service, catalog, _ = policy_service
+ client = _structured_client(_model_output(scopes, dynamic=dynamic))
+ response, preview, _ = _assistant_preview(
+ service, catalog, f"构建一个{prefix}星河释压{'动态' if dynamic else ''}模式", client=client,
+ )
+ assert response["success"] is True, (response, preview)
+ assert response["route"] == "scene_composer"
+ _assert_created(preview, scopes)
+ client.start_structured_turn.assert_called_once()
+ client.start_turn.assert_not_called()
diff --git a/tests/integration/test_mode_draft_parameter_matrix.py b/tests/integration/test_mode_draft_parameter_matrix.py
index 255d81e..8156148 100644
--- a/tests/integration/test_mode_draft_parameter_matrix.py
+++ b/tests/integration/test_mode_draft_parameter_matrix.py
@@ -1237,6 +1237,10 @@ def test_known_mode_name_reuses_repository_profile_even_if_a_plan_is_supplied(
) -> None:
service, user_root = mode_service
nap = service.mode_profiles.get_profile("nap", "denza_n7")
+ selected_operations = [
+ operation for operation in nap.operations
+ if operation.domain != "seat" or operation.target_scope in {"passenger", "front_right"}
+ ]
result = _preview_without_hardware(
service,
"构建一个小憩模式",
@@ -1249,15 +1253,16 @@ def test_known_mode_name_reuses_repository_profile_even_if_a_plan_is_supplied(
draft = _assert_successful_preview(
result,
name=nap.name,
- operation_count=len(nap.operations),
+ operation_count=len(selected_operations),
)
assert draft["generation_source"] == "registered_mode_profile"
assert draft["existing_mode_id"] == "nap"
expected = {
(operation.capability, operation.target_scope): operation.parameters
- for operation in nap.operations
+ for operation in selected_operations
}
actual = _action_map(result)
+ assert {target for capability, target in actual if capability.startswith("seat.")} == {"passenger"}
for (capability, target), parameters in expected.items():
rendered_target = "passenger" if target == "front_right" and capability.startswith("seat.") else target
for name, value in parameters.items():
diff --git a/tests/integration/test_mode_schedule_flow.py b/tests/integration/test_mode_schedule_flow.py
new file mode 100644
index 0000000..9d215ca
--- /dev/null
+++ b/tests/integration/test_mode_schedule_flow.py
@@ -0,0 +1,670 @@
+"""Dynamic scene creation through the real assistant, compiler and saved-mode APIs."""
+
+from __future__ import annotations
+
+import json
+import queue
+from copy import deepcopy
+from threading import Event
+from unittest.mock import MagicMock, patch
+
+import pytest
+from jsonschema import Draft202012Validator
+
+from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
+from cognitive_agent.application.codex_assistant import ActiveCodexTurn, CodexAssistantService
+from cognitive_agent.application.mode_profile import ModeProfileRegistry
+from cognitive_agent.application.mode_schedule import ModeSchedule
+from cognitive_agent.application.mode_schedule_service import public_schedule_status
+from cognitive_agent.application.scene_composer import (
+ SceneCompositionError,
+ build_scene_output_schema,
+ normalize_scene_output,
+)
+from cognitive_agent.application.schedule_intent import needs_mode_schedule
+from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
+from cognitive_agent.infrastructure.can.access import HardwareAccessRequiredError
+
+
+@pytest.fixture
+def schedule_service(tmp_path, monkeypatch):
+ for variable in (
+ "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED", "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED",
+ "COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED", "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED",
+ ):
+ monkeypatch.setenv(variable, "false")
+ service = SeatDemoService(REPOSITORY_ROOT)
+ service.artifact_root = tmp_path / "artifacts"
+ user_root = tmp_path / "user-modes"
+ service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root)
+ with (
+ patch.object(service.hardware_execution, "reserve", side_effect=AssertionError("physical vehicle access forbidden")) as reserve,
+ patch.object(service._vehicle_mqtt_transport, "bus", side_effect=AssertionError("physical MQTT access forbidden")),
+ ):
+ try:
+ yield service, AssistantCapabilityCatalog(service), user_root
+ finally:
+ service.close()
+ reserve.assert_not_called()
+
+
+def assistant_preview(service, catalog, message, conversation="conv-schedule-flow", client=None):
+ if client is None:
+ client = MagicMock()
+ client.available = False
+ assistant = CodexAssistantService(client)
+ snapshot = catalog.snapshot(vehicle_id="denza_n7")
+ fast = catalog.fast_answer(message, snapshot)
+ results, events, passed_plans = [], [], []
+
+ def execute(instruction, progress, assistant_plan=None):
+ passed_plans.append(deepcopy(assistant_plan))
+ result = service.preview_native_can(
+ instruction, provider="local", conversation_id=conversation,
+ execution_mode="hardware", vehicle_id="denza_n7",
+ on_commands_validated=progress, assistant_plan=assistant_plan,
+ )
+ results.append(result)
+ return result
+
+ response = assistant.stream(
+ message, conversation_id=conversation, principal="schedule-test", history=[],
+ authoritative_answer=fast.answer if fast else None,
+ emit=lambda event: events.append(event) or True,
+ execute_control=execute, capability_snapshot=snapshot,
+ capability_context=catalog.compact_context(snapshot),
+ scene_capability_context=catalog.scene_composition_context(snapshot),
+ control_terms=catalog.control_terms(snapshot),
+ )
+ assert len(results) == 1, (response, results)
+ return response, results[0], events, passed_plans, client
+
+
+def create_saved(service, catalog, message="构建一个上车预热的功能模式"):
+ response, preview, _, _, _ = assistant_preview(service, catalog, message)
+ assert response["success"] is True, (response, preview)
+ draft = preview["mode_profile_draft"]
+ saved = service.create_mode_profile_card(
+ draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"],
+ )
+ assert saved["status"] == "succeeded"
+ return preview, saved["mode_profile"]
+
+
+@pytest.mark.parametrize(("message", "template_id", "count", "repeats", "elapsed"), [
+ ("构建一个座椅的动态模式", "seat-dynamic", 4, 5, 300000),
+ ("构建一个上车预热的功能模式", "boarding-warmup", 7, 1, 420000),
+ ("帮我创建一个座椅动态模式吧", "seat-dynamic", 4, 5, 300000),
+ ("我想构建一个上车预热模式并预览", "boarding-warmup", 7, 1, 420000),
+ ("构建一个上车暖舱模式", "boarding-warmup", 7, 1, 420000),
+ ("构建一个空调预热模式", "boarding-warmup", 7, 1, 420000),
+ ("构建一个动态座椅模式", "seat-dynamic", 4, 5, 300000),
+ ("构建一个座椅交替放松模式", "seat-dynamic", 4, 5, 300000),
+])
+def test_user_temporal_requests_create_complete_multistage_template_without_model(
+ schedule_service, message, template_id, count, repeats, elapsed,
+):
+ service, catalog, user_root = schedule_service
+ response, preview, events, plans, client = assistant_preview(service, catalog, message)
+ assert response["success"] is True, (response, preview)
+ assert response["route"] == "scene_template"
+ assert preview["status"] == "succeeded"
+ assert preview["execution_mode"] == "dry_run"
+ assert preview["hardware_execution_allowed"] is False
+ assert preview["orchestration_route"] == "mode_profile_create_preview"
+ draft = preview["mode_profile_draft"]
+ assert draft["source_template_id"] == template_id
+ assert draft["generation_source"] == "scene_template_copy"
+ assert draft["operation_count"] == count
+ expected_schedule = deepcopy(plans[0]["schedule"])
+ for phase in expected_schedule["phases"]:
+ phase["operations"] = [
+ op for op in phase["operations"]
+ if op["domain"] != "seat" or op["target_scope"] == "passenger"
+ ]
+ assert draft["schedule"] == preview["schedule"] == expected_schedule
+ schedule = ModeSchedule.from_dict(preview["schedule"])
+ assert schedule.repeat_count == repeats
+ assert schedule.total_duration_ms == elapsed
+ actions = preview["intent_ir"]["spec"]["actions"]
+ assert len(actions) == count
+ assert len({action["id"] for action in actions}) == count
+ assert {item["phase_id"] for item in preview["schedule_phase_previews"]} == {phase.id for phase in schedule.phases}
+ assert len(preview["schedule_phase_previews"]) == 2
+ assert all(item["status"] == "succeeded" for item in preview["schedule_phase_previews"])
+ assert all(any(key.startswith(phase.id + "-") for key in preview["command_plans"]) for phase in schedule.phases)
+ assert {
+ op["target_scope"] for phase in schedule.phases for op in phase.operations
+ if op["domain"] == "seat"
+ } == {"passenger"}
+ assert events
+ client.start_thread.assert_not_called()
+ client.start_turn.assert_not_called()
+ client.start_structured_turn.assert_not_called()
+ assert not list(user_root.rglob("*.yaml"))
+ assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
+
+
+@pytest.mark.parametrize("message", ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"])
+def test_draft_trial_preserves_all_stages_repeats_and_virtual_time_without_thread(schedule_service, message):
+ service, catalog, _ = schedule_service
+ _, preview, _, _, _ = assistant_preview(service, catalog, message)
+ draft = preview["mode_profile_draft"]
+ with patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("dry-run spawned worker")):
+ trial = service.trial_mode_profile_draft(
+ draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"],
+ )
+ assert trial["status"] == "succeeded"
+ assert trial["hardware_execution_allowed"] is False
+ assert trial["schedule"] == preview["schedule"]
+ execution = trial["schedule_execution"]
+ schedule = ModeSchedule.from_dict(preview["schedule"])
+ assert execution["completed"] is True
+ assert execution["virtual_elapsed_ms"] == schedule.total_duration_ms
+ assert execution["executed_phases"] == len(schedule.phases) * schedule.repeat_count
+ assert [step["phase_id"] for step in execution["phase_results"]] == [phase.id for phase in schedule.phases] * schedule.repeat_count
+ assert len(trial["intent_ir"]["spec"]["actions"]) == schedule.operation_count
+
+
+@pytest.mark.parametrize("message", ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"])
+def test_save_reload_and_direct_execute_keep_temporal_semantics(schedule_service, message):
+ service, catalog, user_root = schedule_service
+ preview, public_profile = create_saved(service, catalog, message)
+ assert public_profile["schedule"] == preview["schedule"]
+ assert public_profile["behavior"] == "cyclic"
+ mode_id = public_profile["id"]
+ service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root)
+ reloaded = service.mode_profiles.get_profile(mode_id, "denza_n7")
+ assert reloaded.schedule.to_dict() == preview["schedule"]
+ with patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("dry-run spawned worker")):
+ executed = service.execute_mode_profile_card(
+ mode_id, conversation_id="conv-schedule-execute", execution_mode="dry_run", vehicle_id="denza_n7",
+ )
+ assert executed["status"] == "succeeded", executed
+ assert executed["orchestration_route"] == "mode_schedule_execute"
+ assert executed["execution_mode"] == "dry_run"
+ assert executed["hardware_execution_allowed"] is False
+ assert "mode_profile_draft" not in executed
+ execution = executed["schedule_execution"]
+ assert execution["completed"] is True
+ assert execution["virtual_elapsed_ms"] == reloaded.schedule.total_duration_ms
+ assert len(execution["phase_results"]) == len(reloaded.schedule.phases) * reloaded.schedule.repeat_count
+ expected_phases = list(reloaded.schedule.phases) * reloaded.schedule.repeat_count
+ for expected, actual in zip(expected_phases, execution["phase_results"], strict=True):
+ phase_result = actual["result"]
+ assert phase_result["status"] == "succeeded"
+ assert phase_result["execution_mode"] == "dry_run"
+ actual_actions = phase_result["intent_ir"]["spec"]["actions"]
+ assert len(actual_actions) == len(expected.operations)
+ expected_params = sorted(json.dumps(op["parameters"], sort_keys=True) for op in expected.operations)
+ actual_params = sorted(json.dumps(op["parameters"], sort_keys=True) for op in actual_actions)
+ assert actual_params == expected_params
+ assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
+
+
+def test_natural_language_enter_saved_schedule_runs_timeline_not_flat_actions(schedule_service):
+ service, catalog, _ = schedule_service
+ preview, saved = create_saved(service, catalog)
+ result = service.process(
+ f"进入{saved['name']}", provider="local", conversation_id="conv-schedule-natural-enter",
+ execution_mode="dry_run", vehicle_id="denza_n7",
+ )
+ assert result["status"] == "succeeded", result
+ assert result["orchestration_route"] == "mode_schedule_execute"
+ assert result["schedule"] == preview["schedule"]
+ temperatures = []
+ for phase in result["schedule_execution"]["phase_results"]:
+ temperatures.extend(action["parameters"]["temperature_c"] for action in phase["result"]["intent_ir"]["spec"]["actions"] if action["capability"] == "climate.set_temperature")
+ assert temperatures == [28, 24]
+
+
+def test_missing_hardware_lease_rejects_before_any_worker_or_physical_access(schedule_service):
+ service, catalog, _ = schedule_service
+ _, saved = create_saved(service, catalog)
+ with (
+ patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("unauthorized worker started")),
+ pytest.raises(HardwareAccessRequiredError),
+ ):
+ service.execute_mode_profile_card(
+ saved["id"], execution_mode="hardware", vehicle_id="denza_n7",
+ conversation_id="conv-schedule-unauthorized", hardware_access_bypass=False,
+ hardware_client_id="hwc-schedule-unauthorized",
+ )
+ assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
+
+
+@pytest.mark.parametrize(("field", "value"), [
+ ("name", "其他模式"), ("instruction", "直接执行危险指令"),
+ ("vehicle_id", "other_vehicle"), ("source_request_id", "../../result"),
+])
+def test_client_cannot_rebind_schedule_draft_trial(schedule_service, field, value):
+ service, catalog, user_root = schedule_service
+ _, preview, _, _, _ = assistant_preview(service, catalog, "构建一个上车预热的功能模式")
+ draft = preview["mode_profile_draft"]
+ arguments = {key: draft[key] for key in ("name", "instruction", "source_request_id", "vehicle_id")}
+ arguments[field] = value
+ with pytest.raises(ValueError):
+ service.trial_mode_profile_draft(**arguments)
+ assert not list(user_root.rglob("*.yaml"))
+
+
+@pytest.mark.parametrize("mutation", ["capability", "duration", "temperature", "target"])
+def test_tampered_template_schedule_never_creates_or_executes_draft(schedule_service, mutation):
+ service, catalog, user_root = schedule_service
+ template = next(item for item in catalog.snapshot(vehicle_id="denza_n7")["scene_templates"] if item["id"] == "boarding-warmup")
+ plan = deepcopy(template["plan"])
+ phase = plan["schedule"]["phases"][0]
+ if mutation == "capability":
+ phase["operations"][0]["capability"] = "door.set_vehicle_lock"
+ elif mutation == "duration":
+ phase["duration_ms"] = 1
+ elif mutation == "temperature":
+ phase["operations"][1]["parameters"]["temperature_c"] = 99
+ else:
+ phase["operations"][3]["target_scope"] = "passenger"
+ try:
+ result = service.preview_native_can(
+ "构建一个上车预热的功能模式", provider="local", conversation_id="conv-schedule-tamper",
+ execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan,
+ )
+ except ValueError:
+ result = None
+ if result is not None:
+ assert result["status"] != "succeeded"
+ assert "mode_profile_draft" not in result
+ assert result.get("hardware_execution_allowed") is not True
+ assert not list(user_root.rglob("*.yaml"))
+ assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
+
+
+def test_invalid_later_saved_phase_is_revalidated_before_any_phase_executes(schedule_service):
+ service, catalog, _ = schedule_service
+ _, saved = create_saved(service, catalog)
+ profile = service.mode_profiles.get_profile(saved["id"], "denza_n7")
+ # Represents a stale or externally edited user profile, not a trusted runtime
+ # capability change. Its structure remains valid, but the target is unsafe.
+ profile.schedule.phases[1].operations[0]["parameters"]["temperature_c"] = 99
+ with patch.object(service.mode_schedule_runner, "simulate", side_effect=AssertionError("invalid later phase reached executor")):
+ result = service.execute_mode_profile_card(
+ saved["id"], conversation_id="conv-schedule-stale-capability",
+ execution_mode="dry_run", vehicle_id="denza_n7",
+ )
+ assert result["status"] == "validation_failed", result
+ assert result["mode_creation_failure"]["code"] == "MODE_SCHEDULE_PHASE_INVALID"
+ assert "mode_profile_draft" not in result
+ assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
+
+
+def test_execution_failure_after_validated_first_phase_prevents_later_repeats(schedule_service):
+ service, catalog, _ = schedule_service
+ _, saved = create_saved(service, catalog, "构建一个主驾和副驾的座椅动态模式")
+ preview_method = service._preview_mode_schedule
+ virtual_method = service.native_can_runtime.execute_virtual
+ execution_started = False
+ executed_massage_modes = []
+
+ def validate_then_arm(*args, **kwargs):
+ nonlocal execution_started
+ result = preview_method(*args, **kwargs)
+ execution_started = True
+ return result
+
+ def fault_on_second_phase(command_plan, function_config):
+ if execution_started:
+ for op in function_config["spec"]["operations"]:
+ if op["capability"] == "seat.set_driver_massage_native":
+ executed_massage_modes.append(op["parameters"]["mode"])
+ if op["parameters"]["mode"] == "slack":
+ raise RuntimeError("injected lower-level actuator failure")
+ return virtual_method(command_plan, function_config)
+
+ with (
+ patch.object(service, "_preview_mode_schedule", side_effect=validate_then_arm),
+ patch.object(service.native_can_runtime, "execute_virtual", side_effect=fault_on_second_phase),
+ ):
+ result = service.execute_mode_profile_card(
+ saved["id"], conversation_id="conv-schedule-injected-failure",
+ execution_mode="dry_run", vehicle_id="denza_n7",
+ )
+ execution = result["schedule_execution"]
+ assert result["status"] == "failed", result
+ assert execution["completed"] is False
+ assert execution["error"]
+ assert execution["executed_phases"] == 1
+ assert execution["virtual_elapsed_ms"] == 30000
+ assert execution["repeat_index"] == 0
+ assert len(execution["phase_results"]) == 2
+ assert executed_massage_modes == ["relax", "slack"]
+ assert result["hardware_execution_allowed"] is False
+
+
+def structured_schedule():
+ return {
+ "requests": [], "ambiguities": [], "confidence": 0.96,
+ "schedule": {
+ "repeat_count": 2,
+ "phases": [
+ {"id": "warm", "name": "暖舱", "duration_ms": 30000,
+ "requests": [{"capability": "climate.set_temperature", "target_scope": "vehicle", "parameters": {"temperature_c": 28}}]},
+ {"id": "settle", "name": "恒温", "duration_ms": 60000,
+ "requests": [{"capability": "climate.set_temperature", "target_scope": "vehicle", "parameters": {"temperature_c": 24}}]},
+ ],
+ },
+ }
+
+
+def structured_client(output):
+ client = MagicMock()
+ client.available = True
+ client.status = "ready"
+ client.start_thread.return_value = "thread-schedule-test"
+ client.start_turn.side_effect = AssertionError("dynamic creation fell back to unstructured agent")
+
+ def start(thread_id, text, *, application_context, output_schema, timeout=None):
+ Draft202012Validator(output_schema).validate(output)
+ turn = ActiveCodexTurn(thread_id=thread_id, turn_id="turn-schedule-test", events=queue.Queue())
+ turn.events.put({"method": "item/completed", "params": {"item": {"id": "answer", "type": "agentMessage", "text": json.dumps(output, ensure_ascii=False)}}})
+ turn.events.put({"method": "turn/completed", "params": {"turn": {"id": turn.turn_id, "status": "completed", "items": [], "error": None}}})
+ return turn
+
+ client.start_structured_turn.side_effect = start
+ return client
+
+
+@pytest.mark.parametrize("message", [
+ "构建一个星云动态模式,空调先28度持续30秒,然后24度持续60秒,循环2次",
+ "构建一个阅读模式,空调先28度持续30秒,然后24度持续60秒,循环2次",
+ "构建一个上车预热的功能模式,空调先28度持续30秒,然后24度持续60秒,循环2次",
+])
+def test_explicit_temporal_parameters_use_structured_composition_not_static_template(schedule_service, message):
+ service, catalog, user_root = schedule_service
+ output = structured_schedule()
+ client = structured_client(output)
+ response, preview, _, plans, _ = assistant_preview(service, catalog, message, client=client)
+ assert response["route"] == "scene_composer", response
+ assert response["success"] is True, (response, preview)
+ assert preview["mode_profile_draft"]["generation_source"] != "scene_template_copy"
+ client.start_structured_turn.assert_called_once()
+ client.start_turn.assert_not_called()
+ assert "schedule" in client.start_structured_turn.call_args.kwargs["output_schema"]["properties"]
+ assert preview["schedule"] == plans[0]["schedule"]
+ assert preview["schedule"]["repeat_count"] == 2
+ assert [phase["duration_ms"] for phase in preview["schedule"]["phases"]] == [30000, 60000]
+ assert [phase["operations"][0]["parameters"]["temperature_c"] for phase in preview["schedule"]["phases"]] == [28, 24]
+ assert ModeSchedule.from_dict(preview["schedule"]).total_duration_ms == 180000
+ assert not list(user_root.rglob("*.yaml"))
+
+
+def test_dynamic_schema_normalization_preserves_complete_phase_contract(schedule_service):
+ _, catalog, _ = schedule_service
+ context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7"))
+ output = structured_schedule()
+ schema = build_scene_output_schema(context, dynamic=True)
+ Draft202012Validator(schema).validate(output)
+ plan = normalize_scene_output(output, context, dynamic=True)
+ assert plan["ambiguities"] == []
+ schedule = ModeSchedule.from_dict(plan["schedule"])
+ assert schedule.total_duration_ms == 180000
+ assert schedule.operation_count == 2
+ assert schedule.phases[0].operations[0]["domain"] == "climate"
+ assert schedule.phases[1].operations[0]["parameters"] == {"temperature_c": 24}
+
+
+@pytest.mark.parametrize("mutation", [
+ "static-root", "unknown-root", "unknown-schedule", "unknown-phase", "unknown-capability",
+ "duplicate-phase", "duplicate-operation", "no-phases", "empty-phase", "bool-duration",
+ "bool-repeat", "long-duration", "long-total", "nan-parameter", "motion-capability",
+])
+def test_invalid_structured_dynamic_plans_fail_closed_during_normalization(schedule_service, mutation):
+ _, catalog, _ = schedule_service
+ context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7"))
+ output = structured_schedule()
+ schedule = output["schedule"]
+ phase = schedule["phases"][0]
+ if mutation == "static-root":
+ output["requests"] = deepcopy(phase["requests"])
+ elif mutation == "unknown-root":
+ output["execute_now"] = True
+ elif mutation == "unknown-schedule":
+ schedule["unbounded"] = True
+ elif mutation == "unknown-phase":
+ phase["skip_safety"] = True
+ elif mutation == "unknown-capability":
+ phase["requests"][0]["capability"] = "unknown.actuate"
+ elif mutation == "motion-capability":
+ phase["requests"][0] = {"capability": "seat.set_driver_axis_native", "target_scope": "driver", "parameters": {"duration_ms": 1000, "axis": "backrest", "direction": "backward"}}
+ elif mutation == "duplicate-phase":
+ schedule["phases"][1]["id"] = phase["id"]
+ elif mutation == "duplicate-operation":
+ phase["requests"].append(deepcopy(phase["requests"][0]))
+ elif mutation == "no-phases":
+ schedule["phases"] = []
+ elif mutation == "empty-phase":
+ phase["requests"] = []
+ elif mutation == "bool-duration":
+ phase["duration_ms"] = True
+ elif mutation == "bool-repeat":
+ schedule["repeat_count"] = True
+ elif mutation == "long-duration":
+ phase["duration_ms"] = 900001
+ elif mutation == "long-total":
+ schedule["repeat_count"] = 20
+ phase["duration_ms"] = 900000
+ elif mutation == "nan-parameter":
+ phase["requests"][0]["parameters"]["temperature_c"] = float("nan")
+ with pytest.raises(SceneCompositionError):
+ normalize_scene_output(output, context, dynamic=True)
+
+
+def test_dynamic_ambiguity_produces_no_schedule_or_executable_actions(schedule_service):
+ _, catalog, _ = schedule_service
+ context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7"))
+ output = {"requests": [], "ambiguities": ["当前未开放该动态动作,请指定其他设备。"], "confidence": 0.2, "schedule": None}
+ Draft202012Validator(build_scene_output_schema(context, dynamic=True)).validate(output)
+ plan = normalize_scene_output(output, context, dynamic=True)
+ assert not plan["requests"]
+ assert "schedule" not in plan
+ assert plan["ambiguities"]
+
+
+def test_model_schedule_with_unresolved_requirement_never_generates_successful_draft(schedule_service):
+ service, catalog, user_root = schedule_service
+ context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7"))
+ output = structured_schedule()
+ output["ambiguities"] = ["不能完成要求的主驾座椅绝对位置控制。"]
+ plan = normalize_scene_output(output, context, dynamic=True)
+ assert "metadata" not in plan
+ with pytest.raises(ValueError, match="未解决"):
+ service.preview_native_can(
+ "构建一个流光动态模式", provider="local", conversation_id="conv-schedule-ambiguous",
+ execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan,
+ )
+ assert not list(user_root.rglob("*.yaml"))
+ assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
+
+
+@pytest.mark.parametrize(("seat", "allowed_targets"), [
+ ("主驾", {"driver", "front_left"}), ("副驾", {"passenger", "front_right"}),
+])
+def test_single_seat_dynamic_execution_cannot_expand_to_both_seats(schedule_service, seat, allowed_targets):
+ service, catalog, _ = schedule_service
+ _, saved = create_saved(service, catalog, "构建一个主驾和副驾的座椅动态模式")
+ result = service.process(
+ f"只让{seat}进入{saved['name']}", provider="local", conversation_id="conv-schedule-single-seat",
+ execution_mode="dry_run", vehicle_id="denza_n7",
+ )
+ if result["status"] == "succeeded":
+ assert result["orchestration_route"] == "mode_schedule_execute"
+ execution = result["schedule_execution"]
+ for phase in execution["phase_results"]:
+ actions = phase["result"]["intent_ir"]["spec"]["actions"]
+ seat_targets = {action["target"].get("instance") or action["target"].get("zone") for action in actions if action["domain"] == "seat"}
+ assert seat_targets and seat_targets <= allowed_targets
+ else:
+ assert result["status"] in {"validation_failed", "needs_clarification", "needs_confirmation", "failed"}
+ assert not result.get("schedule_execution", {}).get("phase_results")
+ assert result.get("hardware_execution_allowed") is not True
+
+
+def start_waiting_schedule(service):
+ """Run only a no-device test callback, holding until the tested stop request."""
+ calls = []
+ entered = Event()
+ schedule = ModeSchedule.from_dict({
+ "schema": "cognitive-agent-mode-schedule/v1", "repeat_count": 1,
+ "phases": [
+ {"id": phase_id, "name": phase_id, "duration_ms": 900000,
+ "operations": [{"domain": "climate", "capability": "climate.set_temperature",
+ "target_scope": "vehicle", "operation": "set",
+ "parameters": {"temperature_c": temperature}, "reason": "test only"}]}
+ for phase_id, temperature in (("first", 28), ("second", 24))
+ ],
+ })
+
+ def execute(phase):
+ calls.append(phase.id)
+ entered.set()
+ return {"status": "succeeded"}
+
+ service.mode_schedule_runner.start("denza_n7", schedule, execute, session_id="conv-stop-status")
+ assert entered.wait(2)
+ assert service.mode_schedule_runner.status("denza_n7")["status"] == "running"
+ return calls
+
+
+def assistant_control(catalog, message, execute):
+ client = MagicMock()
+ client.available = False
+ assistant = CodexAssistantService(client)
+ snapshot = catalog.snapshot(vehicle_id="denza_n7")
+ events = []
+ response = assistant.stream(
+ message, conversation_id="conv-stop-status", principal="schedule-status-test", history=[],
+ emit=lambda event: events.append(event) or True,
+ execute_control=execute, capability_snapshot=snapshot,
+ capability_context=catalog.compact_context(snapshot),
+ scene_capability_context=catalog.scene_composition_context(snapshot),
+ control_terms=catalog.control_terms(snapshot),
+ )
+ client.start_thread.assert_not_called()
+ client.start_structured_turn.assert_not_called()
+ client.start_turn.assert_not_called()
+ return response, events
+
+
+def test_dry_run_stop_does_not_cancel_a_running_vehicle_schedule(schedule_service):
+ service, _, _ = schedule_service
+ calls = start_waiting_schedule(service)
+ result = service.process(
+ "停止动态模式", provider="local", conversation_id="conv-stop-status",
+ execution_mode="dry_run", vehicle_id="denza_n7",
+ )
+ assert result["status"] == "succeeded"
+ assert result["schedule_execution"]["status"] == "simulated"
+ assert result["hardware_execution_allowed"] is False
+ assert service.mode_schedule_runner.status("denza_n7")["status"] == "running"
+ assert calls == ["first"]
+ persisted = json.loads((service.artifact_root / result["request_id"] / "result.json").read_text(encoding="utf-8"))
+ assert persisted["orchestration_route"] == "mode_schedule_stop"
+ assert persisted["trace_events"] == []
+
+
+def test_hardware_stop_cancels_future_stages_and_persists_the_result(schedule_service):
+ service, _, _ = schedule_service
+ calls = start_waiting_schedule(service)
+ result = service.process(
+ "停止动态模式", provider="local", conversation_id="conv-stop-status",
+ execution_mode="hardware", vehicle_id="denza_n7",
+ )
+ assert result["status"] == "succeeded"
+ assert result["schedule_execution"]["status"] == "cancelled"
+ assert result["schedule_execution"]["completed"] is False
+ assert service.mode_schedule_runner.status("denza_n7")["status"] == "cancelled"
+ assert service.mode_schedule_runner.close(timeout=1)
+ assert calls == ["first"]
+ persisted = json.loads((service.artifact_root / result["request_id"] / "result.json").read_text(encoding="utf-8"))
+ assert persisted["orchestration_route"] == "mode_schedule_stop"
+ assert persisted["schedule_execution"]["status"] == "cancelled"
+ assert persisted["trace_events"] == []
+
+
+@pytest.mark.parametrize(("execution_mode", "action_state", "runner_state"), [
+ ("hardware", "cancelled", "cancelled"), ("dry_run", "simulated", "running"),
+])
+def test_assistant_stop_is_zero_model_success_and_preserves_execution_policy(
+ schedule_service, execution_mode, action_state, runner_state,
+):
+ service, catalog, _ = schedule_service
+ calls = start_waiting_schedule(service)
+
+ def execute(message, progress):
+ return service.process(
+ message, provider="local", conversation_id="conv-stop-status",
+ execution_mode=execution_mode, vehicle_id="denza_n7",
+ )
+
+ response, events = assistant_control(catalog, "停止动态模式", execute)
+ assert response["success"] is True, response
+ assert response["route"] == "vehicle_control"
+ action = next(event for event in events if event["type"] == "action.completed")
+ assert action["success"] is True
+ assert action["state"] == action_state
+ assert action["payload"]["orchestration_route"] == "mode_schedule_stop"
+ assert service.mode_schedule_runner.status("denza_n7")["status"] == runner_state
+ assert calls == ["first"]
+ assert "已完成" not in response["answer"]
+ assert "未改变正在运行" in response["answer"] if execution_mode == "dry_run" else "已停止" in response["answer"]
+
+
+def test_assistant_running_schedule_is_accepted_without_claiming_all_phases_completed(schedule_service):
+ service, catalog, _ = schedule_service
+ preview, profile = create_saved(service, catalog)
+ start_waiting_schedule(service)
+ state = service.mode_schedule_runner.status("denza_n7")
+ state["phase_results"] = [{"command_frames": ["sensitive-can-frame"]}]
+
+ def execute(message, progress):
+ return {
+ "status": "running", "execution_mode": "hardware", "hardware_execution_allowed": True,
+ "orchestration_route": "mode_schedule_execute", "schedule_execution": state,
+ "mode_profile": profile, "matched_modes": [profile], "schedule": preview["schedule"],
+ }
+
+ response, events = assistant_control(catalog, f"进入{profile['name']}", execute)
+ assert response["success"] is True, response
+ action = next(event for event in events if event["type"] == "action.completed")
+ assert action["success"] is True
+ assert action["state"] == "running"
+ assert action["payload"]["status"] == "running"
+ assert action["payload"]["schedule_execution"]["completed"] is False
+ assert "phase_results" not in action["payload"]["schedule_execution"]
+ assert "sensitive-can-frame" not in json.dumps(events, ensure_ascii=False)
+ assert "尚未完成所有阶段" in action["display_text"]
+ assert "尚未完成所有阶段" in response["answer"]
+ completed = next(event for event in events if event["type"] == "work.completed")
+ assert completed["state"] == "running"
+ assert "已完成" not in completed["display_text"]
+
+
+def test_public_schedule_status_exposes_only_lifecycle_and_redacts_raw_error():
+ state = {
+ "run_id": "run-public", "vehicle_id": "denza_n7", "status": "failed",
+ "phase_index": 1, "repeat_index": 0, "completed": False,
+ "error": "secret-token /private/path/driver.dll CAN payload 123456",
+ "phase_results": [{"command_plan": {"data_hex": "deadbeef"}}],
+ "raw_error": {"token": "secret-token"}, "command_plans": {"internal": "data"},
+ "phase_count": {"nested": "unexpected"},
+ }
+ public = public_schedule_status(state)
+ assert public["status"] == "failed"
+ assert public["phase_index"] == 1
+ assert public["completed"] is False
+ assert public["error"] and public["error"] != state["error"]
+ assert set(public) == {"run_id", "vehicle_id", "status", "phase_index", "repeat_index", "completed", "error"}
+ for sensitive in ("secret-token", "driver.dll", "deadbeef", "123456"):
+ assert sensitive not in json.dumps(public, ensure_ascii=False)
+
+
+@pytest.mark.parametrize("message", ["外循环通风模式", "内循环模式"])
+def test_hvac_circulation_state_is_not_a_temporal_schedule(message):
+ assert needs_mode_schedule(message) is False
diff --git a/tests/integration/test_mode_schedule_natural_language_edit.py b/tests/integration/test_mode_schedule_natural_language_edit.py
new file mode 100644
index 0000000..ea1c278
--- /dev/null
+++ b/tests/integration/test_mode_schedule_natural_language_edit.py
@@ -0,0 +1,278 @@
+"""Natural-language edits cannot flatten or silently broaden dynamic phases."""
+
+from __future__ import annotations
+
+import hashlib
+from copy import deepcopy
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from cognitive_agent.application.mode_profile import ModeProfileRegistry
+from cognitive_agent.application.mode_schedule import ModeSchedule
+from cognitive_agent.application.mode_schedule_edit import edit_schedule
+from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
+
+
+@pytest.fixture
+def service(tmp_path):
+ with patch.dict(
+ "os.environ",
+ {
+ "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
+ "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
+ "COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false",
+ "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false",
+ },
+ ):
+ instance = SeatDemoService(REPOSITORY_ROOT)
+ instance.artifact_root = tmp_path / "runs"
+ instance.mode_profiles = ModeProfileRegistry(
+ REPOSITORY_ROOT / "config" / "modes",
+ user_root=tmp_path / "user-modes",
+ )
+ try:
+ with patch.object(
+ instance.hardware_execution,
+ "reserve",
+ side_effect=AssertionError("schedule edit reached hardware"),
+ ):
+ yield instance
+ finally:
+ instance.close()
+
+
+def _build(service):
+ template = service.scene_templates.get_template("boarding-warmup")
+ result = service.preview_native_can(
+ "构建一个上车预热模式",
+ provider="local",
+ conversation_id="conv-schedule-edit",
+ execution_mode="hardware",
+ vehicle_id="denza_n7",
+ assistant_plan=template.plan(),
+ )
+ assert result["status"] == "succeeded", result.get("validation")
+ return result
+
+
+def _edit(service, built, change):
+ draft = built["mode_profile_draft"]
+ reference = {key: draft[key] for key in ("name", "source_request_id", "vehicle_id")}
+ return service.preview_native_can(
+ f"把上车预热模式的{change}",
+ provider="local",
+ conversation_id="conv-schedule-edit",
+ execution_mode="hardware",
+ vehicle_id="denza_n7",
+ mode_profile_draft_context=reference,
+ )
+
+
+def _parameters(schedule, index, capability, target="vehicle"):
+ return next(
+ op["parameters"]
+ for op in schedule["phases"][index]["operations"]
+ if op["capability"] == capability and op["target_scope"] == target
+ )
+
+
+@pytest.mark.parametrize(
+ "change",
+ (
+ "第二阶段空调温度调整到26度",
+ "第2个阶段的空调温度变成26度",
+ "舒适恒温阶段空调温度改为26度",
+ ),
+)
+def test_draft_temperature_edit_preserves_first_phase_and_timing(service, change):
+ built = _build(service)
+ original = deepcopy(built["schedule"])
+ revised = _edit(service, built, change)
+ assert revised["status"] == "succeeded", revised.get("mode_creation_failure")
+ expected = deepcopy(original)
+ _parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26
+ assert revised["schedule"] == expected
+ assert built["schedule"] == original
+ assert revised["mode_profile_draft"]["schedule"] == expected
+ assert revised["mode_profile_draft"]["draft_revision"] == 2
+ assert revised["mode_profile_draft"]["revises_source_request_id"] == built["request_id"]
+ assert revised["execution_mode"] == "dry_run"
+ assert revised["hardware_execution_allowed"] is False
+
+
+@pytest.mark.parametrize(
+ ("change", "expected_hold", "expected_repeat"),
+ (
+ ("第二阶段持续时间调整为3分钟", 180000, 1),
+ ("第二阶段保持30秒", 30000, 1),
+ ("第二阶段时长改成2.5分钟", 150000, 1),
+ ("循环次数改成3次", 300000, 3),
+ ("重复两次", 300000, 2),
+ ),
+)
+def test_draft_time_and_repeat_edits_keep_operations(
+ service, change, expected_hold, expected_repeat
+):
+ built = _build(service)
+ revised = _edit(service, built, change)
+ assert revised["status"] == "succeeded", revised.get("mode_creation_failure")
+ assert revised["schedule"]["phases"][1]["duration_ms"] == expected_hold
+ assert revised["schedule"]["repeat_count"] == expected_repeat
+ for index in (0, 1):
+ assert (
+ revised["schedule"]["phases"][index]["operations"]
+ == built["schedule"]["phases"][index]["operations"]
+ )
+
+
+def test_all_phases_temperature_edit_is_explicit_and_preserves_all_other_operations(service):
+ built = _build(service)
+ revised = _edit(service, built, "全部阶段空调温度调整到25度")
+ assert revised["status"] == "succeeded", revised.get("mode_creation_failure")
+ expected = deepcopy(built["schedule"])
+ for index in (0, 1):
+ _parameters(expected, index, "climate.set_temperature")["temperature_c"] = 25
+ assert revised["schedule"] == expected
+
+
+@pytest.mark.parametrize(
+ "change",
+ (
+ "空调温度调整到25度",
+ "第三阶段空调温度调整到25度",
+ "第二阶段持续16分钟",
+ "循环21次",
+ "重复1.5次",
+ "持续30秒",
+ "第二阶段主驾靠背调整到90%",
+ "第二阶段空调温度调整到99度",
+ "第二阶段打开后备箱",
+ "第二阶段空调温度调整到26度并打开后备箱",
+ ),
+)
+def test_ambiguous_unsafe_or_unbounded_edit_leaves_original_draft_unchanged(service, change):
+ built = _build(service)
+ original = deepcopy(built["schedule"])
+ revised = _edit(service, built, change)
+ assert revised["status"] in {"needs_confirmation", "validation_failed"}
+ assert not revised.get("mode_profile_draft")
+ assert revised["hardware_execution_allowed"] is False
+ source = service._load_mode_profile_draft_reference(
+ {
+ key: built["mode_profile_draft"][key]
+ for key in ("name", "source_request_id", "vehicle_id")
+ },
+ conversation_id="conv-schedule-edit",
+ mode_name="上车预热模式",
+ vehicle_id="denza_n7",
+ )[1]
+ assert source["schedule"] == original
+
+
+def test_saved_schedule_edit_revalidates_and_persists_timeline_not_flat_actions(service):
+ built = _build(service)
+ draft = built["mode_profile_draft"]
+ saved = service.create_mode_profile_card(
+ draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"]
+ )
+ mode_id = saved["mode_profile"]["id"]
+ profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
+ path = Path(profile.source)
+ before = hashlib.sha256(path.read_bytes()).hexdigest()
+ rejected = service.preview_native_can(
+ "把上车预热模式的空调温度调整到26度",
+ provider="local",
+ conversation_id="conv-saved-schedule-edit",
+ execution_mode="hardware",
+ vehicle_id="denza_n7",
+ )
+ assert rejected["status"] == "needs_confirmation"
+ assert hashlib.sha256(path.read_bytes()).hexdigest() == before
+
+ revised = service.preview_native_can(
+ "把上车预热模式的第二阶段空调温度调整到26度",
+ provider="local",
+ conversation_id="conv-saved-schedule-edit",
+ execution_mode="hardware",
+ vehicle_id="denza_n7",
+ )
+ assert revised["status"] == "succeeded", revised.get("validation")
+ assert revised["orchestration_route"] == "mode_profile_update"
+ assert revised["hardware_execution_allowed"] is False
+ actual = service.mode_profiles.get_profile(mode_id, "denza_n7").schedule.to_dict()
+ expected = deepcopy(built["schedule"])
+ _parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26
+ assert actual == expected
+ assert revised["schedule"] == expected
+ assert revised["mode_profile"]["behavior"] == "cyclic"
+
+
+def test_combined_phase_action_hold_then_repeat_revision_preserves_timeline(service):
+ built = _build(service)
+ revised = _edit(service, built, "第二阶段空调温度调整到26度,持续3分钟")
+ assert revised["status"] == "succeeded", revised.get("mode_creation_failure")
+ repeated = _edit(service, revised, "重复两次")
+ assert repeated["status"] == "succeeded", repeated.get("mode_creation_failure")
+ expected = deepcopy(built["schedule"])
+ _parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26
+ expected["phases"][1]["duration_ms"] = 180000
+ expected["repeat_count"] = 2
+ assert repeated["schedule"] == expected
+ assert repeated["mode_profile_draft"]["draft_revision"] == 3
+ draft = repeated["mode_profile_draft"]
+ saved = service.create_mode_profile_card(
+ draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"]
+ )
+ assert (
+ service.mode_profiles.get_profile(
+ saved["mode_profile"]["id"], "denza_n7"
+ ).schedule.to_dict()
+ == expected
+ )
+
+
+def test_all_phases_is_not_mistaken_for_all_vehicle_zones(service):
+ source = service.scene_templates.get_template("boarding-warmup").plan()["schedule"]
+ for phase in source["phases"]:
+ for operation in phase["operations"]:
+ if operation["capability"] == "climate.set_temperature":
+ operation["target_scope"] = "passenger"
+ schedule = ModeSchedule.from_dict(source)
+ updated = edit_schedule(
+ schedule,
+ "全部阶段空调温度调整到25度",
+ parse_operations=lambda _: [
+ {
+ "domain": "climate",
+ "capability": "climate.set_temperature",
+ "target_scope": "vehicle",
+ "operation": "set",
+ "parameters": {"temperature_c": 25},
+ }
+ ],
+ target_is_explicit=service._mode_update_target_is_explicit,
+ )
+ for index in (0, 1):
+ assert _parameters(updated.to_dict(), index, "climate.set_temperature", "passenger") == {
+ "temperature_c": 25
+ }
+ assert not any(
+ op["capability"] == "climate.set_temperature" and op["target_scope"] == "vehicle"
+ for op in updated.phases[index].operations
+ )
+
+
+def test_phase_motor_duration_cannot_become_a_hold_duration(service):
+ built = _build(service)
+ result = _edit(service, built, "第二阶段副驾靠背向后移动3s")
+ assert result["status"] in {"needs_confirmation", "validation_failed"}
+ assert not result.get("mode_profile_draft")
+
+
+def test_stage_selector_does_not_authorize_delayed_action_semantics(service):
+ built = _build(service)
+ result = _edit(service, built, "第二阶段30秒后空调温度调整到26度")
+ assert result["status"] == "needs_confirmation"
+ assert not result.get("mode_profile_draft")
diff --git a/tests/integration/test_named_mode_inference_regression.py b/tests/integration/test_named_mode_inference_regression.py
index 80c6129..8802bd4 100644
--- a/tests/integration/test_named_mode_inference_regression.py
+++ b/tests/integration/test_named_mode_inference_regression.py
@@ -273,7 +273,8 @@ def test_name_only_nap_request_reuses_the_verified_repository_profile(
draft = result["mode_profile_draft"]
assert isinstance(draft, dict)
assert draft["name"] == "小憩模式"
- assert draft["operation_count"] == len(preset.operations) == 9
+ assert draft["operation_count"] == 9
+ assert len(preset.operations) == 10 # The registry retains both optional seat variants.
assert set(draft["domains"]) == {"seat", "climate", "roof", "ambient_light"}
assert draft["existing_mode_id"] == "nap"
assert isinstance(draft["existing_mode_revision"], str)
@@ -283,6 +284,7 @@ def test_name_only_nap_request_reuses_the_verified_repository_profile(
actions = _intent_actions(result)
assert {action["capability"] for action in actions} == {
operation.capability for operation in preset.operations
+ if operation.domain != "seat" or operation.target_scope in {"passenger", "front_right"}
} == EXPECTED_NAP_CAPABILITIES
expected_targets_and_parameters = {
operation.capability: (
@@ -292,6 +294,7 @@ def test_name_only_nap_request_reuses_the_verified_repository_profile(
operation.parameters,
)
for operation in preset.operations
+ if operation.domain != "seat" or operation.target_scope in {"passenger", "front_right"}
}
actual_targets_and_parameters = {
action["capability"]: (
diff --git a/tests/integration/test_registered_scene_seat_coverage.py b/tests/integration/test_registered_scene_seat_coverage.py
new file mode 100644
index 0000000..1b5c7c0
--- /dev/null
+++ b/tests/integration/test_registered_scene_seat_coverage.py
@@ -0,0 +1,347 @@
+"""Seat-aware built-in defaults must remain safe and preserve saved user intent."""
+
+from __future__ import annotations
+
+import hashlib
+from collections.abc import Iterator
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from cognitive_agent.application.mode_profile import ModeProfileRegistry
+from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
+
+COMFORT_TEMPLATE_IDS = (
+ "reading",
+ "cinema",
+ "camping",
+ "quiet-night",
+ "reception",
+ "romantic",
+ "winter-warmth",
+ "summer-cooling",
+ "long-trip-comfort",
+ "relaxation",
+ "seat-comfort",
+)
+DRIVER_COMFORT_CAPABILITIES = {
+ "seat.set_driver_ventilation_native",
+ "seat.set_driver_heating_native",
+ "seat.set_driver_massage_native",
+}
+
+
+@pytest.fixture
+def service(tmp_path: Path) -> Iterator[SeatDemoService]:
+ with patch.dict(
+ "os.environ",
+ {
+ "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
+ "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
+ "COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false",
+ "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false",
+ },
+ ):
+ instance = SeatDemoService(REPOSITORY_ROOT)
+ instance.artifact_root = tmp_path / "runs"
+ instance.mode_profiles = ModeProfileRegistry(
+ REPOSITORY_ROOT / "config" / "modes",
+ user_root=tmp_path / "user-modes",
+ )
+ try:
+ with patch.object(
+ instance.hardware_execution,
+ "reserve",
+ side_effect=AssertionError("creating or previewing a mode reached real hardware"),
+ ):
+ yield instance
+ finally:
+ instance.close()
+
+
+def _preview(service: SeatDemoService, instruction: str, *, plan=None) -> dict:
+ result = service.preview_native_can(
+ instruction,
+ provider="local",
+ conversation_id="conv-registered-seat-coverage",
+ execution_mode="hardware",
+ vehicle_id="denza_n7",
+ assistant_plan=plan,
+ )
+ assert result["status"] == "succeeded", result.get("validation")
+ assert result["execution_mode"] == "dry_run"
+ assert result["hardware_execution_allowed"] is False
+ return result
+
+
+def _action_map(result: dict) -> dict:
+ return {
+ (
+ action["capability"],
+ action["target"].get("instance") or action["target"].get("zone"),
+ ): action["parameters"]
+ for action in result["intent_ir"]["spec"]["actions"]
+ }
+
+
+@pytest.mark.parametrize("template_id", COMFORT_TEMPLATE_IDS)
+@pytest.mark.parametrize(("seat_prefix", "targets"), (
+ ("", {"passenger"}), ("主驾", {"driver"}),
+ ("副驾", {"passenger"}), ("主驾和副驾", {"driver", "passenger"}),
+))
+def test_comfort_templates_select_only_requested_seat_without_mutating_seat_seeds(
+ service: SeatDemoService,
+ template_id: str,
+ seat_prefix: str,
+ targets: set[str],
+) -> None:
+ template = service.scene_templates.get_template(template_id)
+ original_revision = template.revision
+ result = _preview(service, f"构建一个{seat_prefix}{template.name}", plan=template.plan())
+ actions = _action_map(result)
+ seat_keys = {key for key in actions if key[0].startswith("seat.")}
+ assert {target for _, target in seat_keys} == targets
+ assert {
+ operation.target_scope for operation in template.operations if operation.domain == "seat"
+ } == {"driver", "passenger"}
+ assert all(
+ operation.capability in DRIVER_COMFORT_CAPABILITIES
+ for operation in template.operations
+ if operation.domain == "seat" and operation.target_scope == "driver"
+ ), "driver positional or timed movement must not enter static comfort defaults"
+ assert not any(
+ "massage" in capability for capability, target in seat_keys if target == "passenger"
+ )
+ assert actions == {
+ (operation.capability, operation.target_scope): operation.parameters
+ for operation in template.operations
+ if operation.domain != "seat" or operation.target_scope in targets
+ }
+ assert result["mode_profile_draft"]["generation_source"] == "scene_template_copy"
+ assert service.scene_templates.get_template(template_id).revision == original_revision
+
+
+@pytest.mark.parametrize(
+ ("mode_id", "mode_name"),
+ (
+ ("office", "办公模式"),
+ ("nap", "小憩模式"),
+ ("meditation_space", "冥想空间模式"),
+ ("leisure_lounger", "休闲躺椅模式"),
+ ),
+)
+@pytest.mark.parametrize(("seat_prefix", "targets"), (
+ ("", {"passenger"}), ("主驾", {"driver"}),
+ ("副驾", {"passenger"}), ("主驾和副驾", {"driver", "passenger"}),
+))
+def test_registered_cabin_modes_select_only_requested_seat_on_creation(
+ service: SeatDemoService,
+ mode_id: str,
+ mode_name: str,
+ seat_prefix: str,
+ targets: set[str],
+) -> None:
+ result = _preview(service, f"构建一个{seat_prefix}{mode_name}")
+ actions = _action_map(result)
+ assert {target for capability, target in actions if capability.startswith("seat.")} == targets
+ if "passenger" in targets:
+ assert ("seat.set_backrest_recline", "passenger") in actions
+ draft = result["mode_profile_draft"]
+ if seat_prefix:
+ assert "existing_mode_id" not in draft, "a selected-seat copy must not overwrite the original preset"
+ else:
+ assert draft["existing_mode_id"] == mode_id
+ assert draft["generation_source"] == "registered_mode_profile"
+ profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
+ assert actions == {
+ (operation.capability, operation.target_scope): operation.parameters
+ for operation in profile.operations
+ if operation.domain != "seat" or operation.target_scope in targets
+ }
+
+
+@pytest.mark.parametrize("template_id", ("fresh-air", "rain-defrost"))
+def test_air_quality_and_visibility_templates_do_not_invent_seat_actions(
+ service: SeatDemoService,
+ template_id: str,
+) -> None:
+ template = service.scene_templates.get_template(template_id)
+ result = _preview(service, f"构建一个{template.name}", plan=template.plan())
+ assert not any(capability.startswith("seat.") for capability, _ in _action_map(result))
+
+
+@pytest.mark.parametrize(
+ "template_id",
+ (
+ "long-trip-comfort",
+ "summer-cooling",
+ "winter-warmth",
+ "reception",
+ "quiet-night",
+ "romantic",
+ ),
+)
+def test_non_posture_templates_keep_both_seat_positions_unchanged(
+ service: SeatDemoService,
+ template_id: str,
+) -> None:
+ template = service.scene_templates.get_template(template_id)
+ assert all(
+ "position_pct" not in operation.parameters and "duration_ms" not in operation.parameters
+ for operation in template.operations
+ if operation.domain == "seat"
+ )
+
+
+def test_native_driver_and_passenger_thermal_levels_are_not_confused(
+ service: SeatDemoService,
+) -> None:
+ for template_id in (
+ "reading",
+ "camping",
+ "quiet-night",
+ "reception",
+ "romantic",
+ "long-trip-comfort",
+ "seat-comfort",
+ ):
+ template = service.scene_templates.get_template(template_id)
+ params = {(op.capability, op.target_scope): op.parameters for op in template.operations}
+ assert params[("seat.set_driver_ventilation_native", "driver")] == {
+ "state": "on",
+ "level": 2,
+ }
+ assert params[("seat.set_ventilation", "passenger")] == {"state": "on", "level": 1}
+
+
+def test_upgraded_template_does_not_silently_add_seats_to_a_saved_custom_mode(
+ service: SeatDemoService,
+ tmp_path: Path,
+) -> None:
+ # Simulate a previously saved user preference with the same name as a template.
+ original = _preview(service, "构建一个观影模式,空调温度调整到25度")
+ draft = original["mode_profile_draft"]
+ assert len(_action_map(original)) == 1
+ saved = service.create_mode_profile_card(
+ draft["name"],
+ draft["instruction"],
+ draft["source_request_id"],
+ draft["vehicle_id"],
+ )
+ assert saved["status"] == "succeeded"
+ paths = list((tmp_path / "user-modes").rglob("*.yaml"))
+ hashes = {path: hashlib.sha256(path.read_bytes()).hexdigest() for path in paths}
+
+ repeated = _preview(service, "构建一个观影模式")
+ assert repeated["mode_profile_draft"]["generation_source"] == "registered_mode_profile"
+ assert _action_map(repeated) == _action_map(original)
+ assert {path: hashlib.sha256(path.read_bytes()).hexdigest() for path in paths} == hashes
+ assert len(service.scene_templates.get_template("cinema").operations) == 11
+
+
+def test_explicit_seat_only_requirement_is_not_expanded_to_other_seat_defaults(
+ service: SeatDemoService,
+) -> None:
+ result = _preview(service, "构建一个阅读模式,副驾座椅通风关闭")
+ assert _action_map(result) == {("seat.set_ventilation", "passenger"): {"state": "off"}}
+ assert result["mode_profile_draft"]["generation_source"] == "explicit_scene_requirements"
+
+
+@pytest.mark.parametrize("mode_id", ("office", "nap", "meditation_space", "leisure_lounger"))
+@pytest.mark.parametrize(("seat", "spoken_seat"), (("driver", "主驾"), ("passenger", "副驾驶")))
+def test_explicit_single_seat_invocation_selects_without_mutating_cabin_mode(
+ service: SeatDemoService,
+ mode_id: str,
+ seat: str,
+ spoken_seat: str,
+) -> None:
+ profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
+ original_revision = service.mode_profiles.profile_revision(profile)
+ expected = {
+ (operation.capability, operation.target_scope): operation.parameters
+ for operation in profile.operations
+ if operation.domain != "seat" or operation.target_scope == seat
+ }
+ result = service.preview_native_can(
+ f"请让{spoken_seat}进入{profile.name}",
+ provider="local",
+ conversation_id="conv-explicit-seat-mode-selection",
+ execution_mode="dry_run",
+ vehicle_id="denza_n7",
+ )
+ assert result["status"] == "succeeded", result.get("validation")
+ assert _action_map(result) == expected
+ assert (
+ service.mode_profiles.profile_revision(
+ service.mode_profiles.get_profile(mode_id, "denza_n7"),
+ )
+ == original_revision
+ )
+ assert any(
+ operation.target_scope != seat
+ for operation in service.mode_profiles.get_profile(mode_id, "denza_n7").operations
+ if operation.domain == "seat"
+ ), "single-seat execution must not edit the persisted dual-seat profile"
+
+
+@pytest.mark.parametrize(
+ ("mode_id", "spoken_seat"),
+ [
+ (mode_id, seat)
+ for mode_id in ("zero_gravity", "easy_entry_exit", "rocking_horse", "office")
+ for seat in ("主驾", "后排", "所有座椅")
+ if (mode_id, seat) != ("office", "主驾")
+ ],
+)
+def test_mode_invocation_still_rejects_genuinely_unsupported_seat_scopes(
+ service: SeatDemoService,
+ mode_id: str,
+ spoken_seat: str,
+) -> None:
+ profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
+ result = service.preview_native_can(
+ f"请让{spoken_seat}进入{profile.name}",
+ provider="local",
+ conversation_id="conv-explicit-seat-mode-rejection",
+ execution_mode="dry_run",
+ vehicle_id="denza_n7",
+ )
+ assert result["status"] in {"no_action", "needs_confirmation"}
+ assert _action_map(result) == {}
+ assert any(
+ issue.get("reason") == "mode_target_scope_mismatch"
+ for issue in result["intent_ir"]["spec"].get("ambiguities", [])
+ )
+
+
+@pytest.mark.parametrize(
+ "message",
+ ("创建一个主驾办公模式", "修改办公模式,只要主驾", "主驾办公模式"),
+)
+def test_mode_seat_selection_requires_an_explicit_invocation_not_configuration(
+ service: SeatDemoService,
+ message: str,
+) -> None:
+ profile = service.mode_profiles.get_profile("office", "denza_n7")
+ assert service._explicit_mode_seat_selection(profile, message) is None
+
+
+@pytest.mark.parametrize("mode_id", ("office", "nap", "meditation_space", "leisure_lounger"))
+def test_unscoped_mode_invocation_keeps_registered_native_driver_actions(
+ service: SeatDemoService,
+ mode_id: str,
+) -> None:
+ profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
+ result = service.preview_native_can(
+ f"进入{profile.name}",
+ provider="local",
+ conversation_id="conv-mode-native-seat-preserved",
+ execution_mode="dry_run",
+ vehicle_id="denza_n7",
+ )
+ assert result["status"] == "succeeded"
+ assert _action_map(result) == {
+ (operation.capability, operation.target_scope): operation.parameters
+ for operation in profile.operations
+ }
diff --git a/tests/integration/test_scene_explicit_seat_scope.py b/tests/integration/test_scene_explicit_seat_scope.py
index 2ea74fa..7ab6bab 100644
--- a/tests/integration/test_scene_explicit_seat_scope.py
+++ b/tests/integration/test_scene_explicit_seat_scope.py
@@ -87,7 +87,10 @@ def test_explicit_scene_seat_scope_rejects_cross_seat_plan_atomically(scene_serv
assert result.get("command_plans") == {}
intent = result.get("intent_ir") or {}
assert (intent.get("spec") or {}).get("actions", []) == []
- assert any("座位范围" in item["message"] for item in result["validation"])
+ assert any(
+ "座位范围" in item["message"] or item.get("code") == "MODE_SEAT_SCOPE_UNAVAILABLE"
+ for item in result["validation"]
+ )
@pytest.mark.parametrize(("message", "scopes"), (
@@ -96,9 +99,9 @@ def test_explicit_scene_seat_scope_rejects_cross_seat_plan_atomically(scene_serv
("创建一个仅主驾舒适模式", ("driver",)),
("构建一个主驾和副驾座椅舒适模式", ("driver", "passenger")),
("构建一个主副驾座椅舒适模式", ("driver", "passenger")),
- ("构建一个云端座椅舒适模式", ("driver", "passenger")),
- ("构建一个副驾空调与座椅舒适模式", ("driver", "passenger")),
- ("构建一个座椅舒适与副驾阅读灯模式", ("driver", "passenger")),
+ ("构建一个云端座椅舒适模式", ("passenger",)),
+ ("构建一个副驾空调与座椅舒适模式", ("passenger",)),
+ ("构建一个座椅舒适与副驾阅读灯模式", ("passenger",)),
("构建一个主驾座椅与副驾空调舒适模式", ("driver",)),
("构建一个主驾阅读灯与副驾座椅舒适模式", ("passenger",)),
))
@@ -120,7 +123,6 @@ def test_scene_scope_preserves_supported_targets_without_borrowing_other_device_
@pytest.mark.parametrize("message", (
"构建一个主驾舒适模式,副驾不要动",
"构建一个主驾舒适模式,不要动副驾",
- "构建一个舒展模式,座椅靠背调整到45%",
))
def test_explicit_atomic_requirements_do_not_receive_scene_scope_relaxation(
scene_service, message,
@@ -131,6 +133,19 @@ def test_explicit_atomic_requirements_do_not_receive_scene_scope_relaxation(
assert result["hardware_execution_allowed"] is False
+def test_unqualified_explicit_seat_requirement_defaults_to_passenger_without_model_additions(scene_service):
+ result = _preview(scene_service, "构建一个舒展模式,座椅靠背调整到45%", execution_mode="dry_run")
+ assert result["status"] == "succeeded", result.get("validation")
+ assert result["execution_mode"] == "dry_run"
+ assert result["hardware_execution_allowed"] is False
+ assert result["mode_profile_draft"]["operation_count"] == 1
+ actions = result["intent_ir"]["spec"]["actions"]
+ assert len(actions) == 1
+ assert actions[0]["capability"] == "seat.set_backrest_recline"
+ assert actions[0]["target"]["instance"] == "passenger"
+ assert actions[0]["parameters"] == {"position_pct": 45}
+
+
def test_explicit_atomic_requirements_override_label_without_adding_model_actions(scene_service):
result = _preview(scene_service, "构建一个主驾舒适模式,副驾靠背调整到45%")
assert result["status"] == "succeeded"
diff --git a/tests/integration/test_scene_template_hybrid.py b/tests/integration/test_scene_template_hybrid.py
index 4f09efd..53c3f63 100644
--- a/tests/integration/test_scene_template_hybrid.py
+++ b/tests/integration/test_scene_template_hybrid.py
@@ -144,7 +144,7 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run(
templates = snapshot["scene_templates"]
assert isinstance(templates, list)
- assert len(templates) == 13
+ assert len(templates) == 15
assert all(template["assistant_callable"] is True for template in templates)
with patch.object(
@@ -169,11 +169,30 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run(
assert result["orchestration_route"] == "mode_profile_create_preview"
draft = result["mode_profile_draft"]
assert draft["generation_source"] == "scene_template_copy"
- assert draft["operation_count"] == template["operation_count"]
assert draft["source_template_id"] == template["id"]
assert draft["source_template_revision"] == template["template_revision"]
assert "existing_mode_id" not in draft
+ expected_requests = plan["requests"]
+ if plan.get("schedule"):
+ expected_schedule = deepcopy(plan["schedule"])
+ for phase in expected_schedule["phases"]:
+ phase["operations"] = [
+ op for op in phase["operations"]
+ if op["domain"] != "seat" or op["target_scope"] == "passenger"
+ ]
+ assert draft["schedule"] == expected_schedule
+ expected_requests = [
+ {**op, "parameters": [{"name": key, "value": value}
+ for key, value in op["parameters"].items()]}
+ for phase in expected_schedule["phases"] for op in phase["operations"]
+ ]
+ else:
+ expected_requests = [
+ request for request in expected_requests
+ if request["domain"] != "seat" or request["target_scope"] == "passenger"
+ ]
+ assert draft["operation_count"] == len(expected_requests)
expected = {
(
str(request["capability"]),
@@ -187,7 +206,7 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run(
sort_keys=True,
),
)
- for request in plan["requests"]
+ for request in expected_requests
}
actual = {
(
@@ -311,7 +330,10 @@ def test_purpose_descriptions_use_a_concise_name_and_zero_model_template_route(
assert draft["name"] == expected_name
assert draft["generation_source"] == "scene_template_copy"
assert draft["source_template_id"] == template_id
- assert draft["operation_count"] == len(template.operations)
+ assert draft["operation_count"] == sum(
+ operation.domain != "seat" or operation.target_scope == "passenger"
+ for operation in template.operations
+ )
assert passed_plans[0]["metadata"]["template_id"] == template_id
assert events[0]["engine"] == "n7web-scene-template"
assert not list(user_root.rglob("*.yaml"))
@@ -353,7 +375,7 @@ def test_explicit_settings_on_a_purpose_scene_bypass_template_defaults(
("构建一个长途舒适模式", "长途舒适模式", "long-trip-comfort"),
),
)
-def test_seating_comfort_requests_preview_both_seats_with_no_model_or_hardware_call(
+def test_seating_comfort_requests_preview_only_passenger_with_no_model_or_hardware_call(
hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path],
message: str,
expected_name: str,
@@ -380,16 +402,13 @@ def test_seating_comfort_requests_preview_both_seats_with_no_model_or_hardware_c
for (capability, target), parameters in _action_map(result).items()
if capability.startswith("seat.")
}
- assert {target for _capability, target in seat_actions} == {"driver", "passenger"}
+ assert {target for _capability, target in seat_actions} == {"passenger"}
assert all(
"driver_axis" not in capability and "driver_position" not in capability
for capability, _target in seat_actions
)
assert ("seat.set_driver_massage_native", "passenger") not in seat_actions
if template_id in {"seat-comfort", "long-trip-comfort"}:
- assert seat_actions["seat.set_driver_ventilation_native", "driver"] == {
- "state": "on", "level": 2,
- }
assert seat_actions["seat.set_ventilation", "passenger"] == {
"state": "on", "level": 1,
}
@@ -422,7 +441,7 @@ def test_unregistered_seat_scene_keeps_both_explicit_targets_through_native_merg
side_effect=AssertionError("scene composition reached hardware execution"),
):
result = service.preview_native_can(
- f"构建一个{name}", provider="local", conversation_id="conv-seat-generated",
+ f"构建一个主驾和副驾的{name}", provider="local", conversation_id="conv-seat-generated",
execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan,
)
assert result["status"] == "succeeded"
@@ -437,7 +456,7 @@ def test_unregistered_seat_scene_keeps_both_explicit_targets_through_native_merg
assert not list(user_root.rglob("*.yaml"))
-def test_explicit_unscoped_seat_instruction_keeps_target_ambiguity_guard(
+def test_unscoped_seat_instruction_inside_mode_creation_defaults_to_passenger(
hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path],
) -> None:
service, _catalog, _user_root = hybrid_service
@@ -446,8 +465,11 @@ def test_explicit_unscoped_seat_instruction_keeps_target_ambiguity_guard(
conversation_id="conv-seat-explicit-target", execution_mode="hardware",
vehicle_id="denza_n7",
)
- assert result["status"] != "succeeded"
- assert result.get("mode_profile_draft") is None
+ assert result["status"] == "succeeded"
+ assert _action_map(result) == {
+ ("seat.set_backrest_recline", "passenger"): {"position_pct": 45},
+ }
+ assert result.get("mode_profile_draft")
assert result["hardware_execution_allowed"] is False
@@ -552,7 +574,7 @@ def test_template_copy_can_be_revised_twice_without_losing_sibling_actions(
assert shade_revised["execution_mode"] == final["execution_mode"] == "dry_run"
assert shade_revised["hardware_execution_allowed"] is False
assert final["hardware_execution_allowed"] is False
- assert len(built_actions) == len(shade_actions) == len(final_actions) == 7
+ assert len(built_actions) == len(shade_actions) == len(final_actions) == 10
assert built_actions[shade_key] == {"position_pct": 0}
assert shade_actions[shade_key] == final_actions[shade_key] == {"position_pct": 100}
assert built_actions[temperature_key] == shade_actions[temperature_key] == {"temperature_c": 24}
diff --git a/tests/unit/mode_schedule_frontend.test.cjs b/tests/unit/mode_schedule_frontend.test.cjs
new file mode 100644
index 0000000..f0b7c82
--- /dev/null
+++ b/tests/unit/mode_schedule_frontend.test.cjs
@@ -0,0 +1,435 @@
+"use strict";
+
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const test = require("node:test");
+const vm = require("node:vm");
+
+const web = path.resolve(__dirname, "../../apps/web-console");
+const assistant = fs.readFileSync(path.join(web, "assistant-chat.js"), "utf8");
+const app = fs.readFileSync(path.join(web, "app.js"), "utf8");
+
+function functionSource(source, name) {
+ const start = source.search(new RegExp(`(?: )?(?:async )?function ${name}\\(`));
+ assert.ok(start >= 0, `missing production function ${name}`);
+ const rest = source.slice(start);
+ const next = rest.slice(1).search(/\n(?: )?(?:async )?function /);
+ return next < 0 ? rest : rest.slice(0, next + 1);
+}
+
+class Element {
+ constructor(tagName) {
+ this.tagName = tagName;
+ this.children = [];
+ this.dataset = {};
+ this.attributes = {};
+ this.listeners = {};
+ this.className = "";
+ this.textContent = "";
+ this.classList = { add: (name) => { this.className += ` ${name}`; } };
+ }
+ append(...children) { this.children.push(...children); }
+ replaceChildren(...children) { this.children = children; }
+ setAttribute(name, value) { this.attributes[name] = value; }
+ addEventListener(name, listener) { this.listeners[name] = listener; }
+ querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
+ querySelectorAll(selector) {
+ return descendants(this).filter((item) => {
+ if (selector.startsWith(".")) return item.className.split(/\s+/).includes(selector.slice(1));
+ const match = selector.match(/^\[data-([\w-]+)(?:="([^"]*)")?\]$/);
+ if (match) {
+ const key = match[1].replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
+ return key in item.dataset && (match[2] === undefined || item.dataset[key] === match[2]);
+ }
+ return item.tagName === selector;
+ });
+ }
+}
+
+function descendants(element) {
+ return element.children.flatMap((item) => typeof item === "object" ? [item, ...descendants(item)] : []);
+}
+
+function textOf(element) {
+ return [element.textContent, ...descendants(element).map((item) => item.textContent)].filter(Boolean).join("\n");
+}
+
+function harness() {
+ const body = new Element("body");
+ const calls = [];
+ const document = {
+ body,
+ createElement: (tag) => new Element(tag),
+ createTextNode: (text) => Object.assign(new Element("#text"), { textContent: text }),
+ querySelectorAll: (selector) => body.querySelectorAll(selector),
+ };
+ const context = {
+ document, window: {}, CSS: { escape: String },
+ $: (selector, parent = body) => parent.querySelector(selector),
+ rememberModeDraftContext() {}, supersedeModeDraftCards() {}, followLatest() {},
+ normalizeModeName: String,
+ saveModeProfileDraft: (...args) => calls.push({ kind: "save", args }),
+ tryModeProfileDraft: (...args) => calls.push({ kind: "trial", args }),
+ modeSecondsLabel: (value) => String(Number(value) / 1000),
+ LEGACY_DYNAMIC_MODE_IDS: new Set(["rocking_horse", "dynamic_cradle"]),
+ };
+ vm.createContext(context);
+ const constants = app.slice(app.indexOf("const MODE_DOMAIN_LABELS"), app.indexOf("function modeOperationParameters"))
+ + app.slice(app.indexOf("const CONFIG_CAPABILITY_LABELS"), app.indexOf("function makeConfigElement"));
+ const assistantNames = ["modeDraftRows", "modeDraftScheduleView", "appendModeDraftRows", "appendModeDraftState"];
+ const appNames = ["formatConfigValue", "modeOperationParameters", "formatModeOperationValue", "modeOperationView", "modeTimelineTargetSummary", "modeTimelineStepView", "modeScheduleDurationLabel", "modeScheduleView", "modeExecutionActionViews", "modeScheduleTrialMatches", "modeScheduleExecutionView", "isUserModeProfile", "isDynamicModeProfile", "createModeGalleryCard", "renderModeActionPopover"];
+ vm.runInContext(constants + assistantNames.map((name) => functionSource(assistant, name)).join("\n")
+ + appNames.map((name) => functionSource(app, name)).join("\n"), context);
+ return { context, body, calls };
+}
+
+function operation(capability, parameters, scope = "vehicle") {
+ return { domain: capability.split(".")[0], capability, target_scope: scope, operation: "set", parameters };
+}
+
+function schedule(overrides = {}) {
+ return {
+ schema: "cognitive-agent-mode-schedule/v1",
+ repeat_count: 2,
+ phases: [
+ { id: "warm", name: "快速预热", duration_ms: 60000, operations: [
+ operation("climate.set_temperature", { temperature_c: 28 }),
+ operation("seat.set_driver_massage_native", { action: "mode", mode: "relax" }, "driver"),
+ operation("seat.set_heating", { state: "on", level: 1 }, "passenger"),
+ ] },
+ { id: "comfortable", name: "舒适保持", duration_ms: 30000, operations: [
+ operation("climate.set_temperature", { temperature_c: 24 }),
+ operation("seat.set_driver_massage_native", { action: "off" }, "driver"),
+ operation("seat.set_heating", { state: "off" }, "passenger"),
+ ] },
+ ],
+ ...overrides,
+ };
+}
+
+test("assistant keeps phases, relative timing, repeat count, total duration and stop guidance", () => {
+ const { context } = harness();
+ const view = context.modeDraftScheduleView({ schedule: schedule() });
+ assert.equal(view.summary, "2 个阶段 · 重复 2 次 · 总时长 3分");
+ assert.equal(view.operationCount, 6);
+ assert.equal(view.phases[0].timing, "+0秒 开始 · 持续 1分");
+ assert.equal(view.phases[1].timing, "+1分 开始 · 持续 30秒");
+ assert.match(view.stopHint, /停止动态模式/);
+ assert.match(view.stopHint, /每轮/);
+});
+
+test("driver and passenger scopes and changed device values are not collapsed", () => {
+ const { context } = harness();
+ const view = context.modeDraftScheduleView({ schedule: schedule() });
+ assert.deepEqual(Array.from(view.phases[0].rows, (row) => [row.label, row.value]), [
+ ["空调温度", "28℃"], ["主驾座椅按摩", "放松模式"], ["副驾座椅加热", "开启 · 1档"],
+ ]);
+ assert.deepEqual(Array.from(view.phases[1].rows, (row) => [row.label, row.value]), [
+ ["空调温度", "24℃"], ["主驾座椅按摩", "关闭"], ["副驾座椅加热", "关闭"],
+ ]);
+});
+
+test("phase previews never apply whole-instruction zero-gravity grouping", () => {
+ const { context } = harness();
+ const view = context.modeDraftScheduleView({ instruction: "副驾零重力", schedule: schedule({ phases: [{
+ duration_ms: 1500, operations: [
+ operation("seat.set_longitudinal_position", { position_pct: 80 }, "passenger"),
+ operation("seat.set_backrest_recline", { position_pct: 60 }, "passenger"),
+ ],
+ }] }) });
+ assert.equal(view.phases[0].rows.length, 2);
+ assert.equal(view.phases[0].name, "阶段 1");
+ assert.equal(view.phases[0].timing, "+0秒 开始 · 持续 1.5秒");
+});
+
+test("per-zone climate targets are explicit even within the same phase", () => {
+ const { context } = harness();
+ const view = context.modeDraftScheduleView({ schedule: schedule({ phases: [{
+ duration_ms: 2000, operations: [
+ operation("climate.set_temperature", { temperature_c: 26 }, "front_left"),
+ operation("climate.set_temperature", { temperature_c: 24 }, "front_right"),
+ ],
+ }] }) });
+ assert.deepEqual(Array.from(view.phases[0].rows, (row) => [row.label, row.value]), [
+ ["主驾空调温度", "26℃"], ["副驾空调温度", "24℃"],
+ ]);
+});
+
+for (const [name, value] of [
+ ["absent", undefined], ["unknown schema", schedule({ schema: "other" })],
+ ["zero repeat", schedule({ repeat_count: 0 })], ["unbounded repeat", schedule({ repeat_count: 21 })],
+ ["fractional repeat", schedule({ repeat_count: 1.5 })], ["string repeat", schedule({ repeat_count: "2" })],
+ ["empty phases", schedule({ phases: [] })], ["non-array phases", schedule({ phases: {} })],
+ ["zero duration", schedule({ phases: [{ duration_ms: 0, operations: [] }] })],
+ ["negative duration", schedule({ phases: [{ duration_ms: -1, operations: [] }] })],
+ ["invalid duration", schedule({ phases: [{ duration_ms: Infinity, operations: [] }] })],
+ ["missing operations", schedule({ phases: [{ duration_ms: 1000 }] })],
+]) {
+ test(`schedule formatter safely rejects ${name}`, () => {
+ const { context } = harness();
+ assert.equal(context.modeDraftScheduleView({ schedule: value }), null);
+ assert.equal(context.modeScheduleView({ schedule: value }), null);
+ });
+}
+
+test("hover formatter prefers stage operations over an overlapping flat projection", () => {
+ const { context } = harness();
+ const mode = { schedule: schedule(), operations: [operation("climate.set_temperature", { temperature_c: 24 })] };
+ const rows = context.modeExecutionActionViews(mode);
+ assert.equal(rows.length, 6);
+ assert.equal(rows[0].phase, "阶段 1:快速预热");
+ assert.equal(rows[3].phase, "阶段 2:舒适保持");
+ assert.equal(rows[0].parameters, "目标温度:28 ℃");
+ assert.equal(rows[3].parameters, "目标温度:24 ℃");
+ assert.equal(rows[1].target, "主驾");
+ assert.equal(rows[2].target, "副驾");
+ assert.equal(context.modeScheduleView(mode).summary, context.modeDraftScheduleView(mode).summary);
+ assert.equal(context.isDynamicModeProfile({ ...mode, behavior: "static" }), true);
+});
+
+test("legacy static modes and seat ModeTimeline retain their original action semantics", () => {
+ const { context } = harness();
+ const operations = [operation("climate.set_temperature", { temperature_c: 24 })];
+ assert.equal(context.modeExecutionActionViews({ operations }).length, 1);
+ assert.equal(context.isDynamicModeProfile({ operations, behavior: "static" }), false);
+ const mode = { operations, timeline: {
+ entry: { targets: { backrest_recline: { position_pct: 40 } }, timeout_ms: 15000 },
+ phases: [{ name: "抬起", targets: { backrest_recline: { position_pct: 50 } }, duration_ms: 2000 }],
+ } };
+ const views = context.modeExecutionActionViews(mode);
+ assert.equal(views.length, 3);
+ assert.equal(views[1].action, "进入动态模式初始位置");
+ assert.match(views[1].parameters, /保护上限 15s/);
+ assert.equal(views[2].action, "循环阶段 1:抬起");
+ assert.match(views[2].parameters, /持续 2s/);
+ assert.equal(context.isDynamicModeProfile(mode), true);
+});
+
+test("assistant renders a stage card, preserving opaque save/trial request context", () => {
+ const { context, body, calls } = harness();
+ const message = { body: new Element("div"), article: new Element("article") };
+ body.append(message.body);
+ const draft = { name: "上车预热模式", source_request_id: "opaque-preview-id", schedule: schedule(), operation_count: 6 };
+ const flatActions = [];
+ context.appendModeDraftState(message, draft, flatActions);
+ const card = message.body.children[0];
+ assert.equal(card.querySelectorAll(".assistant-mode-schedule-phase").length, 2);
+ assert.equal(card.querySelectorAll(".assistant-mode-draft-row").length, 6);
+ assert.equal(card.querySelector(".assistant-mode-draft-badge").textContent, "6 项动作");
+ assert.match(textOf(card), /28℃/);
+ assert.match(textOf(card), /24℃/);
+ assert.match(textOf(card), /重复 2 次 · 总时长 3分/);
+ assert.match(textOf(card), /停止动态模式/);
+ card.querySelector(".assistant-mode-draft-save").listeners.click();
+ card.querySelector(".assistant-mode-draft-trial").listeners.click();
+ assert.equal(calls[0].kind, "save");
+ assert.equal(calls[0].args[2], draft);
+ assert.equal(calls[1].kind, "trial");
+ assert.equal(calls[1].args[1], draft);
+ assert.equal(calls[1].args[2], flatActions);
+});
+
+test("management schedule details are independently expandable without executing the card", () => {
+ const { context } = harness();
+ const card = context.createModeGalleryCard({ id: "custom-warm", name: "上车预热模式", origin: "user", schedule: schedule() });
+ const details = card.querySelector(".mode-gallery-schedule-details");
+ assert.ok(details);
+ assert.equal(details.querySelector("summary").textContent, "查看分阶段执行动作");
+ assert.equal(details.querySelectorAll("li").length, 2);
+ assert.equal(details.querySelectorAll("button").length, 0);
+ assert.match(textOf(details), /快速预热/);
+ assert.match(textOf(details), /舒适保持/);
+ assert.match(textOf(details), /主驾 · 设置主驾座椅按摩:动作:切换按摩模式,工作模式:放松/);
+ assert.match(textOf(card.querySelector(".mode-gallery-card-meta")), /重复 2 次 · 总时长 3分/);
+});
+
+test("popover presents stage timings and stop guidance without interpreting text as HTML", () => {
+ const { context } = harness();
+ const popover = new Element("aside");
+ for (const [tag, key] of [["strong", "modeActionTitle"], ["b", "modeActionCount"], ["ol", "modeActionList"]]) {
+ const item = new Element(tag);
+ item.dataset[key] = "true";
+ popover.append(item);
+ }
+ context.ensureModeActionPopover = () => popover;
+ const value = schedule();
+ value.phases[0].name = '
';
+ context.renderModeActionPopover({ name: "测试动态模式", schedule: value });
+ assert.equal(popover.querySelectorAll(".mode-chip-action-copy").length, 6);
+ assert.equal(popover.querySelectorAll(".mode-schedule-popover-phase").length, 6);
+ assert.match(textOf(popover), /阶段 1:
{
+ const { context } = harness();
+ const running = context.modeScheduleExecutionView({ status: "running", schedule_execution: { status: "running" } });
+ assert.equal(running.state, "processing");
+ assert.equal(running.title, "动态时间轴已启动");
+ assert.doesNotMatch(running.title, /完成/);
+ const trial = context.modeScheduleExecutionView({ status: "succeeded", schedule_execution: { status: "completed", completed: true, virtual_elapsed_ms: 90000 } });
+ assert.equal(trial.title, "动态时间轴模拟试运行通过");
+ assert.match(trial.detail, /未向车辆下发控制/);
+ const stop = context.modeScheduleExecutionView({ status: "succeeded", schedule_execution: { status: "cancelled" } });
+ assert.equal(stop.title, "动态时间轴已停止");
+ assert.match(stop.detail, /不会自动回滚/);
+ assert.equal(context.modeScheduleExecutionView({ status: "failed", schedule_execution: { status: "failed" } }), null);
+});
+
+test("virtual trial verifies temporal structure, not just a flattened action set", () => {
+ const { context } = harness();
+ const draft = { schedule: schedule() };
+ const response = () => ({ schedule: schedule(), schedule_execution: { status: "completed", completed: true, virtual_elapsed_ms: 180000 } });
+ assert.equal(context.modeScheduleTrialMatches(draft, response()), true);
+ assert.equal(context.modeScheduleTrialMatches({}, {}), true, "legacy static drafts remain unchanged");
+ for (const mutate of [
+ (value) => { value.schedule.repeat_count = 1; },
+ (value) => { value.schedule.phases.reverse(); },
+ (value) => { value.schedule.phases[0].duration_ms = 45000; value.schedule.phases[1].duration_ms = 45000; },
+ (value) => { value.schedule.phases[0].operations[0].parameters.temperature_c = 26; },
+ (value) => { value.schedule.phases[0].operations[1].target_scope = "passenger"; },
+ (value) => { value.schedule_execution.virtual_elapsed_ms = 90000; },
+ (value) => { value.schedule_execution.completed = false; },
+ (value) => { value.schedule_execution.status = "running"; },
+ (value) => { delete value.schedule; },
+ ]) {
+ const value = response();
+ mutate(value);
+ assert.equal(context.modeScheduleTrialMatches(draft, value), false);
+ }
+});
+
+function pollHarness(statuses = []) {
+ const { context } = harness();
+ const timers = new Map();
+ const requests = [];
+ const updates = [];
+ const modeUpdates = [];
+ const vehicle = { value: "denza_n7" };
+ let timerId = 0;
+ context.activeSchedulePoll = null;
+ context.AbortController = AbortController;
+ context.conversation = "conversation-1";
+ context.conversationId = () => context.conversation;
+ context.setActionStatus = (_message, copy, state) => updates.push({ copy, state });
+ const originalQuery = context.$;
+ context.$ = (selector, parent) => selector === "#vehicleSelect" ? vehicle : originalQuery(selector, parent);
+ context.window.setTimeout = (callback, delay) => {
+ assert.equal(delay, 2000);
+ timers.set(++timerId, callback);
+ return timerId;
+ };
+ context.window.clearTimeout = (id) => timers.delete(id);
+ context.window.CognitiveAgentConsole = { updateScheduleExecutionStatus: (...args) => modeUpdates.push(args) };
+ context.window.CognitiveAgentNetwork = { request: async (url, options) => {
+ requests.push({ url, options });
+ const next = statuses.shift();
+ if (next instanceof Error) throw next;
+ if (typeof next === "function") return next();
+ return { ok: true, status: 200, json: async () => next };
+ } };
+ vm.runInContext(["stopScheduleStatusPoll", "startScheduleStatusPoll"].map((name) => functionSource(assistant, name)).join("\n"), context);
+ const start = (session = "session-1") => context.startScheduleStatusPoll({
+ vehicle_id: "denza_n7", mode_profile: { id: "test-mode" }, schedule_execution: { session_id: session },
+ }, {});
+ const tick = async () => {
+ const [id, callback] = timers.entries().next().value || [];
+ assert.ok(callback, "poll timer must be scheduled");
+ timers.delete(id);
+ await callback();
+ };
+ return { context, timers, requests, updates, modeUpdates, vehicle, start, tick };
+}
+
+test("polls only the active vehicle using GET and updates the original status until completion", async () => {
+ const h = pollHarness([
+ { status: "running", session_id: "session-1", vehicle_id: "denza_n7", phase_index: 1, repeat_index: 0 },
+ { status: "completed", session_id: "session-1", completed: true },
+ ]);
+ h.start();
+ assert.equal(h.requests.length, 0);
+ await h.tick();
+ assert.equal(h.requests.length, 1);
+ assert.equal(h.requests[0].url, "/api/modes/schedule/status?vehicle_id=denza_n7");
+ assert.equal(h.requests[0].options.method, "GET");
+ assert.equal(h.requests[0].options.body, undefined);
+ assert.match(h.updates[0].copy, /第 1 轮 · 阶段 2/);
+ assert.equal(h.updates[0].state, "working");
+ await h.tick();
+ assert.equal(h.timers.size, 0);
+ assert.equal(h.context.activeSchedulePoll, null);
+ assert.match(h.updates[1].copy, /时间轴已结束/);
+ assert.deepEqual(h.modeUpdates, [["test-mode", "completed"]]);
+});
+
+for (const status of ["failed", "cancelled", "stopped", "idle"]) {
+ test(`terminal ${status} ends polling without a new request or action`, async () => {
+ const h = pollHarness([{ status, session_id: "session-1", error: "阶段下发失败" }]);
+ h.start();
+ await h.tick();
+ assert.equal(h.requests.length, 1);
+ assert.equal(h.timers.size, 0);
+ assert.equal(h.updates[0].state, status === "failed" ? "failed" : "success");
+ if (status === "failed") assert.match(h.updates[0].copy, /阶段下发失败/);
+ if (["cancelled", "stopped"].includes(status)) assert.match(h.updates[0].copy, /已停止/);
+ });
+}
+
+test("a transient status failure is visible and retried, not reported as execution failure", async () => {
+ const h = pollHarness([new Error("offline"), { status: "completed", completed: true }]);
+ h.start();
+ await h.tick();
+ assert.match(h.updates[0].copy, /暂时无法获取时间轴状态/);
+ assert.equal(h.updates[0].state, "working");
+ assert.equal(h.timers.size, 1);
+ await h.tick();
+ assert.equal(h.timers.size, 0);
+});
+
+test("vehicle or conversation changes terminate observation before another request", async () => {
+ for (const kind of ["vehicle", "conversation"]) {
+ const h = pollHarness();
+ h.start();
+ if (kind === "vehicle") h.vehicle.value = "another-vehicle";
+ else h.context.conversation = "conversation-2";
+ await h.tick();
+ assert.equal(h.requests.length, 0);
+ assert.equal(h.context.activeSchedulePoll, null);
+ assert.equal(h.timers.size, 0);
+ }
+});
+
+test("replacement session does not apply a different schedule outcome to the old message", async () => {
+ const h = pollHarness([{ status: "failed", session_id: "another-session" }]);
+ h.start();
+ await h.tick();
+ assert.match(h.updates[0].copy, /已被新的运行任务替换/);
+ assert.doesNotMatch(h.updates[0].copy, /执行失败/);
+ assert.equal(h.timers.size, 0);
+ assert.equal(h.modeUpdates.length, 0);
+});
+
+test("stop aborts an in-flight status read and ignores its late completion", async () => {
+ let resolve;
+ const response = new Promise((done) => { resolve = done; });
+ const h = pollHarness([() => response]);
+ h.start();
+ const pending = h.tick();
+ assert.equal(h.requests.length, 1);
+ assert.equal(h.timers.size, 0, "an in-flight read cannot overlap another timer");
+ h.context.stopScheduleStatusPoll();
+ assert.equal(h.requests[0].options.signal.aborted, true);
+ resolve({ ok: true, status: 200, json: async () => ({ status: "completed", completed: true }) });
+ await pending;
+ assert.equal(h.updates.length, 0);
+ assert.equal(h.timers.size, 0);
+});
+
+test("cleanup is wired to new conversation, vehicle changes and page exit", () => {
+ assert.match(assistant, /if \(!target\?\.closest\("#newConversation"\)\) return;\s+stopScheduleStatusPoll\(\);/);
+ assert.match(assistant, /event\.target\?\.id === "vehicleSelect"\) stopScheduleStatusPoll\(\)/);
+ assert.match(assistant, /window\.addEventListener\("pagehide", stopScheduleStatusPoll\)/);
+});
diff --git a/tests/unit/test_assistant_capability_catalog.py b/tests/unit/test_assistant_capability_catalog.py
index 1168912..bf8299f 100644
--- a/tests/unit/test_assistant_capability_catalog.py
+++ b/tests/unit/test_assistant_capability_catalog.py
@@ -284,7 +284,7 @@ def test_mode_catalog_projects_bounded_action_details_without_free_form_reasons(
snapshot = _catalog(vehicle_service).snapshot()
office = next(item for item in snapshot["modes"] if item["id"] == "office")
- assert len(office["operations"]) == office["operation_count"] == 10
+ assert len(office["operations"]) == office["operation_count"] == 12
temperature = next(
item
for item in office["operations"]
@@ -327,7 +327,7 @@ def test_saved_mode_detail_fast_answer_is_exact_read_only_and_natural(
"遮阳帘",
"氛围灯",
"副驾空调温度设为25℃",
- "共10项动作",
+ "共12项动作",
"本次仅查询,未下发车辆控制",
)
)
@@ -358,7 +358,7 @@ def test_common_scene_templates_are_read_only_callable_catalog_entries(
templates = snapshot["scene_templates"]
assert snapshot["source_status"]["scene_templates"] == "ready"
- assert len(templates) == 13
+ assert len(templates) == 15
assert {item["name"] for item in templates} >= {
"观影模式",
"露营模式",
diff --git a/tests/unit/test_assistant_frontend_contract.py b/tests/unit/test_assistant_frontend_contract.py
index cc938fe..6e5b792 100644
--- a/tests/unit/test_assistant_frontend_contract.py
+++ b/tests/unit/test_assistant_frontend_contract.py
@@ -13,15 +13,15 @@ def test_console_contains_ai_assistant_surface():
assert 'aria-label="AI助手对话"' in html
assert 'placeholder="向 AI助手提问或交代工作"' in html
assert 'aria-label="发送消息"' in html
- assert 'href="/assistant-chat.css?v=codex-assistant-27"' in html
- assert 'src="/assistant-chat.js?v=codex-assistant-27"' in html
+ assert 'href="/assistant-chat.css?v=codex-assistant-28"' in html
+ assert 'src="/assistant-chat.js?v=codex-assistant-28"' in html
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-v278"' in service_worker
- assert '"/assistant-chat.css?v=codex-assistant-27"' in service_worker
- assert '"/assistant-chat.js?v=codex-assistant-27"' in service_worker
+ assert 'const CACHE_VERSION = "cognitive-agent-pad-v279"' in service_worker
+ assert '"/assistant-chat.css?v=codex-assistant-28"' in service_worker
+ assert '"/assistant-chat.js?v=codex-assistant-28"' in service_worker
def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events():
diff --git a/tests/unit/test_energy_monitor_frontend_contract.py b/tests/unit/test_energy_monitor_frontend_contract.py
index 5395ac1..bd909de 100644
--- a/tests/unit/test_energy_monitor_frontend_contract.py
+++ b/tests/unit/test_energy_monitor_frontend_contract.py
@@ -35,10 +35,10 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None:
):
assert asset in html
assert f'"{asset}"' in service_worker
- assert html.index("/assistant-chat.js?v=codex-assistant-27") < html.index(
+ assert html.index("/assistant-chat.js?v=codex-assistant-28") < html.index(
"/energy-monitor.js?v=energy-monitor-4"
)
- assert 'const CACHE_VERSION = "cognitive-agent-pad-v278"' in service_worker
+ assert 'const CACHE_VERSION = "cognitive-agent-pad-v279"' in service_worker
def test_energy_monitor_uses_the_fifth_shared_dock_icon_and_starts_closed() -> None:
diff --git a/tests/unit/test_frontend_theme_contract.py b/tests/unit/test_frontend_theme_contract.py
index 8114dc0..bf48bf2 100644
--- a/tests/unit/test_frontend_theme_contract.py
+++ b/tests/unit/test_frontend_theme_contract.py
@@ -47,7 +47,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
):
assert asset in html
assert f'"{asset}"' in service_worker
- assert 'const CACHE_VERSION = "cognitive-agent-pad-v278"' in service_worker
+ assert 'const CACHE_VERSION = "cognitive-agent-pad-v279"' in service_worker
def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None:
diff --git a/tests/unit/test_mode_creation_seat_policy.py b/tests/unit/test_mode_creation_seat_policy.py
new file mode 100644
index 0000000..dc48c7d
--- /dev/null
+++ b/tests/unit/test_mode_creation_seat_policy.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+from copy import deepcopy
+
+import pytest
+
+from cognitive_agent.application.mode_creation_seat_policy import (
+ ModeCreationSeatPolicyError,
+ creation_seat_scopes,
+ project_seed_operations,
+)
+
+
+@pytest.mark.parametrize(("text", "expected"), [
+ ("构建一个阅读模式", {"passenger"}),
+ ("构建一个座椅动态模式", {"passenger"}),
+ ("构建一个主驾阅读模式", {"driver"}),
+ ("为驾驶员构建一个舒适模式", {"driver"}),
+ ("构建一个副驾阅读模式", {"passenger"}),
+ ("构建一个副驾驶座椅舒适模式", {"passenger"}),
+ ("构建一个副驾驶席阅读模式", {"passenger"}),
+ ("为副驾驶位构建阅读模式", {"passenger"}),
+ ("构建一个主副驾阅读模式", {"driver", "passenger"}),
+ ("构建一个双座舒适模式", {"driver", "passenger"}),
+ ("构建一个前排两座舒适模式", {"driver", "passenger"}),
+ ("构建一个两个座椅阅读模式", {"driver", "passenger"}),
+ ("构建一个两张座椅阅读模式", {"driver", "passenger"}),
+ ("构建一个两座椅阅读模式", {"driver", "passenger"}),
+ ("构建一个主驾空调舒适模式", {"passenger"}),
+ ("构建一个模式,主驾温度26度", {"passenger"}),
+ ("构建一个副驾座椅舒适模式,主驾空调26度", {"passenger"}),
+ ("构建一个主驾舒适模式,副驾空调26度", {"driver"}),
+ ("构建一个阅读模式,不要动主驾", {"passenger"}),
+ ("构建一个主驾阅读模式,副驾不要动", {"driver"}),
+ ("构建一个模式,只联动副驾,不要联动主驾", {"passenger"}),
+ ("构建一个后排座椅舒适模式", {"unsupported"}),
+ ("构建一个未知座位模式", {"unsupported"}),
+ ("构建一个任意座位模式", {"unsupported"}),
+])
+def test_creation_seat_selection(text, expected):
+ assert creation_seat_scopes(text) == expected
+
+
+def test_seed_projection_is_non_mutating_and_does_not_borrow_capabilities():
+ seed = [
+ {"domain": "seat", "capability": "seat.set_driver_ventilation_native", "target_scope": "driver", "parameters": {"level": 2}},
+ {"domain": "seat", "capability": "seat.set_backrest_recline", "target_scope": "passenger", "parameters": {"position_pct": 45}},
+ {"domain": "climate", "capability": "climate.set_temperature", "target_scope": "passenger", "parameters": {"temperature_c": 24}},
+ ]
+ original = deepcopy(seed)
+ projected = project_seed_operations(seed, frozenset({"driver"}))
+ assert projected == [seed[0], seed[2]]
+ projected[0]["parameters"]["level"] = 1
+ assert seed == original
+ with pytest.raises(ModeCreationSeatPolicyError):
+ project_seed_operations(seed[1:], frozenset({"driver"}))
diff --git a/tests/unit/test_mode_creation_seat_routing.py b/tests/unit/test_mode_creation_seat_routing.py
new file mode 100644
index 0000000..446a9e6
--- /dev/null
+++ b/tests/unit/test_mode_creation_seat_routing.py
@@ -0,0 +1,186 @@
+from __future__ import annotations
+
+from copy import deepcopy
+from pathlib import Path
+
+import pytest
+
+from cognitive_agent.application.codex_assistant import (
+ CodexAssistantService,
+ _catalog_exact_mode_match_count,
+ _catalog_exact_scene_template_plan,
+)
+from cognitive_agent.application.mode_profile_intent import (
+ mode_creation_source_name,
+ parse_mode_profile_create,
+)
+from cognitive_agent.application.scene_composer import (
+ SCENE_COMPOSER_INSTRUCTIONS,
+ SCHEDULE_COMPOSER_INSTRUCTIONS,
+ scene_context_for_mode_creation,
+)
+from cognitive_agent.application.scene_templates import SceneTemplateRegistry
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+@pytest.mark.parametrize(("message", "name"), [
+ ("构建主驾阅读模式", "主驾阅读模式"),
+ ("为主驾构建一个阅读模式", "主驾阅读模式"),
+ ("帮我为副驾驶构建一个阅读模式", "副驾阅读模式"),
+ ("给驾驶席创建一个阅读模式", "主驾阅读模式"),
+ ("可以帮我为主驾构建一个阅读模式吗?", "主驾阅读模式"),
+ ("构建一个阅读模式,针对主驾", "主驾阅读模式"),
+ ("构建一个阅读模式,仅联动副驾座椅", "副驾阅读模式"),
+ ("构建一个阅读模式,给主驾使用,先看看", "主驾阅读模式"),
+ ("构建一个主驾驶座椅专属的阅读模式", "主驾阅读模式"),
+ ("构建一个主驾适合看书的功能模式", "主驾看书模式"),
+ ("构建一个适合主驾看书的功能模式", "主驾看书模式"),
+ ("为主驾和副驾创建一个阅读模式", "主副驾阅读模式"),
+ ("构建一个双座阅读模式", "主副驾阅读模式"),
+ ("构建一个阅读模式,针对双座", "主副驾阅读模式"),
+])
+def test_scoped_creation_keeps_distinct_name_and_fast_template_source(message, name):
+ assert parse_mode_profile_create(message) == (name, "")
+ registry = SceneTemplateRegistry(ROOT / "config" / "scene_templates")
+ template = registry.resolve_exact_name(name)
+ assert template is not None and template.id == "reading"
+
+
+@pytest.mark.parametrize(("message", "expected"), [
+ ("为主驾构建一个阅读模式,空调调整到26度", ("主驾阅读模式", "空调调整到26度")),
+ ("构建一个阅读模式,针对主驾,空调调整到26度", ("阅读模式", "针对主驾,空调调整到26度")),
+ ("构建一个主驾阅读模式,靠背向后移动3s", ("主驾阅读模式", "靠背向后移动3s")),
+ ("构建一个名为主驾驶阅读模式", ("主驾驶阅读模式", "")),
+ ("构建一个“阅读模式”,针对主驾", ("阅读模式", "")),
+])
+def test_explicit_names_and_actual_device_instructions_are_preserved(message, expected):
+ assert parse_mode_profile_create(message) == expected
+
+
+@pytest.mark.parametrize("message", [
+ "不要为主驾构建一个阅读模式", "如何为主驾构建一个阅读模式?",
+ "为主驾构建一个阅读模式,但不要执行", "引用为主驾构建一个阅读模式",
+])
+def test_scope_grammar_does_not_bypass_authorization(message):
+ assert parse_mode_profile_create(message) is None
+
+
+@pytest.mark.parametrize("name", ["后排阅读模式", "左后排阅读模式", "第三排阅读模式", "未知座位阅读模式"])
+def test_unsupported_seats_never_alias_to_front_seat_templates(name):
+ assert mode_creation_source_name(name) == name
+ assert SceneTemplateRegistry(ROOT / "config" / "scene_templates").resolve_exact_name(name) is None
+
+
+@pytest.mark.parametrize(("name", "source", "template_id"), [
+ ("主驾座椅舒适模式", "座椅舒适模式", "seat-comfort"),
+ ("主驾和副驾座椅舒适模式", "座椅舒适模式", "seat-comfort"),
+ ("主副驾座椅动态模式", "座椅动态模式", "seat-dynamic"),
+ ("副驾座椅的动态模式", "座椅的动态模式", "seat-dynamic"),
+])
+def test_seat_word_is_preserved_when_it_is_the_template_subject(name, source, template_id):
+ assert mode_creation_source_name(name) == source
+ template = SceneTemplateRegistry(ROOT / "config" / "scene_templates").resolve_exact_name(name)
+ assert template is not None and template.id == template_id
+
+
+def test_catalog_seed_fallback_is_exact_and_preserves_explicit_match_priority():
+ template = SceneTemplateRegistry(ROOT / "config" / "scene_templates").get_template("reading").public_dict(include_plan=True)
+ template["assistant_callable"] = True
+ catalog = {
+ "source_status": {"scene_templates": "ready", "modes": "ready"},
+ "scene_templates": [template],
+ "modes": [{"id": "office", "name": "办公模式"}],
+ }
+ assert _catalog_exact_scene_template_plan(catalog, "主驾阅读模式") == template["plan"]
+ assert _catalog_exact_scene_template_plan(catalog, "后排阅读模式") is None
+ assert _catalog_exact_mode_match_count(catalog, "主驾办公模式") == 1
+ catalog["modes"].extend([
+ {"id": "driver-office-a", "name": "主驾办公模式"},
+ {"id": "driver-office-b", "name": "主驾办公模式"},
+ ])
+ assert _catalog_exact_mode_match_count(catalog, "主驾办公模式") == 2
+
+
+@pytest.mark.parametrize(("message", "expected"), [
+ ("构建一个神游模式", {"passenger"}),
+ ("构建一个主驾神游模式", {"driver", "front_left"}),
+ ("构建一个主副驾神游模式", {"driver", "front_left", "passenger"}),
+ ("构建一个神游模式,主驾空调26度", {"passenger"}),
+ ("构建一个后排神游模式", set()),
+])
+def test_model_context_scopes_only_seat_capabilities_and_does_not_mutate_source(message, expected):
+ context = {
+ "composition_revision": "revision", "vehicle": {"id": "denza_n7"},
+ "capabilities": [
+ {"id": "seat.driver", "domain": "seat", "target_scopes": ["driver", "front_left"]},
+ {"id": "seat.passenger", "domain": "seat", "target_scopes": ["passenger"]},
+ {"id": "climate.zone", "domain": "climate", "target_scopes": ["driver", "passenger"]},
+ {"id": "roof.shade", "domain": "roof", "target_scopes": ["vehicle"]},
+ ],
+ }
+ original = deepcopy(context)
+ projected = scene_context_for_mode_creation(context, message)
+ scopes = {scope for capability in projected["capabilities"] if capability["domain"] == "seat"
+ for scope in capability["target_scopes"]}
+ assert scopes == expected
+ assert [capability for capability in projected["capabilities"] if capability["domain"] != "seat"] == original["capabilities"][2:]
+ assert context == original
+ assert projected["seat_scope_policy"]["default_scope"] == "passenger"
+
+
+def test_static_and_dynamic_prompts_use_one_selected_seat():
+ assert "defaults to the passenger seat only" in SCENE_COMPOSER_INSTRUCTIONS
+ assert "retarget a passenger capability to the driver" in SCENE_COMPOSER_INSTRUCTIONS
+ assert "selected seat scope in every phase" in SCHEDULE_COMPOSER_INSTRUCTIONS
+
+
+def test_identical_explicit_mode_names_do_not_share_cached_plans_across_seats():
+ context = {"composition_revision": "revision", "vehicle": {"id": "denza_n7"}, "capabilities": []}
+ # Explicitly named drafts may intentionally keep the same display name even
+ # when the outer request selects a different seat.
+ driver = scene_context_for_mode_creation(context, "为主驾构建一个名为神游模式")
+ passenger = scene_context_for_mode_creation(context, "为副驾构建一个名为神游模式")
+ driver_key = CodexAssistantService._scene_cache_key(None, mode_name="神游模式", scene_context=driver)
+ passenger_key = CodexAssistantService._scene_cache_key(None, mode_name="神游模式", scene_context=passenger)
+ assert driver_key != passenger_key
+
+
+@pytest.mark.parametrize("seat", [
+ "两个座椅", "两张座椅", "两座椅", "两座", "两个座位", "前排两座",
+ "前排两座椅", "前排两个座椅", "前排两张座椅", "双座", "双座椅",
+])
+@pytest.mark.parametrize("pattern", ["构建一个{}阅读模式", "为{}构建一个阅读模式", "构建一个阅读模式,针对{}"])
+def test_all_two_seat_wordings_have_one_canonical_name(seat, pattern):
+ message = pattern.format(seat)
+ assert parse_mode_profile_create(message) == ("主副驾阅读模式", "")
+ assert mode_creation_source_name(f"{seat}阅读模式") == "阅读模式"
+
+
+@pytest.mark.parametrize("message", [
+ "构建一个主驾阅读模式,针对副驾", "构建一个副驾阅读模式,针对主驾",
+ "为主驾构建一个副驾阅读模式", "为副驾构建一个主驾阅读模式",
+ "构建一个主副驾阅读模式,只针对主驾", "构建一个主驾阅读模式,针对双座",
+ "为主驾构建一个名为副驾阅读模式", "构建一个“副驾阅读模式”,针对主驾",
+])
+def test_conflicting_seat_selection_cannot_silently_become_a_two_seat_creation(message):
+ assert parse_mode_profile_create(message) is None
+
+
+@pytest.mark.parametrize(("name", "source"), [
+ ("副驾驶座椅阅读模式", "阅读模式"), ("主驾座椅的阅读模式", "阅读模式"),
+ ("主驾座椅办公模式", "办公模式"), ("副驾座位的观影模式", "观影模式"),
+ ("副驾座椅看书模式", "看书模式"), ("主驾座椅露营模式", "露营模式"),
+ ("主驾座椅舒适模式", "座椅舒适模式"), ("副驾座椅动态模式", "座椅动态模式"),
+ ("乘客座椅舒适模式", "座椅舒适模式"), ("两个座椅的动态模式", "座椅动态模式"),
+ ("两张座椅舒适模式", "座椅舒适模式"), ("前排两座动态模式", "座椅动态模式"),
+])
+def test_seat_location_wrappers_do_not_hide_generic_scene_sources_or_actual_seat_topics(name, source):
+ assert mode_creation_source_name(name) == source
+
+
+@pytest.mark.parametrize("message", [
+ "构建一个两个座椅的动态模式", "构建一个两张座椅的动态模式", "构建一个前排两座动态模式",
+])
+def test_double_seat_dynamic_topic_is_not_lost_with_the_scope_qualifier(message):
+ assert parse_mode_profile_create(message) == ("主副驾座椅动态模式", "")
diff --git a/tests/unit/test_mode_gallery_grouping.py b/tests/unit/test_mode_gallery_grouping.py
index bb3b733..6d1ad49 100644
--- a/tests/unit/test_mode_gallery_grouping.py
+++ b/tests/unit/test_mode_gallery_grouping.py
@@ -46,8 +46,18 @@ def test_runtime_mode_catalog_exposes_safe_executable_action_details() -> None:
if mode["id"] == "office"
)
- assert len(office["operations"]) == office["operation_count"] == 10
+ assert len(office["operations"]) == office["operation_count"] == 12
assert office["operations"][0] == {
+ "domain": "seat",
+ "capability": "seat.set_driver_ventilation_native",
+ "target_scope": "driver",
+ "operation": "set",
+ "parameters": {"state": "on", "level": 2},
+ }
+ assert next(
+ operation for operation in office["operations"]
+ if operation["capability"] == "seat.set_longitudinal_position"
+ ) == {
"domain": "seat",
"capability": "seat.set_longitudinal_position",
"target_scope": "passenger",
@@ -237,7 +247,7 @@ def test_top_mode_action_formatter_uses_device_target_action_and_parameter_value
actions = json.loads(completed.stdout)
assert len(actions) == office["operation_count"]
- assert actions[0] == {
+ assert next(action for action in actions if action["action"] == "调节座椅前后位置") == {
"device": "座椅",
"target": "副驾",
"action": "调节座椅前后位置",
diff --git a/tests/unit/test_mode_schedule.py b/tests/unit/test_mode_schedule.py
new file mode 100644
index 0000000..32a90ad
--- /dev/null
+++ b/tests/unit/test_mode_schedule.py
@@ -0,0 +1,513 @@
+from __future__ import annotations
+
+from queue import Queue
+from threading import Event, Lock, current_thread
+from unittest.mock import patch
+
+import pytest
+
+from cognitive_agent.application.mode_schedule import (
+ MODE_SCHEDULE_SCHEMA,
+ ModeSchedule,
+ ModeSchedulePhase,
+ ModeScheduleRunner,
+)
+
+
+def operation(capability="climate.set_temperature", target="whole_vehicle", **parameters):
+ return {
+ "domain": capability.split(".")[0], "capability": capability,
+ "target_scope": target, "operation": "set",
+ "parameters": parameters or {"temperature_c": 24}, "reason": "舒适阶段",
+ }
+
+
+def document(*, repeat=1, duration=1000):
+ return {
+ "schema": MODE_SCHEDULE_SCHEMA, "repeat_count": repeat,
+ "phases": [
+ {"id": "warmup", "name": "预热", "duration_ms": duration,
+ "operations": [operation(temperature_c=28)]},
+ {"id": "comfort", "name": "舒适", "duration_ms": duration,
+ "operations": [operation(temperature_c=24)]},
+ ],
+ }
+
+
+def test_round_trip_has_independent_parameters_and_counts():
+ source = document(repeat=3, duration=1500)
+ schedule = ModeSchedule.from_dict(source)
+ assert schedule.to_dict() == source
+ assert schedule.total_duration_ms == 9000
+ assert schedule.operation_count == 2
+ source["phases"][0]["operations"][0]["parameters"]["temperature_c"] = 10
+ returned = schedule.to_dict()
+ returned["phases"][0]["operations"][0]["parameters"]["temperature_c"] = 11
+ assert schedule.phases[0].operations[0]["parameters"]["temperature_c"] == 28
+
+
+@pytest.mark.parametrize("value", [None, [], "schedule", 1, True])
+def test_schedule_root_must_be_object(value):
+ with pytest.raises(ValueError):
+ ModeSchedule.from_dict(value)
+
+
+@pytest.mark.parametrize("field", ["schema", "repeat_count", "phases"])
+def test_schedule_requires_all_contract_fields(field):
+ value = document()
+ del value[field]
+ with pytest.raises(ValueError, match="missing"):
+ ModeSchedule.from_dict(value)
+
+
+@pytest.mark.parametrize("value", [0, 21, -1, 1.0, "2", True, False, None, float("nan")])
+def test_repeat_count_is_a_bounded_non_boolean_integer(value):
+ with pytest.raises(ValueError, match="repeat_count"):
+ ModeSchedule.from_dict(document(repeat=value))
+
+
+@pytest.mark.parametrize("value", [0, 999, 900001, 1000.0, "1000", True, None, float("inf")])
+def test_phase_duration_is_a_bounded_non_boolean_integer(value):
+ with pytest.raises(ValueError, match="duration_ms"):
+ ModeSchedule.from_dict(document(duration=value))
+
+
+@pytest.mark.parametrize("count", [0, 1, 9])
+def test_phase_count_bounds(count):
+ value = document()
+ value["phases"] = [dict(value["phases"][0], id=f"phase-{index}") for index in range(count)]
+ with pytest.raises(ValueError, match="2 to 8"):
+ ModeSchedule.from_dict(value)
+
+
+def test_total_duration_includes_repeats_and_enforces_one_hour():
+ assert ModeSchedule.from_dict(document(repeat=2, duration=900000)).total_duration_ms == 3600000
+ with pytest.raises(ValueError, match="60 minutes"):
+ ModeSchedule.from_dict(document(repeat=3, duration=900000))
+
+
+@pytest.mark.parametrize("path", [(), ("phases", 0), ("phases", 0, "operations", 0)])
+def test_unknown_fields_rejected_at_every_contract_level(path):
+ value = document()
+ target = value
+ for key in path:
+ target = target[key]
+ target["untrusted_instruction"] = "bypass validation"
+ with pytest.raises(ValueError, match="unknown"):
+ ModeSchedule.from_dict(value)
+
+
+def test_non_string_object_keys_are_rejected():
+ value = document()
+ value[1] = "unknown"
+ with pytest.raises(ValueError, match="string keys"):
+ ModeSchedule.from_dict(value)
+
+
+@pytest.mark.parametrize("schema", ["v2", "", None, 1, True])
+def test_schema_is_exact(schema):
+ value = document()
+ value["schema"] = schema
+ with pytest.raises(ValueError, match="schema"):
+ ModeSchedule.from_dict(value)
+
+
+@pytest.mark.parametrize("field", ["id", "name", "duration_ms", "operations"])
+def test_phase_requires_all_contract_fields(field):
+ value = document()
+ del value["phases"][0][field]
+ with pytest.raises(ValueError, match="missing"):
+ ModeSchedule.from_dict(value)
+
+
+@pytest.mark.parametrize("phase_id", ["", "Bad ID", "hello.world", "座椅", "x" * 65, 5])
+def test_phase_ids_are_bounded_identifiers(phase_id):
+ value = document()
+ value["phases"][0]["id"] = phase_id
+ with pytest.raises(ValueError):
+ ModeSchedule.from_dict(value)
+
+
+def test_duplicate_phase_ids_rejected():
+ value = document()
+ value["phases"][1]["id"] = value["phases"][0]["id"]
+ with pytest.raises(ValueError, match="ids must be unique"):
+ ModeSchedule.from_dict(value)
+
+
+@pytest.mark.parametrize("value", [[], [operation()] * 13, {}, "operations"])
+def test_operation_count_and_array_contract(value):
+ source = document()
+ source["phases"][0]["operations"] = value
+ with pytest.raises(ValueError):
+ ModeSchedule.from_dict(source)
+
+
+@pytest.mark.parametrize("capability", [
+ "seat.set_driver_axis_native", "seat.set_driver_position_native", "seat.move",
+ "seat.apply_dynamic_mode", "door.set_vehicle_lock", "window.set_motion", "custom.anything",
+])
+def test_only_stable_scene_composable_capabilities_allowed(capability):
+ value = document()
+ value["phases"][0]["operations"] = [operation(capability)]
+ with pytest.raises(ValueError, match="stable"):
+ ModeSchedule.from_dict(value)
+
+
+@pytest.mark.parametrize("kind", ["toggle", "increase", "decrease", "activate", "apply", None, True])
+def test_relative_or_non_set_operation_types_rejected(kind):
+ value = document()
+ value["phases"][0]["operations"][0]["operation"] = kind
+ with pytest.raises(ValueError, match="absolute set"):
+ ModeSchedule.from_dict(value)
+
+
+@pytest.mark.parametrize("name", ["duration_ms", "duration", "seconds", "direction", "delta", "relative", "axis", "toggle"])
+def test_phase_holds_cannot_be_smuggled_as_axis_motion(name):
+ value = document()
+ value["phases"][0]["operations"][0]["parameters"][name] = 1000
+ with pytest.raises(ValueError, match="relative, motion or timing"):
+ ModeSchedule.from_dict(value)
+
+
+@pytest.mark.parametrize("value", [float("nan"), float("inf"), -float("inf"), {}, [], None])
+def test_non_json_or_non_scalar_parameters_rejected(value):
+ source = document()
+ source["phases"][0]["operations"][0]["parameters"]["temperature_c"] = value
+ with pytest.raises(ValueError):
+ ModeSchedule.from_dict(source)
+
+
+@pytest.mark.parametrize("value", ["toggle", "relative", "increase", "decrease"])
+def test_relative_parameter_values_rejected(value):
+ source = document()
+ source["phases"][0]["operations"][0]["parameters"]["state"] = value
+ with pytest.raises(ValueError, match="relative state"):
+ ModeSchedule.from_dict(source)
+
+
+def test_domain_must_match_capability():
+ value = document()
+ value["phases"][0]["operations"][0]["domain"] = "seat"
+ with pytest.raises(ValueError, match="stable"):
+ ModeSchedule.from_dict(value)
+
+
+@pytest.mark.parametrize("target", ["passenger", "front_right"])
+def test_duplicate_physical_seat_target_aliases_rejected(target):
+ value = document()
+ value["phases"][0]["operations"] = [
+ operation("seat.set_ventilation", "passenger", state="on", level=1),
+ operation("seat.set_ventilation", target, state="off"),
+ ]
+ with pytest.raises(ValueError, match="duplicate"):
+ ModeSchedule.from_dict(value)
+
+
+def test_driver_and_passenger_comfort_are_valid_independent_operations():
+ value = document()
+ value["phases"][0]["operations"] = [
+ operation("seat.set_driver_ventilation_native", "driver", state="on", level=2),
+ operation("seat.set_driver_massage_native", "driver", action="mode", mode="relax"),
+ operation("seat.set_ventilation", "passenger", state="on", level=1),
+ operation("seat.set_backrest_recline", "passenger", position_pct=40),
+ ]
+ assert ModeSchedule.from_dict(value).operation_count == 5
+
+
+def test_direct_dataclass_construction_cannot_bypass_duration_or_repeat_limits():
+ with pytest.raises(ValueError, match="duration_ms"):
+ ModeSchedulePhase("a", "a", True, (operation(),))
+ phase = ModeSchedulePhase.from_dict(document()["phases"][0])
+ second = ModeSchedulePhase.from_dict(document()["phases"][1])
+ with pytest.raises(ValueError, match="repeat_count"):
+ ModeSchedule((phase, second), True)
+
+
+def test_simulation_has_no_thread_or_real_wait_and_preserves_repetition_order():
+ schedule = ModeSchedule.from_dict(document(repeat=3, duration=2000))
+ calls = []
+ caller = current_thread()
+
+ def execute(phase):
+ assert current_thread() is caller
+ calls.append(phase.id)
+ return {"success": True}
+
+ with (
+ patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("thread")),
+ patch("cognitive_agent.application.mode_schedule.Event.wait", side_effect=AssertionError("wait")),
+ ):
+ result = ModeScheduleRunner.simulate(schedule, execute)
+ assert result["status"] == "completed"
+ assert result["completed"] is True
+ assert result["execution_mode"] == "dry_run"
+ assert result["virtual_elapsed_ms"] == 12000
+ assert result["executed_phases"] == 6
+ assert result["phase_index"] == 1
+ assert result["repeat_index"] == 2
+ assert calls == ["warmup", "comfort"] * 3
+ assert [item["virtual_start_ms"] for item in result["phase_results"]] == list(range(0, 12000, 2000))
+
+
+@pytest.mark.parametrize("failed_result", [
+ None, [], {}, {"success": False}, {"status": "failed"}, {"status": "submitted_unverified"},
+ {"status": "error", "success": True}, {"status": "succeeded", "success": False},
+ {"success": True, "error": "rejected by safety"},
+ {"status": "pending"}, {"success": 1},
+])
+def test_simulation_fails_closed_on_unconfirmed_or_failed_execution(failed_result):
+ calls = []
+
+ def execute(phase):
+ calls.append(phase.id)
+ return failed_result
+
+ result = ModeScheduleRunner.simulate(ModeSchedule.from_dict(document(repeat=3)), execute)
+ assert result["status"] == "failed"
+ assert result["completed"] is False
+ assert result["error"]
+ assert result["virtual_elapsed_ms"] == 0
+ assert calls == ["warmup"]
+
+
+def test_simulation_stops_on_second_phase_exception_and_reports_prior_hold():
+ def execute(phase):
+ if phase.id == "comfort":
+ raise RuntimeError("runtime safety rejection")
+ return {"status": "succeeded"}
+
+ result = ModeScheduleRunner.simulate(ModeSchedule.from_dict(document(repeat=3)), execute)
+ assert result["completed"] is False
+ assert result["error"] == "RuntimeError: runtime safety rejection"
+ assert result["virtual_elapsed_ms"] == 1000
+ assert result["executed_phases"] == 1
+
+
+def test_executor_mutations_do_not_change_next_repetition_or_source():
+ values = []
+ schedule = ModeSchedule.from_dict(document(repeat=2))
+
+ def execute(phase):
+ values.append(phase.operations[0]["parameters"]["temperature_c"])
+ phase.operations[0]["parameters"]["temperature_c"] = 999
+ return {"status": "ok"}
+
+ assert ModeScheduleRunner.simulate(schedule, execute)["completed"]
+ assert values == [28, 24, 28, 24]
+ assert schedule.phases[0].operations[0]["parameters"]["temperature_c"] == 28
+
+
+class ManualClock:
+ def __init__(self):
+ self.value = 0.0
+ self.holds = Queue()
+
+ def __call__(self):
+ return self.value
+
+ def wait(self, cancelled, seconds):
+ release = Event()
+ self.holds.put((seconds, release))
+ while not cancelled.is_set():
+ if release.wait(0.01):
+ self.value += seconds
+ return False
+ return True
+
+ def release(self, seconds=1.0):
+ expected, release = self.holds.get(timeout=2)
+ assert expected == seconds
+ release.set()
+
+
+def test_runner_holds_without_repeated_dispatch_and_finishes_finite_repeats():
+ clock = ManualClock()
+ calls = Queue()
+ runner = ModeScheduleRunner(clock=clock, wait=clock.wait)
+ schedule = ModeSchedule.from_dict(document(repeat=2))
+ try:
+ runner.start("car", schedule, lambda phase: calls.put(phase.id) or {"success": True}, session_id="a")
+ for expected in ["warmup", "comfort", "warmup", "comfort"]:
+ assert calls.get(timeout=2) == expected
+ assert calls.empty()
+ state = runner.status("car")
+ assert state["status"] == "running"
+ assert state["completed"] is False
+ assert state["session_id"] == "a"
+ clock.release()
+ # Joining a bounded, virtual-time worker is deterministic and does not
+ # cancel it as close() would do while it leaves its final hold.
+ runner._runs["car"].thread.join(2)
+ assert runner.status("car")["status"] == "completed"
+ assert runner.status("car")["elapsed_ms"] == 4000
+ assert runner.status("car")["executed_phases"] == 4
+ finally:
+ assert runner.close()
+
+
+def test_cancel_interrupts_real_hold_before_next_phase():
+ started = Event()
+ calls = []
+ runner = ModeScheduleRunner()
+
+ def execute(phase):
+ calls.append(phase.id)
+ started.set()
+ return {"status": "success"}
+
+ runner.start("car", ModeSchedule.from_dict(document(duration=900000)), execute)
+ assert started.wait(2)
+ assert runner.cancel("car")["status"] == "cancelled"
+ assert runner.close(timeout=1)
+ assert calls == ["warmup"]
+ assert runner.status("car")["completed"] is False
+
+
+def test_wrong_session_cannot_cancel_active_timeline():
+ started = Event()
+ runner = ModeScheduleRunner()
+ runner.start("car", ModeSchedule.from_dict(document()), lambda phase: started.set() or {"success": True}, session_id="a")
+ assert started.wait(2)
+ assert runner.cancel("car", session_id="b")["status"] == "running"
+ assert runner.cancel("car", session_id="a")["status"] == "cancelled"
+ assert runner.close()
+
+
+def test_replace_waits_for_inflight_callback_and_never_overlaps_same_car():
+ entered = Event()
+ release = Event()
+ second_entered = Event()
+ calls = []
+ inside = 0
+ guard = Lock()
+ runner = ModeScheduleRunner()
+
+ def first(phase):
+ nonlocal inside
+ with guard:
+ inside += 1
+ entered.set()
+ assert release.wait(2)
+ calls.append("old")
+ with guard:
+ inside -= 1
+ return {"success": True}
+
+ def second(phase):
+ with guard:
+ assert inside == 0
+ calls.append("new")
+ second_entered.set()
+ return {"success": True}
+
+ try:
+ old = runner.start("car", ModeSchedule.from_dict(document()), first, session_id="old")
+ old_run = runner._runs["car"]
+ assert entered.wait(2)
+ new = runner.start("car", ModeSchedule.from_dict(document()), second, session_id="new")
+ assert old["run_id"] != new["run_id"]
+ assert old_run.status == "cancelled"
+ assert old_run.cancel_reason == "replaced"
+ assert not second_entered.is_set()
+ release.set()
+ assert second_entered.wait(2)
+ assert calls == ["old", "new"]
+ finally:
+ release.set()
+ assert runner.close()
+
+
+def test_different_vehicles_do_not_share_a_global_execution_lock():
+ first_entered, second_entered, release = Event(), Event(), Event()
+ runner = ModeScheduleRunner()
+
+ def first(phase):
+ first_entered.set()
+ assert release.wait(2)
+ return {"success": True}
+
+ try:
+ runner.start("car-a", ModeSchedule.from_dict(document()), first)
+ assert first_entered.wait(2)
+ runner.start("car-b", ModeSchedule.from_dict(document()), lambda phase: second_entered.set() or {"success": True})
+ assert second_entered.wait(2)
+ finally:
+ release.set()
+ assert runner.close()
+
+
+@pytest.mark.parametrize("failure", ["return", "exception"])
+def test_runner_stops_later_phases_and_repeats_after_failure(failure):
+ calls = []
+ runner = ModeScheduleRunner(wait=lambda event, duration: False)
+
+ def execute(phase):
+ calls.append(phase.id)
+ if phase.id == "comfort":
+ if failure == "exception":
+ raise ValueError("bad runtime capability")
+ return {"status": "rejected", "error": "bad runtime capability"}
+ return {"status": "ok"}
+
+ runner.start("car", ModeSchedule.from_dict(document(repeat=3)), execute)
+ runner._runs["car"].thread.join(2)
+ assert runner.status("car")["status"] == "failed"
+ assert "bad runtime capability" in runner.status("car")["error"]
+ assert runner.status("car")["executed_phases"] == 1
+ assert calls == ["warmup", "comfort"]
+ assert runner.close()
+
+
+def test_close_prevents_start_and_reports_uninterruptible_callback_honestly():
+ entered, release = Event(), Event()
+ runner = ModeScheduleRunner()
+ runner.start("car", ModeSchedule.from_dict(document()), lambda phase: entered.set() or release.wait(2) or {"success": True})
+ assert entered.wait(2)
+ assert runner.close(timeout=0) is False
+ assert runner.status("car")["status"] == "cancelled"
+ assert runner.status("car")["cancel_reason"] == "closed"
+ with pytest.raises(RuntimeError, match="closed"):
+ runner.start("car", ModeSchedule.from_dict(document()), lambda phase: {"success": True})
+ release.set()
+ assert runner.close()
+
+
+def test_idle_and_terminal_cancellation_are_idempotent():
+ runner = ModeScheduleRunner(wait=lambda event, duration: False)
+ assert runner.status("unknown")["status"] == "idle"
+ assert runner.cancel("unknown")["status"] == "idle"
+ runner.start("car", ModeSchedule.from_dict(document()), lambda phase: {"success": True})
+ runner._runs["car"].thread.join(2)
+ assert runner.cancel("car")["status"] == "completed"
+ assert runner.close()
+ assert runner.close()
+
+
+def test_start_revalidates_caller_mutation_before_accepting_schedule():
+ runner = ModeScheduleRunner()
+ schedule = ModeSchedule.from_dict(document())
+ schedule.phases[0].operations[0]["operation"] = "toggle"
+ with pytest.raises(ValueError, match="absolute set"):
+ runner.start("car", schedule, lambda phase: {"success": True})
+ assert runner.status("car")["status"] == "idle"
+ assert runner.close()
+
+
+def test_failed_thread_start_is_not_left_running_and_close_remains_safe():
+ runner = ModeScheduleRunner()
+ with (
+ patch("cognitive_agent.application.mode_schedule.Thread.start", side_effect=RuntimeError("thread unavailable")),
+ pytest.raises(RuntimeError, match="thread unavailable"),
+ ):
+ runner.start("car", ModeSchedule.from_dict(document()), lambda phase: {"success": True})
+ assert runner.status("car")["status"] == "failed"
+ assert runner.close()
+
+
+def test_injected_cancelled_wait_cannot_leave_stale_running_status():
+ runner = ModeScheduleRunner(wait=lambda event, duration: True)
+ runner.start("car", ModeSchedule.from_dict(document()), lambda phase: {"success": True})
+ runner._runs["car"].thread.join(2)
+ assert runner.status("car")["status"] == "cancelled"
+ assert runner.close()
diff --git a/tests/unit/test_saved_mode_management_intent.py b/tests/unit/test_saved_mode_management_intent.py
index 5810e27..e33268e 100644
--- a/tests/unit/test_saved_mode_management_intent.py
+++ b/tests/unit/test_saved_mode_management_intent.py
@@ -46,6 +46,26 @@ def test_saved_mode_update_parser_accepts_natural_variants(
assert parse_mode_profile_update(message) == expected
+@pytest.mark.parametrize("change", (
+ "第二阶段保持30秒", "第二阶段持续16分钟", "循环21次", "重复两次",
+ "重复1.5次", "持续30秒", "全部阶段持续2分钟",
+))
+def test_schedule_update_predicates_stay_on_control_plane_even_when_bounds_are_invalid(change):
+ assert parse_mode_profile_update(f"把上车预热模式的{change}") == ("上车预热模式", change)
+
+
+@pytest.mark.parametrize("message", (
+ "把上车预热模式的第二阶段保持30秒合适吗?",
+ "不要把上车预热模式的第二阶段保持30秒",
+ "上车预热模式的第二阶段不要保持30秒",
+ "把上车预热模式的重复两次,但是不要保存",
+ "解释把上车预热模式的重复两次",
+ "创建一个上车预热模式,第二阶段保持30秒",
+))
+def test_schedule_update_predicates_do_not_authorize_queries_negation_or_creation(message):
+ assert parse_mode_profile_update(message) is None
+
+
@pytest.mark.parametrize(
"message",
(
diff --git a/tests/unit/test_scene_composer_fast_path.py b/tests/unit/test_scene_composer_fast_path.py
index 772c5a0..30b8e46 100644
--- a/tests/unit/test_scene_composer_fast_path.py
+++ b/tests/unit/test_scene_composer_fast_path.py
@@ -792,7 +792,13 @@ def test_unknown_mode_uses_one_structured_turn_then_local_dry_run() -> None:
"roof.set_shade_position",
]
call = client.structured_calls[0]
- assert call["application_context"] == _scene_context()
+ expected_context = _scene_context()
+ expected_context["seat_scope_policy"] = {
+ "selected_scopes": ["passenger"],
+ "default_scope": "passenger",
+ "applies_to": "seat capabilities in every phase",
+ }
+ assert call["application_context"] == expected_context
assert call["output_schema"]["type"] == "object"
assert call["output_schema"]["additionalProperties"] is False
assert "requests" in call["output_schema"]["required"]