test: cover assistant stream deployment failures

This commit is contained in:
vee
2026-09-07 17:54:15 +08:00
parent c96970f068
commit 53d80ab662
3 changed files with 270 additions and 0 deletions
+1
View File
@@ -14,6 +14,7 @@ validate: can-protocol-check
python tools/validate_repository.py
test-frontend:
python tools/run_node.py --test tests/unit/assistant_stream_runtime.test.cjs
python tools/run_node.py --test tests/unit/speech_input_runtime.test.cjs
python -m pytest tests/unit/test_energy_monitor_frontend_contract.py -q
+33
View File
@@ -7,12 +7,14 @@ import urllib.error
import urllib.request
from collections.abc import Callable
from http.server import ThreadingHTTPServer
from unittest.mock import patch
from cognitive_agent.application.codex_assistant import (
ActiveCodexTurn,
CodexAssistantService,
)
from cognitive_agent.auth import AuthConfig, AuthRuntime
from cognitive_agent.infrastructure.codex.app_server import CodexAppServerError
from cognitive_agent.server import SeatDemoHandler, VehicleDisplaySync
COOLING_INSTRUCTION = "打开空调,将全车温度设置为23度,风量设置为2档"
@@ -498,6 +500,37 @@ class TestCodexAssistantWebApi:
assert not AssistantApiHandler.service.calls
assert not FAKE_CODEX.bridge_results
def test_model_auth_failure_is_a_safe_terminal_ndjson_event(self) -> None:
secret = "deployment-secret-must-never-reach-the-browser"
provider_error = CodexAppServerError(
f"provider rejected Authorization: Bearer {secret}"
)
with patch.object(FAKE_CODEX, "start_thread", side_effect=provider_error):
events = self.post_assistant(
{
"message": "解释一下 CAN FD",
"conversation_id": "conv-api-provider-auth-failure",
"history": [],
}
)
assert [event["type"] for event in events] == [
"message.accepted",
"work.started",
"error",
]
assert events[-1] == {
"type": "error",
"error": "assistant_model_failed",
"message": "Codex AI 助手暂时没有响应,请稍后再试。",
}
serialized = json.dumps(events, ensure_ascii=False)
assert secret not in serialized
assert "Bearer" not in serialized
assert not AssistantApiHandler.service.calls
assert not FAKE_CODEX.bridge_results
def test_hot_cabin_uses_internal_bridge_and_explicit_trusted_cooling_instruction(self) -> None:
events = self.post_assistant(
{
@@ -0,0 +1,236 @@
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);
});