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 ROOT = path.resolve(__dirname, "../.."); const ASSISTANT_SOURCE = fs.readFileSync( path.join(ROOT, "apps/web-console/assistant-chat.js"), "utf8", ); class MockClassList { constructor() { this.values = new Set(); } add(...names) { names.forEach((name) => this.values.add(name)); } remove(...names) { names.forEach((name) => this.values.delete(name)); } contains(name) { return this.values.has(name); } toggle(name, force) { const enabled = force === undefined ? !this.values.has(name) : Boolean(force); if (enabled) this.values.add(name); else this.values.delete(name); return enabled; } } class MockEventTarget { constructor() { this.listeners = new Map(); } addEventListener(type, listener) { const listeners = this.listeners.get(type) || []; listeners.push(listener); this.listeners.set(type, listeners); } removeEventListener(type, listener) { const listeners = this.listeners.get(type) || []; this.listeners.set(type, listeners.filter((candidate) => candidate !== listener)); } dispatchEvent(event) { if (!event?.type) throw new TypeError("event.type is required"); if (!event.target) event.target = this; for (const listener of this.listeners.get(event.type) || []) listener.call(this, event); return event.defaultPrevented !== true; } } class MockElement extends MockEventTarget { constructor(tagName = "div") { super(); this.attributes = new Map(); this.children = []; this.classList = new MockClassList(); this.dataset = {}; this.disabled = false; this.hidden = false; this.id = ""; this.innerHTML = ""; this.parentElement = null; this.scrollHeight = 100; this.scrollTop = 100; this.clientHeight = 100; this.style = {}; this.tagName = tagName.toUpperCase(); this.textContent = ""; this.title = ""; this.value = ""; } get className() { return [...this.classList.values].join(" "); } set className(value) { this.classList.values = new Set(String(value).split(/\s+/).filter(Boolean)); } append(...nodes) { nodes.forEach((node) => { this.children.push(node); if (node && typeof node === "object") node.parentElement = this; }); } closest(selector) { if (selector.startsWith("#") && this.id === selector.slice(1)) return this; return this.parentElement?.closest?.(selector) || null; } focus() {} matches(selector) { if (selector === "button") return this.tagName === "BUTTON"; if (selector.startsWith(".")) return this.classList.contains(selector.slice(1)); const dataSelector = selector.match(/^\[data-([a-z-]+)(?:="([^"]*)")?\]$/); if (!dataSelector) return false; const key = dataSelector[1].replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()); if (!(key in this.dataset)) return false; return dataSelector[2] === undefined || this.dataset[key] === dataSelector[2]; } querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } querySelectorAll(selector) { const matches = []; const visit = (node) => { if (!(node instanceof MockElement)) return; if (node.matches(selector)) matches.push(node); node.children.forEach(visit); }; this.children.forEach(visit); return matches; } replaceChildren(...nodes) { this.children = []; this.append(...nodes); } setAttribute(name, value) { this.attributes.set(name, String(value)); } toggleAttribute(name, force) { this[name] = Boolean(force); } } function ndjsonResponse(events) { const chunks = events.map((event) => Buffer.from(`${JSON.stringify(event)}\n`, "utf8")); let index = 0; return { ok: true, status: 200, body: { getReader() { return { async cancel() {}, async read() { if (index >= chunks.length) return { done: true, value: undefined }; return { done: false, value: chunks[index++] }; }, releaseLock() {}, }; }, }, }; } function createHarness(events) { const document = new MockEventTarget(); const windowEvents = new MockEventTarget(); const body = new MockElement("body"); const input = new MockElement("textarea"); const messages = new MockElement("div"); const runButton = new MockElement("button"); const transcript = new MockElement("section"); input.id = "utterance"; messages.id = "assistantMessages"; runButton.id = "runButton"; transcript.id = "assistantTranscript"; const selectors = new Map([ ["#utterance", input], ["#assistantMessages", messages], ["#runButton", runButton], ["#assistantTranscript", transcript], ]); document.body = body; document.createElement = (tagName) => new MockElement(tagName); document.createTextNode = (text) => ({ nodeType: 3, textContent: String(text) }); document.querySelector = (selector) => selectors.get(selector) || null; document.querySelectorAll = (selector) => ( selector === "#assistantMessages [data-message-role]" ? [...messages.children] : messages.querySelectorAll(selector) ); const storage = new Map(); const requestCalls = []; const completed = []; const refreshedModeProfiles = []; windowEvents.addEventListener("cognitive-agent:request-complete", (event) => { completed.push(event.detail); }); const sandbox = { AbortController, Buffer, CSS: { escape: (value) => String(value) }, CustomEvent: class CustomEvent { constructor(type, options = {}) { this.type = type; this.detail = options.detail; } }, Element: MockElement, Event: class Event { constructor(type, options = {}) { this.type = type; this.bubbles = options.bubbles === true; } }, HTMLFormElement: class HTMLFormElement extends MockElement {}, JSON, Math, Promise, TextDecoder, Uint8Array, clearTimeout, console, crypto: { randomUUID: () => `runtime-${requestCalls.length}-${messages.children.length}` }, document, localStorage: { getItem: () => null, removeItem() {}, setItem() {} }, requestAnimationFrame: (callback) => callback(), sessionStorage: { getItem: (key) => storage.get(key) || null, removeItem: (key) => storage.delete(key), setItem: (key, value) => storage.set(key, String(value)), }, setTimeout, __N7WEB_ASSISTANT_TEST__: true, }; sandbox.window = sandbox; sandbox.addEventListener = windowEvents.addEventListener.bind(windowEvents); sandbox.removeEventListener = windowEvents.removeEventListener.bind(windowEvents); sandbox.dispatchEvent = windowEvents.dispatchEvent.bind(windowEvents); sandbox.CognitiveAgentConsole = { getExecutionMode: () => "dry_run", async refreshModeProfiles(modeProfileId) { refreshedModeProfiles.push(modeProfileId); }, }; sandbox.CognitiveAgentNetwork = { async request(url, options) { requestCalls.push({ url, options }); if (url === "/api/mode-profiles" || url.startsWith("/api/mode-profiles/")) { return { ok: true, status: 200, async json() { return { mode_profile: { id: "mode-saved", name: "午休模式" } }; }, }; } return ndjsonResponse(events); }, }; vm.createContext(sandbox); vm.runInContext(ASSISTANT_SOURCE, sandbox, { filename: "assistant-chat.js" }); return { completed, input, messages, refreshedModeProfiles, requestCalls, sandbox, }; } test("a late NDJSON model error is rendered after an HTTP 200 stream", async () => { const publicMessage = "小迪暂时没有响应,请稍后再试。"; const harness = createHarness([ { type: "message.accepted", route: "agent", engine: "codex-app-server" }, { type: "work.started", display_text: "小迪正在理解并处理……" }, { type: "work.progress", display_text: "小迪正在重新连接……" }, { type: "error", error: "assistant_model_failed", message: publicMessage }, ]); harness.input.value = "解释一下 CAN FD"; await harness.sandbox.CognitiveAgentAssistant.submit(); assert.equal(harness.requestCalls.length, 1); assert.equal(harness.requestCalls[0].url, "/api/assistant/stream"); assert.equal(harness.requestCalls[0].options.method, "POST"); assert.equal(harness.requestCalls[0].options.headers.Accept, "application/x-ndjson"); assert.equal(harness.messages.children.length, 2); const assistantArticle = harness.messages.children[1]; assert.equal(assistantArticle.dataset.messageRole, "assistant"); assert.equal(assistantArticle.dataset.messageContent, publicMessage); const actionStatus = assistantArticle.children.at(-1).children.at(-1); assert.equal(actionStatus.className, "assistant-action-status failed"); assert.equal(actionStatus.children.at(-1).textContent, "本次请求未完成"); assert.equal(harness.completed.length, 1); assert.equal(harness.completed[0].error.code, "assistant_model_failed"); assert.equal(harness.completed[0].error.message, publicMessage); assert.equal(harness.completed[0].result, null); }); test("a completed NDJSON answer remains the authoritative visible response", async () => { const harness = createHarness([ { type: "message.accepted", route: "agent", engine: "codex-app-server" }, { type: "answer.delta", delta: "流式草稿" }, { type: "work.completed", success: true, display_text: "小迪已完成" }, { type: "answer.completed", route: "agent", answer: "最终回答" }, ]); harness.input.value = "普通问题"; await harness.sandbox.CognitiveAgentAssistant.submit(); const assistantArticle = harness.messages.children[1]; assert.equal(assistantArticle.dataset.messageContent, "最终回答"); assert.equal(harness.completed.length, 1); assert.equal(harness.completed[0].error, null); }); test("unsupported-control suggestions render once and click through as a new command", async () => { const suggestions = [ { id: "climate-on", label: "打开空调", command: "打开空调", domain: "climate" }, { id: "shade-close", label: "关闭遮阳帘", command: "关闭遮阳帘", domain: "roof" }, { id: "climate-copy", label: "重复项", command: " 打开 空调 ", domain: "climate" }, ]; const events = [ { type: "control.suggestions", message_id: "msg-unsupported", suggestions, }, { type: "answer.completed", route: "unsupported_control", engine: "n7web-capability-registry", answer: "当前车端暂不支持该功能控制哦,您可以尝试以下操作。", suggestions, }, ]; const harness = createHarness(events); harness.input.value = "打开后备箱"; await harness.sandbox.CognitiveAgentAssistant.submit(); const suggestionGroups = harness.messages.querySelectorAll(".assistant-control-suggestions"); assert.equal(suggestionGroups.length, 1); const buttons = suggestionGroups[0].querySelectorAll("button"); assert.equal(buttons.length, 2); assert.equal(buttons[0].type, "button"); assert.equal(buttons[0].dataset.suggestionCommand, "打开空调"); assert.equal(buttons[0].attributes.get("aria-label"), "执行控制指令:打开空调"); assert.equal(buttons[0].attributes.get("aria-busy"), "false"); buttons[0].dispatchEvent({ type: "click" }); buttons[0].dispatchEvent({ type: "click" }); assert.equal(buttons.every((button) => button.disabled), true); await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0)); const streamCalls = harness.requestCalls.filter( (call) => call.url === "/api/assistant/stream", ); assert.equal(streamCalls.length, 2); assert.equal(JSON.parse(streamCalls[1].options.body).message, "打开空调"); assert.equal(harness.messages.children[2].dataset.messageContent, "打开空调"); assert.equal(buttons.every((button) => !button.disabled), true); assert.equal(buttons[0].attributes.get("aria-busy"), "false"); }); test("answer fallback accepts recommended_commands and caps unsafe suggestion payloads", async () => { const harness = createHarness([ { type: "answer.completed", route: "unsupported_control", answer: "当前车端暂不支持该功能控制哦。", recommended_commands: [ "打开空调", { id: "seat", label: "调节座椅", command: "副驾座椅调到80%", domain: "seat" }, { id: "empty", label: "空指令", command: "" }, { id: "window", label: "关闭车窗", command: "关闭所有车窗", domain: "window" }, { id: "light", label: "打开氛围灯", command: "打开氛围灯", domain: "lighting" }, { id: "extra", label: "不应显示", command: "空调调到25度", domain: "climate" }, ], }, ]); harness.input.value = "打开后备箱"; await harness.sandbox.CognitiveAgentAssistant.submit(); const group = harness.messages.querySelector(".assistant-control-suggestions"); assert.ok(group); const buttons = group.querySelectorAll("button"); assert.equal(buttons.length, 4); assert.deepEqual( buttons.map((button) => button.dataset.suggestionCommand), ["打开空调", "副驾座椅调到80%", "关闭所有车窗", "打开氛围灯"], ); buttons.forEach((button) => { assert.equal(button.type, "button"); assert.match(button.attributes.get("aria-label"), /^执行控制指令:/); }); }); test("a natural-language saved-mode update refreshes the mode gallery", async () => { const harness = createHarness([ { type: "message.accepted", route: "mode_profile_management", engine: "n7web-mode-registry" }, { type: "action.completed", success: true, payload: { status: "succeeded", execution_mode: "dry_run", orchestration_route: "mode_profile_update", mode_profile: { id: "mode-reading", name: "阅读模式" }, }, }, { type: "answer.completed", route: "mode_profile_management", answer: "已更新阅读模式。" }, ]); harness.input.value = "把阅读模式中空调温度调整到26度"; await harness.sandbox.CognitiveAgentAssistant.submit(); assert.deepEqual(harness.refreshedModeProfiles, ["mode-reading"]); const state = harness.messages.querySelectorAll(".assistant-mode-created"); assert.equal(state.length, 1); assert.equal(state[0].children[1].children[0].textContent, "已更新「我的模式」"); }); test("a natural-language saved-mode deletion refreshes the mode gallery", async () => { const harness = createHarness([ { type: "message.accepted", route: "mode_profile_management", engine: "n7web-mode-registry" }, { type: "action.completed", success: true, payload: { status: "succeeded", execution_mode: "dry_run", orchestration_route: "mode_profile_delete", deleted_mode_profile: { id: "mode-reading", name: "阅读模式" }, }, }, { type: "answer.completed", route: "mode_profile_management", answer: "已删除阅读模式。" }, ]); harness.input.value = "删除阅读模式"; await harness.sandbox.CognitiveAgentAssistant.submit(); assert.deepEqual(harness.refreshedModeProfiles, [undefined]); assert.equal(harness.messages.querySelectorAll(".assistant-mode-created").length, 0); }); function modeDraftEvents(sourceRequestId, temperatureC) { return [ { type: "message.accepted", route: "vehicle_control", engine: "n7web-mode-builder" }, { type: "action.completed", success: true, payload: { status: "succeeded", execution_mode: "dry_run", orchestration_route: "mode_profile_create_preview", mode_profile_draft: { name: "午休模式", instruction: `空调调整到${temperatureC}度`, operation_count: 1, source_request_id: sourceRequestId, vehicle_id: "demo-vehicle", }, actions: [{ domain: "climate", capability: "climate.set_temperature", target: { zone: "all" }, parameters: { temperature_c: temperatureC }, }], }, }, { type: "answer.completed", route: "vehicle_control", answer: "模式草稿已生成" }, ]; } test("a follow-up turn carries only the active authoritative draft reference", async () => { const events = modeDraftEvents("req-draft-one", 24); const harness = createHarness(events); harness.input.value = "构建一个午休模式"; await harness.sandbox.CognitiveAgentAssistant.submit(); events.splice(0, events.length, { type: "message.accepted", route: "agent", engine: "codex-app-server" }, { type: "answer.completed", route: "agent", answer: "已收到调整请求" }, ); harness.input.value = "把午休模式的空调温度调整到25度"; await harness.sandbox.CognitiveAgentAssistant.submit(); const request = JSON.parse(harness.requestCalls[1].options.body); assert.equal(request.mode_profile_draft_context.name, "午休模式"); assert.equal(request.mode_profile_draft_context.source_request_id, "req-draft-one"); assert.equal(request.mode_profile_draft_context.vehicle_id, "demo-vehicle"); assert.equal(Object.hasOwn(request.mode_profile_draft_context, "actions"), false); }); test("the change-into predicate carries only a same-name opaque draft reference", async () => { const events = modeDraftEvents("req-draft-change-into", 24); const harness = createHarness(events); harness.input.value = "构建一个午休模式"; await harness.sandbox.CognitiveAgentAssistant.submit(); events.splice(0, events.length, { type: "message.accepted", route: "agent", engine: "codex-app-server" }, { type: "answer.completed", route: "agent", answer: "已处理" }, ); harness.input.value = "把午休模式的空调温度变成25度"; await harness.sandbox.CognitiveAgentAssistant.submit(); harness.input.value = "午休模式的空调温度变成25度了吗?"; await harness.sandbox.CognitiveAgentAssistant.submit(); harness.input.value = "把露营模式的空调温度变成25度"; await harness.sandbox.CognitiveAgentAssistant.submit(); const sameMode = JSON.parse(harness.requestCalls[1].options.body); const question = JSON.parse(harness.requestCalls[2].options.body); const anotherMode = JSON.parse(harness.requestCalls[3].options.body); assert.deepEqual( Object.keys(sameMode.mode_profile_draft_context).sort(), ["name", "source_request_id", "vehicle_id"], ); assert.equal(sameMode.mode_profile_draft_context.name, "午休模式"); assert.equal(sameMode.mode_profile_draft_context.source_request_id, "req-draft-change-into"); assert.equal(sameMode.mode_profile_draft_context.vehicle_id, "demo-vehicle"); assert.equal(Object.hasOwn(question, "mode_profile_draft_context"), false); assert.equal(Object.hasOwn(anotherMode, "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 = [ { type: "message.accepted", route: "vehicle_control", engine: "n7web-mode-builder" }, { type: "action.completed", success: true, payload: { status: "succeeded", execution_mode: "dry_run", orchestration_route: "mode_profile_create_preview", mode_profile_draft: { name: "遮阳测试模式", instruction: "遮阳帘目标开度测试", operation_count: positions.length, source_request_id: "req-draft-shade-positions", vehicle_id: "demo-vehicle", }, actions: positions.map((position) => ({ domain: "roof", capability: "roof.set_shade_position", target: { zone: "vehicle" }, parameters: { position_pct: position }, })), }, }, { type: "answer.completed", route: "vehicle_control", answer: "模式草稿已生成" }, ]; const harness = createHarness(events); harness.input.value = "构建一个遮阳测试模式"; await harness.sandbox.CognitiveAgentAssistant.submit(); const rows = harness.messages.querySelectorAll(".assistant-mode-draft-row"); assert.equal(rows.length, 4); assert.deepEqual( rows.map((row) => ({ label: row.children[1].textContent, value: row.children[2].textContent, })), [ { label: "遮阳帘", value: "完全关闭(目标开度 0%)" }, { label: "遮阳帘", value: "目标开度 30%" }, { label: "遮阳帘", value: "半开(目标开度 50%)" }, { label: "遮阳帘", value: "完全打开(目标开度 100%)" }, ], ); }); test("ordinary questions and updates for another mode do not carry the draft reference", async () => { const events = modeDraftEvents("req-draft-scoped", 24); const harness = createHarness(events); harness.input.value = "构建一个午休模式"; await harness.sandbox.CognitiveAgentAssistant.submit(); events.splice(0, events.length, { type: "message.accepted", route: "agent", engine: "codex-app-server" }, { type: "answer.completed", route: "agent", answer: "普通回答" }, ); harness.input.value = "空调制冷原理是什么"; await harness.sandbox.CognitiveAgentAssistant.submit(); harness.input.value = "把露营模式的空调温度调整到25度"; await harness.sandbox.CognitiveAgentAssistant.submit(); const ordinary = JSON.parse(harness.requestCalls[1].options.body); const anotherMode = JSON.parse(harness.requestCalls[2].options.body); assert.equal(Object.hasOwn(ordinary, "mode_profile_draft_context"), false); assert.equal(Object.hasOwn(anotherMode, "mode_profile_draft_context"), false); }); test("device action predicates carry context while mode questions stay ordinary", async () => { const events = modeDraftEvents("req-draft-actions", 24); const harness = createHarness(events); harness.input.value = "构建一个午休模式"; await harness.sandbox.CognitiveAgentAssistant.submit(); events.splice(0, events.length, { type: "message.accepted", route: "agent", engine: "codex-app-server" }, { type: "answer.completed", route: "agent", answer: "已处理" }, ); harness.input.value = "请帮我把午休模式里的遮阳帘关闭"; await harness.sandbox.CognitiveAgentAssistant.submit(); harness.input.value = "把午休模式的车窗全关"; await harness.sandbox.CognitiveAgentAssistant.submit(); harness.input.value = "午休模式的空调设置有哪些?"; await harness.sandbox.CognitiveAgentAssistant.submit(); const deviceAction = JSON.parse(harness.requestCalls[1].options.body); const secondDeviceAction = JSON.parse(harness.requestCalls[2].options.body); const ordinaryQuestion = JSON.parse(harness.requestCalls[3].options.body); assert.equal(deviceAction.mode_profile_draft_context.source_request_id, "req-draft-actions"); assert.equal(secondDeviceAction.mode_profile_draft_context.source_request_id, "req-draft-actions"); assert.equal(Object.hasOwn(ordinaryQuestion, "mode_profile_draft_context"), false); }); test("a replacement draft disables the stale same-name card", async () => { const events = modeDraftEvents("req-draft-old", 24); const harness = createHarness(events); harness.input.value = "构建一个午休模式"; await harness.sandbox.CognitiveAgentAssistant.submit(); events.splice(0, events.length, ...modeDraftEvents("req-draft-new", 25)); harness.input.value = "把午休模式的空调温度调整到25度"; await harness.sandbox.CognitiveAgentAssistant.submit(); const oldCard = harness.messages.querySelector( '[data-assistant-mode-draft="req-draft-old"]', ); const newCard = harness.messages.querySelector( '[data-assistant-mode-draft="req-draft-new"]', ); assert.ok(oldCard); assert.ok(newCard); assert.equal(oldCard.classList.contains("is-superseded"), true); oldCard.querySelectorAll("button").forEach((button) => assert.equal(button.disabled, true)); assert.equal( oldCard.children.at(-1).textContent, "已由下方最新草稿替换", ); }); test("saving the replacement card uses its latest request and clears follow-up context", async () => { const events = modeDraftEvents("req-draft-before-save", 24); const harness = createHarness(events); harness.input.value = "构建一个午休模式"; await harness.sandbox.CognitiveAgentAssistant.submit(); events.splice(0, events.length, ...modeDraftEvents("req-draft-after-edit", 25)); harness.input.value = "把午休模式的空调温度调整到25度"; await harness.sandbox.CognitiveAgentAssistant.submit(); const latestCard = harness.messages.querySelector( '[data-assistant-mode-draft="req-draft-after-edit"]', ); latestCard.querySelectorAll("button")[0].dispatchEvent({ type: "click" }); await new Promise((resolve) => setTimeout(resolve, 0)); const saveCall = harness.requestCalls.find((call) => call.url === "/api/mode-profiles"); assert.ok(saveCall); assert.equal(JSON.parse(saveCall.options.body).source_request_id, "req-draft-after-edit"); events.splice(0, events.length, { type: "message.accepted", route: "agent", engine: "codex-app-server" }, { type: "answer.completed", route: "agent", answer: "已保存后的回答" }, ); harness.input.value = "把午休模式的空调温度调整到26度"; await harness.sandbox.CognitiveAgentAssistant.submit(); const finalStreamCall = harness.requestCalls.filter( (call) => call.url === "/api/assistant/stream", ).at(-1); assert.equal( Object.hasOwn(JSON.parse(finalStreamCall.options.body), "mode_profile_draft_context"), false, ); }); test("starting a new conversation clears the active draft reference", async () => { const events = modeDraftEvents("req-draft-reset", 24); const harness = createHarness(events); harness.input.value = "构建一个午休模式"; await harness.sandbox.CognitiveAgentAssistant.submit(); const reset = new MockElement("button"); reset.id = "newConversation"; harness.sandbox.document.dispatchEvent({ type: "click", target: reset }); events.splice(0, events.length, { type: "message.accepted", route: "agent", engine: "codex-app-server" }, { type: "answer.completed", route: "agent", answer: "新的对话" }, ); harness.input.value = "普通问题"; await harness.sandbox.CognitiveAgentAssistant.submit(); const request = JSON.parse(harness.requestCalls[1].options.body); assert.equal(Object.hasOwn(request, "mode_profile_draft_context"), false); });