Files
Jyotisha/frontend/tests/chat-composer-queue.test.ts
T
Jesse_ChenandClaude Opus 5.5 84eb23152a feat(rectification): remove the rectification regenerate button and endpoint (BUG-1056)
T2 of TASK-rectification-grounding-20260927 (product decision P1).
The rectification 「重试(重新生成)」 was a blind agent.generate rewrite that
persisted unchecked text (invented range / fit rate reproduced). Removed:
the regenerate route, regenerate-turn.ts, the regeneration agent and its
read-only tool set, the client regenerate action/state and the
regenerating/canRegenerate props. ChatMessageActions renders the regenerate
button only when onRegenerate is passed; ordinary consultation is unchanged.
The DB function regenerate_agentic_rectification_turn is kept (AGENTS §7.6,
retire in a later round). DESIGN.md / VOICE.md updated; source-contract
tests follow with 原值/新值/原因 notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-27 03:28:27 +08:00

172 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ts from "typescript";
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";
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
import { abortWiringOf } from "./rectification-chat-run-fixtures.ts";
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const composer = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8");
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const rectification = rectificationChatSurface;
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);
// 原值:`inputDisabled={sessionMessagesLoading || rectificationSurfaceOpen || activeRectificationSession || (!profileComplete && ...)}`
// 新值:表达式前增加 `subjectDeleted ||`,其余结构性禁用条件不变。
// 原因:BUG-1000 已删除人物的历史会话只读;BUG-1014 同步合同,不改生成中仍可输入的性质。
assert.match(
page,
/inputDisabled=\{subjectDeleted \|\| sessionMessagesLoading \|\| rectificationSurfaceOpen \|\| activeRectificationSession \|\| \(!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/);
// 原值 `composerInput.current?.focus()` / 新值 `focus({ preventScroll: true })`
// / 原因:裸 focus 会让浏览器滚动所有可滚动祖先,包括 overflow 的 `.chat-panel`——
// 线上实测把 46px 顶栏顶到了 y=-88 且无法滚回。焦点行为本身没变,只是不再连带滚动。
assert.match(consultationRun, /composerInput\.current\?\.focus\(\{ preventScroll: true \}\)/);
assert.match(rectification, /inputDisabled=\{readonly\}/);
assert.doesNotMatch(
rectification.slice(rectification.indexOf("<ChatComposer"), rectification.indexOf("onStop={stopRun}")),
/inputDisabled=\{!canSend\}/,
);
// 原值: /if \(busy \|\| regeneratingMessageKey\) \{/。新值: /if \(busy\) \{/。
// 原因: BUG-1056(TASK-rectification-grounding-20260927 P1)删除生时校正的重新生成接口与按钮,排队条件只剩 busy,排队行为不变。
assert.match(rectification, /if \(busy\) \{/);
assert.match(rectification, /queued\.enqueue\(draft\)/);
});
test("composer inputDisabled expressions never depend on generation or cancellation", () => {
// Parse the complete JSX expression: a first-'}' regex can miss identifiers
// after nested objects, and matching the whole prop makes order significant.
const source = ts.createSourceFile("page.tsx", page, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const expressions: ts.Expression[] = [];
function visit(node: ts.Node) {
if ((ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node))
&& node.tagName.getText(source) === "ChatComposer") {
const attribute = node.attributes.properties.find(
(property): property is ts.JsxAttribute => ts.isJsxAttribute(property)
&& property.name.getText(source) === "inputDisabled",
);
assert.ok(attribute?.initializer && ts.isJsxExpression(attribute.initializer),
"each ChatComposer must have an explicit inputDisabled expression");
assert.ok(attribute.initializer.expression, "inputDisabled must not be empty");
expressions.push(attribute.initializer.expression);
}
ts.forEachChild(node, visit);
}
visit(source);
assert.ok(expressions.length > 0, "must inspect at least one ChatComposer inputDisabled expression");
for (const expression of expressions) {
function check(node: ts.Node) {
if (ts.isIdentifier(node)) {
assert.ok(node.text !== "isLoading" && node.text !== "cancellationPending",
`inputDisabled must not depend on ${node.text}: ${expression.getText(source)}`);
}
ts.forEachChild(node, check);
}
check(expression);
}
});
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", async () => {
assert.match(rectification, /stopped: true/);
assert.match(rectification, /failed: false,\s*stopped: true/);
assert.doesNotMatch(rectification, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\)/);
// 原值: 停止提示 prop 写在 rectification-agentic-chat.tsx。
// 新值: 同一表达式在行组件内。
// 原因: BUG-725。
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
assert.match(messageEntry, /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/);
// 原值: 从 chat 源码切 submitStructuredChoice / acceptCandidate 两段,各匹配 `runAbort.current = abortController` 与 `signal: abortController.signal`。
// 新值: 调用 runRectificationStructuredChoice / runRectificationCandidateAccept(假 fetch):请求带的 signal
// 就是 runAbort 里那个控制器的(停止按钮能掐断它),结束后 runAbort 清空。
// 原因: TASK-rectification-code-split-20260926 两段函数体原样搬进参数函数;行为断言覆盖接线本身。
const choiceWiring = await abortWiringOf("choice");
assert.equal(choiceWiring.url, "/api/rectification/agent");
assert.equal(choiceWiring.signalMatchesRunAbort, true);
assert.equal(choiceWiring.runAbortCleared, true);
const adoptWiring = await abortWiringOf("accept");
assert.match(adoptWiring.url, /\/api\/rectification\/cases\/[^/]+\/candidates\/accept$/);
assert.equal(adoptWiring.signalMatchesRunAbort, true);
assert.equal(adoptWiring.runAbortCleared, true);
assert.equal(chatReplyAnnouncer("stopped"), "chat_notice_toast");
assert.doesNotMatch(
rectification.slice(rectification.indexOf("{error &&"), rectification.indexOf("{readonly &&")),
/RECTIFICATION_STOPPED_NOTICE/,
);
});