// 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; 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 字/); 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); assert.equal(opener?.steps[0]?.label, "用盘上的反差回答这个问题"); });