fix(web): keep composer enabled while generating and treat stop as neutral (BUG-551, BUG-552)
Enter now queues one follow-up instead of dropping it, and a rectification stop leaves the streamed reply with a grey notice instead of an error alert. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { createElement, createRef } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { ChatComposer } from "../src/components/chat-composer.tsx";
|
||||
import {
|
||||
appendQueuedText,
|
||||
COMPOSER_QUEUE_LABEL,
|
||||
COMPOSER_QUEUE_RECALL_LABEL,
|
||||
queuedDraftSettleAction,
|
||||
} from "../src/lib/queued-draft.ts";
|
||||
import { chatReplyAnnouncer } from "../src/lib/chat-reply-announcement.ts";
|
||||
import { RECTIFICATION_STOPPED_NOTICE } from "../src/lib/rectification-surface-state.ts";
|
||||
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const composer = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8");
|
||||
const rectification = readFileSync(
|
||||
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const consultationRun = readFileSync(
|
||||
new URL("../src/hooks/use-consultation-run.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const messageRow = readFileSync(
|
||||
new URL("../src/components/chat-message-row.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
test("queued drafts append onto one card and settle by outcome", () => {
|
||||
assert.equal(appendQueuedText("", " 先问事业 "), "先问事业");
|
||||
assert.equal(appendQueuedText("先问事业", "再问婚姻"), "先问事业\n再问婚姻");
|
||||
assert.equal(appendQueuedText("先问事业", " "), "先问事业");
|
||||
assert.equal(queuedDraftSettleAction("completed"), "send");
|
||||
assert.equal(queuedDraftSettleAction("succeeded"), "send");
|
||||
assert.equal(queuedDraftSettleAction("stopped"), "restore");
|
||||
assert.equal(queuedDraftSettleAction("failed"), "restore");
|
||||
assert.equal(queuedDraftSettleAction("recovering"), "restore");
|
||||
assert.equal(queuedDraftSettleAction("readonly"), "restore");
|
||||
});
|
||||
|
||||
test("generating does not disable the textarea; Enter queues instead of dropping", () => {
|
||||
assert.match(composer, /disabled=\{inputDisabled\}/);
|
||||
assert.match(composer, /queued\?: \{ text: string; onRecall: \(\) => void \}/);
|
||||
assert.match(composer, /className="composer-queue"/);
|
||||
assert.match(composer, /COMPOSER_QUEUE_LABEL/);
|
||||
assert.match(composer, /COMPOSER_QUEUE_RECALL_LABEL/);
|
||||
assert.doesNotMatch(styles, /composer-queue[\s\S]{0,400}spinner|composer-queue[\s\S]{0,400}skeleton/i);
|
||||
|
||||
assert.match(
|
||||
page,
|
||||
/inputDisabled=\{sessionMessagesLoading \|\| rectificationSurfaceOpen \|\| \(!profileComplete && \(onboardingStep !== "name" \|\| !presetMessageFinished \|\| profileSaving\)\)\}/,
|
||||
);
|
||||
assert.doesNotMatch(page, /inputDisabled=\{isLoading \|\| sessionMessagesLoading \|\| cancellationPending/);
|
||||
assert.match(page, /if \(isLoading \|\| cancellationPending\) return void enqueueQueuedDraft\(composerDraftSnapshot\(\)\);/);
|
||||
assert.match(consultationRun, /queuedDraftSettleAction\(settlePhase\) === "send"/);
|
||||
assert.match(consultationRun, /function enqueueQueuedDraft/);
|
||||
assert.match(consultationRun, /composerInput\.current\?\.focus\(\)/);
|
||||
|
||||
assert.match(rectification, /inputDisabled=\{readonly\}/);
|
||||
assert.doesNotMatch(
|
||||
rectification.slice(rectification.indexOf("<ChatComposer"), rectification.indexOf("onStop={stopRun}")),
|
||||
/inputDisabled=\{!canSend\}/,
|
||||
);
|
||||
assert.match(rectification, /if \(busy \|\| regeneratingMessageKey\) \{/);
|
||||
assert.match(rectification, /queued\.enqueue\(draft\)/);
|
||||
});
|
||||
|
||||
test("a queued card renders above the live textarea", () => {
|
||||
const html = renderToStaticMarkup(createElement(ChatComposer, {
|
||||
inputRef: createRef<HTMLTextAreaElement>(),
|
||||
value: "还想再问一句",
|
||||
inputLabel: "输入你的问题",
|
||||
placeholder: "例如:未来半年是否适合换工作?",
|
||||
maxLength: 500,
|
||||
inputDisabled: false,
|
||||
submitLabel: "发送",
|
||||
submitBlocked: true,
|
||||
stopVisible: true,
|
||||
stopLabel: "停止回答",
|
||||
stopTitle: "停止回答",
|
||||
queued: { text: "先问事业", onRecall() {} },
|
||||
onSubmit() {},
|
||||
onChange() {},
|
||||
onKeyDown() {},
|
||||
onStop() {},
|
||||
}));
|
||||
assert.match(html, /composer-queue/);
|
||||
assert.match(html, new RegExp(COMPOSER_QUEUE_LABEL));
|
||||
assert.match(html, /先问事业/);
|
||||
assert.match(html, new RegExp(COMPOSER_QUEUE_RECALL_LABEL));
|
||||
assert.doesNotMatch(html, /<textarea[^>]*\sdisabled=/);
|
||||
assert.match(html, /还想再问一句/);
|
||||
});
|
||||
|
||||
test("rectification abort settles as stopped, not a failed alert", () => {
|
||||
assert.match(rectification, /stopped: true/);
|
||||
assert.match(rectification, /failed: false,\s*stopped: true/);
|
||||
assert.doesNotMatch(rectification, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\)/);
|
||||
assert.match(rectification, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
|
||||
assert.match(messageRow, /stoppedNotice/);
|
||||
assert.match(messageRow, /className="message-stopped-notice"/);
|
||||
assert.equal(RECTIFICATION_STOPPED_NOTICE, "已停止,已生成的内容保留;本次不会扣点。");
|
||||
assert.match(rectification, /signal: abortController\.signal/);
|
||||
const choiceFetch = rectification.slice(
|
||||
rectification.indexOf("const submitStructuredChoice"),
|
||||
rectification.indexOf("const acceptCandidate"),
|
||||
);
|
||||
assert.match(choiceFetch, /runAbort\.current = abortController/);
|
||||
assert.match(choiceFetch, /signal: abortController\.signal/);
|
||||
const adoptFetch = rectification.slice(
|
||||
rectification.indexOf("const acceptCandidate"),
|
||||
rectification.indexOf("async function copyMessage"),
|
||||
);
|
||||
assert.match(adoptFetch, /runAbort\.current = abortController/);
|
||||
assert.match(adoptFetch, /signal: abortController\.signal/);
|
||||
assert.equal(chatReplyAnnouncer("stopped"), "chat_notice_toast");
|
||||
assert.doesNotMatch(
|
||||
rectification.slice(rectification.indexOf("{error &&"), rectification.indexOf("{readonly &&")),
|
||||
/RECTIFICATION_STOPPED_NOTICE/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user