The natal loop's step after run-jyotish-consultation saw the evidence, but its text was drained and a second, blind compose stream (history + question only, empty findings) wrote the user-visible answer. Remove compose, interpret and the drain; keep the loop's own final-step text. - stepScopedAnswer: per-step holding; text of a step that calls a tool is dropped, so narration around tool calls never reaches the answer - writing shape (opener + four headings) moves into the user turn - length continuation receives the calculation result; Pass 4 whole-answer reject retries through retryForAnswer with the rewrite hint - createConsultationRunClock: tools keep the 110s tool phase; the loop is handed to the 70s answer clock when the calculation result arrives - settlement judges the step that wrote the answer (BUG-1051 kept) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
176 lines
8.1 KiB
TypeScript
176 lines
8.1 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
applyThinkingSectionProgress,
|
|
consultationContinueMessages,
|
|
consultationContinuePrompt,
|
|
consultationReportHeadings,
|
|
consultationSpokenHeadingRule,
|
|
dailyAnswerShapeInstruction,
|
|
dailyConsultationThinkingPlan,
|
|
natalAnswerShapeInstruction,
|
|
DAILY_HEADING,
|
|
natalConsultationThinkingPlan,
|
|
REPORT_HEADING,
|
|
splitAnswerByHeadings,
|
|
thinkPlanSteps,
|
|
visibleThinkingSteps,
|
|
windowConsultationThinkingPlan,
|
|
} from "../src/lib/consultation-thinking-plan.ts";
|
|
|
|
test("multi-domain thinking plan uses Chinese product labels and hides tool ids", () => {
|
|
const sections = natalConsultationThinkingPlan({
|
|
domains: ["career", "wealth"],
|
|
requiredBlocks: [
|
|
"raw_structure",
|
|
"raman_six_step",
|
|
"yoga_table",
|
|
"timing",
|
|
"synthesis",
|
|
"technique_audit_table",
|
|
"modern_wrap",
|
|
],
|
|
mustUseLayers: ["D1", "run-jyotish-consultation", "D10", "skill_read"],
|
|
});
|
|
const encoded = JSON.stringify(sections);
|
|
assert.deepEqual(sections.map((section) => section.heading), [
|
|
REPORT_HEADING.question,
|
|
REPORT_HEADING.support,
|
|
REPORT_HEADING.timing,
|
|
REPORT_HEADING.action,
|
|
]);
|
|
assert.ok(sections.some((section) => section.id === "support"));
|
|
assert.ok(JSON.stringify(sections).includes("事业"));
|
|
assert.equal(encoded.includes("run-jyotish"), false);
|
|
assert.equal(encoded.includes("skill_read"), false);
|
|
assert.ok(sections.some((section) => section.steps.some((step) => step.label.includes("D1"))));
|
|
});
|
|
|
|
test("window thinking plan stays on stable-layer copy", () => {
|
|
const [section] = windowConsultationThinkingPlan();
|
|
assert.equal(section?.heading, REPORT_HEADING.question);
|
|
assert.equal(JSON.stringify(section).includes("run-jyotish"), false);
|
|
});
|
|
|
|
test("visible steps collapse extras into a remaining count", () => {
|
|
const steps = Array.from({ length: 6 }, (_, index) => ({
|
|
id: `s${index}`,
|
|
label: `步骤 ${index + 1}`,
|
|
status: "pending" as const,
|
|
}));
|
|
const visible = visibleThinkingSteps(steps);
|
|
assert.equal(visible.visible.length, 4);
|
|
assert.equal(visible.hiddenCount, 2);
|
|
});
|
|
|
|
test("answer split follows the required heading order", () => {
|
|
const headings = consultationReportHeadings(["career", "wealth"]);
|
|
const text = [
|
|
`## ${REPORT_HEADING.question}`,
|
|
"外松内紧。",
|
|
`## ${REPORT_HEADING.support}`,
|
|
"事业宫被土星压着。",
|
|
`## ${REPORT_HEADING.timing}`,
|
|
"这轮大运还没过完。",
|
|
`## ${REPORT_HEADING.action}`,
|
|
"这周只把合同条款写清楚。",
|
|
].join("\n");
|
|
const { slices } = splitAnswerByHeadings(text, headings);
|
|
assert.match(slices[REPORT_HEADING.question] ?? "", /外松内紧/);
|
|
assert.match(slices[REPORT_HEADING.support] ?? "", /土星/);
|
|
assert.match(slices[REPORT_HEADING.timing] ?? "", /大运/);
|
|
assert.match(slices[REPORT_HEADING.action] ?? "", /合同条款/);
|
|
});
|
|
|
|
test("settled answers mark leftover thinking sections done", () => {
|
|
const sections = natalConsultationThinkingPlan({ domains: ["career"] });
|
|
const progressed = applyThinkingSectionProgress(
|
|
sections,
|
|
"今日行运已经写完,但没有领域二级标题。",
|
|
{ settled: true },
|
|
);
|
|
assert.equal(progressed.every((section) => section.steps.every((step) => step.status === "done")), true);
|
|
});
|
|
|
|
test("progress marks the current heading active and completed ones done", () => {
|
|
const sections = natalConsultationThinkingPlan({ domains: ["career"] });
|
|
const progressed = applyThinkingSectionProgress(sections, `## ${REPORT_HEADING.question}\n外松内紧。\n`);
|
|
assert.equal(progressed[0]?.steps[0]?.status, "done");
|
|
assert.equal(progressed[1]?.steps[0]?.status, "active");
|
|
});
|
|
|
|
test("continue prompt asks to resume after the last complete heading", () => {
|
|
const prompt = consultationContinuePrompt("## 事业\n方向稳定。\n");
|
|
assert.match(prompt, /最后一个完整标题是:## 事业/);
|
|
assert.match(prompt, /不要重复已写出的段落/);
|
|
assert.match(prompt, /不要写思考过程清单/);
|
|
});
|
|
|
|
test("natal spoken answers must use markdown lists for parallel points", () => {
|
|
assert.match(consultationSpokenHeadingRule("natal"), /Markdown bullet lists/);
|
|
});
|
|
|
|
test("think plan flattens natal sections into v2 steps", () => {
|
|
const steps = thinkPlanSteps(natalConsultationThinkingPlan({
|
|
domains: ["career", "wealth"],
|
|
}));
|
|
assert.deepEqual(steps.map((step) => step.id), ["question", "support", "timing", "action"]);
|
|
});
|
|
|
|
test("section prompt asks for one heading and forbids another calculation", () => {
|
|
// 原值: 锁 consultationSectionPrompt(只写一节、不要再调用排盘工具)与 consultationComposePrompt 禁写统一参数
|
|
// 新值: 两个提示词都已删除;改锁搬进用户轮的本命写作要求:只用本轮计算结果、开场无标题、
|
|
// 一次写完四个标题、禁写统一参数与技法审计表
|
|
// 原因: BUG-1053 删除单独的 compose 流(分段写作早在 BUG-944 已取消,consultationSectionPrompt 是死代码);
|
|
// 写作形态要求改由主循环看到的用户轮携带。测试名保留,避免名单无说明地消失
|
|
const natal = natalAnswerShapeInstruction();
|
|
assert.match(natal, /只用结果里的盘面事实/);
|
|
assert.match(natal, /开场不要标题/);
|
|
for (const heading of Object.values(REPORT_HEADING)) assert.ok(natal.includes(`## ${heading}`));
|
|
assert.match(natal, /不要写「统一参数与原始结构」/);
|
|
assert.match(natal, /不要写技法审计表/);
|
|
assert.doesNotMatch(natal, /不要再调用排盘工具/, "the loop still has to call the tool before writing");
|
|
});
|
|
|
|
test("daily thinking plan is three sections without a close heading", () => {
|
|
const plan = dailyConsultationThinkingPlan();
|
|
assert.deepEqual(plan.map((section) => section.heading), [
|
|
DAILY_HEADING.trend,
|
|
DAILY_HEADING.actAvoid,
|
|
DAILY_HEADING.action,
|
|
]);
|
|
assert.equal(plan.at(-1)?.title, "一个行动 + 边界句");
|
|
assert.equal(plan.some((section) => section.id === "close"), false);
|
|
assert.deepEqual(
|
|
thinkPlanSteps(plan).map((step) => step.id),
|
|
["daily-trend", "daily-act-avoid", "daily-action"],
|
|
);
|
|
const written = [
|
|
`## ${DAILY_HEADING.trend}\n今日宜把已谈妥的细节落成文字。`,
|
|
`## ${DAILY_HEADING.actAvoid}\n适合推进合同核对,避开新开战场。`,
|
|
`## ${DAILY_HEADING.action}\n今晚只做一件:把待确认条款列成清单。探索性日提示,不是确定预测。`,
|
|
].join("\n\n");
|
|
const next = applyThinkingSectionProgress(plan, written);
|
|
assert.equal(next.every((section) => section.steps.every((step) => step.status === "done")), true);
|
|
assert.equal(written.startsWith(`## ${DAILY_HEADING.trend}`), true);
|
|
assert.match(consultationSpokenHeadingRule("daily"), /今日趋势/);
|
|
});
|
|
|
|
test("empty-retry section prompt asks to write the heading directly", () => {
|
|
// 原值: 锁 consultationSectionPrompt 的 empty-retry 分支(「上一段没有输出正文,请直接写这一节」)
|
|
// 新值: 该提示词已删除;改锁今日入口的写作要求(三节标题、审计表与边界句进最后一节)和
|
|
// 续写消息:带上本轮计算结果,再接已写正文与续写要求
|
|
// 原因: BUG-1053 删除 compose 提示词链;空回答走 answer-retry,续写不得看不到证据。测试名保留
|
|
const daily = dailyAnswerShapeInstruction();
|
|
assert.match(daily, new RegExp(`${DAILY_HEADING.trend}、${DAILY_HEADING.actAvoid.replace(/[/]/g, "\\/")}、${DAILY_HEADING.action}`));
|
|
assert.match(daily, /探索性日提示,不是确定预测/);
|
|
const base = [{ role: "user" as const, content: "问题" }];
|
|
const withEvidence = consultationContinueMessages(base, `## ${REPORT_HEADING.question}\n岁差`, { moon: 3.2259 });
|
|
assert.equal(withEvidence.length, 4);
|
|
assert.match(String(withEvidence[1]?.content), /本轮服务器计算结果[\s\S]*3\.2259/);
|
|
assert.equal(withEvidence[2]?.role, "assistant");
|
|
assert.equal(withEvidence[3]?.content, consultationContinuePrompt(`## ${REPORT_HEADING.question}\n岁差`));
|
|
assert.equal(consultationContinueMessages(base, "x").length, 3, "no evidence, no evidence message");
|
|
});
|