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
122 lines
6.3 KiB
TypeScript
122 lines
6.3 KiB
TypeScript
// 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, "先用一句话回答");
|
||
});
|