明说要校正时,第一步就能打开(BUG-1317)

本命和申报时段的第 0 步,校正工具挂上时可以排盘,也可以直接打开校正。还没看盘就顺口提议不算已经提议过。规则第一句写明 explicitRequest=true。定向测试 149 通过,类型检查和 lint 通过,隐私标记通过。名人生平回测未跑。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-13 22:02:41 +08:00
co-authored by Grok (agent loop)
parent 0c04755560
commit bccceff91f
12 changed files with 251 additions and 33 deletions
@@ -679,26 +679,40 @@ test("guided-topic entrypoint ignores a model domain rewrite instead of executin
test("natal first step exposes only the chart calculation tool", () => {
assert.equal(CONSULTATION_NATAL_CALC_TOOL_ID, "run-jyotish-consultation");
// 原值: 第 0 步 toolChoice "required" / 新值: "auto"
// 原因: BUG-282 供应商拒收 thinking 模式下的 required,BUG-937 撤回
// 原值: 第 0 步 toolChoice "required",后来改为只有排盘工具且 toolChoice "auto"
// 新值: 没挂校正工具时第 0 步仍只有排盘工具;挂了时是排盘工具和 offer-birth-time-rectification。toolChoice 仍是 "auto"。第 1 步不收窄。
// 原因: BUG-937 保持 auto;BUG-1317 按产品选择,第 0 步在工具确实挂上时可以打开校正。
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0 }), {
activeTools: ["run-jyotish-consultation"],
toolChoice: "auto",
});
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 1 }), {
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0, rectificationOffer: false }), {
activeTools: ["run-jyotish-consultation"],
toolChoice: "auto",
});
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0, rectificationOffer: true }), {
activeTools: ["run-jyotish-consultation", "offer-birth-time-rectification"],
toolChoice: "auto",
});
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 1, rectificationOffer: true }), {
toolChoice: "auto",
});
});
test("window first step requires the window consultation tool", () => {
assert.equal(CONSULTATION_WINDOW_CALC_TOOL_ID, "run-jyotish-window-consultation");
// 原值: 第 0 步 toolChoice "required" / 新值: "auto"
// 原因: BUG-282 供应商拒收 thinking 模式下的 required,BUG-937 撤回
// 原值: 第 0 步 toolChoice "required",后来改为只有窗口排盘工具且 toolChoice "auto"
// 新值: 没挂校正工具时第 0 步仍只有窗口排盘工具;挂了时再加上 offer-birth-time-rectification。toolChoice 仍是 "auto"。
// 原因: 与本命同一条 BUG-1317 决定,申报时段也要能在第一步打开校正。
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 0 }), {
activeTools: ["run-jyotish-window-consultation"],
toolChoice: "auto",
});
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 1 }), {
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 0, rectificationOffer: true }), {
activeTools: ["run-jyotish-window-consultation", "offer-birth-time-rectification"],
toolChoice: "auto",
});
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 1, rectificationOffer: true }), {
toolChoice: "auto",
});
});
@@ -233,8 +233,12 @@ test("consult streams reserve an answer budget and keep provider thinking on a s
route.indexOf("const natalStreamOptions = {"),
);
assert.doesNotMatch(sharedStreamOptions, /prepareStep/);
assert.match(route, /prepareStep: consultationNatalPrepareStep/);
assert.match(route, /prepareStep: consultationWindowPrepareStep/);
// 原值:prepareStep: consultationNatalPrepareStep / consultationWindowPrepareStep,函数本身不知道校正工具在不在。
// 新值:路由把 rectificationOffer: true 传进去,因为这两条路线都会挂上校正工具。
// 原因:BUG-1317,第 0 步在工具挂上时要能打开校正;没传时函数仍只留排盘工具。
assert.match(route, /prepareStep: \(input: \{ stepNumber: number \}\) => consultationNatalPrepareStep\(\{/);
assert.match(route, /prepareStep: \(input: \{ stepNumber: number \}\) => consultationWindowPrepareStep\(\{/);
assert.match(route, /rectificationOffer: true/);
assert.match(route, /streamWithOverflowRetry\(agent, natalStreamOptions\)/);
assert.match(route, /streamWithOverflowRetry\(agent, windowStreamOptions\)/);
assert.match(
@@ -72,8 +72,14 @@ function offerTool(options: {
alreadyOffered?: boolean;
decide?: () => Promise<OfferDecision>;
loadResumableSessionId?: () => Promise<string | null>;
/** False only for a step-0 call that has not seen the chart yet. */
chartReady?: boolean;
} = {}) {
const state = createConsultationRuntimeState();
if (options.chartReady !== false) {
state.consultationToolCompleted = true;
state.consultationToolSuccessCount = 1;
}
let decisions = 0;
let loads = 0;
const tools = createConsultationTools({
@@ -520,6 +526,111 @@ test("an ordinary consultation still retries once when the calculation never fin
assert.equal(events.some((event) => event.type === "activity" && event.label === "正在补齐方法与计算步骤"), true);
});
test("step 0 explicit request opens rectification without calculating a chart", async () => {
const fresh = offerTool({ chartReady: false });
const result = await fresh.tool.execute({ explicitRequest: true });
assert.equal(result.directEntry, "new");
assert.equal(fresh.state.consultationToolCallCount, 0);
assert.equal(fresh.state.consultationToolCompleted, false);
assert.equal(fresh.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_NEW_COPY);
let contractRetries = 0;
const response = streamAgentResponse({
runId: "run-step0-explicit",
requestId: REQUEST_ID,
state: fresh.state,
requireTool: true,
pass4Mode: "verified_chart",
stepScopedAnswer: true,
toolStatus: () => "ready",
receipt: () => receipt(fresh.state),
stream: (async function* () {
yield { type: "text-delta", payload: { text: "我先看看盘,然后写一段解读。" } };
yield STOP_FINISH;
})(),
retry: async () => {
contractRetries += 1;
return (async function* () { yield STOP_FINISH; })();
},
});
const events = await readStream(response);
assert.equal(contractRetries, 0);
assert.equal(visibleAnswer(events), RECTIFICATION_DIRECT_NEW_COPY);
assert.equal(events.some((event) => event.type === "run.completed" && event.responseKind === "smalltalk"), true);
});
test("step 0 text without a tool still retries into a chart answer", async () => {
const state = createConsultationRuntimeState();
let contractRetries = 0;
const response = streamAgentResponse({
runId: "run-step0-empty",
requestId: REQUEST_ID,
state,
requireTool: true,
pass4Mode: "verified_chart",
stepScopedAnswer: true,
toolStatus: () => "ready",
receipt: () => receipt(state),
stream: (async function* () {
yield { type: "text-delta", payload: { text: "还没排盘就想写。" } };
yield STOP_FINISH;
})(),
retry: async () => {
contractRetries += 1;
state.consultationToolCompleted = true;
state.consultationToolSuccessCount = 1;
return (async function* () {
yield { type: "text-delta", payload: { text: "事业方向可以看。" } };
yield STOP_FINISH;
})();
},
});
const events = await readStream(response);
assert.equal(contractRetries, 1);
assert.equal(visibleAnswer(events), "事业方向可以看。");
assert.equal(state.rectificationDirectEntry, undefined);
assert.equal(events.some((event) => event.type === "activity" && event.label === "正在补齐方法与计算步骤"), true);
});
test("a soft offer before the chart is not recorded, then a chart answer can still offer", async () => {
const fresh = offerTool({ chartReady: false });
const early = await fresh.tool.execute({});
assert.equal(early.offered, false);
assert.match(String(early.note), /先排盘,看过盘再决定要不要提/);
// 记在局部上再断言。直接断言 state 上的字段会把它收成永远是空,排盘后再读就对不上类型。
const offerBeforeChart = fresh.state.rectificationOffer;
assert.equal(offerBeforeChart, undefined);
assert.equal(fresh.state.rectificationDirectEntry, undefined);
assert.equal(fresh.decisions(), 0);
fresh.state.consultationToolCompleted = true;
fresh.state.consultationToolSuccessCount = 1;
const later = await fresh.tool.execute({});
assert.equal(later.offered, true);
const offerAfterChart = fresh.state.rectificationOffer;
assert.equal(offerAfterChart?.status, "offered");
assert.equal(offerAfterChart?.autoOpen, undefined);
assert.equal(fresh.decisions(), 1);
const response = streamAgentResponse({
runId: "run-step0-soft",
requestId: REQUEST_ID,
state: fresh.state,
requireTool: true,
pass4Mode: "verified_chart",
stepScopedAnswer: true,
toolStatus: () => "ready",
receipt: () => receipt(fresh.state),
stream: (async function* () {
yield { type: "text-delta", payload: { text: "事业方向可以看。" } };
yield STOP_FINISH;
})(),
retry: async () => {
throw new Error("chart contract should already be ready");
},
});
const events = await readStream(response);
assert.equal(visibleAnswer(events), "事业方向可以看。");
assert.equal(visibleAnswer(events).includes(RECTIFICATION_DIRECT_NEW_COPY), false);
});
test("the offer rule is defined once, and direct entry settles with the free completion", () => {
const definitions = walkSources(fileURLToPath(new URL("../src/", import.meta.url))).filter((file) => (
readFileSync(file, "utf8").includes("export const RECTIFICATION_OFFER_RULE")
@@ -530,7 +641,12 @@ test("the offer rule is defined once, and direct entry settles with the free com
assert.match(RECTIFICATION_OFFER_RULE, /不在对话里收集经历做校正/);
assert.match(RECTIFICATION_OFFER_RULE, /explicitRequest=true/);
assert.match(RECTIFICATION_OFFER_RULE, /只写那一句过渡,不写解读/);
assert.match(RECTIFICATION_OFFER_RULE, /只是不认同某个解释,按纠正规则认下、改读,不提校正/);
// 原值:规则以「用户说你推得不准时」开头,并写「按纠正规则认下」。
// 新值:第一句就写 explicitRequest=true,并改称「改读规则」。全文不含「纠正规则」。
// 原因:BUG-1317,「生时纠正」和「纠正解读」撞词,正向句被埋在禁止句中间。
assert.match(RECTIFICATION_OFFER_RULE.split("。")[0] ?? "", /explicitRequest=true/);
assert.match(RECTIFICATION_OFFER_RULE, /按改读规则认下、改读,不提校正/);
assert.doesNotMatch(RECTIFICATION_OFFER_RULE, /纠正规则/);
assert.match(RECTIFICATION_OFFER_RULE, /userAsked=true/);
const tools = src("../src/mastra/consultation-tools.ts");
@@ -117,6 +117,11 @@ test("the server decides whether a card may be shown: self, product on, no case
function offerTool(options: { alreadyOffered?: boolean; decide?: () => Promise<{ ok: true; creditCost: number; questionEstimate: number } | { ok: false; reason: "not_self" | "product_disabled" | "case_in_progress" | "pricing_unavailable" }> } = {}) {
const state = createConsultationRuntimeState();
// 原值:建出来还没排盘也能直接提议。
// 新值:这些用例表示已经看过盘,所以先标计算完成。
// 原因:BUG-1317,还没排盘的顺口提议不算已经提议过。
state.consultationToolCompleted = true;
state.consultationToolSuccessCount = 1;
const tools = createConsultationTools({
userId: "user-fictional",
sessionId: SOURCE_SESSION,
@@ -194,7 +199,10 @@ test("the C2 rule is defined once and reaches first turns, follow-ups and declar
assert.ok(followUpAnswerShapeInstruction().includes(RECTIFICATION_OFFER_RULE));
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
assert.match(route, /这只是粗看。" \+ RECTIFICATION_OFFER_RULE;/);
assert.match(RECTIFICATION_OFFER_RULE, /只是不认同某个解释,按纠正规则认下、改读,不提校正/);
// 原值:/按纠正规则认下、改读,不提校正/
// 新值:/按改读规则认下、改读,不提校正/
// 原因:BUG-1317,避免「生时纠正」被读成「用户在纠正解读」。
assert.match(RECTIFICATION_OFFER_RULE, /只是不认同某个解释,按改读规则认下、改读,不提校正/);
assert.match(RECTIFICATION_OFFER_RULE, /userAsked=true/);
});