fix(web): keep rectification replies as model text, hide thinking
Give the interview a hidden reasoning channel so planning leaves the spoken reply, publish terminal text-delta as-is, and stop regex or Case templates from replacing the model. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5531,6 +5531,38 @@
|
||||
- 复发自:无
|
||||
- 修复版本:未修复
|
||||
|
||||
## BUG-377 | 生时纠正直播回复必须是模型正文,不得用正则或 Case 模板顶替
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-25
|
||||
- 最近更新:2026-08-25
|
||||
- 影响面:`runV9AgentTurn`、纠正组 `thinking`、公开 `answer.delta`、生时纠正聊天气泡
|
||||
- 用户现象:关 thinking 后模型把规划写进 `text-delta`。漏检网按句式切口语,换一种说法就再次把规划当回复。用服务器按 Case 拼「已经记下」会丢掉模型自己的访谈措辞。
|
||||
- 触发条件:纠正组 `thinking: disabled`;公开流不发 `thinking.delta`。工具完成后的最终 `text-delta` 混有规划和对用户说话,或直播路径用正则 / Case 模板决定回复。
|
||||
- 根因:规划没有稳定表面形式,词表无法从同一段散文里拆出口语。关掉 provider thinking 后模型没有隐藏思维链通道,只能写进正文。用服务器叙述顶替正文等于不用大模型写对用户说的话。
|
||||
- 修复:直播用户可见回复是模型终端 `text-delta` 原样。打开 hidden thinking(`reasoning-delta`),正文预算与思维链预算分开,规划离开正文。公开流仍丢弃 `thinking.delta`。纠正 UI 不渲染 `thinkingText`,阶段仍来自工具进度。服务器叙述只在工具后没有正文时兜底。正则只用于旧 Turn 水合,不得再当直播分流。不改 Skill `10.0.11`。
|
||||
- 验证:`frontend/tests/rectification-v9-stream.test.ts`、`frontend/tests/rectification-agentic-entry.test.ts`、`frontend/tests/rectification-spoken-answer.test.ts`。
|
||||
- 防复发:禁止在 `runV9AgentTurn` 里用 `splitRectificationSpokenAndThinking` 决定 `answer.delta`。禁止用 Case 模板替换已有模型正文。禁止把 `thinking.delta` 发给纠正公开流或写成用户可见思考块。
|
||||
- 相关记录:BUG-357、BUG-368、BUG-373、BUG-374、BUG-376
|
||||
- 复发自:BUG-376(继续加正则切规划;随后一度误用服务器叙述顶替模型回复)
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-376 | 生时纠正再次把中文规划写进回复:账本/草稿/探针/我应该
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-25
|
||||
- 最近更新:2026-08-25
|
||||
- 影响面:`POST /api/rectification/agent`、`splitRectificationSpokenAndThinking`、生时纠正聊天气泡
|
||||
- 用户现象:工具阶段(读取校正记录、整理证据、比较候选)之后,回复气泡里先出现大段自我规划:账本几件已确认、草稿待确认、当前探针、current_question、我应该继续访谈,最后才是对用户说的话。有一轮整段都是规划,没有口语。
|
||||
- 触发条件:纠正组 `thinking: disabled`;公开流不发 `thinking.delta`。模型把中文规划写进最终 `text-delta`。句式不在 BUG-374 漏检网里。
|
||||
- 根因:漏检网只认上一轮字段名(occupation_note / candidate_contrast_packet / 这意味着)。新规划改用「账本 / 草稿 / 探针 / 用户后面 / 我应该 / 根据规则 / current_question」。整段匹配失败后被当成口语并落盘。界面还把拆出的 thinkingText 渲成灰色思考块。
|
||||
- 修复:纠正聊天不再渲染思考正文,只保留阶段进度和口语。漏检网补上账本/草稿/探针等句式,仅用于旧 Turn 水合,不得当直播分流。直播根因由 BUG-377 处理:打开 hidden thinking,用户可见回复用模型 `text-delta`。不改 Skill `10.0.11`。
|
||||
- 验证:`frontend/tests/rectification-spoken-answer.test.ts`、`frontend/tests/rectification-v9-stream.test.ts`、`frontend/tests/rectification-agentic-entry.test.ts`。
|
||||
- 防复发:纠正 UI 不得把 `thinkingText` 当作用户可见输出。直播路径不得再靠加词表从正文切思考。规划必须走 `reasoning-delta`。
|
||||
- 相关记录:BUG-357、BUG-368、BUG-373、BUG-374、BUG-377
|
||||
- 复发自:BUG-374(关 thinking 后用正则从正文切思考;句式换了就漏)
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-375 | 生时纠正分不开时 A/B/C/D 出不来,职业回答不计分,「没有了」不停问
|
||||
|
||||
- 状态:resolved
|
||||
|
||||
@@ -52,10 +52,7 @@ import {
|
||||
stableChoiceActionKey,
|
||||
type ChoiceOptionId,
|
||||
} from "@/lib/rectification-agentic/v9/choice-action";
|
||||
import {
|
||||
finalizeRectificationSpokenAndThinking,
|
||||
settleRectificationSpokenAndThinking,
|
||||
} from "@/lib/rectification-agentic/v9/spoken-answer";
|
||||
import { finalizeRectificationSpokenAndThinking } from "@/lib/rectification-agentic/v9/spoken-answer";
|
||||
import {
|
||||
CHOICE_STOP_MESSAGE,
|
||||
choiceCardUserMessage,
|
||||
@@ -229,11 +226,9 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
|
||||
if (failed && !raw) return [];
|
||||
if (isIncompleteRunBanner(raw)) return [];
|
||||
const split = raw ? finalizeRectificationSpokenAndThinking(raw) : { thinking: "", spoken: raw };
|
||||
const thinkingText = split.thinking.trim() || undefined;
|
||||
return [{
|
||||
role: "assistant",
|
||||
text: split.spoken,
|
||||
...(thinkingText ? { thinkingText } : {}),
|
||||
renderKey: key,
|
||||
state: turn.status === "completed" || failed ? "settled" : "thinking",
|
||||
completedReceipt: completedReceiptFromPersisted(turn.receipt),
|
||||
@@ -449,7 +444,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
setDraft("");
|
||||
|
||||
let raw = "";
|
||||
let thinkingRaw = "";
|
||||
let activityTrace: readonly AgentActivityTraceItem[] = emptyActivityTrace();
|
||||
let activityReceiptState = createRectificationActivityReceiptState();
|
||||
let completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
|
||||
@@ -528,14 +522,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
if (event.type === "answer.delta" && typeof event.text === "string") {
|
||||
raw += event.text;
|
||||
activityTrace = freezeLiveThink(activityTrace);
|
||||
const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw);
|
||||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||||
? {
|
||||
...message,
|
||||
text: settled.spoken,
|
||||
thinkingText: settled.thinking.trim() || undefined,
|
||||
text: raw,
|
||||
activityTrace,
|
||||
state: settled.spoken ? "streaming" : "thinking",
|
||||
state: raw.trim() ? "streaming" : "thinking",
|
||||
activity: nextActivityView(message.activity, {
|
||||
phase: "answer-composition",
|
||||
label: "正在组织回答…",
|
||||
@@ -555,7 +547,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
: message));
|
||||
} else if (event.type === "attempt.reset") {
|
||||
raw = "";
|
||||
thinkingRaw = "";
|
||||
activityTrace = emptyActivityTrace();
|
||||
activityReceiptState = createRectificationActivityReceiptState();
|
||||
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
|
||||
@@ -639,8 +630,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw);
|
||||
const parsed = completed && !streamFailed ? parseAgentReply(settled.spoken) : { text: "", title: undefined };
|
||||
const parsed = completed && !streamFailed ? parseAgentReply(raw) : { text: "", title: undefined };
|
||||
const succeeded = completed && !streamFailed && Boolean(parsed.text);
|
||||
setMessages((current) => current.flatMap((message): RenderMessage[] => {
|
||||
if (message.renderKey !== assistantRenderKey) return [message];
|
||||
@@ -648,7 +638,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
return [{
|
||||
...message,
|
||||
text: parsed.text,
|
||||
thinkingText: settled.thinking.trim() || undefined,
|
||||
activityTrace: completeActivityTrace(activityTrace),
|
||||
state: "settled",
|
||||
completedReceipt,
|
||||
@@ -657,11 +646,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
activity: undefined,
|
||||
}];
|
||||
}
|
||||
if (streamFailed || hasActivityReceipt(completedReceipt) || settled.spoken.trim()) {
|
||||
if (streamFailed || hasActivityReceipt(completedReceipt) || raw.trim()) {
|
||||
return [{
|
||||
...message,
|
||||
text: settled.spoken,
|
||||
thinkingText: settled.thinking.trim() || undefined,
|
||||
text: raw,
|
||||
activityTrace: completeActivityTrace(activityTrace),
|
||||
state: "settled",
|
||||
completedReceipt,
|
||||
@@ -671,7 +659,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
return [];
|
||||
}));
|
||||
if (!succeeded && settled.spoken.trim()) {
|
||||
if (!succeeded && raw.trim()) {
|
||||
setError((current) => current || "回答未完成,已保留现有内容;本次不会扣点。");
|
||||
} else if (!succeeded && runFailedMessage) {
|
||||
setError((current) => current || runFailedMessage);
|
||||
@@ -691,14 +679,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
? caught.name === "AbortError"
|
||||
: caught instanceof Error && caught.name === "AbortError";
|
||||
if (aborted) {
|
||||
const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw);
|
||||
setMessages((current) => current.flatMap((message): RenderMessage[] => {
|
||||
if (message.renderKey !== assistantRenderKey) return [message];
|
||||
if (settled.spoken.trim() || settled.thinking.trim() || hasActivityReceipt(completedReceipt)) {
|
||||
if (raw.trim() || hasActivityReceipt(completedReceipt)) {
|
||||
return [{
|
||||
...message,
|
||||
text: settled.spoken,
|
||||
thinkingText: settled.thinking.trim() || undefined,
|
||||
text: raw,
|
||||
activityTrace: completeActivityTrace(activityTrace),
|
||||
state: "settled",
|
||||
completedReceipt,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* V10 rectification turn runner over the durable V9 Case/Evidence domains.
|
||||
*
|
||||
* Tool activity and answer tokens are published as they happen so the browser
|
||||
* can render progress. A retried attempt emits `attempt.reset` first so the
|
||||
* client discards the abandoned attempt's visible text. Durable receipts,
|
||||
* billing, and settled history still come only from the successful attempt.
|
||||
* Tool activity is published as it happens so the browser can render stages.
|
||||
* The user-visible reply is the model's terminal `text-delta`. Provider
|
||||
* thinking stays on `reasoning-delta` and is not a public stream event.
|
||||
* Server narration is only the empty-stream fallback after tools. A retried
|
||||
* attempt emits `attempt.reset` first so the client discards the abandoned
|
||||
* attempt. Durable receipts, billing, and settled history still come only
|
||||
* from the successful attempt.
|
||||
*/
|
||||
import type { Agent } from "@mastra/core/agent";
|
||||
import { RectificationAgentAction, resolveRectificationStepBudget } from "@/mastra/agentic-rectification";
|
||||
@@ -35,7 +38,6 @@ import {
|
||||
isPublicRectificationToolName,
|
||||
type PublicStreamEvent,
|
||||
} from "./stream-mapping";
|
||||
import { splitRectificationSpokenAndThinking } from "./spoken-answer";
|
||||
import { mapModelFinishToErrorCode, userFacingRunFailure } from "./run-diagnostic";
|
||||
import {
|
||||
applyStepAnswerChunk,
|
||||
@@ -575,8 +577,9 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
emittedKeys.add("event:skill.bound::");
|
||||
|
||||
const generation = agentGenerationSettings(options.generationModel, {
|
||||
thinking: "disabled",
|
||||
thinking: "enabled",
|
||||
answerTokens: 8_192,
|
||||
thinkingTokens: 8_192,
|
||||
});
|
||||
const result = await (agent as unknown as {
|
||||
stream(
|
||||
@@ -628,7 +631,9 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
const stepAnswer = createStepAnswerState();
|
||||
|
||||
const publishSpokenStep = async (pieces: readonly string[]) => {
|
||||
const spoken = splitRectificationSpokenAndThinking(pieces.join("")).spoken.trim();
|
||||
// The model's terminal text-delta is the user-visible reply. Do not
|
||||
// regex-split it, and do not replace it with Case narration.
|
||||
const spoken = pieces.join("").trim();
|
||||
if (!spoken || !caseLoaded) return;
|
||||
answerText += spoken;
|
||||
answerDeltas.push(spoken);
|
||||
@@ -732,7 +737,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
const flushed = flushStepAnswerOnStreamFinish(stepAnswer, flushReason);
|
||||
if (flushed.kind === "publish") await publishSpokenStep(flushed.pieces);
|
||||
}
|
||||
answerText = splitRectificationSpokenAndThinking(answerText).spoken.trim();
|
||||
|
||||
if (!skillBound) return failedAttempt(attemptId, "skill_not_loaded");
|
||||
if (!caseLoaded) return failedAttempt(attemptId, "case_not_loaded");
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/**
|
||||
* Legacy and leak-net helpers for rectification replies.
|
||||
*
|
||||
* Live thinking comes from provider `reasoning-delta`. This module only:
|
||||
* - recovers old turns that stored process talk in `assistant_message`
|
||||
* - drops process-only `text-delta` leaks so they cannot complete as the reply
|
||||
* Hydrate recovery for old Turns that stored process talk in
|
||||
* `assistant_message`. Live `answer.delta` is the model text as-is; the
|
||||
* runner and chat must not call these helpers on the live stream.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -12,9 +10,11 @@ import {
|
||||
} 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|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(?:_packet)?|choice_frame|deferred_followup)\b/;
|
||||
const PROCESS_ZH_RE = /skill\s*规则|不得猜补|让我(?:调用|记录|batch|提交|继续|用)|我(?:决定|倾向|batch|需要用|需要继续|继续收集|继续访谈|自然地|用自然语言)|权衡:|内部矛盾|思维链|调用 batch|批量工具|写入(?:这些)?证据|datePrecision|occurredFrom|occurredTo|方法覆盖|方法资料已齐|还不能出牌|不得出牌|不得\s*offer|本轮对照了|这意味着|服务器给了|第.{0,4}条边界|不可分宽度|重新计算了候选|带评分日期|当前还应继续收集|根据 method_followup/;
|
||||
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|current_question|current_probe|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(?:_packet)?|choice_frame|deferred_followup|resolve-focus|questionId|active[\s_-]?focus)\b/i;
|
||||
const PROCESS_ZH_RE = /skill\s*规则|不得猜补|让我(?:调用|记录|batch|提交|继续|用|看看|自然)|我(?:决定|倾向|batch|需要用|需要继续|需要考虑|继续收集|继续访谈|继续自然|自然地|用自然语言|应该|认为|可以尝试|不能把|直接自然|确认理解|先向用户)|权衡:|内部矛盾|思维链|调用 batch|批量工具|写入(?:这些)?证据|datePrecision|occurredFrom|occurredTo|方法覆盖|方法资料已齐|还不能出牌|不得出牌|不得\s*offer|本轮对照了|这意味着|服务器给了|第.{0,4}条边界|不可分宽度|重新计算了候选|带评分日期|当前还应继续收集|根据 method_followup|根据规则|规则要求|不调用工具|严格来说|实际上规则|也许我应该|当前探针|这回应的是当前探针|账本|草稿|quote 路径|写入需要日期精度|没有具体日期|先确认草稿|纠缠草稿|自然访谈|待确认状态/;
|
||||
const THIRD_PERSON_USER_RE = /用户/;
|
||||
const AGENT_SELF_RE = /我(?:应该|认为|可以|先|需要|不能|直接)|让我|也许我/;
|
||||
const ADDRESSES_USER_RE = /你|您|记下了|已经记下|已记录了|已收到|不用急|别担心|哪一年|有没有|哪件|大概年份|对吗|是不是|请你/;
|
||||
|
||||
const ACTIVITY_ECHO_LABELS = [
|
||||
...Object.values(RECTIFICATION_TOOL_DONE_LABELS),
|
||||
@@ -43,6 +43,7 @@ export function isRectificationProcessNarration(text: string): boolean {
|
||||
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;
|
||||
if (AGENT_SELF_RE.test(trimmed) && !ADDRESSES_USER_RE.test(trimmed)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -91,9 +92,17 @@ export function splitRectificationSpokenAndThinking(text: string): SplitSpokenAn
|
||||
}
|
||||
|
||||
if (thinking.length === 0) return { thinking: "", spoken: text };
|
||||
const userFacing: string[] = [];
|
||||
for (const paragraph of spoken) {
|
||||
if (ADDRESSES_USER_RE.test(paragraph) || /[??]/.test(paragraph) || paragraph.length > 60) {
|
||||
userFacing.push(paragraph);
|
||||
} else {
|
||||
thinking.push(paragraph);
|
||||
}
|
||||
}
|
||||
return {
|
||||
thinking: thinking.join("\n\n"),
|
||||
spoken: spoken.join("\n\n"),
|
||||
spoken: userFacing.join("\n\n"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑
|
||||
3. 事实只能来自用户原话;不得虚构或补全事件、日期、人物关系、动机、分盘、评分、候选或出生分钟。日期精度按用户真实表达保留。复述事件必须使用服务器返回的 display_date_label;禁止把日级说成“年份已确定为 YYYY”。用户确认“是 / 对”不得改 date_precision。
|
||||
4. 工具只传最小引用。拒答和修订必须引用服务器返回且仍 active 的 focusId/evidenceId;用户对已有 pending 说“对/是”时可省略 focusId。无法唯一指向时只做简短澄清,不得猜测。
|
||||
5. candidate、accepted、confirmed 严格分离。Agent 不控制 billing、ownership、profile 写入、不可逆状态,也不得授予 exact-minute confirmation。
|
||||
6. 工具执行过程保持静默。思考过程必须用简体中文,只写在思维链里:可以说你在核对哪类经历,禁止写工具名、错误码、参数、内部 ID、评分或密钥。正文像正常人说话,不写“本轮做了什么”,不描述 Skill、Case、Dossier、工具、内部 Activity、参数、错误或推理过程;完成凭证完全由服务端公开 Activity/receipt 展示。
|
||||
6. 工具执行过程保持静默。思考过程必须用简体中文,只写在思维链里:可以说你在核对哪类经历,禁止写工具名、错误码、参数、内部 ID、评分或密钥。对用户说的话必须自己写在正文里,不要只写规划等服务器代写。正文像正常人说话,不写“本轮做了什么”,不描述 Skill、Case、Dossier、工具、内部 Activity、参数、错误或推理过程;完成凭证完全由服务端公开 Activity/receipt 展示。
|
||||
7. 只基于成功 attempt 输出正文。工具失败时说明面向用户的边界,不声称未执行的方法或结果。
|
||||
8. 当前轮新事件一律走 rectification-record-evidence-batch(一件也可以)。优先传 source 原文的 quoteStart/quoteEnd,不要改写 quote。rectification-confirm-evidence 只用于用户对已有 pending 明确说“对/是”。不得要求用户把已说清的事件再发一遍。
|
||||
9. 不得在同一回复中一边要求继续补证据,一边提供候选采用。落实 next_user_action:id=verify_adopted_time 时本轮只核一件前事,A 走 batch 并 compare,C 关闭该问,不要 offer 也不要 start_consultation。id=start_consultation 时请用户用当前采用时间看盘,对不上同时请改选其他候选。id 不是 adopt_representative 时不得调用 rectification-offer-candidates,也不得请用户采用。selection_allowed 只表示可以采用代表性时间,不是本轮必须出示卡片;propose_allowed 才是提出门。挡住出牌的方法层未齐时,source=event_probe 的冲突前事继续问并挡住出牌。方法覆盖已齐只进入候选区分,不等于 adopt。无日期 occupation_note 算职业已覆盖,不要再问职业,也不要因它出牌。id=ask_candidate_discriminator 或 session_outcome=discriminate_candidates 时按 candidate_contrast_packet / next_followup 问一件能拆开候选的前事,不得 offer。id=ask_holdout_validation 时做盘外核对,不得 offer。id=offer_provisional_range 时说明并列可信区间,不要称某分钟为当前推荐。accepted_time 为空且 session_outcome=adopt_representative 或 next_user_action.id=adopt_representative 时本轮结果是采用代表性时间,不要再问 next_followup;正文必须说本会话以代表性时间收口,不确认唯一分钟。unique_minute_path=closed_at_representative 时不得调用 confirm,不得把唯一分钟确认当下一步。用户说“暂时想不到了 / 没有更多 / 没有了 / 没了 / 没有其它 / 想不起来了 / 先这样”时改走 on_user_stop:账本为空则把已说的带日期经历 batch 写入再比较,有事件无结果则本轮 compare,已有代表性结果且尚未采用则解释、调用 offer-candidates 并请采用下方时间卡片,已采用则按 on_user_stop 看盘或改选。禁止只说记下了、会话会保留、以后再继续。出牌/采用轮把工具返回的 skill_verification_report 写入正文:筛选窗、事件–Dasha–Gochara 表、D9/D10 类型对照、六亲六步、职业类型表、占问 observation_only、文末技法审计表。80%/60% 只描述事件吻合率,不得写成已确认唯一出生分钟,也不得写成候选已经分开。确认门以 latest_result.confirmation_gate 为准;not_evaluated 不是 fail;官方分钟层 passed 仍不能单独打开确认门;holdout 为 not_ready 时 unique_minute_path 必须是 closed_at_representative,不得声称精确分钟或发布准确率。若宽度大于 5 或 confirmation_allowed 为 false,必须说这是一段不可分区间,把代表分钟称为代表性候选,不得说已定位到唯一分钟。候选未拉开时不得出示赢家卡;D9/D10 差异和精度阶段追问要用来区分,不得直接宣布不可分。用户仍可 accepted 代表性候选。
|
||||
|
||||
@@ -84,7 +84,7 @@ 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(chat, /finalizeRectificationSpokenAndThinking/);
|
||||
assert.match(chat, /settleRectificationSpokenAndThinking/);
|
||||
assert.doesNotMatch(chat, /settleRectificationSpokenAndThinking/);
|
||||
assert.match(chat, /text: split\.spoken,/);
|
||||
assert.doesNotMatch(chat, /split\.spoken \|\| raw/);
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.length > 0 \? "ready" : "loading"\}`\}/);
|
||||
@@ -238,12 +238,14 @@ test("usage completes or releases without hiding settlement failures", () => {
|
||||
assert.match(run, /billing\.complete\(/);
|
||||
assert.match(run, /billing\.release\(/);
|
||||
assert.match(run, /usage_settlement_failed/);
|
||||
assert.match(run, /thinking: "disabled"/);
|
||||
assert.match(run, /thinking: "enabled"/);
|
||||
assert.match(run, /answerTokens: 8_192/);
|
||||
assert.match(run, /thinkingTokens: 8_192/);
|
||||
assert.match(run, /activity.changed/);
|
||||
assert.match(run, /splitRectificationSpokenAndThinking/);
|
||||
assert.match(run, /if \(!answerText\.trim\(\)\) \{[\s\S]*composeRectificationTurnNarration/);
|
||||
assert.doesNotMatch(run, /splitRectificationSpokenAndThinking/);
|
||||
assert.match(run, /emit\(event: PublicStreamEvent\): Promise<void> \| void;/);
|
||||
assert.match(run, /agentGenerationSettings\(options\.generationModel, \{[\s\S]*thinking: "disabled"/);
|
||||
assert.match(run, /agentGenerationSettings\(options\.generationModel, \{[\s\S]*thinking: "enabled"/);
|
||||
assert.match(route, /featureKey: "rectification"/);
|
||||
assert.match(route, /rectification:case:\$\{caseId\}/);
|
||||
});
|
||||
@@ -321,6 +323,8 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr
|
||||
assert.match(chat, /回答未完成,已保留现有内容;本次不会扣点/);
|
||||
assert.doesNotMatch(chat, /reasoning-delta|chain-of-thought/);
|
||||
assert.doesNotMatch(chat, /event.type === "thinking.delta"/);
|
||||
assert.doesNotMatch(chat, /thinkingText: settled\.thinking/);
|
||||
assert.doesNotMatch(chat, /\.\.\.\(thinkingText \? \{ thinkingText \}/);
|
||||
assert.match(chat, /event.type === "activity.changed"/);
|
||||
assert.match(chat, /userFacingRunFailure/);
|
||||
assert.doesNotMatch(chat, /本轮处理未完成,已保留服务端记录的执行进度/);
|
||||
@@ -539,11 +543,17 @@ test("rectification keeps the composer but never renders generated suggestion ch
|
||||
});
|
||||
|
||||
test("does not parse suggestions from an incomplete run", () => {
|
||||
assert.match(chat, /completed && !streamFailed \? parseAgentReply/);
|
||||
assert.doesNotMatch(chat, /parseAgentReply\(raw\)/);
|
||||
assert.match(chat, /completed && !streamFailed \? parseAgentReply\(raw\)/);
|
||||
assert.doesNotMatch(chat, /parseAgentReply\(partial/);
|
||||
});
|
||||
|
||||
test("live answer.delta is the model reply, not a spoken-thinking split", () => {
|
||||
assert.match(chat, /raw \+= event\.text/);
|
||||
assert.match(chat, /text: raw,/);
|
||||
assert.doesNotMatch(chat, /settleRectificationSpokenAndThinking/);
|
||||
assert.doesNotMatch(chat, /text: settled\.spoken/);
|
||||
});
|
||||
|
||||
test("does not auto-submit a suggestion during render or recovery", () => {
|
||||
assert.match(chat, /origin: "typed"/);
|
||||
assert.match(chat, /origin: "choice_click"/);
|
||||
@@ -571,6 +581,7 @@ test("rectification Agent output stays natural and keeps tool execution silent",
|
||||
);
|
||||
assert.match(agent, /工具执行过程保持静默/);
|
||||
assert.match(agent, /思考过程必须用简体中文/);
|
||||
assert.match(agent, /对用户说的话必须自己写在正文里,不要只写规划等服务器代写/);
|
||||
assert.match(agent, /本轮做了什么/);
|
||||
assert.match(agent, /完成凭证完全由服务端公开 Activity\/receipt 展示/);
|
||||
assert.match(agent, /禁止只说记下了、会话会保留、以后再继续/);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* These helpers recover old leaked Turns on hydrate. They are not the live
|
||||
* spoken-answer classifier; `runV9AgentTurn` must not call them.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
@@ -137,6 +141,57 @@ test("planning about candidate_contrast_packet stays out of the spoken answer",
|
||||
assert.doesNotMatch(settled.spoken, /candidate_contrast_packet/);
|
||||
});
|
||||
|
||||
test("draft-ledger planning stays out of the spoken answer", () => {
|
||||
const processTalk = [
|
||||
"第二件离职事件已记录为草稿。现在账本有两件已确认加一件草稿。",
|
||||
"既然实习开始和实习离职都属于同一段职业经历并且相关联,用户后面的消息会继续补充,我先向用户确认这件离职信息,同时继续收集。",
|
||||
"由于离职是事务性的独立事件且 quote 路径有障碍,我继续自然访谈。",
|
||||
"先确认草稿再继续收集下一件。",
|
||||
"这里草稿是服务器接受但待确认状态。",
|
||||
"我应该继续访谈而不是纠缠草稿确认。",
|
||||
].join("\n\n");
|
||||
const spoken = [
|
||||
"好的,已经记下你这段职业经历:那年春天开始实习、秋天离职。加上之前上学,目前有了比较清晰的两大块时间线。",
|
||||
"不用急着回忆全部。毕业后正式入职的第一份工作,大概哪一年?有没有恋爱、结婚这样的关系节点?哪一件印象深就先说哪件,大概年份就好。",
|
||||
].join("\n\n");
|
||||
|
||||
const split = splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`);
|
||||
assert.equal(split.spoken, spoken);
|
||||
assert.match(split.thinking, /账本/);
|
||||
assert.match(split.thinking, /我应该继续访谈/);
|
||||
assert.match(split.thinking, /quote 路径/);
|
||||
assert.doesNotMatch(split.spoken, /账本/);
|
||||
assert.doesNotMatch(split.spoken, /我应该/);
|
||||
assert.doesNotMatch(split.spoken, /草稿是服务器/);
|
||||
assert.equal(isRectificationProcessNarration("我应该继续访谈而不是纠缠草稿确认。"), true);
|
||||
assert.equal(isRectificationProcessNarration(spoken.split("\n\n")[0]!), false);
|
||||
});
|
||||
|
||||
test("probe-deliberation with no user-facing close stays off the spoken answer", () => {
|
||||
const processTalk = [
|
||||
"这回应的是当前探针(感情前事,用于对照 D9 差异)。",
|
||||
"当前探针是请核对一段还没用进评分的感情前事。用户给出的是一个主观感受,不是具体带日期的事件。",
|
||||
"让我看看是否应该把它作为一条证据记录。",
|
||||
"严格来说,用户没有给出有确定日期的新事件。根据规则,没有具体带日期事件就按 focus 处理。",
|
||||
"current_question 为 null,但 current_probe 有一条。",
|
||||
"也许我应该直接自然回应,不调用工具。规则要求承接。",
|
||||
"我认为这里不需要写证据。写入需要日期精度。",
|
||||
"我直接自然问。",
|
||||
].join("\n\n");
|
||||
|
||||
const split = splitRectificationSpokenAndThinking(processTalk);
|
||||
assert.equal(split.spoken, "");
|
||||
assert.match(split.thinking, /当前探针/);
|
||||
assert.match(split.thinking, /current_question/);
|
||||
assert.match(split.thinking, /我直接自然问/);
|
||||
assert.equal(finalizeRectificationSpokenAndThinking(processTalk).spoken, "");
|
||||
|
||||
const settled = settleRectificationSpokenAndThinking(processTalk, "");
|
||||
assert.equal(settled.spoken, "");
|
||||
assert.doesNotMatch(settled.spoken, /探针/);
|
||||
assert.doesNotMatch(settled.spoken, /根据规则/);
|
||||
});
|
||||
|
||||
test("leaked process text on the answer channel is not mixed into native thinking", () => {
|
||||
const processTalk = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户";
|
||||
const spoken = "记下了升学这两件。接下来有没有一件带大概年份的工作变化?";
|
||||
|
||||
@@ -70,6 +70,7 @@ test("system prompt carries only high-priority boundaries, never the method copy
|
||||
assert.match(prompt, /禁止只说记下了/);
|
||||
assert.match(prompt, /工具执行过程保持静默/);
|
||||
assert.match(prompt, /思考过程必须用简体中文/);
|
||||
assert.match(prompt, /对用户说的话必须自己写在正文里,不要只写规划等服务器代写/);
|
||||
assert.match(prompt, /skill_verification_report/);
|
||||
assert.match(prompt, /ask_candidate_discriminator/);
|
||||
assert.match(prompt, /方法覆盖已齐只进入候选区分/);
|
||||
@@ -308,8 +309,8 @@ test("server-loaded Skill is bound before the provider and the first model step
|
||||
assert.ok(
|
||||
((await observedStreamOptions.prepareStep?.({ stepNumber: 1 })) as { activeTools?: string[] }).activeTools?.includes("rectification-read-case"),
|
||||
);
|
||||
assert.equal(observedStreamOptions.modelSettings?.maxOutputTokens, 8192);
|
||||
assert.deepEqual(observedStreamOptions.providerOptions?.openai, { thinking: { type: "disabled" } });
|
||||
assert.equal(observedStreamOptions.modelSettings?.maxOutputTokens, 16_384);
|
||||
assert.deepEqual(observedStreamOptions.providerOptions?.openai, { thinking: { type: "enabled" } });
|
||||
assert.equal(emitted.filter((event) => event.type === "skill.bound").length, 1);
|
||||
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
|
||||
assert.equal(
|
||||
|
||||
@@ -1301,7 +1301,7 @@ test("does not publish intermediate tool-step text as answer.delta", async () =>
|
||||
assert.equal(emitted.some((event) => event.type === "thinking.delta"), false);
|
||||
});
|
||||
|
||||
test("Chinese interview planning in the terminal text-delta is not the spoken answer", async () => {
|
||||
test("Chinese interview planning stays on reasoning-delta; the spoken answer is the model text-delta", async () => {
|
||||
const spoken = "职业类型已经记下。接下来想请你回想一下这份工作的时间段——**你大概是在哪一年入职的?**只要个大概年份就行。";
|
||||
const { options, emitted } = runOptions({
|
||||
buildAgent: async () => fakeAgentStream([
|
||||
@@ -1312,16 +1312,14 @@ test("Chinese interview planning in the terminal text-delta is not the spoken an
|
||||
chunk("tool-result", { toolName: "rectification-read-case" }),
|
||||
chunk("tool-call", { toolName: "rectification-compare-candidates", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", { toolName: "rectification-compare-candidates" }),
|
||||
chunk("text-delta", {
|
||||
chunk("reasoning-delta", {
|
||||
text: [
|
||||
"读取校正记录",
|
||||
"比较候选时间",
|
||||
"方法覆盖上,职业这一层已经通过 occupation_note 补齐了(不计分)。",
|
||||
"让我继续访谈,问一件能帮助区分候选的职业前事。",
|
||||
"本轮对照了Gochara、D1 本命盘、D10 事业分盘。",
|
||||
spoken,
|
||||
].join("\n\n"),
|
||||
}),
|
||||
chunk("text-delta", { text: spoken }),
|
||||
chunk("finish"),
|
||||
]) as never,
|
||||
});
|
||||
@@ -1336,6 +1334,31 @@ test("Chinese interview planning in the terminal text-delta is not the spoken an
|
||||
assert.doesNotMatch(publicText, /occupation_note/);
|
||||
assert.doesNotMatch(publicText, /让我继续访谈/);
|
||||
assert.doesNotMatch(publicText, /本轮对照了/);
|
||||
assert.equal(emitted.some((event) => event.type === "thinking.delta"), false);
|
||||
});
|
||||
|
||||
test("model terminal text-delta is the reply even when Case narration could be composed", async () => {
|
||||
const spoken = "职业已经记下。你入职大概是哪一年?说个年份就行。";
|
||||
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("text-delta", { text: spoken }),
|
||||
chunk("finish"),
|
||||
]) as never,
|
||||
});
|
||||
const result = await runV9AgentTurn(options);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.answerText, spoken);
|
||||
assert.deepEqual(
|
||||
emitted.filter((event) => event.type === "answer.delta"),
|
||||
[{ type: "answer.delta", text: spoken }],
|
||||
);
|
||||
assert.doesNotMatch(result.answerText, /已经记下:/);
|
||||
assert.doesNotMatch(result.answerText, /2016年9月离家去北京开始工作/);
|
||||
});
|
||||
|
||||
test("does not reset the whole attempt after duplicate_focus", async () => {
|
||||
|
||||
Reference in New Issue
Block a user