780 lines
30 KiB
JavaScript
780 lines
30 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 = [];
|
||
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("assistant bridges request start and validated local controls to the console", async () => {
|
||
const payload = {
|
||
request_id: "req-local-mixed",
|
||
utterance: "空调温度设置到23度,打开闪烁灯",
|
||
execution_mode: "dry_run",
|
||
provider_used: "local_rules+infrared_local_rules",
|
||
validated_control_commands: [
|
||
{
|
||
operation: {
|
||
id: "climate-temperature-1",
|
||
capability: "climate.set_temperature",
|
||
target: { zone: "driver" },
|
||
parameters: { temperature_c: 23 },
|
||
parse_source: "local_rule",
|
||
},
|
||
channel: "CAN1",
|
||
actual_command: "ac temp driver 23",
|
||
frames: [{
|
||
label: "CAN1 帧",
|
||
value: "0x1DE · 00 00 00 00 17 00 00 00",
|
||
}],
|
||
},
|
||
],
|
||
trace_events: [{ stage: "compile", duration_ms: 2 }],
|
||
};
|
||
const harness = createHarness([
|
||
{ type: "validated_control_commands", payload },
|
||
{ type: "answer.completed", route: "vehicle_control", answer: "模拟编排已完成。" },
|
||
]);
|
||
const starts = [];
|
||
const validated = [];
|
||
harness.sandbox.addEventListener("cognitive-agent:control-request-start", (event) => {
|
||
starts.push(event.detail);
|
||
});
|
||
harness.sandbox.addEventListener("cognitive-agent:validated-can-commands", (event) => {
|
||
validated.push(event.detail);
|
||
});
|
||
harness.input.value = payload.utterance;
|
||
harness.input.dataset.speechUtteranceId = "speech-local-mixed";
|
||
harness.input.dataset.speechValue = payload.utterance;
|
||
|
||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||
|
||
assert.deepEqual(JSON.parse(JSON.stringify(starts)), [{
|
||
utterance: payload.utterance,
|
||
speech_utterance_id: "speech-local-mixed",
|
||
speech_value: payload.utterance,
|
||
}]);
|
||
assert.deepEqual(JSON.parse(JSON.stringify(validated)), [payload]);
|
||
assert.equal(harness.requestCalls.length, 1);
|
||
assert.equal(JSON.parse(harness.requestCalls[0].options.body).execution_mode, "dry_run");
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
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 = [
|
||
{ 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);
|
||
});
|