fix(consult): a stored smalltalk reply is not a prior answer; summary counts as one (acceptance fix for BUG-1071)
Independent Staging Quality Gate / validate (push) Successful in 14m58s
Independent Staging Quality Gate / publish (push) Successful in 3m40s

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
This commit is contained in:
Jesse_Chen
2026-09-27 23:43:57 +08:00
co-authored by Claude Fable 5.1
parent 2b435f11d0
commit 9d01757f27
11 changed files with 83 additions and 15 deletions
+3 -3
View File
@@ -995,8 +995,8 @@ export async function POST(request: Request) {
// A session that already holds an answer gets the follow-up shape
// (BUG-1071): a direct answer, no skeleton. That is read from the stored
// history, never from the wording of the question.
const followUpTurn = hasPriorAssistantAnswer(history);
const natalInstruction = `${natalToolInstruction}${natalUserTurnShape({ entrypoint: consultEntrypoint, history })}`;
const followUpTurn = hasPriorAssistantAnswer(history, { summaryText: historyWindow.summaryText });
const natalInstruction = `${natalToolInstruction}${natalUserTurnShape({ entrypoint: consultEntrypoint, history, summaryText: historyWindow.summaryText })}`;
const adoptedRangeNote = consultationMode === "verified_chart"
&& prepared.serverChart?.truth.birthTimeStatus === "accepted"
&& prepared.serverChart.toolInput.candidate_range
@@ -1518,7 +1518,7 @@ export async function POST(request: Request) {
const workflowReceipt = consultationWorkflowReceipt(workflowContext);
// The grounded fallback writes from the injected workflow; same first-turn
// / follow-up split as the agentic path (BUG-1071), one statement of it.
const legacyNatalInstruction = hasPriorAssistantAnswer(history)
const legacyNatalInstruction = hasPriorAssistantAnswer(history, { summaryText: historyWindow.summaryText })
? followUpAnswerShapeInstruction()
: "先用不超过 400 字口语直接回答下面的问题,不要加标题;形状为:先给反差(表面 A,底下 B,都是盘上的结构,命名成一个格局)、再说清谁在推谁在修、然后「别去应 X 的象,去扮演 Y 的象」;开场不写行动。然后按三个二级标题写:## 盘里支持这个判断的地方(原始结构、六步宫位、Yoga 表作为依据,只写开场没说过的,表格与正文二选一)、## 时间怎么看、## 这周可以做的一件事(最多三条行动,只在这里出现一次)。全文不超过 900 字。骨架不可省略;不写「先回答你的问题」这个标题,文末技法审计表由产品界面折叠展示,不写进正文。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。";
@@ -15,6 +15,8 @@ export const SESSION_CONTEXT_SUMMARY_HEADING = "【会话摘要(服务端维
export type ConsultationHistoryMessage = Readonly<{
role: "user" | "assistant";
text: string;
/** A stored smalltalk reply (BUG-976/977 fast path); it is not a natal answer. */
responseKind?: "smalltalk";
}>;
export type SessionContextSummaryV1 = Readonly<{
@@ -37,6 +39,7 @@ type StoredTurn = Readonly<{
role: "user" | "assistant";
text: string;
requestId: string | null;
responseKind?: "smalltalk";
}>;
function clamp(value: number, min: number, max: number): number {
@@ -107,7 +110,7 @@ export function storedConsultationTurns(
const rows: StoredTurn[] = [];
messages.forEach((message, index) => {
if (!message || typeof message !== "object") return;
const stored = message as { role?: unknown; text?: unknown; requestId?: unknown };
const stored = message as { role?: unknown; text?: unknown; requestId?: unknown; responseKind?: unknown };
if (options.excludeRequestId && stored.requestId === options.excludeRequestId) return;
if (stored.role !== "user" && stored.role !== "assistant") return;
if (typeof stored.text !== "string" || !stored.text) return;
@@ -116,6 +119,7 @@ export function storedConsultationTurns(
role: stored.role,
text: stored.text,
requestId: typeof stored.requestId === "string" ? stored.requestId : null,
...(stored.responseKind === "smalltalk" ? { responseKind: "smalltalk" as const } : {}),
});
});
return rows;
@@ -156,6 +160,7 @@ export function consultationHistoryWindow(
const clipped = selected.map((turn) => ({
role: turn.role,
text: clipConsultationHistoryText(turn.text),
...(turn.responseKind ? { responseKind: turn.responseKind } : {}),
}));
const budget = historyBudgetChars(options.contextWindow);
let droppedCount = 0;
+16 -5
View File
@@ -383,11 +383,21 @@ export function followUpAnswerShapeInstruction(): string {
].join("");
}
/** The stored history already holds an assistant answer: this turn is a follow-up (D6, no intent regex). */
/**
* The session already holds a natal answer, so this turn is a follow-up (D6,
* no intent regex). A stored smalltalk reply (「你好」 fast path) is an
* assistant message but not an answer, so it does not count; a session
* context summary only exists after earlier consultation turns were
* summarized away, so it counts even when the tail no longer holds them.
*/
export function hasPriorAssistantAnswer(
history: ReadonlyArray<Readonly<{ role: "user" | "assistant"; text: string }>>,
history: ReadonlyArray<Readonly<{ role: "user" | "assistant"; text: string; responseKind?: "smalltalk" }>>,
options: Readonly<{ summaryText?: string | null }> = {},
): boolean {
return history.some((message) => message.role === "assistant" && message.text.trim().length > 0);
if (options.summaryText?.trim()) return true;
return history.some((message) => (
message.role === "assistant" && message.responseKind !== "smalltalk" && message.text.trim().length > 0
));
}
/**
@@ -397,10 +407,11 @@ export function hasPriorAssistantAnswer(
*/
export function natalUserTurnShape(input: {
entrypoint?: string;
history: ReadonlyArray<Readonly<{ role: "user" | "assistant"; text: string }>>;
history: ReadonlyArray<Readonly<{ role: "user" | "assistant"; text: string; responseKind?: "smalltalk" }>>;
summaryText?: string | null;
}): string {
if (input.entrypoint === "daily_starlanguage") return dailyAnswerShapeInstruction();
return hasPriorAssistantAnswer(input.history)
return hasPriorAssistantAnswer(input.history, { summaryText: input.summaryText })
? followUpAnswerShapeInstruction()
: natalAnswerShapeInstruction();
}
+1 -1
View File
@@ -66,6 +66,6 @@ Raman yogas and house steps remain governed_support_only_candidate_ready: write
Never:
- Skip a card fact the question needs in order to keep the answer short — and never write a card fact the question does not need in order to look thorough.
- Put the Technique Audit Table or 统一参数 heading back into the spoken body.
- State what the user or the person asked about wants, needs, or feels; state where the chart puts them.
- State what the user or the person asked about wants, needs, or feels. Say where the chart puts them instead.
The first natal career/wealth/marriage/family answer of a session uses the opener-plus-skeleton above; a follow-up turn follows the shape instruction carried in the user turn (a direct answer of about 200 characters, no headings) unless the user changes domain or asks for the full reading.`;
@@ -432,8 +432,11 @@ test("the consult route has no separate compose stream and wires the single-pass
// 原值: assert.match(route, /natalAnswerShapeInstruction\(\)/)
// 新值: 用户轮形状经 natalUserTurnShape 按「会话已有回答」切换首轮 / 追问轮,思考栏与工具上下文带 followUpTurn
// 原因: TASK-consult-answer-the-question-20260927 D3/D6(BUG-1071)
assert.match(route, /natalUserTurnShape\(\{ entrypoint: consultEntrypoint, history \}\)/);
assert.match(route, /const followUpTurn = hasPriorAssistantAnswer\(history\);/);
// 原值: natalUserTurnShape({ entrypoint, history }) / hasPriorAssistantAnswer(history)
// 新值: 两处都带 summaryText(Claude 验收补修:寒暄回复不算上一轮解读,摘要算)
// 原因: 「你好」的回复也存成 assistant 消息,只看 role 会把第一个正式问题当追问
assert.match(route, /natalUserTurnShape\(\{ entrypoint: consultEntrypoint, history, summaryText: historyWindow\.summaryText \}\)/);
assert.match(route, /const followUpTurn = hasPriorAssistantAnswer\(history, \{ summaryText: historyWindow\.summaryText \}\);/);
assert.match(route, /followUp: followUpTurn,/);
assert.match(route, /followUpTurn,\n\s+serverChart: prepared\.serverChart,/);
assert.doesNotMatch(route, /natalAnswerShapeInstruction\(\)/);
@@ -73,6 +73,19 @@ test("a 20_000 character message is clipped to 12_000 with an omission count", (
assert.equal(history.tail[0]?.text, clipped);
});
test("a stored smalltalk reply keeps its responseKind through the history window", () => {
// Claude acceptance fix for the follow-up detection: the route reads responseKind
// off the window tail to tell a 「你好」 reply from a natal answer.
const history = consultationHistoryWindow([
{ role: "user", text: "你好" },
{ role: "assistant", text: "你好,想聊什么都可以", responseKind: "smalltalk" },
{ role: "user", text: "我和父母的亲疏关系如何" },
{ role: "assistant", text: "外顺内远。" },
], null, { contextWindow: 128_000 });
assert.equal(history.tail[1]?.responseKind, "smalltalk");
assert.equal("responseKind" in (history.tail[3] ?? {}), false);
});
test("historyBudgetChars uses 64k, 32k, and null-as-128k windows", () => {
assert.equal(historyBudgetChars(64_000), 6_000);
assert.equal(historyBudgetChars(32_000), 4_000);
@@ -232,6 +232,20 @@ test("a follow-up turn answers directly and the thinking bar says so (TASK-consu
{ role: "user", text: "我和父母的亲疏关系如何" },
{ role: "assistant", text: "外顺内远。" },
]), true);
// Claude acceptance fix: a stored smalltalk reply (「你好」 fast path, responseKind "smalltalk")
// is an assistant message but not a natal answer, so the first real question after it
// still gets the first-turn shape; a session summary means earlier answers were folded away.
assert.equal(hasPriorAssistantAnswer([
{ role: "user", text: "你好" },
{ role: "assistant", text: "你好,想聊什么都可以", responseKind: "smalltalk" },
]), false);
assert.equal(hasPriorAssistantAnswer([], { summaryText: "此前一轮讲过父母:外顺内远。" }), true);
assert.equal(hasPriorAssistantAnswer([], { summaryText: " " }), false);
assert.equal(
natalUserTurnShape({ history: [{ role: "user", text: "你好" }, { role: "assistant", text: "你好", responseKind: "smalltalk" }] }),
natalAnswerShapeInstruction(),
);
assert.equal(natalUserTurnShape({ history: [], summaryText: "此前一轮讲过父母。" }), followUpAnswerShapeInstruction());
// ① empty history → first-turn shape; ② prior answer → follow-up; ④ daily is untouched by history.
const first = natalUserTurnShape({ history: [] });
@@ -182,7 +182,9 @@ test("the voice answers the sentence asked: no motive guessing, yes/no first, co
assert.match(voice, /Good \(是非题,用户问「我妈是不是不太在意我」/);
assert.match(voice, /不是。4 宫主水星逆行落 10 宫/);
assert.match(voice, /Bad \(同一问题\): 你其实要的是被听见/);
assert.match(voice, /State what the user or the person asked about wants, needs, or feels; state where the chart puts them/);
// 原值: "…wants, needs, or feels; state where the chart puts them"(Never 列表里第二句读成也被禁止)
// 新值: 两句拆开,第二句是替代做法 原因: Claude 验收改措辞
assert.match(voice, /State what the user or the person asked about wants, needs, or feels\. Say where the chart puts them instead/);
// The three-mode shared voice still carries all of it (general and window expand the same literal).
const sharedVoice = voice.slice(0, voice.indexOf("export const natalSpokenReportContract"));
assert.ok(sharedVoice.includes("不揣测(回答对象是用户问的那句话)"));