Files
Cognitive_Agent/tests/unit/assistant_stream_runtime.test.cjs
T

530 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("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);
});