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 = ""; } append(...nodes) { nodes.forEach((node) => { this.children.push(node); if (node && typeof node === "object") node.parentElement = this; }); } focus() {} querySelector() { return null; } 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] : [] ); 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 }); 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); });