450 lines
17 KiB
JavaScript
450 lines
17 KiB
JavaScript
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 = [];
|
|
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" };
|
|
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, requestCalls, sandbox };
|
|
}
|
|
|
|
test("a late NDJSON model error is rendered after an HTTP 200 stream", async () => {
|
|
const publicMessage = "Codex AI 助手暂时没有响应,请稍后再试。";
|
|
const harness = createHarness([
|
|
{ type: "message.accepted", route: "agent", engine: "codex-app-server" },
|
|
{ type: "work.started", display_text: "Codex 正在理解并处理……" },
|
|
{ type: "work.progress", display_text: "Codex 正在重新连接……" },
|
|
{ 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: "Codex 已完成" },
|
|
{ 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);
|
|
});
|
|
|
|
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("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);
|
|
});
|