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("window instructions still require the tool for timing questions (BUG-958)", () => { const window = mastra.slice( mastra.indexOf("const windowJyotishInstructions"), mastra.indexOf("export function getWindowJyotishAgent"), ); assert.match(window, /Timing questions still require calling the tool first/); assert.match(window, /Do not skip the calculation or refuse the whole question because precise timing is unavailable/); assert.match(window, /jyotishSkillMethodCoreBlock/); assert.doesNotMatch(window, /\$\{jyotishSkillMethodBlock\}/); }); function agentConstructorBodies(source: string): string[] { const bodies: string[] = []; const needle = "new Agent("; let search = 0; while (true) { const start = source.indexOf(needle, search); if (start < 0) break; const openParen = start + needle.length - 1; let depth = 0; let inStr: string | null = null; let escaped = false; let i = openParen; for (; i < source.length; i += 1) { const ch = source[i]; if (inStr) { if (escaped) { escaped = false; continue; } if (ch === "\\") { escaped = true; continue; } if (ch === inStr) inStr = null; continue; } if (ch === "'" || ch === '"' || ch === "`") { inStr = ch; continue; } if (ch === "(") depth += 1; else if (ch === ")") { depth -= 1; if (depth === 0) { i += 1; break; } } } bodies.push(source.slice(start, i)); search = i; } return bodies; } function constTemplateBody(file: string, name: string): string { return new RegExp(`(?:const|let) ${name} = \`([\\s\\S]*?)\`;`).exec(file)?.[1] ?? ""; } function instructionSource(agentBody: string, file: string): string { const named = /instructions:\s*([A-Za-z_][A-Za-z0-9_]*)/.exec(agentBody); let text = named ? `${agentBody}\n${constTemplateBody(file, named[1])}` : (/instructions:\s*`([\s\S]*?)`/.exec(agentBody)?.[1] ?? agentBody); for (const match of text.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) { text += `\n${constTemplateBody(file, match[1])}`; } return text; } test("every agent that attaches jyotishSkillBinding carries the method marker (BUG-954)", () => { const boundAgents = agentConstructorBodies(mastra).filter((body) => body.includes("...jyotishSkillBinding()")); assert.ok(boundAgents.length >= 3, `expected natal, legacy, and window agents, got ${boundAgents.length}`); for (const body of boundAgents) { const instructions = instructionSource(body, mastra); const hasMarker = instructions.includes("jyotishSkillMethodBlock") || instructions.includes("jyotishSkillMethodCoreBlock") || instructions.includes(" { // 原值: 第 0 步 toolChoice "required" / 新值: "auto" // 原因: BUG-282 供应商拒收 thinking 模式下的 required,BUG-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/); // 原值:/modelOutput: toModelOutput\(agentContext, domainPlan\)/ // 新值:多传执行领域 domain(parents / children 的必用层按计划领域取,引擎路由是 family)。 // 原因:TASK-consult-evidence-card-20260927 T2。 assert.match(tools, /modelOutput: toModelOutput\(agentContext, domainPlan, domain\)/); 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;/); // 原值: 锁 AGENT_SLICE_MAX_STEPS = 1 与路由里的 maxSteps: AGENT_SLICE_MAX_STEPS // 新值: 锁该常数已删除、路由不再出现单步写作流 // 原因: BUG-1053 删除 compose(它是唯一的单步流),写回答发生在 AGENT_MAX_STEPS 的主循环里 assert.doesNotMatch(tools, /AGENT_SLICE_MAX_STEPS/); 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.doesNotMatch(route, /AGENT_SLICE_MAX_STEPS/); // 原值: assert.match(route, /AbortSignal\.timeout\(AGENT_TIMEOUT_MS\)/) // 新值: 工具阶段的 AGENT_TIMEOUT_MS 交给 run clock(createConsultationRunClock 内部 AbortSignal.timeout) // 原因: BUG-1053 主循环在拿到计算结果后改走答案时钟;工具仍在 110s 的 toolSignal 下 assert.match(route, /toolPhaseMs: AGENT_TIMEOUT_MS,/); assert.match(tools, /const toolSignal = AbortSignal\.timeout\(toolPhaseMs\);/); 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, /consultationContinueGenerationSettings\(selectedModel\.model\)/); // 原值: 锁 composeAnswer 接线、恰好一个 composeAnswer、续写签名 (output: string) 三处、 // 以及 compose 的 toolChoice: "none" // 新值: 锁路由没有 composeAnswer、没有 toolChoice: "none"(主循环开 thinking,BUG-937 不得用非 auto); // 续写仍是三处,本命与申报时段两处接收 evidence // 原因: BUG-1053 删除 compose;续写必须拿到计算结果 assert.doesNotMatch(route, /composeAnswer/); assert.doesNotMatch(route, /maxOutputTokens:\s*\d/); assert.equal(route.match(/const continueAfterLength = async \(output: string(, evidence\?: unknown)?\) => \{/g)?.length, 3); assert.equal(route.match(/const continueAfterLength = async \(output: string, evidence\?: unknown\) => \{/g)?.length, 2); assert.doesNotMatch(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\)/); // 原值: 锁 compose 块用 streamOptions、带 retryHint、不用 natalStreamOptions // 新值: Pass 4 的 retryHint 改由本命 retryForAnswer 携带;该重试保留工具(natalStreamOptions,命中同请求缓存) // 原因: BUG-1053 删除 compose;Pass 4 整篇被拒后的重写改走带工具的 answer-retry const natalAnswerRetry = route.slice( route.indexOf("const retryForAnswer = async (retryHint?: string) => {", route.indexOf("if (!prepared.serverChart)")), route.indexOf("const continueAfterLength = async", route.indexOf("if (!prepared.serverChart)")), ); assert.match(natalAnswerRetry, /retryHint/); assert.match(natalAnswerRetry, /\.\.\.natalStreamOptions, abortSignal: answerPhaseSignal\(\)/); assert.doesNotMatch(stream, /section-empty-retry/); assert.match(tools, /dailyConsultationThinkingPlan/); assert.match(tools, /pinsConsultationDomains/); assert.match(stream, /thinking\.section/); assert.match(stream, /think\.plan/); // 原值: assert.match(stream, /think\.step/); // 新值: 咨询流不再发 think.step;计划行由第一条 answer.delta / run.* 收口(timeline completeLiveThink) // 原因: BUG-1053 删除 interpret/compose 阶段。think.step 只由 publishFindings 在 compose 前发出, // 而 interpretFindings 只回 id、文本恒空,事件只是把行从 running 翻到 done;事件 schema 与客户端 reducer 保留兼容 assert.doesNotMatch(stream, /publishFindings|interpretFindings/); assert.match(stream, /continueAfterLength/); // 原值: assert.match(stream, /composeAnswer/); assert.match(stream, /drainSpoken/); // 新值: 流里没有 composeAnswer / drainSpoken;回答按步取(stepScopedAnswer),续写带 calculationEvidence // 原因: BUG-1053 删除 compose 与「丢弃主循环正文」 assert.doesNotMatch(stream, /composeAnswer|drainSpoken/); assert.match(stream, /stepScopedAnswer/); assert.match(stream, /continueAfterLength\(pendingAnswer\(\), calculationEvidence\)/); 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 \};/); // 原值:transformText 读 workflowReceipt.preciseTiming === "allowed" 再套恒等壳 // 新值:Pass 4 只看 consultationMode,不再读 preciseTiming 开关去挖日期 // 原因:BUG-948,恒等壳下线;日期观察按模式,不按分钟敏感开关。 assert.match(agenticBranch, /pass4Mode: consultationMode/); assert.doesNotMatch(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"/); }); test("window calculation is precomputed on the agent context cache (BUG-957)", () => { const windowFactory = tools.slice(tools.indexOf("export function createWindowConsultationTools")); const natalFactory = tools.slice( tools.indexOf("export function createConsultationTools"), tools.indexOf("export async function precomputeWindowConsultation"), ); assert.match(tools, /export async function precomputeWindowConsultation/); assert.match(tools, /calculationCache/); assert.doesNotMatch(windowFactory, /let calculation/); assert.match(natalFactory, /let calculation/); assert.match(mastra, /tools: createWindowConsultationTools\(context\)/); assert.match(mastra, /If this turn already includes a server-owned window packet/); const windowBranch = route.slice( route.indexOf("if (shouldRunDeclaredWindowWorkflow"), route.indexOf("if (!prepared.serverChart)"), ); const natalBranch = route.slice(route.indexOf("if (!prepared.serverChart)")); assert.match(windowBranch, /precomputeWindowConsultation/); assert.match(windowBranch, /warmup:/); assert.match(windowBranch, /windowPrecomputedPacketMessage/); assert.match(route, /let windowPacketMessage: string \| null = null/); assert.match(route, /if \(windowPacketMessage\) \{/); assert.match(route, /content: windowPacketMessage/); assert.doesNotMatch(windowBranch, /toolChoice:\s*"required"/); assert.doesNotMatch(natalBranch, /precomputeWindowConsultation/); assert.doesNotMatch(natalBranch, /warmup:/); });