fix(web): keep rectification interview planning out of the spoken answer

Thinking is disabled, so Chinese process talk after tools arrives as
text-delta. Widen the leak-net so tool-label echoes and internal field
names stay out of answer.delta, and keep the user-facing follow-up.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-25 12:55:34 +08:00
co-authored by Cursor
parent 1f9b2f0be6
commit 32f7d774b6
4 changed files with 120 additions and 9 deletions
@@ -6,20 +6,40 @@
* - drops process-only `text-delta` leaks so they cannot complete as the reply
*/
import {
RECTIFICATION_ACTIVITY_PROGRESS_LABELS,
RECTIFICATION_TOOL_DONE_LABELS,
} from "../../rectification-activity-labels.ts";
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 = /用户(?:在上|提到|先(?:说|提到)|说|自己|的核心|想表达|原话|的最终|对年份|提供了)/;
const INTERNAL_TOKEN_RE = /\b(?:datePrecision|occurredFrom|occurredTo|proposedKind|education_start|missing_evidence|SKILL\.md|rectification-[a-z0-9-]+|focusId|evidenceId|display_date_label|occupation_note|method_followup_plan|open_question|next_action|next_user_action|not_separated|propose_allowed|selection_allowed|information_gain|event_probe|session_outcome|unique_minute_path|confirmation_allowed|collect_method_evidence|candidate_contrast|deferred_followup)\b/;
const PROCESS_ZH_RE = /skill\s*规则|不得猜补|让我(?:调用|记录|batch|提交|继续|用)|我(?:决定|倾向|batch|需要用|需要继续|继续收集|继续访谈|自然地|用自然语言)|权衡:|内部矛盾|思维链|调用 batch|批量工具|写入(?:这些)?证据|datePrecision|occurredFrom|occurredTo|方法覆盖|还不能出牌|不得出牌|本轮对照了|不可分宽度|重新计算了候选|带评分日期|当前还应继续收集|根据 method_followup/;
const THIRD_PERSON_USER_RE = /^用户|用户(?:在上|提到|先(?:说|提到)|说|自己|的核心|想表达|原话|的最终|对年份|提供了)/;
const ACTIVITY_ECHO_LABELS = [
...Object.values(RECTIFICATION_TOOL_DONE_LABELS),
...Object.values(RECTIFICATION_ACTIVITY_PROGRESS_LABELS),
];
export type SplitSpokenAndThinking = Readonly<{
thinking: string;
spoken: string;
}>;
function isActivityEcho(text: string): boolean {
const trimmed = text.trim().replace(/[。.…]+$/u, "");
if (!trimmed) return false;
return ACTIVITY_ECHO_LABELS.some((label) => {
const bare = label.replace(/[。.…]+$/u, "");
return trimmed === bare || trimmed === `正在${bare}`;
});
}
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 (isActivityEcho(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;
@@ -31,17 +51,31 @@ export function nextStableChannelDelta(published: string, next: string): string
return next.slice(published.length);
}
function splitSentences(text: string): string[] {
const parts = text.split(/(?<=[。!?])\s*/u).map((part) => part.trim()).filter(Boolean);
return parts.length > 0 ? parts : [text];
}
function splitUnits(text: string): string[] {
const lines = text.split(/\n/).map((line) => line.trim()).filter(Boolean);
if (lines.length > 1) return lines.flatMap(splitSentences);
return splitSentences(text);
}
function isMixed(units: readonly string[]): boolean {
return units.length > 1
&& units.some(isRectificationProcessNarration)
&& units.some((unit) => !isRectificationProcessNarration(unit));
}
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];
const units = splitUnits(trimmed);
return isMixed(units) ? units : [trimmed];
});
}