fix(consult): 第 0 步改回 auto,供应商 error 块不再被吞掉

BUG-937 撤回 thinking 模式下的 required toolChoice,只留 activeTools。BUG-938 让咨询流识别 Mastra error 块,公开码 calculation_failed,内部码进日志且不触发合同 retry。
This commit is contained in:
jesse-ux
2026-09-17 23:37:49 +08:00
parent b51e7aff43
commit 8b11ae7dab
11 changed files with 278 additions and 19 deletions
@@ -149,6 +149,14 @@ test("error normalization never records arbitrary exception messages", () => {
toAgentObservabilityErrorCode(new Error("answer_truncated")),
"answer_truncated",
);
assert.equal(
toAgentObservabilityErrorCode(new Error("provider_error")),
"provider_error",
);
assert.equal(
toAgentObservabilityErrorCode(new Error("thinking_tool_choice_unsupported")),
"thinking_tool_choice_unsupported",
);
assert.equal(
toAgentObservabilityErrorCode(new Error("/opt/internal/users/alice.json")),
"calculation_failed",
@@ -656,11 +656,11 @@ 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");
// 原值: toolChoice "auto" / 新值: 第 0 步 "required"、第 1 步仍 "auto"
// 原因: BUG-923 每轮必须先调排盘工具,提示词例外已删
// 原值: 第 0 步 toolChoice "required" / 新值: "auto"
// 原因: BUG-282 供应商拒收 thinking 模式下的 requiredBUG-937 撤回
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0 }), {
activeTools: ["run-jyotish-consultation"],
toolChoice: "required",
toolChoice: "auto",
});
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 1 }), {
toolChoice: "auto",
@@ -669,9 +669,11 @@ test("natal first step exposes only the chart calculation tool", () => {
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 模式下的 requiredBUG-937 撤回
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 0 }), {
activeTools: ["run-jyotish-window-consultation"],
toolChoice: "required",
toolChoice: "auto",
});
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 1 }), {
toolChoice: "auto",
@@ -1266,6 +1268,79 @@ test("incomplete runtime contract fails without saving a successful answer", asy
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.failed").length, 1);
});
test("a thinking-mode toolChoice rejection fails without a contract retry (BUG-938)", async () => {
const state = createConsultationRuntimeState();
let onErrorMessage = "";
async function* chunks() {
yield { type: "error", error: new Error("Thinking mode does not support this tool_choice") };
}
const response = streamAgentResponse({
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
toolStatus: () => "blocked",
receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }),
retry: async () => {
assert.fail("provider error must not trigger a contract retry");
return chunks();
},
onError: (error) => {
onErrorMessage = error instanceof Error ? error.message : String(error);
},
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
assert.deepEqual(events.map((event) => (event as { type: string }).type), [
"run.started",
"skill.started",
"skill.completed",
"run.failed",
]);
const failure = events.find((event) => (event as { type?: string }).type === "run.failed") as {
code: string;
receipt?: { steps: Array<{ kind: string; name: string; status: string }> };
};
assert.equal(failure.code, "calculation_failed");
assert.equal(onErrorMessage, "thinking_tool_choice_unsupported");
assert.equal(
events.some((event) => (event as { type?: string; phase?: string }).type === "activity"
&& (event as { phase?: string }).phase === "loading-method"),
false,
);
assert.ok(failure.receipt?.steps.some((step) =>
step.kind === "validation" && step.name === "model-stream-error" && step.status === "failed"));
assert.doesNotMatch(JSON.stringify(events), /Thinking mode does not support this tool_choice/);
});
test("a generic provider stream error is calculation_failed and skips retry (BUG-938)", async () => {
const state = createConsultationRuntimeState();
let onErrorMessage = "";
async function* chunks() {
yield { type: "error", error: new Error("upstream 502") };
}
const response = streamAgentResponse({
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
toolStatus: () => "blocked",
receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }),
retry: async () => {
assert.fail("provider error must not trigger a contract retry");
return chunks();
},
onError: (error) => {
onErrorMessage = error instanceof Error ? error.message : String(error);
},
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
const failure = events.find((event) => (event as { type?: string }).type === "run.failed") as { code: string };
assert.equal(failure.code, "calculation_failed");
assert.equal(onErrorMessage, "provider_error");
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.failed").length, 1);
assert.doesNotMatch(JSON.stringify(events), /upstream 502/);
});
function toolOnlyRunState() {
const state = createConsultationRuntimeState();
state.jyotishSkillBound = true;
@@ -26,22 +26,29 @@ test("natal and window instructions require a chart tool every turn (BUG-922)",
});
test("natal and window first steps require the chart tool (BUG-923)", () => {
// 原值: 第 0 步 toolChoice "required" / 新值: "auto"
// 原因: BUG-282 供应商拒收 thinking 模式下的 requiredBUG-937 撤回
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0 }), {
activeTools: ["run-jyotish-consultation"],
toolChoice: "required",
toolChoice: "auto",
});
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 1 }), {
toolChoice: "auto",
});
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 0 }), {
activeTools: ["run-jyotish-window-consultation"],
toolChoice: "required",
toolChoice: "auto",
});
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 1 }), {
toolChoice: "auto",
});
});
test("consultation prepareStep never sends required or named toolChoice (BUG-282 / BUG-937)", () => {
assert.doesNotMatch(tools, /toolChoice:\s*"required"/);
assert.doesNotMatch(tools, /type:\s*"tool",\s*toolName/);
});
test("consultation plans are server-owned and bounded", () => {
assert.match(plan, /consultationPlanSchema/);
assert.match(plan, /requestedDomains/);