fix(rectification): keep one spoken paragraph and strip body questions (BUG-584, BUG-585)
Independent Staging Quality Gate / validate (push) Successful in 9m48s
Independent Staging Quality Gate / publish (push) Successful in 7m33s

Set-focus must not append a second narration, and a server-owned stem must not also appear as a question in the assistant body.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-08 09:02:27 +08:00
parent 9aec502961
commit 9d1c08cab1
14 changed files with 430 additions and 53 deletions
@@ -74,6 +74,7 @@ export const RECTIFICATION_USER_COPY = {
questionUpdated: "这一问刚换成新的,刷新后再答就行。",
adoptCue: "我按你说的经历认真分析过了,下面是这次的结果。",
hostNarrationFallback: "我按现有材料继续往下收。",
collectHandoff: "接下来我们继续。",
continueCollectFallback: "请继续说下一件你记得比较清楚、大概带年份的经历。",
collectDeclinedAck: "记下了,这方面先跳过。",
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
@@ -315,6 +316,7 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.questionUpdated,
RECTIFICATION_USER_COPY.adoptCue,
RECTIFICATION_USER_COPY.hostNarrationFallback,
RECTIFICATION_USER_COPY.collectHandoff,
RECTIFICATION_USER_COPY.continueCollectFallback,
RECTIFICATION_USER_COPY.collectDeclinedAck,
RECTIFICATION_USER_COPY.uncertaintyStop,
@@ -34,7 +34,9 @@ import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./dat
import { decideFromDossier } from "./decision-from-dossier";
import { persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card";
import { withCompareFailedRetryNotice } from "../user-copy";
import { RECTIFICATION_USER_COPY, withCompareFailedRetryNotice } from "../user-copy";
import { stripQuestionSentences } from "./collect-prompt";
import { focusSpokenPrompt } from "./turn-question";
import {
resolveExactSkillPackage,
type ResolvedSkillPackageIdentity,
@@ -110,7 +112,6 @@ export type V9AgentRunResult = Readonly<{
toolsUsed: readonly string[];
errorCode: string | null;
previousFocusId: string | null;
collectSpokenEmitted: boolean;
}>;
type AttemptStatus = "completed" | "failed" | "retryable";
@@ -281,7 +282,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}
}
const previousFocusId = dossier.conversationSummary.activeFocus?.id ?? null;
const collectSpokenEmitted = false;
if (dossier.case.sessionId !== sessionId) {
throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch");
}
@@ -378,7 +378,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
toolsUsed: [],
errorCode: null,
previousFocusId,
collectSpokenEmitted,
};
}
if (existingStatus !== "pending") await billing.release();
@@ -489,7 +488,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
toolsUsed: outcome.toolsUsed,
errorCode: outcome.errorCode,
previousFocusId,
collectSpokenEmitted,
};
}
@@ -524,7 +522,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
toolsUsed: [],
errorCode: "usage_settlement_failed",
previousFocusId,
collectSpokenEmitted,
};
}
@@ -592,7 +589,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
toolsUsed: outcome.toolsUsed,
errorCode: null,
previousFocusId,
collectSpokenEmitted,
};
async function streamAttempt(attemptNumber: number, attemptId: string): Promise<AttemptOutcome> {
@@ -885,25 +881,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
await emitVisibleSpoken(withCompareFailedRetryNotice(answerText));
}
const discriminatorInvariant = async (): Promise<{ ok: true } | { ok: false; errorCode: string }> => {
try {
const latest = await loadV9CaseDossier(accounting, userId, caseId);
const decision = decideFromDossier(latest);
if (decision.nextAction !== "ask_candidate_discriminator") return { ok: true };
const focus = latest.conversationSummary.activeFocus;
if (
focus
&& isPersistedFocusId(focus.id)
&& parseAgentChoiceCopy(focus.expectedAnswerSchema)
) {
return { ok: true };
}
return { ok: false, errorCode: "state_invariant_failed" };
} catch {
return { ok: false, errorCode: "state_invariant_failed" };
}
};
const completeAttempt = async (): Promise<AttemptOutcome> => {
let inputTokens = 0;
let outputTokens = 0;
@@ -967,8 +944,32 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
return failedAttempt(attemptId, mapped);
}
if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream");
const invariant = await discriminatorInvariant();
if (!invariant.ok) return failedAttempt(attemptId, invariant.errorCode);
let latestDossier: V9CaseDossier;
try {
latestDossier = await loadV9CaseDossier(accounting, userId, caseId);
} catch {
return failedAttempt(attemptId, "state_invariant_failed");
}
const decision = decideFromDossier(latestDossier);
if (decision.nextAction === "ask_candidate_discriminator") {
const openFocus = latestDossier.conversationSummary.activeFocus;
if (!(
openFocus
&& isPersistedFocusId(openFocus.id)
&& parseAgentChoiceCopy(openFocus.expectedAnswerSchema)
)) {
return failedAttempt(attemptId, "state_invariant_failed");
}
}
const askedFocus = latestDossier.conversationSummary.activeFocus;
if (askedFocus?.askedTurnId === turnId) {
const stem = focusSpokenPrompt(askedFocus.expectedAnswerSchema);
if (stem) {
const stripped = stripQuestionSentences(answerText, stem);
const next = stripped || RECTIFICATION_USER_COPY.collectHandoff;
if (next !== answerText) await emitVisibleSpoken(next);
}
}
return completeAttempt();
} finally {
clearTimeout(timeout);
@@ -4,22 +4,46 @@
* “did this body already ask”.
*/
const SENTENCE_SPLIT = /(?<=[。!??\n])/;
const NARRATIVE_SENTENCE = /范围|记下|对照|\d{1,2}:\d{2}/;
function isQuestionSentence(text: string, stem: string): boolean {
if (!text) return false;
if (stem && text === stem) return true;
const prefix = stem.slice(0, 12);
if (prefix && text.startsWith(prefix)) return true;
return /[?]$/.test(text);
}
export function stripQuestionSentences(body: string, stem: string): string {
const prompt = stem.trim();
const spoken = body.trim();
if (!spoken) return "";
const kept: string[] = [];
let dropContinuation = false;
for (const part of spoken.split(SENTENCE_SPLIT)) {
const text = part.trim();
if (!text) continue;
if (isQuestionSentence(text, prompt)) {
dropContinuation = true;
continue;
}
if (dropContinuation && !NARRATIVE_SENTENCE.test(text)) {
dropContinuation = false;
continue;
}
dropContinuation = false;
kept.push(part);
}
return kept.join("").trim();
}
export function composeCollectSpokenAssistantText(body: string, prompt: string): string {
const stem = prompt.trim();
const spoken = body.trim();
if (!stem) return spoken;
if (!spoken || spoken === stem) return stem;
const prefix = stem.slice(0, 12);
const stripped = spoken
.split(/(?<=[。!?\n])/)
.filter((sentence) => {
const text = sentence.trim();
if (!text) return false;
if (text === stem) return false;
return !(prefix && text.startsWith(prefix));
})
.join("")
.trim();
const stripped = stripQuestionSentences(spoken, stem);
if (!stripped) return stem;
const suffix = `\n\n${stem}`;
if (stripped.includes(stem)) return stripped;
@@ -7,7 +7,8 @@
* or live-unlock after compare/offer/result tools — tokens may stream as
* `live`. A later public tool-call retracts that speculative stream so
* "Let me set" never stays in `answer.delta`. Exception: `rectification-set-focus`
* only attaches the question stem; live greeting/handoff in the same step stays.
* keeps already-live greeting/handoff and does not retract it; after the stem
* is attached, a second spoken paragraph in the same turn is discarded.
*/
export type StepAnswerChunk = Readonly<{
@@ -27,6 +28,8 @@ export type StepAnswerState = {
live: boolean;
publishedUpTo: number;
unlocked: boolean;
stemAttached: boolean;
publishedAny: boolean;
};
export type StepAnswerEffect =
@@ -67,6 +70,8 @@ export function createStepAnswerState(): StepAnswerState {
live: false,
publishedUpTo: 0,
unlocked: false,
stemAttached: false,
publishedAny: false,
};
}
@@ -116,6 +121,7 @@ function liveRemainder(state: StepAnswerState): StepAnswerEffect {
const next = state.text.slice(state.publishedUpTo);
if (!next) return { kind: "none" };
state.live = true;
state.publishedAny = true;
state.publishedUpTo = state.text.length;
return { kind: "live", text: next };
}
@@ -127,9 +133,24 @@ function retractLive(state: StepAnswerState): StepAnswerEffect {
state.live = false;
state.publishedUpTo = 0;
state.unlocked = false;
state.publishedAny = false;
return wasLive ? { kind: "retract" } : { kind: "none" };
}
function publishUnpublishedSetFocus(state: StepAnswerState): StepAnswerEffect {
const unpublished = state.text.slice(state.publishedUpTo);
if (hasCjk(unpublished)) {
state.publishedAny = true;
state.publishedUpTo = state.text.length;
const pieces = [unpublished];
resetBuffers(state);
return { kind: "publish", pieces };
}
const leftover = unpublished.trim().length > 0;
resetBuffers(state);
return leftover ? { kind: "discard" } : { kind: "none" };
}
/**
* Advance the per-step buffer. Terminal Chinese may stream as `live` once
* the step is unlocked or a spoken sentence has closed. Tool-result ends
@@ -149,6 +170,7 @@ export function applyStepAnswerChunk(
resetBuffers(state);
return { kind: "none" };
case "text-delta": {
if (state.stemAttached && state.publishedAny) return { kind: "discard" };
const text = typeof chunk.payload?.text === "string" ? chunk.payload.text : "";
if (text) {
state.text += text;
@@ -160,7 +182,9 @@ export function applyStepAnswerChunk(
case "tool-call":
if (isPublicToolCall(chunk, isPublicTool)) {
state.calledTool = true;
if (KEEP_LIVE_SPOKEN_TOOLS.has(toolName(chunk))) return { kind: "none" };
if (KEEP_LIVE_SPOKEN_TOOLS.has(toolName(chunk))) {
return publishUnpublishedSetFocus(state);
}
const retracted = retractLive(state);
return retracted.kind === "retract" ? retracted : { kind: "none" };
}
@@ -169,6 +193,7 @@ export function applyStepAnswerChunk(
case "tool-error": {
if (isPublicToolCall(chunk, isPublicTool)) state.calledTool = true;
if (KEEP_LIVE_SPOKEN_TOOLS.has(toolName(chunk))) {
state.stemAttached = true;
resetBuffers(state);
return { kind: "none" };
}
@@ -188,11 +213,13 @@ export function applyStepAnswerChunk(
const reason = stepFinishReason(chunk);
if (state.live) {
const rest = state.text.slice(state.publishedUpTo);
if (rest) state.publishedAny = true;
resetBuffers(state);
return rest ? { kind: "publish", pieces: [rest] } : { kind: "none" };
}
const publish = shouldPublishStepText(state, reason);
const pieces = publish ? [...state.pieces] : [];
if (publish) state.publishedAny = true;
resetBuffers(state);
return publish ? { kind: "publish", pieces } : { kind: "discard" };
}
@@ -207,6 +234,7 @@ export function flushStepAnswerOnStreamFinish(
): StepAnswerEffect {
if (state.live) {
const rest = state.text.slice(state.publishedUpTo);
if (rest) state.publishedAny = true;
resetBuffers(state);
return rest ? { kind: "publish", pieces: [rest] } : { kind: "none" };
}
@@ -1,4 +1,4 @@
import { detachCollectSpokenAssistantText } from "./collect-prompt";
import { stripQuestionSentences } from "./collect-prompt";
import { parseAgentChoiceCopy, type ChoiceKey } from "./choice-card";
import type { ConversationFocus } from "./tool-service";
@@ -114,7 +114,7 @@ export function attachQuestionsToTurns<T extends { id: string; role: string; tex
if (!focus) return { ...turn, question: null };
const question = turnQuestionFromFocus(focus);
const text = question && turn.text
? detachCollectSpokenAssistantText(turn.text, question.prompt)
? stripQuestionSentences(turn.text, question.prompt)
: turn.text;
return { ...turn, text, question };
});