fix: support conversational mode draft updates

This commit is contained in:
vee
2026-09-07 23:59:42 +08:00
parent 6b2b644ee6
commit f0a248ede3
19 changed files with 2326 additions and 109 deletions
+215 -2
View File
@@ -72,6 +72,11 @@ class MockElement extends MockEventTarget {
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);
@@ -79,8 +84,31 @@ class MockElement extends MockEventTarget {
});
}
closest(selector) {
if (selector.startsWith("#") && this.id === selector.slice(1)) return this;
return this.parentElement?.closest?.(selector) || null;
}
focus() {}
querySelector() { return null; }
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);
@@ -133,7 +161,9 @@ function createHarness(events) {
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] : []
selector === "#assistantMessages [data-message-role]"
? [...messages.children]
: messages.querySelectorAll(selector)
);
const storage = new Map();
@@ -181,6 +211,15 @@ function createHarness(events) {
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);
},
};
@@ -234,3 +273,177 @@ test("a completed NDJSON answer remains the authoritative visible response", asy
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);
});
+54 -5
View File
@@ -13,15 +13,15 @@ def test_console_contains_ai_assistant_surface():
assert 'aria-label="AI助手对话"' in html
assert 'placeholder="向 AI助手提问或交代工作"' in html
assert 'aria-label="发送消息"' in html
assert 'href="/assistant-chat.css?v=codex-assistant-22"' in html
assert 'src="/assistant-chat.js?v=codex-assistant-22"' in html
assert 'href="/assistant-chat.css?v=codex-assistant-23"' in html
assert 'src="/assistant-chat.js?v=codex-assistant-23"' in html
def test_codex_assistant_assets_are_versioned_together_in_the_service_worker():
service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8")
assert 'const CACHE_VERSION = "cognitive-agent-pad-v271"' in service_worker
assert '"/assistant-chat.css?v=codex-assistant-22"' in service_worker
assert '"/assistant-chat.js?v=codex-assistant-22"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v272"' in service_worker
assert '"/assistant-chat.css?v=codex-assistant-23"' in service_worker
assert '"/assistant-chat.js?v=codex-assistant-23"' in service_worker
def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events():
@@ -157,6 +157,55 @@ def test_assistant_mode_creation_previews_then_saves_to_the_compact_mode_strip()
assert ".assistant-mode-created" in css
def test_assistant_carries_only_the_active_mode_draft_reference_to_follow_up_turns():
assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
request_body = assistant.split("function requestBody(message)", 1)[1].split(
"async function readErrorResponse", 1
)[0]
context_flow = assistant.split("function sanitizeModeDraftContext", 1)[1].split(
"function loadStoredMessages", 1
)[0]
assert (
'const ACTIVE_MODE_DRAFT_STORAGE_KEY = "cognitive-agent.active-mode-draft"'
in assistant
)
assert "mode_profile_draft_context: modeProfileDraftContext" in request_body
assert "requestModeDraftContext(message)" in request_body
assert "name: context.name" in context_flow
assert "source_request_id: context.source_request_id" in context_flow
assert "vehicle_id: context.vehicle_id" in context_flow
assert "actions" not in context_flow
assert "rememberModeDraftContext(draft)" in assistant
assert "activeModeDraftContext = loadActiveModeDraftContext()" in assistant
assert "function isModeDraftUpdateRequest(message, context)" in assistant
assert "normalizeModeName(matched[1]) === normalizeModeName(context.name)" in assistant
def test_latest_same_name_mode_draft_retires_stale_card_and_lifecycle_clears_context():
assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
css = (ROOT / "apps/web-console/assistant-chat.css").read_text(encoding="utf-8")
replace_flow = assistant.split("function supersedeModeDraftCards", 1)[1].split(
"function appendModeDraftState", 1
)[0]
save_flow = assistant.split("async function saveModeProfileDraft", 1)[1].split(
"async function tryModeProfileDraft", 1
)[0]
new_conversation_flow = assistant.split(
'if (!target?.closest("#newConversation")) return;', 1
)[1].split("}, true);", 1)[0]
assert "card.dataset.assistantModeName !== nextName" in replace_flow
assert "card.dataset.assistantModeDraft === nextRequestId" in replace_flow
assert 'card.classList.add("is-superseded")' in replace_flow
assert "button.disabled = true" in replace_flow
assert "已由下方最新草稿替换" in replace_flow
assert "clearActiveModeDraftContext(draft.source_request_id)" in save_flow
assert "clearActiveModeDraftContext()" in new_conversation_flow
assert ".assistant-mode-draft.is-superseded" in css
assert ".assistant-mode-draft-status.superseded" in css
def test_assistant_composer_grows_to_a_cap_then_scrolls_its_own_content():
assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
css = (ROOT / "apps/web-console/assistant-chat.css").read_text(encoding="utf-8")
+177
View File
@@ -341,6 +341,7 @@ def _stream(
execute_control: Callable[..., dict[str, object]] | None = None,
capability_snapshot: dict[str, object] | None = None,
capability_context: dict[str, object] | None = None,
mode_profile_draft_context: dict[str, object] | None = None,
) -> tuple[dict[str, object], list[dict[str, object]]]:
events: list[dict[str, object]] = []
result = assistant.stream(
@@ -354,6 +355,7 @@ def _stream(
control_terms=CONTROL_TERMS,
capability_snapshot=capability_snapshot,
capability_context=capability_context,
mode_profile_draft_context=mode_profile_draft_context,
)
return result, events
@@ -426,6 +428,181 @@ def test_registered_name_only_mode_keeps_the_fast_local_route_without_codex() ->
assert events[0]["engine"] == "n7web-mode-builder"
def test_pending_mode_draft_update_uses_fast_configuration_route_without_codex() -> None:
client = FakeCodexClient(available=False)
assistant = CodexAssistantService(client)
calls: list[str] = []
result_payload = _sample_mode_preview_result(
"小憩模式",
generation_source="draft_revision",
existing_mode_id="nap",
)
draft = result_payload["mode_profile_draft"]
assert isinstance(draft, dict)
draft.update(
{
"source_request_id": "req-mode-revision",
"draft_revision": 2,
"revises_source_request_id": "req-mode-preview",
}
)
result, events = _stream(
assistant,
"把小憩模式的空调温度调整到25度",
execute_control=lambda instruction, _progress: (
calls.append(instruction) or result_payload
),
mode_profile_draft_context={
"name": "小憩模式",
"source_request_id": "req-mode-preview",
"vehicle_id": "denza_n7",
},
)
assert result["route"] == "vehicle_control"
assert result["success"] is True
assert calls == ["把小憩模式的空调温度调整到25度"]
assert client.turn_calls == []
assert events[0]["engine"] == "n7web-mode-builder"
assert "调整待保存" in result["answer"]
def test_active_draft_context_does_not_hijack_an_ordinary_question() -> None:
client = FakeCodexClient()
assistant = CodexAssistantService(client)
result, events = _stream(
assistant,
"解释一下空调内循环和外循环的区别",
mode_profile_draft_context={
"name": "小憩模式",
"source_request_id": "req-mode-preview",
"vehicle_id": "denza_n7",
},
)
assert result == {"route": "agent", "answer": "这是 Codex 的流式回答。"}
assert client.turn_calls[0]["text"] == "解释一下空调内循环和外循环的区别"
assert events[0]["engine"] == "codex-app-server"
def test_malformed_stale_draft_context_is_ignored_for_an_ordinary_question() -> None:
client = FakeCodexClient()
assistant = CodexAssistantService(client)
events: list[dict[str, object]] = []
result = assistant.stream(
"解释一下空调内循环和外循环的区别",
conversation_id="conv-stale-draft-context",
principal="alice",
history=[],
emit=lambda event: events.append(event),
execute_control=lambda _instruction, _progress: pytest.fail(
"vehicle control was unexpected"
),
control_terms=CONTROL_TERMS,
mode_profile_draft_context={"untrusted": "stale"},
)
assert result["route"] == "agent"
assert events[0]["engine"] == "codex-app-server"
def test_malformed_matching_draft_reference_is_rejected_before_control() -> None:
assistant = CodexAssistantService(FakeCodexClient(available=False))
with pytest.raises(ValueError, match="fields are invalid"):
assistant.stream(
"把小憩模式的空调温度调整到25度",
conversation_id="conv-invalid-draft-context",
principal="alice",
history=[],
emit=lambda _event: True,
execute_control=lambda _instruction, _progress: pytest.fail(
"vehicle control was unexpected"
),
control_terms=CONTROL_TERMS,
mode_profile_draft_context={"name": "小憩模式"},
)
def test_nonmatching_draft_context_does_not_claim_another_mode_update() -> None:
authorization = _authorize_vehicle_request(
"把观影模式的空调温度调整到25度",
CONTROL_TERMS,
{
"name": "小憩模式",
"source_request_id": "req-mode-preview",
"vehicle_id": "denza_n7",
},
)
# The stale card reference has been ignored. The sentence is still an
# explicit registered control under the legacy saved-mode update route.
assert authorization.reason == "explicit_registered_control"
@pytest.mark.parametrize(
"message",
(
"把小憩模式的空调介绍一下",
"小憩模式的空调有什么作用",
"小憩模式的空调和普通空调有什么区别",
"小憩模式的空调设置有哪些?",
"小憩模式的空调该调几度",
"把小憩模式的空调调到25度合适吗",
"调整小憩模式的空调参数,列出来",
),
)
def test_device_word_air_conditioner_does_not_fake_a_draft_update_verb(
message: str,
) -> None:
authorization = _authorize_vehicle_request(
message,
CONTROL_TERMS,
{
"name": "小憩模式",
"source_request_id": "req-mode-preview",
"vehicle_id": "denza_n7",
},
)
assert authorization.reason != "explicit_mode_profile_draft_update"
@pytest.mark.parametrize(
"message",
(
"修改小憩模式的空调设置是什么",
"小憩模式的空调该调几度",
"把小憩模式的空调调到25度合适吗",
"调整小憩模式的空调参数,列出来",
"小憩模式的空调调到25度会怎样",
),
)
def test_mode_profile_question_with_control_words_never_calls_vehicle_executor(
message: str,
) -> None:
assistant = CodexAssistantService(FakeCodexClient())
result, events = _stream(
assistant,
message,
execute_control=lambda _instruction, _progress: pytest.fail(
"mode-profile question reached vehicle executor"
),
mode_profile_draft_context={
"name": "小憩模式",
"source_request_id": "req-mode-preview",
"vehicle_id": "denza_n7",
},
)
assert result["route"] == "agent"
assert events[0]["engine"] == "codex-app-server"
def test_unknown_name_only_mode_uses_codex_and_overrides_model_execution_claim() -> None:
client = FakeCodexClient()
assistant = CodexAssistantService(client)
@@ -36,10 +36,10 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None:
):
assert asset in html
assert f'"{asset}"' in service_worker
assert html.index("/assistant-chat.js?v=codex-assistant-22") < html.index(
assert html.index("/assistant-chat.js?v=codex-assistant-23") < html.index(
"/energy-monitor.js?v=energy-monitor-4"
)
assert 'const CACHE_VERSION = "cognitive-agent-pad-v271"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v272"' in service_worker
def test_energy_monitor_uses_the_fifth_shared_dock_icon_and_starts_closed() -> None:
+1 -1
View File
@@ -48,7 +48,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
):
assert asset in html
assert f'"{asset}"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v271"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v272"' in service_worker
def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None: