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
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-09-08 — 生时校正每一轮只说一段旁白,题目只出现在问题块
生时校正记下你刚说的事后,助手气泡里只留一段说明。下一问只出现在下面的问题块,不会在正文里再问一遍。Skill 版本仍是 10.0.15。
## 2026-09-08 — 七个领域问完后是职业题或区间卡,不再补问「再说一件」
生时校正把学业、感情、事业、家人、财务、迁居、健康问过或跳过后,下一问是「你平时主要做什么工作?」,或者直接出区间交付卡。不会再出现「也可以再说一件你记得大概时间的事」。Skill 版本仍是 10.0.15。
+34 -2
View File
@@ -7517,7 +7517,7 @@
- 修复:不再渲染采集题槽。最新助手气泡按题干精确身份 `composeCollectSpokenAssistantText`;定稿后的 Case snapshot、发送下一条、以及挂载后的 snapshot 回调把组合文本写进该条消息状态,复制走组合文本。选择卡仍走问题槽。模型仍不自己提问,避免气泡里两句相近问法。不 bump Skill,不放宽确认门。
- 验证:`rectification-spoken-collect` 锁气泡拼接、禁止 `rectification-question-slot__prompt`;既有 collect-prompt / v9-agent / regenerate / voice 锁保持。staging 门禁 run 2304 因 `showActivity={bubbleMessage.state…}` 对不上 `rectification-agentic-entry` 源码锁失败;`showActivity` 改回 `displayedMessage.state`(气泡仍走 `bubbleMessage`)。run 2305 前端 2509/2509 后 ESLint 在 `currentQuestionRef.current = currentQuestion`render)和 snapshot `setMessages` effect 上报 `react-hooks/refs` / `react-hooks/set-state-in-effect`ref 改到 `useLayoutEffect`,题干写入改到 snapshot/send 回调。
- 防复发:口述采集题干不得作为动作图标下的兄弟节点。不得用正文字符串判断「有没有问过」。不得同时让模型提问又拼接同一句服务端题干。
- 相关记录:BUG-471、BUG-485、BUG-487
- 相关记录:BUG-471、BUG-485、BUG-487、BUG-585
- 复发自:BUG-487(服务端拼接后槽位兜底仍可见)
- 修复版本:待发布
@@ -8241,7 +8241,7 @@
- 修复:`rectification-set-focus` 的 call/result 不再收回已播出正文。读诊断、记证据仍收回。
- 验证:`rectification-step-answer`:打招呼 + set-focus 为 `none`,其后短句仍 `live`read-diagnostics 仍 `retract`
- 防复发:set-focus 不得把同轮已播出的正文 replace 成空。不得把这个例外扩到 record/compare/diagnostics。
- 相关记录:
- 相关记录:BUG-584
- 复发自:无
- 修复版本:待发布
@@ -9046,6 +9046,38 @@
- 复发自:无(区间卡 `ab57d03f` 引入;任务书 4.2 验收未过)
- 修复版本:待发布(`codex/rectification-range-delivery-fix-20260907`
## BUG-584 | 同一轮采集旁白说两遍
- 状态:resolved
- 首次发现:2026-09-07
- 最近更新:2026-09-08
- 影响面:`applyStepAnswerChunk``KEEP_LIVE_SPOKEN_TOOLS``publishSpokenStep``POST /api/rectification/agent`
- 用户现象:每一轮采集助手气泡里有两段近似旁白。第一段已经承接用户刚说的事,第二段换个说法再讲一遍。
- 触发条件:模型在 `rectification-set-focus` 前写出正文,拿到工具结果后再写一段。
- 根因:BUG-533 让 set-focus 不撤回已直播正文。set-focus 之后下一个 step 的正文仍按 `shouldPublishStepText` 发布;`publishSpokenStep` 把两段无分隔拼进 `assistant_message`
- 修复:set-focus 结果后置 `stemAttached`。本轮已经发布过正文则丢掉后一段;尚未发布则仍放行开场问候。set-focus 调用时把未直播的中文半句 `publish` 出去,英文规划稿仍 discard。
- 验证:`frontend/tests/rectification-step-answer.test.ts``frontend/tests/rectification-v9-agent.test.ts` 文本→set-focus→文本只保留第一段。
- 防复发:set-focus 之后不得再拼接第二段正文。开场仍允许「先问候、后 set-focus」或「只在 set-focus 后说一句」。不得把这个例外扩到 record/compare/diagnostics。
- 相关记录:BUG-533
- 复发自:无(533 的放行例外把第二段也留下了)
- 修复版本:待发布(`codex/rectification-duplicate-narration-20260907`
## BUG-585 | 题干在正文和问题块各出现一次
- 状态:resolved
- 首次发现:2026-09-07
- 最近更新:2026-09-08
- 影响面:`stripQuestionSentences``attachQuestionsToTurns``runV9AgentTurn``RECTIFICATION_USER_COPY.collectHandoff`
- 用户现象:旁白末尾已经用自然语言问了下一题,问题块再显示同一句服务端题干。
- 触发条件:本轮 set-focus 成功,模型把问句写进正文;`detachCollectSpokenAssistantText` 只剥逐字后缀。
- 根因:不让模型提问的约束只在提示词。BUG-488 防复发写「不得同时让模型提问又拼接同一句服务端题干」,`d9404976` 把题干改成消息内问题块后没有代码守卫。`collectSpokenEmitted` 是从未使用的常量。
- 修复:有本轮 focus 时,`answer.composed` 前按句删掉与题干相同、题干前 12 字开头、或以问号结尾的句子;剪空则用 `collectHandoff`。变化时 `answer.delta replace:true`。历史回看同样走 `stripQuestionSentences`。删除 `collectSpokenEmitted`
- 验证:`frontend/tests/rectification-collect-prompt.test.ts``frontend/tests/rectification-v9-agent.test.ts``frontend/tests/agent-voice-copy-contract.test.ts`
- 防复发:有 focus 的已结算助手正文不得再出现以问号结尾的句子。不得靠提示词单独保证模型不问。不得恢复把题干拼进 `assistant_message`
- 相关记录:BUG-488、BUG-461、BUG-471、`d9404976`
- 复发自:BUG-488(防线只在提示词)
- 修复版本:待发布(`codex/rectification-duplicate-narration-20260907`
## BUG-586 | 七领域问完落到补问兜底句,职业题被覆盖、区间卡出不来
- 状态:mitigated
@@ -0,0 +1,41 @@
# PROGRESS · 同一轮旁白说两遍、题干出现两次(2026-09-07)
工作树:`.worktrees/rectification-duplicate-narration-20260907`
分支:`codex/rectification-duplicate-narration-20260907`
任务书:`docs/tasks/TASK-rectification-duplicate-narration-20260907.md`
基线:`origin/staging` @ `6c7a8b02`(任务书写 `f3e72702` / 代码头 `ab57d03f`;其后 `a00b069d` 已合入,tsc 不再报 `rectification-agentic-chat.tsx` L1655
本单状态:**待验收**。未 commit、未 push。
未改:提示词、Skill 版本(仍 10.0.15)、采用门、确认门、`MIN_SEPARATION_LEAD``_relative_support``minute_step=1``page.tsx`
并行:`codex/rectification-other-collect-fallback-20260908`BUG-586)也改 `user-copy.ts``agent-voice-copy-contract.test.ts`。本单只加 `collectHandoff`;谁后合谁 rebase。
## 做了什么
- **4.2 / BUG-585** `stripQuestionSentences`:按句删掉与题干相同、题干前 12 字开头、或以问号结尾的句子;问号后的非叙事尾巴一并丢掉。`composeCollectSpokenAssistantText` 改调它。agent-run 在 `answer.composed` 前复用 discriminator 那次 `loadV9CaseDossier`:本轮 focus 有 spokenPrompt 则剪,剪空用 `collectHandoff`,变化时 `replace:true``attachQuestionsToTurns` 同样剪。删除 `collectSpokenEmitted`
- **4.1 / BUG-584** `StepAnswerState` 增加 `stemAttached``publishedAny`,不随 `resetBuffers` 清。set-focus 之后若本轮已发布正文,后续 `text-delta` discard。
- **决策 3** set-focus `tool-call` 把未直播的中文半句 `publish`;无 CJK 的规划稿 discard。未后置为 P3。
## 三栏(被触碰断言)
| 用例 | 原值 | 新值 | 理由 |
| --- | --- | --- | --- |
| set-focus 后第二段正文 | `live` 发布 | `discard` | 决策 2,丢掉工具结果后的复述 |
| set-focus 前无正文、后有一段 | (原测试未覆盖) | 后段 `live` | BUG-533 开场仍通 |
| 无句末标点的中文半句 + set-focus | 静默丢掉 | `publish` | 决策 3 |
| 英文规划稿 + set-focus | 不直播 | `discard` | 维持原 discard |
| `collectSpokenEmitted` | 恒 `false` | 字段删除 | 决策 4 |
| 历史回看剥题干 | 只剥 `\n\n` 后缀 | `stripQuestionSentences` | 决策 1 |
## 测试
- `frontend` `./node_modules/.bin/tsc --noEmit`exit 0
- 任务书指定 TS 切片(`tests/rectification-*.test.ts` + `agent-voice-copy-contract.test.ts`,排除 database):**988 passed / 0 failed**
- Docker `database-rectification-*.test.ts`:本机未跑,记 blocked
- 浏览器真人走查:无登录态,记环境缺口,见 `docs/testing/rectification-scenarios-20260907.md` §6
## 偏离
1. 任务书点名的 `rectification-v9-agent-run*.test.ts` 不存在;agent-run 序列写在既有 `rectification-v9-agent.test.ts`
2. 问号后的「有年份就行。」按非叙事续句丢掉,否则验收 (a) 会留下这半句。
+1 -1
View File
@@ -72,7 +72,7 @@
| `TASK-rectification-stop-rescore-fix-20260907.md` | `PROGRESS-rectification-stop-rescore-fix-20260907.md` | **P0**:BUG-579 的停止路径用「裸重算」(`rescoreMinuteAfterWindowChange` 不重建 `inference_state`、不重放已答探针、不写 transition),停止后范围变宽、卡片变成引擎裸支持度、采用 RPC 报 `candidate_state_inconsistent`;无框区分题经 `spokenCollectFallbackFollowup` 变成 domain=other 的采集题,再次冒出开场句 `GENERIC_COLLECT_QUESTION`;holdout 采集排在带年月采集之前 | 已验收通过(逻辑);但同提交含区间卡 tsc 错,整体不可部署 | `ab57d03f`BUG-581582 |
| `TASK-rectification-range-delivery-card-20260907.md` | `PROGRESS-rectification-range-delivery-card-20260907.md` | 结尾重设计:四张分钟卡 + 相对支持度换成一张区间交付卡(范围、代表分钟、稳定/敏感主题、边界句)+ 只列未问 D9/D10/月宿分歧的 2~3 列对比面板(文案只取类型表),动作只有「按这个范围用 / 再补一件经历」;删交付旁白里三行预测窗口;Skill §9 口径改、bump 10.0.15 | **未通过**:tsc 1 错(判空顺序);P2 预测窗口句兑现不了;P3 再补经历无提示 | `ab57d03f`;修复单 `TASK-rectification-range-delivery-fix-20260907.md` |
| `TASK-rectification-range-delivery-fix-20260907.md` | `PROGRESS-rectification-range-delivery-fix-20260907.md` | 区间卡修复单:`candidateResult` 判空提到最前(tsc 阻断);预测窗口接到采用后首轮或删句;「再补一件经历」隐藏卡后给采集提示;进度记录须贴 tsc 原文 | 待验收 | `codex/rectification-range-delivery-fix-20260907`BUG-583 |
| `TASK-rectification-duplicate-narration-20260907.md` | `PROGRESS-rectification-duplicate-narration-20260907.md` | 每轮采集旁白说两遍 + 题干在正文与问题块各一次:`step-answer` 的 set-focus 例外(BUG-533)放行了 set-focus 前后两个 step 的正文并拼接;`detachCollectSpokenAssistantText` 只剥逐字后缀,模型写进正文的问句没有代码守卫(BUG-488 防线只在提示词) | 待执行 | `codex/rectification-duplicate-narration-20260907`BUG-584585 |
| `TASK-rectification-duplicate-narration-20260907.md` | `PROGRESS-rectification-duplicate-narration-20260907.md` | 每轮采集旁白说两遍 + 题干在正文与问题块各一次:`step-answer` 的 set-focus 例外(BUG-533)放行了 set-focus 前后两个 step 的正文并拼接;`detachCollectSpokenAssistantText` 只剥逐字后缀,模型写进正文的问句没有代码守卫(BUG-488 防线只在提示词) | 待验收 | `codex/rectification-duplicate-narration-20260907`BUG-584585 |
| `TASK-rectification-other-collect-fallback-20260908.md` | `PROGRESS-rectification-other-collect-fallback-20260908.md` | 七领域问完落到「也可以再说一件」、职业题从未出现、不自动出卡:`USER_COLLECT_QUESTION.other` 仍有三条活路(职业焦点 `target_domain` 压成 other 后被 active-focus 承接 followup 重建;set-focus 两次无效兜底查表;无领域采集归 other),`collect:other:*` 焦点挂着让 `persistNextInterviewIfIdle` 的出卡路径永远不跑 | 待验收(2.2 触发链仍 investigating | `codex/rectification-other-collect-fallback-20260908`BUG-586 |
| `TASK-api-not-configured-mislabel-20260904.md` | `PROGRESS-api-not-configured-mislabel-20260904.md` | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 已验收 | `5483649b`(BUG-542);2 条子进程测试留 CI Node 22 复核 |
| `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505509) |
@@ -102,3 +102,15 @@
- 不得出现补问兜底句「也可以再说一件你记得大概时间的事」
- 职业答过、又没有剩余采集时,必须出现区间交付卡,不能只剩停止按钮
## 7. 每轮助手气泡只有一段旁白,题干只在问题块出现一次
资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。
开场后说一件带年月的虚构经历,等到出现下一问。
期望:
- 助手气泡正文只有一段旁白,没有第二段换个说法再讲一遍
- 题干只出现在同一条消息的问题块里一次
- 正文不以问号结尾,也不把下一题再问一遍
@@ -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 };
});
@@ -14,6 +14,8 @@ import {
stopReasonPrefix,
} from "../src/lib/rectification-agentic/user-copy.ts";
import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts";
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { CASE_ID, FOCUS_ID, TURN_ID } from "./rectification-v9-test-support.ts";
/** Task 0 frozen “before” snapshot: accident case f83d9b42, origin/staging @ 8617eb56. */
export const ACCIDENT_CASE_BEFORE_COPY = {
@@ -165,6 +167,33 @@ test("GENERIC_COLLECT_QUESTION is only allowed on opening collect:other focus",
assert.equal(listUserVisibleCopy().some((item) => item.includes(banned)), false);
});
test("settled assistant body with a focus has no question-mark sentences", () => {
const stem = "你大概是哪一年搬的家?";
const linked = attachQuestionsToTurns([{
id: TURN_ID,
role: "assistant",
text: "范围收到 05:0005:10。你大概哪一年搬过家?",
}], [{
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "collect:relocation:collect_method_evidence",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: "relocation",
targetKind: null,
expectedAnswerSchema: { prompt: stem, collect: true },
status: "active",
askedAt: "2026-09-07T00:00:00.000Z",
resolvedAt: null,
askedTurnId: TURN_ID,
}]);
assert.doesNotMatch(linked[0]?.text ?? "", /[?]/);
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectHandoff));
assert.doesNotMatch(RECTIFICATION_USER_COPY.collectHandoff, /请回答下面的问题/);
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
assert.match(agentRun, /stripQuestionSentences/);
});
test("delivery narration defers career windows and the range card replaces minute cards", () => {
const choice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { composeCollectSpokenAssistantText, detachCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { composeCollectSpokenAssistantText, detachCollectSpokenAssistantText, stripQuestionSentences } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { GENERIC_COLLECT_QUESTION, RECTIFICATION_USER_COPY, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { CASE_ID, FOCUS_ID, TURN_ID } from "./rectification-v9-test-support.ts";
test("composeCollectSpokenAssistantText joins by exact prompt identity", () => {
@@ -88,7 +88,36 @@ test("runtime no longer composes the stem into assistant_message", () => {
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8");
assert.doesNotMatch(agentRun, /composeCollectSpokenAssistantText/);
assert.match(attach, /detachCollectSpokenAssistantText/);
assert.match(attach, /stripQuestionSentences/);
assert.match(attach, /if \(!focus\.askedTurnId\) continue/);
});
test("stripQuestionSentences drops a rewritten trailing question and its tag", () => {
const body = "范围已经收到,收在 05:00–05:10。你大概哪一年搬过家?有年份就行。";
const stem = "你大概是哪一年搬的家?";
assert.equal(stripQuestionSentences(body, stem), "范围已经收到,收在 05:0005:10。");
});
test("stripQuestionSentences drops a mid-body stem and keeps the surrounding sentences", () => {
const stem = "你大概是哪一年搬的家?";
const body = "记下了。你大概是哪一年搬的家?范围还在 05:00–05:10。";
assert.equal(stripQuestionSentences(body, stem), "记下了。范围还在 05:0005:10。");
});
test("stripQuestionSentences returns empty when the body is only a question", () => {
const stem = "你大概是哪一年搬的家?";
assert.equal(stripQuestionSentences("你大概哪一年搬过家?", stem), "");
assert.equal(RECTIFICATION_USER_COPY.collectHandoff.includes("请回答下面的问题"), false);
});
test("attachQuestionsToTurns leaves body unchanged when the turn has no focus", () => {
const body = "范围收到 05:0005:10。你大概哪一年搬过家?";
const turns = attachQuestionsToTurns([{
id: TURN_ID,
role: "assistant" as const,
text: body,
}], []);
assert.equal(turns[0]?.text, body);
assert.equal(turns[0]?.question, null);
});
@@ -132,13 +132,80 @@ test("keeps a live opening greeting when the next public tool is set-focus", ()
).kind,
"none",
);
assert.deepEqual(
// 原值: set-focus 后第二段 live 发布
// 新值: discard
// 原因: BUG-584 决策 2,保留 set-focus 前的问候,丢掉工具结果后的复述
assert.equal(
applyStepAnswerChunk(
state,
chunk("text-delta", { text: "我们慢慢来就好——想到哪件就聊哪件,不用一次说完。" }),
isPublicTool,
).kind,
"discard",
);
});
test("set-focus with no prior spoken text still publishes the later greeting", () => {
const state = createStepAnswerState();
assert.equal(
applyStepAnswerChunk(
state,
chunk("tool-call", { toolName: "rectification-set-focus" }),
isPublicTool,
).kind,
"none",
);
assert.equal(
applyStepAnswerChunk(
state,
chunk("tool-result", { toolName: "rectification-set-focus" }),
isPublicTool,
).kind,
"none",
);
assert.deepEqual(
applyStepAnswerChunk(
state,
chunk("text-delta", { text: "你好,我们从你最容易想起的经历开始就行。" }),
isPublicTool,
),
{ kind: "live", text: "我们慢慢来就好——想到哪件就聊哪件,不用一次说完。" },
{ kind: "live", text: "你好,我们从你最容易想起的经历开始就行。" },
);
});
test("set-focus publishes an unfinished CJK remainder instead of dropping it", () => {
const state = createStepAnswerState();
assert.equal(
applyStepAnswerChunk(
state,
chunk("text-delta", { text: "范围已经收到 05:00 到 05:10" }),
isPublicTool,
).kind,
"none",
);
assert.deepEqual(
applyStepAnswerChunk(
state,
chunk("tool-call", { toolName: "rectification-set-focus" }),
isPublicTool,
),
{ kind: "publish", pieces: ["范围已经收到 05:00 到 05:10"] },
);
});
test("English planning before set-focus is still discarded", () => {
const state = createStepAnswerState();
assert.equal(
applyStepAnswerChunk(state, chunk("text-delta", { text: "Let me set the next collect focus" }), isPublicTool).kind,
"none",
);
assert.equal(
applyStepAnswerChunk(
state,
chunk("tool-call", { toolName: "rectification-set-focus" }),
isPublicTool,
).kind,
"discard",
);
});
+110 -2
View File
@@ -23,7 +23,7 @@ import { runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification
import { persistV9Candidate } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { GENERIC_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { GENERIC_COLLECT_QUESTION, RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import {
CASE_ID,
CANDIDATE_RANGE,
@@ -393,7 +393,6 @@ test("collect_spoken stem is persisted on the same turn via asked_turn_id, not a
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, greeting);
assert.equal(result.collectSpokenEmitted, false);
const replaced = emitted.find((event) => (
event.type === "answer.delta" && (event as { replace?: unknown }).replace === true
)) as { text?: string } | undefined;
@@ -677,3 +676,112 @@ test("reply regeneration is a separate Jyotisha agent with only read-case access
assert.match(agentSource, /这不是新一轮校正/);
assert.match(agentSource, /只能使用 rectification-read-case/);
});
test("set-focus after spoken text does not append a second paragraph", async () => {
const first = "2016 年 9 月去北京工作,记下了。范围收到 05:00 到 05:10。";
const second = "2016 年那件事对校正很有帮助,我们再对一下搬家。";
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({ turnCount: 0 }),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted } = runOptions({
accounting: accounting.client,
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: first }),
chunk("tool-call", { toolName: "rectification-set-focus" }),
chunk("tool-result", { toolName: "rectification-set-focus" }),
chunk("text-delta", { text: second }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, first);
assert.equal(result.answerText.includes(second), false);
const deltas = emitted.filter((event) => event.type === "answer.delta") as Array<{
text?: string;
replace?: boolean;
}>;
assert.equal(deltas.some((item) => (item.text ?? "").includes(second)), false);
assert.equal(deltas.some((item) => item.replace === true && item.text === ""), false);
});
test("agent-run strips question sentences when this turn owns the focus", async () => {
const body = "范围已经收到,收在 05:00–05:10。你大概哪一年搬过家?有年份就行。";
const stem = "你大概是哪一年搬的家?";
const collectDossier = dossierFixture({
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
askedTurnId: TURN_ID,
intent: "collect_method_evidence",
expectedAnswerSchema: { prompt: stem, collect: true },
}),
}),
});
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => collectDossier,
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted } = runOptions({
accounting: accounting.client,
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: body }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, "范围已经收到,收在 05:0005:10。");
const replaced = emitted.find((event) => (
event.type === "answer.delta" && (event as { replace?: unknown }).replace === true
)) as { text?: string } | undefined;
assert.equal(replaced?.text, "范围已经收到,收在 05:0005:10。");
});
test("agent-run uses collectHandoff when the owned-focus body is only a question", async () => {
const stem = "你大概是哪一年搬的家?";
const collectDossier = dossierFixture({
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
askedTurnId: TURN_ID,
intent: "collect_method_evidence",
expectedAnswerSchema: { prompt: stem, collect: true },
}),
}),
});
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => collectDossier,
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options } = runOptions({
accounting: accounting.client,
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: "你大概哪一年搬过家?" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, RECTIFICATION_USER_COPY.collectHandoff);
});