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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -68,8 +68,10 @@ test("shows honest agent activity states before and during streamed text", () =>
|
||||
assert.match(messageRowSource, /正在处理…/);
|
||||
assert.match(messageRowSource, /showActivity = message\.state !== "settled"/);
|
||||
assert.match(messageRowSource, /showThinkingPanel &&/);
|
||||
assert.match(messageRowSource, /message\.text && \(/);
|
||||
assert.match(messageRowSource, /showSpokenAnswer/);
|
||||
assert.match(messageRowSource, /<ChatMessageContent[\s\S]*text=\{message\.text\}[\s\S]*auditRows=\{message\.agentExecutionReceipt\?\.techniqueAuditTable\}/);
|
||||
assert.match(messageRowSource, /stackedThinkingAndAnswer/);
|
||||
assert.match(messageRowSource, /aria-label="回复"/);
|
||||
assert.match(globalStyles, /\.agent-activity-status \+ \.message-answer/);
|
||||
assert.match(activitySource, /<ThinkingOrb aria-hidden="true" state=\{state\} size=\{20\}/);
|
||||
assert.match(activitySource, /className="agent-activity-status__text"/);
|
||||
@@ -99,7 +101,7 @@ test("shows honest agent activity states before and during streamed text", () =>
|
||||
assert.match(reportSource, /groups=\{progressed\.map/);
|
||||
assert.doesNotMatch(reportSource, /analysisForSection|splitAnswerByHeadings/);
|
||||
assert.match(globalStyles, /\.markdown-list/);
|
||||
assert.match(activitySource, /思考过程/);
|
||||
assert.match(activitySource, />思考</);
|
||||
assert.match(pageSource, /event\.type === "run\.failed"/);
|
||||
assert.match(pageSource, /event\.code === "answer_truncated"/);
|
||||
assert.match(pageSource, /throw new ConsultationResponseError/);
|
||||
@@ -109,6 +111,7 @@ test("shows honest agent activity states before and during streamed text", () =>
|
||||
assert.match(pageSource, /\.\.\.\(thinkingSections\.length \? \{ thinkingSections \} : \{\}\)/);
|
||||
assert.match(globalStyles, /\.consultation-report-analysis/);
|
||||
assert.match(globalStyles, /\.consultation-thinking-report/);
|
||||
assert.match(globalStyles, /\.consultation-thinking-report \.message-thinking/);
|
||||
});
|
||||
|
||||
test("nothing sits between the transcript and the composer to shift height while streaming", () => {
|
||||
|
||||
@@ -83,7 +83,9 @@ test("birth-time rectification entry mounts the V9 case-ref chat", () => {
|
||||
test("persisted rectification turns hydrate after the async Case refresh", () => {
|
||||
assert.match(chat, /function messagesFromTurns\(initialTurns:/);
|
||||
assert.match(chat, /useState<RenderMessage\[\]>\(\(\) => messagesFromTurns\(initialTurns\)\)/);
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.at\(-1\)\?\.id \?\? "loading"\}`\}/);
|
||||
assert.match(chat, /finalizeRectificationSpokenAndThinking/);
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.length > 0 \? "ready" : "loading"\}`\}/);
|
||||
assert.doesNotMatch(page, /rectificationTurns\.at\(-1\)\?\.id/);
|
||||
assert.match(page, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/);
|
||||
assert.match(component, /methods\?: readonly string\[\]/);
|
||||
});
|
||||
@@ -92,11 +94,14 @@ test("opening is server-owned: shouldStartOpening drives the first turn, never c
|
||||
assert.doesNotMatch(chat, /initialMessages\.length > 0 \|\| openingStarted/);
|
||||
assert.doesNotMatch(chat, /agenticOpeningInstruction|用户刚进入生时校正会话/);
|
||||
assert.match(chat, /shouldStartOpening/);
|
||||
assert.match(chat, /if \(initialTurns\.length > 0\) \{/);
|
||||
assert.match(chat, /if \(readonly \|\| openingStarted\.current \|\| !shouldStartOpening\) return/);
|
||||
assert.match(chat, /onOpeningConsumed\?\.\(\)/);
|
||||
assert.match(chat, /void send\("opening", ""\)/);
|
||||
assert.match(route, /action: z\.enum\(\["opening", "message", "read_only"\]\)/);
|
||||
assert.match(page, /shouldStartOpening=\{rectificationShouldStartOpening\}/);
|
||||
assert.match(page, /setRectificationShouldStartOpening\(opened\.shouldStartOpening\)/);
|
||||
assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
|
||||
});
|
||||
|
||||
test("incomplete profiles stay in the shared onboarding flow before any open request", () => {
|
||||
@@ -186,9 +191,11 @@ test("agent tool calls never end silently; the runner owns completion and failur
|
||||
test("persisted turns survive remounts; duplicate openings are suppressed by the server", () => {
|
||||
assert.match(chat, /initialTurns/);
|
||||
assert.match(chat, /const openingStarted = useRef\(false\)/);
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.at\(-1\)\?\.id \?\? "loading"\}`\}/);
|
||||
assert.match(chat, /if \(initialTurns\.length > 0\) \{/);
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.length > 0 \? "ready" : "loading"\}`\}/);
|
||||
assert.match(page, /initialTurns=\{rectificationTurns\}/);
|
||||
assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/);
|
||||
assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
|
||||
});
|
||||
|
||||
test("the agent route verifies the exact Case/Session binding before any turn", () => {
|
||||
@@ -229,6 +236,7 @@ test("usage completes or releases without hiding settlement failures", () => {
|
||||
assert.match(run, /usage_settlement_failed/);
|
||||
assert.match(run, /thinking: "disabled"/);
|
||||
assert.match(run, /toPublicThinkingDelta/);
|
||||
assert.match(run, /splitRectificationSpokenAndThinking/);
|
||||
assert.match(route, /featureKey: "rectification"/);
|
||||
assert.match(route, /rectification:case:\$\{caseId\}/);
|
||||
});
|
||||
@@ -301,7 +309,7 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr
|
||||
assert.doesNotMatch(chat, /reasoning-delta|chain-of-thought/);
|
||||
assert.match(chat, /event.type === "thinking.delta"/);
|
||||
assert.match(activityStatus, /className="message-thinking"/);
|
||||
assert.match(activityStatus, /思考过程/);
|
||||
assert.match(activityStatus, />思考</);
|
||||
assert.match(activityStatus, /userOpen \?\? !hasAnswer/);
|
||||
assert.match(styles, /\.message-thinking-body/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
finalizeRectificationSpokenAndThinking,
|
||||
nextStableChannelDelta,
|
||||
splitRectificationSpokenAndThinking,
|
||||
} from "../src/lib/rectification-agentic/v9/spoken-answer.ts";
|
||||
|
||||
test("a plain spoken follow-up stays on the answer channel", () => {
|
||||
const spoken = [
|
||||
"记下了,大约六岁入学小学。",
|
||||
"接下来你大概哪一年上的初中?说个大概年份或范围就行。",
|
||||
].join("\n\n");
|
||||
|
||||
assert.deepEqual(splitRectificationSpokenAndThinking(spoken), {
|
||||
thinking: "",
|
||||
spoken,
|
||||
});
|
||||
});
|
||||
|
||||
test("process self-talk leaves the spoken conclusion on the answer channel", () => {
|
||||
const processTalk = [
|
||||
"用户提到先给了一个很晚的年份,后又改口说六岁入学。这里有个明显的内部矛盾。",
|
||||
"但按 skill 规则,日期精度真实保留,不得猜补。datePrecision 用 year,occurredFrom 只能按用户原话来。",
|
||||
"我决定先按六岁入学记下来,再在正文里确认那句晚年份是不是口误。",
|
||||
"让我调用 batch 写入 education_start。",
|
||||
].join("\n\n");
|
||||
const spoken = [
|
||||
"入学小学这条先按大约六岁记下。你第一句提到的那个很晚的年份,我理解是口误对吗?",
|
||||
"接下来你大概哪一年上的初中?说个大概年份或范围就行。",
|
||||
].join("\n\n");
|
||||
|
||||
assert.deepEqual(splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`), {
|
||||
thinking: processTalk,
|
||||
spoken,
|
||||
});
|
||||
});
|
||||
|
||||
test("English process talk is classified as thinking, not the spoken answer", () => {
|
||||
const split = splitRectificationSpokenAndThinking([
|
||||
"The proposedKind value was rejected. Retrying with education_start.",
|
||||
"记下了,那年九月上大学。",
|
||||
].join("\n\n"));
|
||||
|
||||
assert.match(split.thinking, /proposedKind/);
|
||||
assert.equal(split.spoken, "记下了,那年九月上大学。");
|
||||
});
|
||||
|
||||
test("stable channel deltas only emit the newly classified suffix", () => {
|
||||
assert.equal(nextStableChannelDelta("", "先核对升学年份。"), "先核对升学年份。");
|
||||
assert.equal(
|
||||
nextStableChannelDelta("先核对升学年份。", "先核对升学年份。\n\n再问初中。"),
|
||||
"\n\n再问初中。",
|
||||
);
|
||||
assert.equal(nextStableChannelDelta("先核对升学年份。", "另一段"), "");
|
||||
});
|
||||
|
||||
test("a lone process paragraph stays in thinking until a spoken conclusion arrives", () => {
|
||||
const processTalk = "用户提到先给了一个很晚的年份。这里有个明显的内部矛盾。";
|
||||
assert.deepEqual(splitRectificationSpokenAndThinking(`${processTalk}\n\n`), {
|
||||
thinking: processTalk,
|
||||
spoken: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("all-process text keeps a spoken fallback only when the turn is finalized", () => {
|
||||
const first = "用户提到先给了一个很晚的年份。这里有个明显的内部矛盾。";
|
||||
const last = "但按 skill 规则,日期精度真实保留,不得猜补。";
|
||||
assert.deepEqual(finalizeRectificationSpokenAndThinking(`${first}\n\n${last}`), {
|
||||
thinking: first,
|
||||
spoken: last,
|
||||
});
|
||||
});
|
||||
@@ -530,6 +530,47 @@ test("English tool-retry narration never becomes the spoken answer", async () =>
|
||||
assert.doesNotMatch(publicText, /invalid_event_kind/);
|
||||
});
|
||||
|
||||
test("Chinese process self-talk after tools is thinking, not the spoken answer", async () => {
|
||||
const { options, emitted } = runOptions({
|
||||
buildAgent: async () => fakeAgentStream([
|
||||
chunk("start"),
|
||||
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
|
||||
chunk("tool-result", { toolName: "skill" }),
|
||||
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", { toolName: "rectification-read-case" }),
|
||||
chunk("tool-call", {
|
||||
toolName: "rectification-record-evidence-batch",
|
||||
args: { caseId: CASE_ID, proposedKind: "education_start" },
|
||||
}),
|
||||
chunk("tool-result", { toolName: "rectification-record-evidence-batch" }),
|
||||
chunk("text-delta", {
|
||||
text: "用户提到先给了一个很晚的年份,后又改口说六岁入学。这里有个明显的内部矛盾。\n\n",
|
||||
}),
|
||||
chunk("text-delta", {
|
||||
text: "但按 skill 规则,日期精度真实保留,不得猜补。datePrecision 用 year。\n\n",
|
||||
}),
|
||||
chunk("text-delta", {
|
||||
text: "记下了,大约六岁入学小学。接下来你大概哪一年上的初中?",
|
||||
}),
|
||||
chunk("finish"),
|
||||
]) as never,
|
||||
});
|
||||
const result = await runV9AgentTurn(options);
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(
|
||||
emitted.filter((event) => event.type === "answer.delta").map((event) => event.text).join(""),
|
||||
"记下了,大约六岁入学小学。接下来你大概哪一年上的初中?",
|
||||
);
|
||||
const thinking = emitted
|
||||
.filter((event) => event.type === "thinking.delta")
|
||||
.map((event) => event.text)
|
||||
.join("");
|
||||
assert.match(thinking, /内部矛盾/);
|
||||
assert.match(thinking, /datePrecision/);
|
||||
assert.doesNotMatch(thinking, /哪一年上的初中/);
|
||||
assert.equal(result.answerText, "记下了,大约六岁入学小学。接下来你大概哪一年上的初中?");
|
||||
});
|
||||
|
||||
test("a length-limited spoken answer is not billed or persisted as a completed turn", async () => {
|
||||
const pinched = "**先看候选结构(本会话以代表性时间收口";
|
||||
const accounting = fakeAccounting({
|
||||
|
||||
Reference in New Issue
Block a user