fix(rectification): put the collect stem in the assistant bubble
Independent Staging Quality Gate / validate (push) Failing after 8m27s
Independent Staging Quality Gate / publish (push) Has been skipped

The live question slot still rendered the collect prompt below the action icons when the streamed body was only a greeting. Join the GET prompt into that bubble and stop drawing a sibling collect slot.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-02 16:30:26 +08:00
parent 39c30b5597
commit 42c6de142b
3 changed files with 67 additions and 47 deletions
+16
View File
@@ -7504,3 +7504,19 @@
- 相关记录:BUG-449、BUG-461、BUG-469、BUG-471、BUG-485
- 复发自:BUG-485(槽位可见但不是聊天历史)
- 修复版本:待发布
## BUG-488 | 口述采集题干仍画在动作图标下方,没有进气泡正文
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`rectification-agentic-chat` 最新助手气泡、`.rectification-question-slot__prompt`
- 用户现象:开场气泡只有打招呼,采集题干仍出现在点赞/复制/重生成图标下面。staging 已是 BUG-487 的 SHA。
- 触发条件:新 Case opening`current_question.kind=collect_spoken`GET 已有 prompt。
- 根因:BUG-487 把题干接到 `assistant_message` 并允许槽位兜底。直播正文仍可能只有打招呼(replace 未进客户端 raw、或 GET 后才有 current_question),于是 `showCollectSpokenPrompt` 把同一句画在图标下。用户要的是气泡正文,不是槽。
- 修复:不再渲染采集题槽。最新助手气泡按题干精确身份 `composeCollectSpokenAssistantText`;定稿与 snapshot 把组合文本写进该条消息状态,复制走组合文本。选择卡仍走问题槽。模型仍不自己提问,避免气泡里两句相近问法。不 bump Skill,不放宽确认门。
- 验证:`rectification-spoken-collect` 锁气泡拼接、禁止 `rectification-question-slot__prompt`;既有 collect-prompt / v9-agent / regenerate / voice 锁保持。
- 防复发:口述采集题干不得作为动作图标下的兄弟节点。不得用正文字符串判断「有没有问过」。不得同时让模型提问又拼接同一句服务端题干。
- 相关记录:BUG-471、BUG-485、BUG-487
- 复发自:BUG-487(服务端拼接后槽位兜底仍可见)
- 修复版本:待发布
@@ -323,12 +323,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const previousBoardResult = useRef<CandidateResult>(null);
const runAbort = useRef<AbortController | null>(null);
const choiceActionIds = useRef(new Map<string, string>());
const currentQuestionRef = useRef(currentQuestion);
currentQuestionRef.current = currentQuestion;
const [compactBoard, setCompactBoard] = useState(false);
const [boardOpen, setBoardOpen] = useState(false);
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
const boardId = useId();
const boardTitleId = useId();
const collectSpokenPromptId = useId();
const composerRemainingId = useId();
// The same anchor-and-follow the consultation surface uses: streamed tokens and
// new cards land the viewport on the bottom only while the reader is there.
@@ -427,6 +428,25 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
return () => controller.abort();
}, [applyCaseSnapshot, caseId, sessionId]);
useEffect(() => {
const prompt = currentQuestion?.kind === "collect_spoken" ? currentQuestion.prompt : null;
if (!prompt || busy || regeneratingMessageKey !== null) return;
setMessages((current) => {
const last = [...current].reverse().find((message) => (
message.role === "assistant"
&& message.state === "settled"
&& !message.failed
&& Boolean(message.text)
));
if (!last) return current;
const combined = composeCollectSpokenAssistantText(last.text, prompt);
if (combined === last.text) return current;
return current.map((item) => (
item.renderKey === last.renderKey ? { ...item, text: combined } : item
));
});
}, [busy, currentQuestion, regeneratingMessageKey]);
const send = useCallback(async (action: "opening" | "message" | "read_only", messageText: string) => {
const trimmed = action === "message" ? messageText.trim() : "";
if ((action === "message" && !trimmed) || busy || readonly) return;
@@ -647,12 +667,18 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const parsed = completed && !streamFailed ? parseAgentReply(raw) : { text: "", title: undefined };
const succeeded = completed && !streamFailed && Boolean(parsed.text);
const collectPrompt = currentQuestionRef.current?.kind === "collect_spoken"
? currentQuestionRef.current.prompt
: null;
const settledText = succeeded && collectPrompt
? composeCollectSpokenAssistantText(parsed.text, collectPrompt)
: parsed.text;
setMessages((current) => current.flatMap((message): RenderMessage[] => {
if (message.renderKey !== assistantRenderKey) return [message];
if (succeeded) {
return [{
...message,
text: parsed.text,
text: settledText,
activityTrace: completeActivityTrace(activityTrace),
state: "settled",
completedReceipt,
@@ -684,7 +710,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (succeeded) {
onMessagesChange?.([
...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []),
{ role: "assistant", text: parsed.text },
{ role: "assistant", text: settledText },
]);
onCompleted?.();
await loadCaseSnapshot();
@@ -1048,20 +1074,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const collectSpokenPrompt = currentQuestion?.kind === "collect_spoken"
? currentQuestion.prompt
: null;
const latestCollectBody = latestSettledAssistant?.text ?? "";
const collectStemAlreadyInLatestBody = Boolean(
collectSpokenPrompt
&& latestSettledAssistant
&& composeCollectSpokenAssistantText(latestCollectBody, collectSpokenPrompt) === latestCollectBody.trim(),
);
const showCollectSpokenPrompt = Boolean(
collectSpokenPrompt
&& !collectStemAlreadyInLatestBody
&& !showLiveChoiceCard
&& !readonly
&& !busy
&& regeneratingMessageKey === null,
);
const resumableCase = caseStatus !== null && isResumableStatus(caseStatus);
const showMissingQuestion = Boolean(
caseSnapshotLoaded
@@ -1081,7 +1093,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
);
const showQuestionSlot = Boolean(
showLiveChoiceCard
|| showCollectSpokenPrompt
|| showMissingQuestion
|| showUnavailableQuestion,
);
@@ -1164,12 +1175,21 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? vargaSentenceFromMethods(message.completedReceipt?.methods)
: null;
const isLatestMessage = message.renderKey === messages[messages.length - 1]?.renderKey;
const bubbleText = !regenerating
&& isLatestMessage
&& message.role === "assistant"
&& collectSpokenPrompt
? composeCollectSpokenAssistantText(displayedMessage.text, collectSpokenPrompt)
: displayedMessage.text;
const bubbleMessage = bubbleText === displayedMessage.text
? displayedMessage
: { ...displayedMessage, text: bubbleText };
return (
<div key={message.renderKey} className="rectification-message-wrap rectification-message-entry">
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
{(!message.failed || Boolean(bubbleMessage.text) || regenerating) && (
<ChatMessageRow
message={displayedMessage}
showActivity={displayedMessage.state !== "settled"}
message={bubbleMessage}
showActivity={bubbleMessage.state !== "settled"}
vargaSentence={vargaSentence}
/>
)}
@@ -1182,7 +1202,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
...current,
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], requested),
}))}
onCopy={() => void copyMessage(message)}
onCopy={() => void copyMessage({ ...message, text: bubbleText })}
onRegenerate={() => void regenerateMessage(message)}
/>
)}
@@ -1220,11 +1240,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onStop={submitStop}
/>
)}
{showCollectSpokenPrompt && (
<p id={collectSpokenPromptId} className="rectification-question-slot__prompt">
{collectSpokenPrompt}
</p>
)}
{showMissingQuestion && (
<p className="rectification-question-slot__status" role="status">
@@ -1253,11 +1268,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onStop={submitStop}
/>
)}
{showCollectSpokenPrompt && (
<p id={collectSpokenPromptId} className="rectification-question-slot__prompt">
{collectSpokenPrompt}
</p>
)}
{showMissingQuestion && (
<p className="rectification-question-slot__status" role="status">
@@ -1298,9 +1308,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
)}
<ChatComposer
inputRef={composer}
describedBy={showCollectSpokenPrompt
? collectSpokenPromptId
: undefined}
value={draft}
remainingId={composerRemainingId}
inputLabel={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
@@ -1308,7 +1315,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? "该校正已结束,只能查看历史;需要再次校正请新建。"
: showLiveChoiceCard
? "点上面的选项即可;想补一句细节再写"
: showCollectSpokenPrompt || collectStemAlreadyInLatestBody
: collectSpokenPrompt
? "请回答上面的问题…"
: "继续说你记得的人生经历,或回答刚才的问题…"}
maxLength={RECTIFICATION_COMPOSER_MAX_LENGTH}
@@ -105,23 +105,21 @@ test("cases current_question drives the unified question slot", () => {
assert.equal(parseRectificationChoiceCard(COLLECT_GET_QUESTION), null);
});
test("collect_spoken stem stays in the same assistant body; the slot is fallback only", () => {
test("collect_spoken stem is joined into the latest assistant bubble, never a slot sibling", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
assert.match(chat, /currentQuestion\?\.kind === "collect_spoken"/);
assert.match(chat, /const collectSpokenPrompt =/);
assert.match(chat, /composeCollectSpokenAssistantText\(latestCollectBody, collectSpokenPrompt\)/);
assert.match(chat, /const collectStemAlreadyInLatestBody = Boolean\(/);
assert.match(chat, /const showCollectSpokenPrompt = Boolean\(/);
assert.match(chat, /!collectStemAlreadyInLatestBody/);
assert.match(chat, /!busy/);
assert.match(chat, /composeCollectSpokenAssistantText\(displayedMessage\.text, collectSpokenPrompt\)/);
assert.match(chat, /composeCollectSpokenAssistantText\(last\.text, prompt\)/);
assert.match(chat, /composeCollectSpokenAssistantText\(parsed\.text, collectPrompt\)/);
assert.doesNotMatch(chat, /showCollectSpokenPrompt/);
assert.doesNotMatch(chat, /rectification-question-slot__prompt/);
assert.doesNotMatch(chat, /collectSpokenPromptId/);
assert.match(chat, /showQuestionSlot/);
assert.match(chat, /isLatestMessage && showQuestionSlot/);
assert.match(chat, /messages\.length === 0 && showQuestionSlot/);
assert.match(chat, /showCollectSpokenPrompt \|\| collectStemAlreadyInLatestBody/);
assert.match(chat, /rectification-question-slot__prompt/);
assert.match(chat, /describedBy=\{showCollectSpokenPrompt/);
assert.match(chat, /collectSpokenPrompt\n\s+\? "请回答上面的问题…"/);
assert.match(
readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8"),
/\[describedBy, showRemaining \? remainingId : undefined\]\.filter\(Boolean\)\.join\(" "\)/,
@@ -137,7 +135,6 @@ test("collect_spoken stem stays in the same assistant body; the slot is fallback
assert.doesNotMatch(agentRun, /answerText\.(?:includes|match|search)\(/);
assert.match(styles, /\.rectification-question-slot \{[\s\S]*width: calc\(100% - var\(--assistant-content-inset\)\)/);
assert.match(styles, /\.rectification-question-slot \{[\s\S]*margin-inline-start: var\(--assistant-content-inset\)/);
assert.match(styles, /\.rectification-question-slot__prompt \{/);
assert.doesNotMatch(styles, /\.rectification-question-slot \.rectification-choice-card \{/);
});