fix(web): split rectification process talk from the spoken reply

Keep provider thinking off, classify CoT as 思考, and stop remounted sessions from firing a second opening.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-23 10:14:35 +08:00
parent f699c35f56
commit f03a2706b4
13 changed files with 376 additions and 32 deletions
+5
View File
@@ -873,6 +873,11 @@ button:disabled { cursor: default; opacity: .45; }
.consultation-report-analysis .message-markdown {
color: var(--color-ink-strong);
}
.consultation-thinking-report .message-thinking {
margin-bottom: 0;
padding-bottom: var(--space-3);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
}
.message-markdown ul.markdown-list,
.message-markdown ol.markdown-list {
display: grid;
+8 -2
View File
@@ -4034,7 +4034,7 @@ export default function Home() {
{rectificationSurfaceOpen && rectificationCaseId && (
<ConversationalBirthTimeRectification
key={`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.at(-1)?.id ?? "loading"}`}
key={`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.length > 0 ? "ready" : "loading"}`}
caseId={rectificationCaseId}
sessionId={rectificationSessionId ?? ""}
readonly={rectificationReadonly}
@@ -4044,7 +4044,13 @@ export default function Home() {
selectedModelId={activeSession?.modelId ?? ""}
onSelectModel={(modelId) => void selectSessionModel(modelId)}
onMessagesChange={handleRectificationMessagesChange}
onCompleted={() => void refreshAccount()}
onOpeningConsumed={() => setRectificationShouldStartOpening(false)}
onCompleted={() => {
void refreshAccount();
if (rectificationCaseId && rectificationSessionId) {
void refreshRectificationCase(rectificationCaseId, rectificationSessionId);
}
}}
onPendingChange={setRectificationMutationPending}
onProfileIncomplete={handleRectificationProfileIncomplete}
onSaved={() => void refreshAccount()}
@@ -59,7 +59,7 @@ function MessageThinkingTrace({
setUserOpen((event.currentTarget as HTMLDetailsElement).open);
}}
>
<summary></summary>
<summary></summary>
<div className="message-thinking-body">{text}</div>
</details>
);
+37 -17
View File
@@ -64,6 +64,8 @@ export function ChatMessageRow({
const thinkingSections = message.thinkingSections ?? [];
const showReport = thinkingSections.length > 0;
const showThinkingPanel = !showReport && (showActivity || Boolean(message.thinkingText?.trim()));
const showSpokenAnswer = !showReport && Boolean(message.text);
const stackedThinkingAndAnswer = showThinkingPanel && showSpokenAnswer;
useEntryEffect(() => {
const row = messageRow.current;
@@ -89,6 +91,29 @@ export function ChatMessageRow({
return () => motion.revert();
}, [message.role]);
const thinkingPanel = showThinkingPanel
? (
<AgentActivityStatus
state={activityState}
label={activityLabel}
startedAt={message.activity?.startedAt}
completedTrail={message.activity?.completedTrail}
thinkingText={message.thinkingText}
hasAnswer={hasAnswer}
showLive={showActivity}
/>
)
: null;
const spokenAnswer = showSpokenAnswer
? (
<ChatMessageContent
text={message.text}
auditRows={message.agentExecutionReceipt?.techniqueAuditTable}
vargaSentence={vargaSentence}
/>
)
: null;
return (
<article
ref={messageRow}
@@ -112,23 +137,18 @@ export function ChatMessageRow({
vargaSentence={vargaSentence}
/>
)}
{showThinkingPanel && (
<AgentActivityStatus
state={activityState}
label={activityLabel}
startedAt={message.activity?.startedAt}
completedTrail={message.activity?.completedTrail}
thinkingText={message.thinkingText}
hasAnswer={hasAnswer}
showLive={showActivity}
/>
)}
{!showReport && message.text && (
<ChatMessageContent
text={message.text}
auditRows={message.agentExecutionReceipt?.techniqueAuditTable}
vargaSentence={vargaSentence}
/>
{stackedThinkingAndAnswer ? (
<div className="consultation-thinking-report">
{thinkingPanel}
<section className="consultation-report-analysis" aria-label="回复">
{spokenAnswer}
</section>
</div>
) : (
<>
{thinkingPanel}
{spokenAnswer}
</>
)}
</>
) : <p>{message.text}</p>}
@@ -34,6 +34,7 @@ export type ConversationalBirthTimeRectificationProps = Readonly<{
onProfileIncomplete?: () => void;
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
onStartConsultation?: () => void;
onOpeningConsumed?: () => void;
pendingConsultationQuestion?: string | null;
onRestart?: () => void;
headerSlot: HTMLElement | null;
@@ -33,6 +33,7 @@ import {
isPublicRectificationMethod,
isPublicRectificationTool,
} from "@/lib/rectification-agentic/v9/public-receipt";
import { finalizeRectificationSpokenAndThinking } from "@/lib/rectification-agentic/v9/spoken-answer";
import {
CHOICE_STOP_MESSAGE,
choiceCardUserMessage,
@@ -136,6 +137,7 @@ type RectificationAgenticChatProps = Readonly<{
onProfileIncomplete?: () => void;
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
onStartConsultation?: () => void;
onOpeningConsumed?: () => void;
pendingConsultationQuestion?: string | null;
onRestart?: () => void;
headerSlot: HTMLElement | null;
@@ -200,9 +202,13 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
const key = `persisted-${turn.id}-${index}`;
if (turn.role === "assistant") {
const failed = persistedTurnFailed(turn);
const raw = failed ? "" : turn.text ?? "";
const split = raw ? finalizeRectificationSpokenAndThinking(raw) : { thinking: "", spoken: raw };
const thinkingText = split.thinking.trim() || undefined;
return [{
role: "assistant",
text: failed ? "" : turn.text ?? "",
text: split.spoken || raw,
...(thinkingText ? { thinkingText } : {}),
renderKey: key,
state: turn.status === "completed" || failed ? "settled" : "thinking",
completedReceipt: completedReceiptFromPersisted(turn.receipt),
@@ -235,6 +241,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onProfileIncomplete,
onSaved,
onStartConsultation,
onOpeningConsumed,
pendingConsultationQuestion,
onRestart,
headerSlot,
@@ -617,10 +624,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}, [busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onProfileIncomplete, readonly, selectedModelId, sessionId, setPending]);
useEffect(() => {
if (initialTurns.length > 0) {
if (shouldStartOpening) onOpeningConsumed?.();
return;
}
if (readonly || openingStarted.current || !shouldStartOpening) return;
openingStarted.current = true;
onOpeningConsumed?.();
void send("opening", "");
}, [readonly, send, shouldStartOpening]);
}, [initialTurns.length, onOpeningConsumed, readonly, send, shouldStartOpening]);
const acceptCandidate = useCallback(async (candidateId: string) => {
if (!candidateResult || acceptingCandidateId || readonly) return;
@@ -35,6 +35,11 @@ import {
isPublicRectificationToolName,
type PublicStreamEvent,
} from "./stream-mapping";
import {
finalizeRectificationSpokenAndThinking,
nextStableChannelDelta,
splitRectificationSpokenAndThinking,
} from "./spoken-answer";
export type V9RunBilling = Readonly<{
reserve(): Promise<{ success: boolean; reason?: string; status: number }>;
@@ -468,6 +473,9 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
let finished = false;
let answerText = "";
const answerDeltas: string[] = [];
let spokenBuffer = "";
let publishedThinking = "";
let publishedSpoken = "";
const phases: string[] = [];
const toolsUsed = new Set<string>();
const events: PublicStreamEvent[] = [];
@@ -609,11 +617,20 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
const thinking = toPublicThinkingDelta(text);
if (thinking) await publish(thinking);
} else {
const hadVisible = Boolean(answerText.trim());
answerText += text;
answerDeltas.push(text);
if (hadVisible || text.trim()) {
await emit(phaseEvent);
spokenBuffer += text;
const published = await publishSplitSpokenAndThinking(
spokenBuffer,
publishedThinking,
publishedSpoken,
answerText,
publish,
emit,
);
publishedThinking = published.thinking;
publishedSpoken = published.spoken;
if (published.spokenDelta) {
answerText += published.spokenDelta;
answerDeltas.push(published.spokenDelta);
}
}
}
@@ -642,6 +659,23 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}
}
if (spokenBuffer) {
const published = await publishSplitSpokenAndThinking(
spokenBuffer,
publishedThinking,
publishedSpoken,
answerText,
publish,
emit,
true,
);
if (published.spokenDelta) {
answerText += published.spokenDelta;
answerDeltas.push(published.spokenDelta);
}
if (published.spoken) answerText = published.spoken;
}
if (!skillBound) return failedAttempt(attemptId, "skill_not_loaded");
if (!caseLoaded) return failedAttempt(attemptId, "case_not_loaded");
if (streamFailed || abortController.signal.aborted) return failedAttempt(attemptId, "stream_aborted");
@@ -803,4 +837,33 @@ function buildAgentMessages(
}];
}
async function publishSplitSpokenAndThinking(
buffer: string,
publishedThinking: string,
publishedSpoken: string,
answerText: string,
publish: (event: PublicStreamEvent) => Promise<void>,
emit: (event: PublicStreamEvent) => Promise<void>,
finalize = false,
): Promise<{ thinking: string; spoken: string; spokenDelta: string }> {
const split = finalize
? finalizeRectificationSpokenAndThinking(buffer)
: splitRectificationSpokenAndThinking(buffer);
const thinkingDelta = nextStableChannelDelta(publishedThinking, split.thinking);
const thinking = split.thinking.startsWith(publishedThinking) ? split.thinking : publishedThinking;
if (thinkingDelta) {
const thinkingEvent = toPublicThinkingDelta(thinkingDelta);
if (thinkingEvent) await publish(thinkingEvent);
}
const spokenDelta = nextStableChannelDelta(publishedSpoken, split.spoken);
const spoken = split.spoken.startsWith(publishedSpoken) ? split.spoken : publishedSpoken;
const emitSpoken = Boolean(spokenDelta) && Boolean(answerText.trim() || spokenDelta.trim());
if (emitSpoken) await emit({ type: "answer.delta", text: spokenDelta });
return {
thinking,
spoken,
spokenDelta: emitSpoken ? spokenDelta : "",
};
}
export { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION };
@@ -0,0 +1,77 @@
/**
* Separate process narration from the user-facing rectification reply.
*
* Provider thinking stays disabled so hidden CoT cannot pinch the spoken
* budget. The model still dumps Chinese self-talk onto text-delta; this
* splitter routes that talk onto `thinking.delta` and keeps only the spoken
* conclusion on `answer.delta`.
*/
const CJK_RE = /[\u4e00-\u9fff]/;
const INTERNAL_TOKEN_RE = /\b(?:datePrecision|occurredFrom|occurredTo|proposedKind|education_start|missing_evidence|SKILL\.md|rectification-[a-z0-9-]+|focusId|evidenceId|display_date_label)\b/;
const PROCESS_ZH_RE = /skill\s*规则|不得猜补|让我(?:调用|记录|batch|提交)|我(?:决定|倾向|batch)|权衡:|内部矛盾|思维链|调用 batch|datePrecision|occurredFrom|occurredTo/;
const THIRD_PERSON_USER_RE = /用户(?:提到|先(?:说|提到)|说|自己|的核心|想表达|原话|的最终|对年份)/;
export type SplitSpokenAndThinking = Readonly<{
thinking: string;
spoken: string;
}>;
export function isRectificationProcessNarration(text: string): boolean {
const trimmed = text.trim();
if (!trimmed) return false;
if (/[A-Za-z]{4,}/.test(trimmed) && !CJK_RE.test(trimmed)) return true;
if (INTERNAL_TOKEN_RE.test(trimmed)) return true;
if (PROCESS_ZH_RE.test(trimmed)) return true;
if (THIRD_PERSON_USER_RE.test(trimmed)) return true;
return false;
}
export function nextStableChannelDelta(published: string, next: string): string {
if (!next.startsWith(published)) return "";
return next.slice(published.length);
}
function splitParagraphs(text: string): string[] {
return text
.split(/\n{2,}/)
.flatMap((block) => {
const trimmed = block.trim();
if (!trimmed) return [];
const lines = trimmed.split(/\n/).map((line) => line.trim()).filter(Boolean);
if (lines.length <= 1) return [trimmed];
const hasProcess = lines.some(isRectificationProcessNarration);
const hasSpoken = lines.some((line) => !isRectificationProcessNarration(line));
return hasProcess && hasSpoken ? lines : [trimmed];
});
}
export function splitRectificationSpokenAndThinking(text: string): SplitSpokenAndThinking {
const paragraphs = splitParagraphs(text);
if (paragraphs.length === 0) return { thinking: "", spoken: text };
const thinking: string[] = [];
const spoken: string[] = [];
for (const paragraph of paragraphs) {
if (isRectificationProcessNarration(paragraph)) thinking.push(paragraph);
else spoken.push(paragraph);
}
if (thinking.length === 0) return { thinking: "", spoken: text };
return {
thinking: thinking.join("\n\n"),
spoken: spoken.join("\n\n"),
};
}
export function finalizeRectificationSpokenAndThinking(text: string): SplitSpokenAndThinking {
const split = splitRectificationSpokenAndThinking(text);
if (split.spoken.trim()) return split;
if (!split.thinking.trim()) return { thinking: "", spoken: text };
const paragraphs = splitParagraphs(split.thinking);
if (paragraphs.length < 2) return { thinking: "", spoken: text };
return {
thinking: paragraphs.slice(0, -1).join("\n\n"),
spoken: paragraphs.at(-1) ?? text,
};
}