feat: add seat firmware workbench and OTA delivery

This commit is contained in:
vee
2026-09-16 17:55:30 +08:00
parent 3eeca2ea75
commit ab8e43050c
70 changed files with 22594 additions and 316 deletions
@@ -552,6 +552,58 @@ test("the change-into predicate carries only a same-name opaque draft reference"
assert.equal(Object.hasOwn(anotherMode, "mode_profile_draft_context"), false);
});
for (const phrase of [
"在午休模式中加入空调、氛围灯、阅读灯、车窗等设备动作",
"请帮我在午休模式里添加空调和车窗动作",
"给午休模式增加氛围灯",
"为午休模式补充阅读灯",
"往午休模式内加入空调,温度26度",
"向午休模式中加上关闭车窗",
"午休模式中再加入空调温度26度",
"午休模式再补充车窗和阅读灯",
"把关闭车窗加入到午休模式中",
"请将空调26度和柔和氛围灯添加到午休模式",
]) {
test(`device additions carry only the same-name opaque draft: ${phrase}`, async () => {
const events = modeDraftEvents("req-draft-add-devices", 24);
const harness = createHarness(events);
harness.input.value = "构建一个午休模式";
await harness.sandbox.CognitiveAgentAssistant.submit();
events.splice(0, events.length, { type: "answer.completed", answer: "已收到草稿补充请求" });
harness.input.value = phrase;
await harness.sandbox.CognitiveAgentAssistant.submit();
const request = JSON.parse(harness.requestCalls.at(-1).options.body);
assert.equal(request.message, phrase);
assert.deepEqual(request.mode_profile_draft_context, {
name: "午休模式", source_request_id: "req-draft-add-devices", vehicle_id: "demo-vehicle",
});
assert.equal(harness.requestCalls.every((call) => call.url === "/api/assistant/stream"), true);
});
}
for (const phrase of [
"在露营模式中加入空调和车窗",
"把空调添加到露营模式中",
"在午休模式中加入空调可以吗?",
"午休模式中加入哪些设备合适?",
"在午休模式中不要加入空调",
"在午休模式中先别添加车窗",
"打开空调",
"设置空调出风模式为吹脚",
]) {
test(`non-update or other-mode additions do not borrow a draft: ${phrase}`, async () => {
const events = modeDraftEvents("req-draft-add-isolation", 24);
const harness = createHarness(events);
harness.input.value = "构建一个午休模式";
await harness.sandbox.CognitiveAgentAssistant.submit();
events.splice(0, events.length, { type: "answer.completed", answer: "已收到" });
harness.input.value = phrase;
await harness.sandbox.CognitiveAgentAssistant.submit();
const request = JSON.parse(harness.requestCalls.at(-1).options.body);
assert.equal(Object.hasOwn(request, "mode_profile_draft_context"), false);
});
}
test("shade position rows explain the target opening at semantic boundaries", async () => {
const positions = [0, 30, 50, 100];
const events = [
@@ -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-29"' in html
assert 'src="/assistant-chat.js?v=codex-assistant-29"' in html
assert 'href="/assistant-chat.css?v=codex-assistant-30"' in html
assert 'src="/assistant-chat.js?v=codex-assistant-30"' 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-v283"' in service_worker
assert '"/assistant-chat.css?v=codex-assistant-29"' in service_worker
assert '"/assistant-chat.js?v=codex-assistant-29"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v284"' in service_worker
assert '"/assistant-chat.css?v=codex-assistant-30"' in service_worker
assert '"/assistant-chat.js?v=codex-assistant-30"' in service_worker
def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events():
@@ -214,7 +214,7 @@ def test_assistant_carries_only_the_active_mode_draft_reference_to_follow_up_tur
assert "rememberModeDraftContext(draft)" in assistant
assert "activeModeDraftContext = loadActiveModeDraftContext()" in assistant
assert "function isModeDraftUpdateRequest(message, context)" in assistant
assert "normalizeModeName(matched[1]) === normalizeModeName(context.name)" in assistant
assert "normalizeModeName(matched[nameIndex]) === normalizeModeName(context.name)" in assistant
def test_latest_same_name_mode_draft_retires_stale_card_and_lifecycle_clears_context():
@@ -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-29") < html.index(
assert html.index("/assistant-chat.js?v=codex-assistant-30") < html.index(
"/energy-monitor.js?v=energy-monitor-4"
)
assert 'const CACHE_VERSION = "cognitive-agent-pad-v283"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v288"' in service_worker
def test_energy_monitor_uses_the_fifth_shared_dock_icon_and_starts_closed() -> None:
+253
View File
@@ -0,0 +1,253 @@
from __future__ import annotations
import hashlib
import json
import threading
import time
import zipfile
from pathlib import Path
from cognitive_agent.application.firmware_workbench import (
FINAL_RELEASE_MESSAGE,
FirmwareWorkbenchService,
)
ROOT = Path(__file__).resolve().parents[2]
def wait_for_terminal(service: FirmwareWorkbenchService, task_id: str) -> dict[str, object]:
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
task = service.task(task_id)
if task["status"] not in {"waiting", "running", "pending_confirmation"}:
return task
time.sleep(0.02)
raise AssertionError("firmware pipeline did not reach a terminal state")
def make_image(tmp_path: Path) -> Path:
image = tmp_path / "Seat_Controller_demo.hex"
image.write_bytes(b":020000040000FA\n" + b":100000000102030405060708090A0B0C0D0E0F1068\n" * 80)
return image
def test_pipeline_publishes_a_downloadable_ota_package(
tmp_path: Path, monkeypatch
) -> None:
monkeypatch.setenv(
"FIRMWARE_WORKBENCH_OTA_PUBLIC_BASE_URL",
"http://192.168.43.130:8000/api/firmware-workbench/releases",
)
image = make_image(tmp_path)
def build_runner(
_task_id: str, log: object, _cancel: threading.Event
) -> Path:
log("info", "IAR build: 0 errors, 0 warnings")
return image
service = FirmwareWorkbenchService(
ROOT,
artifact_root=tmp_path / "workbench",
ota_root=tmp_path / "ota",
build_runner=build_runner,
)
instruction = "增加支持座椅位置参数控制的能力,增加座椅通风功能,增加座椅加热功能"
first = service.start_task(instruction)
task = wait_for_terminal(service, str(first["task"]["id"]))
assert task["status"] == "success"
assert task["message"] == FINAL_RELEASE_MESSAGE
assert [stage["status"] for stage in task["stages"]] == ["success"] * 8
assert len(task["test_results"]) == 7
snapshot = service.snapshot()
assert snapshot["baselines"] == [
{
"id": "time_only",
"name": "时间控制基线",
"capabilities": ["时间控制"],
}
]
assert len(snapshot["artifacts"]) == 2
assert len(snapshot["releases"]) == 1
artifact = next(item for item in snapshot["artifacts"] if item["task_id"] == task["id"])
baseline = next(item for item in snapshot["artifacts"] if item.get("is_baseline"))
release = snapshot["releases"][0]
assert baseline["capabilities"] == ["时间控制"]
assert baseline["protected"] is True
assert artifact["capabilities"] == ["时间控制", "位置控制", "座椅通风", "座椅加热"]
assert artifact["status"] == "published"
assert release["download_url"] == (
f"http://192.168.43.130:8000/api/firmware-workbench/releases/"
f"{release['id']}/download"
)
assert service.latest_release()["download_url"] == release["download_url"]
latest_manifest = json.loads((tmp_path / "ota" / "latest.json").read_text("utf-8"))
assert latest_manifest["download_url"] == release["download_url"]
package, filename = service.resolve_release_download(str(release["id"]))
assert filename == artifact["firmware_filename"]
assert hashlib.sha256(package.read_bytes()).hexdigest() == artifact["sha256"]
with zipfile.ZipFile(package) as archive:
assert {
"Seat_Controller_demo.hex",
"manifest.json",
"requirements.json",
"validation.json",
"feature_profile.json",
} <= set(archive.namelist())
assert (tmp_path / "ota" / "latest.json").is_file()
assert service.publish_artifact(str(artifact["id"]))["id"] == release["id"]
service.close()
monkeypatch.setenv(
"FIRMWARE_WORKBENCH_OTA_PUBLIC_BASE_URL",
"http://192.168.43.131:8000/api/firmware-workbench/releases",
)
migrated = FirmwareWorkbenchService(
ROOT,
artifact_root=tmp_path / "workbench",
ota_root=tmp_path / "ota",
build_runner=build_runner,
)
migrated_release = migrated.latest_release()
assert migrated_release["download_url"].startswith("http://192.168.43.131:8000/")
migrated_latest = json.loads((tmp_path / "ota" / "latest.json").read_text("utf-8"))
assert migrated_latest["download_url"] == migrated_release["download_url"]
migrated.close()
def test_repeated_instruction_always_creates_and_executes_a_new_task(tmp_path: Path) -> None:
image = make_image(tmp_path)
def build_runner(
_task_id: str, log: object, _cancel: threading.Event
) -> Path:
log("info", "IAR build: 0 errors, 0 warnings")
return image
service = FirmwareWorkbenchService(
ROOT,
artifact_root=tmp_path / "workbench",
ota_root=tmp_path / "ota",
build_runner=build_runner,
)
first = service.start_task("增加座椅加热功能。")
first_task = wait_for_terminal(service, str(first["task"]["id"]))
second = service.start_task("增加座椅加热功能。")
assert first["task"]["id"] != second["task"]["id"]
assert "duplicate" not in first
assert "duplicate" not in second
second_task = wait_for_terminal(service, str(second["task"]["id"]))
assert first_task["status"] == "success"
assert second_task["status"] == "success"
assert first_task["target_capabilities"] == ["时间控制", "座椅加热"]
assert second_task["target_capabilities"] == ["时间控制", "座椅加热"]
service.close()
def test_non_baseline_artifact_can_be_deleted_but_baseline_is_protected(tmp_path: Path) -> None:
image = make_image(tmp_path)
def build_runner(
_task_id: str, log: object, _cancel: threading.Event
) -> Path:
log("info", "IAR build: 0 errors, 0 warnings")
return image
service = FirmwareWorkbenchService(
ROOT,
artifact_root=tmp_path / "workbench",
ota_root=tmp_path / "ota",
build_runner=build_runner,
)
started = service.start_task("增加座椅通风功能")
task = wait_for_terminal(service, str(started["task"]["id"]))
package_path, _ = service.resolve_artifact_download(str(task["artifact_id"]))
release_path, _ = service.resolve_release_download(str(task["release_id"]))
result = service.delete_artifact(str(task["artifact_id"]))
assert result["deleted"] is True
assert not package_path.exists()
assert not release_path.exists()
snapshot = service.snapshot()
assert [artifact["id"] for artifact in snapshot["artifacts"]] == [
"fw-art-time-only-baseline"
]
assert snapshot["releases"] == []
try:
service.delete_artifact("fw-art-time-only-baseline")
except Exception as exc: # noqa: BLE001 - public error type is asserted below
assert getattr(exc, "code", None) == "baseline_protected"
else:
raise AssertionError("time-only baseline must not be deletable")
service.close()
def test_position_instruction_merges_with_time_only_baseline(tmp_path: Path) -> None:
image = make_image(tmp_path)
def build_runner(
_task_id: str, log: object, _cancel: threading.Event
) -> Path:
log("info", "IAR build: profile position=1 ventilation=0 heating=0")
return image
service = FirmwareWorkbenchService(
ROOT,
artifact_root=tmp_path / "workbench",
ota_root=tmp_path / "ota",
build_runner=build_runner,
)
started = service.start_task("增加支持座椅位置参数控制的能力。", "time_only")
task = wait_for_terminal(service, str(started["task"]["id"]))
assert task["status"] == "success"
assert task["baseline_capabilities"] == ["时间控制"]
assert task["requested_capabilities"] == ["位置控制"]
assert task["target_capabilities"] == ["时间控制", "位置控制"]
artifact = service.snapshot()["artifacts"][0]
assert artifact["capabilities"] == ["时间控制", "位置控制"]
assert artifact["capability_diff"]["added"] == ["位置控制"]
assert "vent" not in artifact["firmware_filename"]
assert "heat" not in artifact["firmware_filename"]
service.close()
def test_running_pipeline_can_be_canceled_and_retried(tmp_path: Path) -> None:
image = make_image(tmp_path)
allow_build = threading.Event()
def build_runner(
_task_id: str, log: object, cancel: threading.Event
) -> Path:
log("info", "IAR build started")
while not allow_build.is_set():
if cancel.wait(0.01):
raise InterruptedError
return image
service = FirmwareWorkbenchService(
ROOT,
artifact_root=tmp_path / "workbench",
ota_root=tmp_path / "ota",
build_runner=build_runner,
)
started = service.start_task("增加座椅通风功能")
task_id = str(started["task"]["id"])
deadline = time.monotonic() + 3
while service.task(task_id)["current_stage"] != "build" and time.monotonic() < deadline:
time.sleep(0.01)
service.cancel_task(task_id)
canceled = wait_for_terminal(service, task_id)
assert canceled["status"] == "canceled"
assert any(stage["status"] == "canceled" for stage in canceled["stages"])
allow_build.set()
retried = service.retry_task(task_id)
assert retried["id"] == task_id
completed = wait_for_terminal(service, task_id)
assert completed["status"] == "success"
service.close()
@@ -0,0 +1,62 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
WEB = ROOT / "apps" / "web-console"
def source(name: str) -> str:
return (WEB / name).read_text(encoding="utf-8")
def test_workbench_is_a_standalone_development_surface() -> None:
html = source("firmware-workbench.html")
script = source("firmware-workbench.js")
assert "座椅固件开发工作台" in html
assert '<textarea id="instructionInput"' in html
assert 'id="startTaskButton"' in html
assert 'id="cancelTaskButton"' in html
assert 'id="pipeline"' in html
assert 'id="logStream"' in html
assert 'id="artifactTableBody"' in html
assert 'id="baselineSelect"' in html
assert 'id="targetCapabilities"' in html
assert "执行摘要" not in html
assert "单屏工作台" not in html
assert "相同指令只会复用同一任务" not in html
assert "固件交付流水线</h2>" not in html
assert html.count("<option") == 1
assert '<option value="time_only">时间控制</option>' in html
for english_label in ("DEVELOPMENT BRIEF", "DELIVERY PIPELINE", "LIVE EXECUTION", "SOFTWARE ARTIFACTS"):
assert english_label not in html
assert "普通聊天" not in html
for stage in ("指令输入", "需求解析", "基线检查", "代码生成", "编译构建", "测试验证", "固件打包", "发布服务器"):
assert stage in script or stage in html
for status in ("等待", "运行中", "待确认", "成功", "失败", "取消", "阻塞"):
assert status in script or status in html
def test_workbench_exposes_artifact_actions_and_always_starts_a_new_task() -> None:
html = source("firmware-workbench.html")
script = source("firmware-workbench.js")
entry = source("firmware-workbench-entry.js")
index = source("index.html")
worker = source("service-worker.js")
for label in ("查看详情", "能力差异", "代码差异", "验证记录", "下载固件", "发布到服务器", "删除固件产物"):
assert label in script
assert "检测到相同指令" not in script
assert "result.duplicate" not in script
assert "已发布,待车机端下载升级" not in script
assert 'id = "firmwareWorkbenchToggle"' in entry
assert 'className = "firmware-workbench-layer"' in entry
assert "/firmware-workbench-entry.js?v=2" in index
assert "/firmware-workbench-entry.css?v=1" in index
for asset in (
"/firmware-workbench-entry.css?v=1",
"/firmware-workbench-entry.js?v=2",
"/firmware-workbench.html",
"/firmware-workbench.css?v=5",
"/firmware-workbench.js?v=3",
):
assert f'"{asset}"' in worker
+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-v283"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v288"' in service_worker
def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None:
@@ -0,0 +1,123 @@
from __future__ import annotations
from copy import deepcopy
import pytest
from cognitive_agent.application.codex_assistant import (
CodexAssistantService,
_authorize_vehicle_request,
_validated_mode_profile_draft_context,
)
from cognitive_agent.application.mode_profile_intent import (
is_mode_profile_update_shape,
parse_mode_profile_update,
)
DRAFT_REFERENCE = {
"name": "躺平模式", "source_request_id": "req-augmentation-source-123456", "vehicle_id": "denza_n7",
}
CONTROL_TERMS = ("空调", "氛围灯", "阅读灯", "车窗", "座椅")
@pytest.mark.parametrize("prefix", ["在", "给", "为", "往", "向", ""])
@pytest.mark.parametrize("verb", ["加入", "添加", "增加", "补充", "加上", "追加", "新增"])
def test_named_mode_device_augmentation_is_recognized(prefix, verb):
message = f"{prefix}躺平模式中{verb}空调、氛围灯、阅读灯、车窗等设备动作"
assert parse_mode_profile_update(message) == ("躺平模式", f"{verb}空调、氛围灯、阅读灯、车窗等设备动作")
assert _validated_mode_profile_draft_context(DRAFT_REFERENCE, message=message) == DRAFT_REFERENCE
authorization = _authorize_vehicle_request(message, CONTROL_TERMS, DRAFT_REFERENCE)
assert authorization.instruction == message
assert authorization.reason == "explicit_mode_profile_draft_update"
@pytest.mark.parametrize(("message", "change"), [
("请在躺平模式里补充空调", "补充空调"),
("帮我给躺平模式添加空调", "添加空调"),
("为躺平模式新增阅读灯", "新增阅读灯"),
("躺平模式再补充车窗", "补充车窗"),
("躺平模式中再加入阅读灯", "再加入阅读灯"),
("在躺平模式中加入空调24度、阅读灯打开", "加入空调24度、阅读灯打开"),
("把空调24度、阅读灯打开加入到躺平模式中", "加入空调24度、阅读灯打开"),
("将车窗、氛围灯追加进躺平模式", "追加车窗、氛围灯"),
("请把空调加入躺平模式吧", "加入空调"),
("把副驾空调温度25.5度补充到躺平模式", "补充副驾空调温度25.5度"),
("在“躺平模式”中添加空调", "添加空调"),
])
def test_append_word_order_preserves_device_parameters_and_scoped_name(message, change):
parsed = parse_mode_profile_update(message)
assert parsed is not None and parsed[0] == "躺平模式"
# A leading 再 modifier is harmless; neither parameters nor target words
# may be lost when the reverse-order request is normalized.
assert parsed[1].removeprefix("再") == change.removeprefix("再")
@pytest.mark.parametrize("message", [
"不要在躺平模式中加入空调", "在躺平模式中不要加入空调",
"躺平模式中先不要补充阅读灯", "给躺平模式添加空调,但不要执行",
"给躺平模式添加空调,算了", "给躺平模式添加空调,撤销",
"躺平模式中取消加入空调", "如何在躺平模式中加入空调?",
"在躺平模式中加入空调好吗?", "在躺平模式中能否加入空调",
"把空调加入到躺平模式会怎样?", "解释在躺平模式中加入空调这句话",
"在躺平模式中加入哪些设备?", "引用在躺平模式中加入空调",
"在空调出风模式中加入阅读灯", "在副驾按摩模式中添加车窗",
"在躺平模式中加入", "给躺平模式补充",
])
def test_negated_read_only_or_device_property_requests_do_not_authorize_augmentation(message):
assert parse_mode_profile_update(message) is None
assert _validated_mode_profile_draft_context(DRAFT_REFERENCE, message=message) is None
authorization = _authorize_vehicle_request(message, CONTROL_TERMS, DRAFT_REFERENCE)
assert authorization.reason not in {"explicit_mode_profile_draft_update", "explicit_mode_profile_update"}
def test_other_named_mode_never_borrows_the_selected_draft_reference():
message = "在阅读模式中加入空调"
assert parse_mode_profile_update(message) == ("阅读模式", "加入空调")
assert _validated_mode_profile_draft_context(DRAFT_REFERENCE, message=message) is None
assert _authorize_vehicle_request(message, CONTROL_TERMS, DRAFT_REFERENCE).reason == "explicit_mode_profile_update"
def test_augmentation_reference_cannot_include_browser_supplied_device_actions():
reference = {**DRAFT_REFERENCE, "operations": [{"capability": "seat.anything"}]}
with pytest.raises(ValueError, match="fields are invalid"):
_validated_mode_profile_draft_context(reference, message="在躺平模式中加入空调")
def test_speech_update_shape_preserves_named_augmentation_even_when_negated():
assert is_mode_profile_update_shape("在躺平模式中加入空调,但不要执行")
assert parse_mode_profile_update("在躺平模式中加入空调,但不要执行") is None
def test_device_augmentation_bypasses_general_agent_and_partial_control_paths():
client = type("UnavailableClient", (), {"available": False})()
assistant = CodexAssistantService(client)
message = "在躺平模式中加入空调、氛围灯、阅读灯、车窗等设备动作"
calls = []
events = []
def execute_control(instruction, _progress):
calls.append(instruction)
return {
"request_id": "req-revised-123456", "status": "succeeded", "execution_mode": "dry_run",
"orchestration_route": "mode_profile_create_preview",
"intent_ir": {"spec": {"actions": [{"domain": "climate"}]}},
"mode_profile_draft": {
"name": "躺平模式", "source_request_id": "req-revised-123456", "vehicle_id": "denza_n7",
"instruction": "进入躺平模式", "operation_count": 1,
"generation_source": "draft_revision", "draft_revision": 2,
},
}
reference = deepcopy(DRAFT_REFERENCE)
result = assistant.stream(
message, conversation_id="conv-augmentation-intent-123456", principal="test",
history=[], emit=events.append, execute_control=execute_control,
preview_control=lambda *_args: pytest.fail("augmentation must not run partial hardware controls"),
supplement_control=lambda *_args: pytest.fail("augmentation must not become a vehicle supplement"),
mode_profile_draft_context=reference, control_terms=CONTROL_TERMS,
)
assert calls == [message]
assert result["success"] is True
assert result["result"]["mode_profile_draft"]["name"] == "躺平模式"
assert reference == DRAFT_REFERENCE
assert events[0]["route"] == "vehicle_control"