Files
Jyotisha/frontend/tests/consultation-workflow-contract.test.ts
T
jesse-ux 8b11ae7dab fix(consult): 第 0 步改回 auto,供应商 error 块不再被吞掉
BUG-937 撤回 thinking 模式下的 required toolChoice,只留 activeTools。BUG-938 让咨询流识别 Mastra error 块,公开码 calculation_failed,内部码进日志且不触发合同 retry。
2026-09-17 23:37:49 +08:00

261 lines
15 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.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
consultationNatalPrepareStep,
consultationWindowPrepareStep,
} from "../src/mastra/consultation-tools.ts";
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const reportsRoute = readFileSync(new URL("../src/app/api/reports/route.ts", import.meta.url), "utf8");
const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8");
const tools = readFileSync(new URL("../src/mastra/consultation-tools.ts", import.meta.url), "utf8");
const stream = readFileSync(new URL("../src/lib/stream-agent-response.ts", import.meta.url), "utf8");
const workflow = readFileSync(new URL("../src/mastra/consultation-workflow.ts", import.meta.url), "utf8");
const plan = readFileSync(new URL("../src/lib/consultation-plan.ts", import.meta.url), "utf8");
const routeService = readFileSync(new URL("../src/lib/consultation-route-service.ts", import.meta.url), "utf8");
const pythonPlanContract = readFileSync(new URL("../../scripts/consultation_plan_contract.py", import.meta.url), "utf8");
const stagingCompose = readFileSync(new URL("../../deploy/docker-compose.staging.yml", import.meta.url), "utf8");
test("natal and window instructions require a chart tool every turn (BUG-922)", () => {
assert.doesNotMatch(mastra, /follow-ups may use the existing/);
assert.match(mastra, /Call run-jyotish-consultation before answering every turn/);
assert.match(mastra, /Call run-jyotish-window-consultation before answering every turn/);
});
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: "auto",
});
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 1 }), {
toolChoice: "auto",
});
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 0 }), {
activeTools: ["run-jyotish-window-consultation"],
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/);
assert.match(plan, /requiredEvidenceCategories/);
assert.match(plan, /createConsultationPlan/);
assert.match(tools, /const userIntent = ctx\.plan\?\.userIntent \?\? input\.question/);
assert.match(tools, /input\.domains === undefined && context\.plan && context\.theme/);
assert.match(tools, /return \[context\.theme\]/);
assert.match(tools, /chartCalculationProgressLabel\(index \+ 1, domains\.length\)/);
assert.match(tools, /phase: "chart-calculation"/);
assert.match(tools, /userIntent,[\s\S]*theme: domain/);
assert.match(tools, /question: userIntent,[\s\S]*theme: domain/);
assert.match(tools, /plan: domainPlan/);
assert.match(workflow, /packet_version: "consultation-evidence-packet-v2"/);
assert.match(workflow, /claim_cards/);
assert.match(tools, /modelOutput: toModelOutput\(agentContext, domainPlan\)/);
assert.match(tools, /modelOutput: toModelOutput[\s\S]*ctx\.state\.consultationToolCompleted = true/);
assert.match(plan, /precisionBoundary/);
assert.match(plan, /consultationPlanMaxCreditCost = 1/);
const modelToolInput = tools.slice(tools.indexOf("const consultationToolInputSchema"), tools.indexOf("const MAX_RECORDED_STEPS"));
assert.doesNotMatch(modelToolInput, /birth|latitude|longitude|engine|script|credit/i);
const prepareCall = route.slice(route.indexOf("prepared = await prepareConsultationRoute"), route.indexOf("const modelSelection"));
assert.match(prepareCall, /beforeReserve: \(\{ consultationMode \}\) => createConsultationPlan\(\{/);
assert.ok(prepareCall.indexOf("beforeReserve:") < prepareCall.indexOf("reserve: () => reserveConsultationModel("));
assert.ok(routeService.indexOf("const preReserveResult = input.beforeReserve") < routeService.indexOf("const reservation = await input.reserve()"));
assert.match(route, /plan: prepared\.preReserveResult,[\s\S]*theme: consultationTheme/);
assert.match(route, /runConsultationWorkflow\(toolInput, \{[\s\S]*plan: prepared\.preReserveResult/);
assert.match(workflow, /plan_version: workflowRequest\.plan_version/);
assert.match(workflow, /strict_workflow_route: workflowRequest\.strictWorkflowRoute/);
assert.match(workflow, /required_layers: workflowRequest\.requiredLayers/);
assert.match(workflow, /claim_boundary: workflowRequest\.claimBoundary/);
assert.match(workflow, /precision_boundary: workflowRequest\.precision_boundary/);
assert.match(workflow, /required_evidence_categories: workflowRequest\.required_evidence_categories/);
assert.match(pythonPlanContract, /PLAN_VERSION = "consultation-plan-v2"/);
assert.match(pythonPlanContract, /server_allowlist_validated/);
assert.match(pythonPlanContract, /can_answer_precise_timing": False/);
});
test("the model step budget and the wall-clock budget are declared as one pair", () => {
// The pair now lives beside the domain cap it funds: the cap is derived from
// the wall clock, so a change to one that forgets the other is impossible.
assert.match(tools, /export const AGENT_MAX_STEPS = 8;\nexport const AGENT_TIMEOUT_MS = 110_000;/);
assert.match(tools, /export const AGENT_SLICE_MAX_STEPS = 1;/);
assert.match(tools, /MAX_CONSULTATION_DOMAINS = Math\.max\(\s*1,\s*Math\.floor\(CONSULTATION_DOMAIN_WALL_CLOCK_MS \/ CONSULTATION_DOMAIN_DURATION_MS\),\s*\)/);
assert.doesNotMatch(route, /const AGENT_(MAX_STEPS|TIMEOUT_MS) =/);
assert.match(route, /maxSteps: AGENT_MAX_STEPS,/);
assert.match(route, /maxSteps: AGENT_SLICE_MAX_STEPS,/);
assert.match(route, /AbortSignal\.timeout\(AGENT_TIMEOUT_MS\)/);
assert.match(route, /createConsultationRuntimeState\(\{ plannedSteps: AGENT_MAX_STEPS \}\)/);
assert.doesNotMatch(route, /maxSteps: \d/);
assert.doesNotMatch(route, /AbortSignal\.timeout\(\d/);
});
test("consult streams reserve an answer budget and keep provider thinking on a separate channel", () => {
const settings = readFileSync(new URL("../src/lib/agent-generation-settings.ts", import.meta.url), "utf8");
assert.match(settings, /export const AGENT_ANSWER_OUTPUT_TOKENS = 16_384;/);
assert.match(settings, /export const AGENT_THINKING_OUTPUT_TOKENS = 8_192;/);
assert.match(settings, /export const AGENT_MAX_OUTPUT_TOKENS = AGENT_ANSWER_OUTPUT_TOKENS;/);
assert.match(settings, /options\.thinking \?\? "disabled"/);
assert.match(settings, /agentOutputTokenBudget\(thinkingMode,/);
assert.match(tools, /function consultationGenerationSettings/);
assert.match(tools, /return agentGenerationSettings\(model, \{ thinking: "enabled" \}\)/);
assert.match(tools, /function consultationContinueGenerationSettings/);
assert.match(tools, /return agentGenerationSettings\(model, \{ thinking: "disabled" \}\)/);
assert.match(tools, /function consultationSliceGenerationSettings/);
assert.match(tools, /reasoningEffort: "low"/);
assert.match(tools, /AGENT_MAX_OUTPUT_TOKENS as CONSULTATION_MAX_OUTPUT_TOKENS/);
assert.match(route, /\.\.\.consultationGenerationSettings\(selectedModel\.model\)/);
assert.match(route, /consultationContinueGenerationSettings\(selectedModel\.model\)/);
assert.match(route, /consultationSliceGenerationSettings\(selectedModel\.model\)/);
assert.match(route, /composeSection,/);
assert.doesNotMatch(route, /maxOutputTokens:\s*\d/);
assert.equal(route.match(/const continueAfterLength = async \(output: string\) => \{/g)?.length, 3);
assert.equal(route.match(/const composeSection = async \(\s*heading: string,\s*priorOutput: string,\s*reason: "write" \| "empty-retry" = "write",\s*\) => \{/g)?.length, 1);
assert.match(route, /toolChoice: "none"/);
assert.match(route, /entrypoint: consultEntrypoint/);
assert.match(route, /entrypoint: parsed\.data\.entrypoint/);
assert.match(tools, /export function consultationNatalPrepareStep/);
assert.match(tools, /export function consultationWindowPrepareStep/);
assert.match(tools, /activeTools: \[CONSULTATION_NATAL_CALC_TOOL_ID\]/);
assert.match(tools, /activeTools: \[CONSULTATION_WINDOW_CALC_TOOL_ID\]/);
const sharedStreamOptions = route.slice(
route.indexOf("const streamOptions = {"),
route.indexOf("const natalStreamOptions = {"),
);
assert.doesNotMatch(sharedStreamOptions, /prepareStep/);
assert.match(route, /prepareStep: consultationNatalPrepareStep/);
assert.match(route, /prepareStep: consultationWindowPrepareStep/);
assert.match(route, /streamWithOverflowRetry\(agent, natalStreamOptions\)/);
assert.match(route, /streamWithOverflowRetry\(agent, windowStreamOptions\)/);
assert.match(
route,
/请调用 run-jyotish-window-consultation 完成计算[\s\S]*?windowStreamOptions\)/,
);
assert.doesNotMatch(route, /先加载 Jyotish Skill/);
assert.match(route, /pinsConsultationDomains\(consultEntrypoint\)/);
assert.match(route, /调用时不要填写 domains,沿用服务器已选定的主题/);
const natalRetry = route.slice(
route.indexOf("if (!prepared.serverChart)"),
route.indexOf("const continueAfterLength = async (output: string) => {", route.indexOf("if (!prepared.serverChart)")),
);
assert.match(natalRetry, /], natalStreamOptions\)/);
assert.doesNotMatch(natalRetry, /], streamOptions\)/);
const composeBlock = route.slice(
route.indexOf("const composeSection = async ("),
route.indexOf("const executionReceipt = (): AgentExecutionReceipt => ({", route.indexOf("const composeSection = async (")),
);
assert.match(composeBlock, /\.\.\.streamOptions,/);
assert.doesNotMatch(composeBlock, /natalStreamOptions/);
assert.match(stream, /section-empty-retry/);
assert.match(tools, /dailyConsultationThinkingPlan/);
assert.match(tools, /pinsConsultationDomains/);
assert.match(stream, /thinking\.section/);
assert.match(stream, /thinking\.delta/);
assert.match(stream, /continueAfterLength/);
assert.match(stream, /composeSection/);
assert.match(stream, /drainSpoken/);
assert.match(stream, /answer-continue/);
});
test("uses one runtime step append entry and no scattered hard-coded step cap", () => {
assert.match(tools, /export function appendConsultationRuntimeStep/);
assert.doesNotMatch(tools, /steps\.length\s*>=\s*32/);
assert.doesNotMatch(stream, /state\.steps\.push/);
assert.doesNotMatch(stream, /steps\.length\s*<\s*32/);
});
test("personal consultation lets the Agent invoke the server-bound workflow tool", () => {
const agenticStart = route.indexOf("async function runAgenticConsultation");
const agenticBranch = route.slice(
agenticStart,
// Former value: `const { history } = parsed.data;` ended the agentic function.
// History now comes from stored session messages, not the client field.
route.indexOf(" const history = storedHistory;", agenticStart),
);
assert.match(agenticBranch, /createConsultationAgentContext/);
assert.match(agenticBranch, /getJyotishAgent\(selectedModel, agentContext\)/);
assert.doesNotMatch(agenticBranch, /await runConsultationWorkflow/);
assert.doesNotMatch(agenticBranch, /JSON\.stringify\(toolInput\)/);
assert.match(tools, /\(ctx\.runWorkflow \?\? runConsultationWorkflow\)\(toolInput, \{/);
assert.match(tools, /return \{ "run-jyotish-consultation": consultationTool \};/);
assert.match(agenticBranch, /state\.workflowReceipt\?\.preciseTiming === "allowed"/);
assert.match(route, /createConsultationReplyMetadata/);
});
test("defers optional external evidence only for foreground chat", () => {
assert.match(workflow, /defer_optional_external_evidence: options\?\.foreground === true/);
assert.match(reportsRoute, /runWorkflow: \(input\) => runConsultationWorkflow\(input\)/);
assert.doesNotMatch(reportsRoute, /foreground:\s*true/);
});
test("personal Agent owns the Skill and context-bound calculation tool", () => {
const personalFactory = mastra.slice(
mastra.indexOf("export function getJyotishAgent"),
mastra.indexOf("export function getLegacyJyotishAgent"),
);
assert.match(personalFactory, /getJyotishAgent\(model: ResolvedLanguageModel, context: ConsultationAgentContext\)/);
assert.match(personalFactory, /\.\.\.jyotishSkillBinding\(\)/);
assert.match(personalFactory, /tools: createConsultationTools\(context\)/);
assert.doesNotMatch(personalFactory, /server-computed-jyotish-workflow/);
});
test("validates and emits non-sensitive workflow and execution receipts", () => {
assert.match(workflow, /consultationWorkflowResponseSchema/);
assert.match(workflow, /safeParse\(data\)/);
assert.match(workflow, /consultationWorkflowReceipt/);
assert.match(route, /agentExecutionReceipt/);
assert.match(route, /streamAgentResponse/);
});
test("consultationWorkflowResponseSchema still parses without western_evidence_packet", () => {
const schema = workflow.slice(
workflow.indexOf("export const consultationWorkflowResponseSchema"),
workflow.indexOf("function record("),
);
assert.match(schema, /success: z\.boolean\(\)/);
assert.match(schema, /chart: z\.record\(z\.unknown\(\)\)/);
assert.match(schema, /routing: z\.record\(z\.unknown\(\)\)/);
assert.match(schema, /consumer_context:/);
assert.match(schema, /\)\.passthrough\(\);/);
assert.doesNotMatch(schema, /western_evidence_packet/);
});
test("carries commercial technique truth into the model contract", () => {
assert.match(workflow, /technique_truth/);
assert.match(mastra, /deterministic_claims_forbidden_for/);
assert.doesNotMatch(mastra, /AYANAM_SUGGESTIONS|AYANAM_TITLE/);
assert.doesNotMatch(mastra, /2-5 short paragraphs/);
assert.match(mastra, /reference_only/);
assert.match(mastra, /Do not use a restricted technique/);
});
test("projects consultation themes through explicit strict workflow taxonomy", () => {
const projection = readFileSync(new URL("../src/lib/consultation-workflow-request.ts", import.meta.url), "utf8");
const registry = readFileSync(new URL("../src/lib/consultation-domain-registry.ts", import.meta.url), "utf8");
assert.match(projection, /consultationDomainDefinition/);
assert.match(registry, /strictWorkflowRoute/);
assert.match(registry, /claimBoundary/);
assert.match(registry, /requiredLayers/);
assert.match(registry, /negative holdout gate/);
});
test("agentic consultation is the safe default and legacy is explicit rollback", () => {
assert.match(stagingCompose, /CONSULTATION_AGENTIC_RUNTIME: enabled/);
assert.match(route, /CONSULTATION_AGENTIC_RUNTIME\?\.trim\(\)\.toLowerCase\(\) \?\? "enabled"/);
assert.match(route, /mode === "legacy"/);
assert.match(route, /mode !== "canary"/);
});