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
172 lines
9.4 KiB
TypeScript
172 lines
9.4 KiB
TypeScript
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/,
|
||
);
|
||
});
|