Files
Jyotisha/frontend/tests/consult-answer-the-question-20260927.test.ts
T
Jesse_ChenandClaude Opus 5.5 2ec2762dcf feat(consult): plain answer shape — conclusion first, one section per person, no word caps (BUG-1132~1134)
Retire the 09-17 opener shape (coined pattern name, push/tidy periods,
play-a-role) that produced riddles; one shape definition in
consultation-thinking-plan; parents card names mother/father roles with MK;
empty-house rule; thinking bar closes the opener at any H2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-01 09:42:35 +08:00

122 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// TASK-consult-answer-the-question-20260927 T2 (BUG-1071): a follow-up turn
// (the session already holds a natal answer) carries the direct-answer shape
// instead of the first-turn skeleton, still calls the calculation tool once,
// and its short heading-free answer reaches the user in full — the
// step-scoped release (`ANSWER_RELEASE_CHARS` or a heading) holds the text
// only until the step ends, and a step that did not call a tool is released.
//
// Real `getJyotishAgent` + prompt-recording fake model; the calculation is
// the golden engine capture of a public AA chart (AGENTS §7.4).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
followUpAnswerShapeInstruction,
natalAnswerShapeInstruction,
OPENER_THINKING_TITLE,
REPORT_HEADING,
} from "../src/lib/consultation-thinking-plan.ts";
import { ANSWER_RELEASE_CHARS } from "../src/lib/stream-agent-response.ts";
import {
firstPromptWithToolResult,
pieces,
runNatalAgent,
type Turn,
} from "./consult-natal-agent-test-support.ts";
type Json = Record<string, unknown>;
const golden = JSON.parse(readFileSync(
new URL("./fixtures/consult-evidence-card-golden.json", import.meta.url),
"utf8",
)) as { charts: Array<{ id: string; workflow: Json }> };
const workflow = golden.charts.find((chart) => chart.id === "steve_jobs")!.workflow;
const PRE_TOOL = "我先排一下盘,稍等。";
// A fictional yes/no follow-up in the brief's fixture shape: verdict first, one
// chart fact, no heading, well under the release threshold.
const FOLLOW_UP_QUESTION = "我妈是不是不太在意我";
const FOLLOW_UP_ANSWER = "不是。4 宫主落在 10 宫,她把心力投在你的前途上——不是不在意,是不会用你想要的方式在意。";
const calcCall = (text?: string): Turn => ({
parts: [
...(text ? [{ text }] : []),
{ tool: { name: "run-jyotish-consultation", input: { question: FOLLOW_UP_QUESTION, domains: ["parents"] } } },
],
finish: "tool-calls",
});
test("a follow-up turn still calculates once, and its short heading-free answer is released whole (BUG-1071)", async () => {
assert.ok(Array.from(FOLLOW_UP_ANSWER).length < ANSWER_RELEASE_CHARS, "the fixture is shorter than the release threshold");
const run = await runNatalAgent(
[calcCall(PRE_TOOL), { parts: pieces(FOLLOW_UP_ANSWER), finish: "stop" }],
{ workflow, question: FOLLOW_UP_QUESTION, instruction: followUpAnswerShapeInstruction(), followUpTurn: true },
);
assert.deepEqual(run.terminal.map((event) => event.type), ["run.completed"]);
// ③ The calculation is request-scoped: one tool run, two model calls, the
// writer sees the result — exactly as on the first turn (D7).
assert.equal(run.workflowRuns, 1);
assert.equal(run.calls, 2);
assert.equal(firstPromptWithToolResult(run.prompts, "run-jyotish-consultation"), 1);
// ② The writer's prompt carries the follow-up shape, not the skeleton.
const writerPrompt = JSON.stringify(run.prompts[1]);
assert.match(writerPrompt, /这是追问轮/);
// 原值: assert.match(writerPrompt, /不超过 200 字/)
// 新值: 写作提示带「讲清楚为准,不限字数」,且没有任何「不超过 N 字」
// 原因: TASK-consult-plain-answer-20261001 D8:产品取消字数上限(追问轮仍不重开骨架)
assert.match(writerPrompt, /讲清楚为准,不限字数/);
assert.doesNotMatch(writerPrompt, /不超过 \d+ 字/);
assert.doesNotMatch(writerPrompt, /这三个二级标题:/);
assert.doesNotMatch(writerPrompt, /开场不写行动清单/);
// The answer has no heading and never crossed ANSWER_RELEASE_CHARS, yet it
// reaches the user whole: the step ended without a tool call.
assert.equal(run.answer, FOLLOW_UP_ANSWER);
assert.equal(run.completed, FOLLOW_UP_ANSWER);
assert.equal(run.charges, 1);
assert.equal(run.answer.includes(PRE_TOOL), false);
assert.doesNotMatch(run.answer, /^## /m);
// T4: the thinking bar names the opener step for what it is on a follow-up.
const sections = run.events.filter((event) => event.type === "thinking.section") as unknown as Array<{ title: string; steps: Array<{ label: string }> }>;
const opener = sections.find((section) => section.title === OPENER_THINKING_TITLE);
assert.ok(opener, "the opener section is announced");
assert.equal(opener.steps[0]?.label, "直接回答这句话");
assert.equal(JSON.stringify(sections).includes("先抓住你真正在问的事"), false);
});
test("the first turn of a session keeps the opener-plus-skeleton shape (control)", async () => {
const OPENER = "你和父母这条线,表面是各过各的,底下是 4 宫主和 9 宫主都不在家,这叫「隔着距离才亲」。";
const ANSWER = [
OPENER,
"",
`## ${REPORT_HEADING.support}`,
"4 宫空宫,宫主落 10 宫。",
"",
`## ${REPORT_HEADING.timing}`,
"这一段子运看父亲那条线。",
"",
`## ${REPORT_HEADING.action}`,
"- 替他们办一件具体的杂事。",
"",
].join("\n");
const run = await runNatalAgent(
[calcCall(), { parts: pieces(ANSWER), finish: "stop" }],
{ workflow, question: "我和父母的亲疏关系如何", instruction: natalAnswerShapeInstruction() },
);
assert.deepEqual(run.terminal.map((event) => event.type), ["run.completed"]);
assert.equal(run.workflowRuns, 1);
const writerPrompt = JSON.stringify(run.prompts[1]);
// ① The first-turn shape: three body headings, no 先回答 heading, actions once.
for (const heading of Object.values(REPORT_HEADING)) assert.ok(writerPrompt.includes(`## ${heading}`));
assert.doesNotMatch(writerPrompt, /## 先回答你的问题/);
assert.match(writerPrompt, /开场不写行动清单/);
assert.doesNotMatch(writerPrompt, /这是追问轮/);
assert.equal(run.answer, ANSWER);
assert.ok(run.answer.startsWith(OPENER), "the opener has no heading");
const sections = run.events.filter((event) => event.type === "thinking.section") as unknown as Array<{ title: string; steps: Array<{ label: string }> }>;
const opener = sections.find((section) => section.title === OPENER_THINKING_TITLE);
// 原值: "用盘上的反差回答这个问题";新值: "先用一句话回答"
// 原因: TASK-consult-plain-answer-20261001 D1/T2(BUG-1132):反差开场形状下线
assert.equal(opener?.steps[0]?.label, "先用一句话回答");
});