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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user