feat: add seat-scoped static and dynamic assistant modes

This commit is contained in:
vee
2026-09-12 14:56:30 +08:00
parent d09b5967f8
commit d8dad22ec0
60 changed files with 5847 additions and 149 deletions
+435
View File
@@ -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 = '<img src=x onerror="alert(1)">';
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:<img/);
assert.match(textOf(popover), /停止动态模式/);
assert.match(textOf(popover), /\+1分 开始 · 持续 30秒/);
assert.equal(popover.querySelectorAll("img").length, 0);
});
test("execution distinguishes accepted schedule, virtual trial and stopped device state", () => {
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\)/);
});
@@ -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} >= {
"观影模式",
"露营模式",
@@ -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():
@@ -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:
+1 -1
View File
@@ -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:
@@ -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"}))
@@ -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) == ("主副驾座椅动态模式", "")
+12 -2
View File
@@ -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": "调节座椅前后位置",
+513
View File
@@ -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()
@@ -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",
(
+7 -1
View File
@@ -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"]