import assert from "node:assert/strict"; import test from "node:test"; import { AGENT_MAX_STEPS, AGENT_TIMEOUT_MS, CONSULTATION_MAX_OUTPUT_TOKENS, CONSULTATION_NATAL_CALC_TOOL_ID, CONSULTATION_WINDOW_CALC_TOOL_ID, mergeConsultationAnswerPolicies, CONSULTATION_DOMAIN_WALL_CLOCK_MS, MAX_CONSULTATION_DOMAINS, appendConsultationRuntimeStep, canonicalDomainPlan, consultationGenerationSettings, consultationNatalPrepareStep, consultationWindowPrepareStep, consultationSliceGenerationSettings, consultationModelStepTelemetry, consultationStepBudgetReceipt, consultationToolFailureCode, createConsultationRuntimeHooks, createConsultationTools, createConsultationRuntimeState, domainFitsRunBudget, executableDomainPlan, publicConsultationRuntimeSteps, } from "../src/mastra/consultation-tools.ts"; import { ConsultationWorkflowError, consultationWorkflowFailureCode, } from "../src/mastra/consultation-workflow.ts"; import { agentExecutionReceiptSchema } from "../src/lib/consultation-agent-events.ts"; import { consultationDomainIds, consultationDomainPlanValues } from "../src/lib/consultation-domain-registry.ts"; import { natalConsultationThinkingPlan, dailyConsultationThinkingPlan, DAILY_HEADING, REPORT_HEADING, } from "../src/lib/consultation-thinking-plan.ts"; import { consultationAgentPublicEventSchema, createNdjsonParser } from "../src/lib/consultation-agent-events.ts"; import { createConsultationPlan } from "../src/lib/consultation-plan.ts"; import { collectAgentPublicEvents, CONTRACT_DEGRADED_NOTE, streamAgentResponse, } from "../src/lib/stream-agent-response.ts"; import { GENERAL_NO_BIRTH_TIME_REFUSAL } from "../src/lib/timing-output-guard.ts"; const serverChart = { name: "测试", toolInput: { year: 1990, month: 1, day: 2, hour: 3, minute: 4, city: "台北", lat: 25.03, lon: 121.56, tz: 8, ayanamsa: "raman" as const, declared_accuracy: "15min" as const, time_source: "family_vague" }, truth: { birthDate: "1990-01-02", reportedBirthTime: "03:04", activeBirthTime: null, selectedTimeKind: "reported" as const, birthTimeSource: "reported", birthTimeStatus: "reported", placeLabel: "台北", placeCodes: { countryCode: "TW", provinceCode: null, cityCode: null, districtCode: null }, placeId: null, placeType: "city", placeProvider: "profile", latitude: 25.03, longitude: 121.56, timezoneId: "Asia/Taipei", timezoneSource: "profile", timezoneOffset: 8, }, }; // Mastra validates against the model-facing schema before execute() runs, so a // test can only send what the model can send. The cast keeps the argument // checked against that shape without depending on Mastra's inferred type. type ModelConsultationToolInput = { question: string; domains?: string[] }; const modelInput = (input: ModelConsultationToolInput) => input as never; const rejectedByInputSchema = (input: { question: string; theme?: string; domains?: string[] }) => input as never; type WorkflowOptions = { status?: "ready" | "degraded" | "blocked"; missingLayers?: string[]; preciseTiming?: boolean; availableLayers?: string[]; hardBlockers?: string[]; leadWithLimitations?: boolean; limitation?: string; chart?: Record; }; function workflow(theme = "career", options: WorkflowOptions = {}) { return { success: true, question: "综合看看", chart: options.chart ?? {}, routing: { primary_theme: theme }, consumer_context: { route: theme, core_status: options.status ?? "ready", available_layers: options.availableLayers ?? [], missing_route_layers: options.missingLayers ?? [], hard_blockers: options.hardBlockers ?? [], technique_truth: { status: "verified" }, answer_policy: { can_answer_direction: true, can_answer_precise_timing: options.preciseTiming ?? true, ...(options.leadWithLimitations === undefined ? {} : { should_lead_with_limitations: options.leadWithLimitations }), }, ...(options.limitation === undefined ? {} : { user_facing_limitation: options.limitation }), }, }; } // The natal projection only survives the evidence allowlist when the chart // actually carries allowlisted placements, and the hoisting test needs it to. const natalChart = { ascendant: { sign: "Leo", degree: 12.5 }, planets: [{ name: "Sun", sign: "Leo", degree: 1.25 }, { name: "Moon", sign: "Pisces", degree: 20.5 }], houses: [{ number: 1, sign: "Leo" }], }; const toolContext = { observe: { span: async (_n: string, fn: () => Promise) => fn(), log() {} } } as never; type PlanResult = Record & { domains: string[]; omitted_domains: string[]; consultations: Array & { domain: string; claim_cards: Array<{ category: string }> }>; evidence_contract: { available_layers: string[]; missing_route_layers: string[]; hard_blockers: string[]; answer_policy: Record; user_facing_limitation?: string; }; rectification: { boundary: string }; claim_cards: Array<{ category: string }>; }; async function runDomainPlan( domains: string[], runWorkflow: (theme: string) => ReturnType, options: { now?: () => number; requestId?: string } = {}, ) { const state = createConsultationRuntimeState(); const tool = createConsultationTools({ userId: "u", sessionId: "s", requestId: options.requestId ?? `r-${domains.join("-")}`, consultationMode: "verified_chart", serverChart, state, ...(options.now ? { now: options.now } : {}), runWorkflow: async (input) => runWorkflow(input.theme), })["run-jyotish-consultation"]; const result = await tool.execute!(modelInput({ question: "综合看看", domains }), toolContext) as PlanResult; return { result, state }; } test("the server-selected domain stays authoritative when the model omits domains", async () => { let calls = 0; let captured: unknown; let capturedPlan: unknown; const state = createConsultationRuntimeState(); const plan = createConsultationPlan({ userIntent: "事业如何", theme: "career", consultationMode: "unverified_birth_time", modelCreditCost: 1, }); assert.throws( () => (plan.requestedDomains as unknown as string[]).push("timing"), TypeError, ); const tools = createConsultationTools({ userId: "u", sessionId: "s", requestId: "r", consultationMode: "unverified_birth_time", plan, theme: "career", serverChart, state, runWorkflow: async (input, options) => { calls += 1; captured = input; capturedPlan = options?.plan; return workflow(); }, }); const tool = tools["run-jyotish-consultation"]; // A single-value theme is deliberately absent: two mutually exclusive ways to // name a domain cost the model a step per call to discover the rule. assert.deepEqual(Object.keys((tool.inputSchema as unknown as { shape: object }).shape), ["question", "domains"]); const execute = tool.execute!; const context = { observe: { span: async (_n: string, fn: () => Promise) => fn(), log() {} } } as never; const [first, second] = await Promise.all([ execute(modelInput({ question: "尝试改成精确应期" }), context), execute(modelInput({ question: "尝试改成婚恋" }), context), ]); assert.equal(calls, 1); assert.deepEqual(first, second); assert.deepEqual(captured, { ...serverChart.toolInput, entryMode: "direct_chart", question: "事业如何", theme: "career" }); assert.strictEqual(capturedPlan, plan); assert.equal(state.consultationToolCallCount, 1); assert.equal(state.consultationToolSuccessCount, 1); assert.equal(state.workflowReceipt?.preciseTiming, "allowed"); assert.deepEqual(state.workflowReceipt?.domains, ["career"]); assert.deepEqual((first as { domains?: string[] }).domains, ["career"]); }); test("multi-domain plan canonicalizes aliases, de-duplicates, preserves order, and runs every domain", async () => { const calls: Array<{ theme: string; question: string }> = []; const state = createConsultationRuntimeState(); const tools = createConsultationTools({ userId: "u", sessionId: "s", requestId: "r", consultationMode: "verified_chart", serverChart, state, runWorkflow: async (input) => { calls.push({ theme: input.theme, question: input.question }); if (input.theme === "wealth") return workflow(input.theme, { status: "degraded", missingLayers: ["D11"] }); return workflow(input.theme); }, }); // 原值:别名 career/finance/home 三个都执行,domains 含 migration // 新值:别名仍规范化,执行上限 2,migration 进 omitted_domains // 原因:BUG-945/946,墙钟只付得起 2 个领域。 const result = await tools["run-jyotish-consultation"].execute!( modelInput({ question: "事业、财富和迁居怎么一起规划", domains: ["career", "finance", "home"] }), { observe: { span: async (_n: string, fn: () => Promise) => fn(), log() {} } } as never, ) as { domains: string[]; omitted_domains: string[]; consultations: Array<{ domain: string }> }; assert.deepEqual(calls, [ { theme: "career", question: "事业、财富和迁居怎么一起规划" }, { theme: "wealth", question: "事业、财富和迁居怎么一起规划" }, ]); assert.deepEqual(result.domains, ["career", "wealth"]); assert.deepEqual(result.omitted_domains, ["migration"]); assert.deepEqual(result.consultations.map((item) => item.domain), ["career", "wealth"]); assert.deepEqual(state.workflowReceipt, { route: "multi-domain", status: "degraded", preciseTiming: "allowed", missingLayers: ["D11"], domains: ["career", "wealth"], omittedDomains: ["migration"], }); }); test("a multi-domain result exposes the same top-level answer contract as a single domain", async () => { const single = await runDomainPlan(["career"], (theme) => workflow(theme, { availableLayers: ["D10"], chart: natalChart, })); const multi = await runDomainPlan(["career", "wealth"], (theme) => workflow(theme, { availableLayers: theme === "career" ? ["D10"] : ["D11"], chart: natalChart, })); // Every path jyotishInstructions states an output rule against has to resolve // in both shapes. With none of them present the model has no contract that // authorizes it to speak, which is how a successful calculation produced no // answer at all. for (const key of ["packet_version", "question", "route", "status", "evidence_contract", "claim_cards", "rectification"]) { assert.equal(key in single.result, true, `single-domain result is missing ${key}`); assert.equal(key in multi.result, true, `multi-domain result is missing ${key}`); } for (const contract of [single.result.evidence_contract, multi.result.evidence_contract]) { assert.equal(Array.isArray(contract.available_layers), true); assert.equal(Array.isArray(contract.missing_route_layers), true); assert.equal(Array.isArray(contract.hard_blockers), true); assert.equal(typeof contract.answer_policy.can_answer_direction, "boolean"); assert.equal(typeof contract.answer_policy.can_answer_precise_timing, "boolean"); } assert.equal(single.result.rectification.boundary, "not_auto_rectified"); assert.equal(multi.result.rectification.boundary, "not_auto_rectified"); assert.equal(multi.result.packet_version, single.result.packet_version); assert.equal(multi.result.question, single.result.question); assert.equal(multi.result.route, "multi-domain"); assert.equal(multi.result.status, "ready"); assert.equal(multi.result.success, true); assert.equal(single.result.success, true); // An available layer stays available: it really was computed for one domain. assert.deepEqual(multi.result.evidence_contract.available_layers, ["D10", "D11"]); assert.deepEqual(multi.result.consultations.map((item) => item.domain), ["career", "wealth"]); }); test("the merged answer policy is the most restrictive of the executed domains", async () => { // 原值:career+timing+wealth 三个都执行,D11 / 限制句来自 wealth // 新值:只执行 2 个;把限制性字段放到 timing 上,合并规则不变 // 原因:BUG-946,MAX=2 后第三域进 omitted,不能再靠它提供合并输入。 const { result, state } = await runDomainPlan(["career", "timing"], (theme) => workflow(theme, { // One domain forbidding precise timing must forbid it for the whole answer. preciseTiming: theme !== "timing", status: theme === "timing" ? "degraded" : "ready", missingLayers: theme === "timing" ? ["D11"] : [], hardBlockers: theme === "timing" ? ["negative_holdout_gate"] : [], leadWithLimitations: theme === "timing", limitation: theme === "timing" ? "财富层证据不完整。" : undefined, chart: natalChart, })); const policy = result.evidence_contract.answer_policy; assert.equal(policy.can_answer_precise_timing, false); assert.equal(policy.can_answer_direction, true); assert.equal(policy.should_lead_with_limitations, true); assert.deepEqual(result.evidence_contract.hard_blockers, ["negative_holdout_gate"]); assert.deepEqual(result.evidence_contract.missing_route_layers, ["D11"]); assert.equal(result.status, "degraded"); assert.equal(result.evidence_contract.user_facing_limitation, "财富层证据不完整。"); assert.equal(state.workflowReceipt?.preciseTiming, "blocked"); assert.equal(state.workflowReceipt?.status, "degraded"); const blocked = await runDomainPlan(["career", "health"], (theme) => workflow(theme, { status: theme === "health" ? "blocked" : "ready", chart: natalChart, })); assert.equal(blocked.result.status, "blocked"); }); test("merging answer policies can only ever restrict", () => { // Merged directly, because the projection currently emits only three policy // fields and the rules have to hold for any field it may emit later. assert.deepEqual( mergeConsultationAnswerPolicies([ { can_answer_direction: true, can_answer_precise_timing: true }, { can_answer_direction: true, can_answer_precise_timing: false }, ]), { can_answer_direction: true, can_answer_precise_timing: false }, ); assert.deepEqual( mergeConsultationAnswerPolicies([ { can_answer_direction: true, can_answer_precise_timing: true, should_lead_with_limitations: false }, { can_answer_direction: false, can_answer_precise_timing: true, should_lead_with_limitations: true }, ]), { can_answer_direction: false, can_answer_precise_timing: true, should_lead_with_limitations: true }, ); // A prohibition list unions: a technique one domain forbids stays forbidden. const prohibitions = mergeConsultationAnswerPolicies([ { can_answer_direction: true, can_answer_precise_timing: true, deterministic_claims_forbidden_for: ["narayana"] }, { can_answer_direction: true, can_answer_precise_timing: true, deterministic_claims_forbidden_for: ["transit", "narayana"] }, ]); assert.deepEqual(prohibitions.deterministic_claims_forbidden_for, ["narayana", "transit"]); // A boolean that is absent for one domain is not consent from that domain. assert.equal( mergeConsultationAnswerPolicies([{ can_answer_chart_interpretation: true }, {}]).can_answer_chart_interpretation, false, ); // A field the domains disagree on in a way that cannot be merged is reported // as unresolved and forces the answer to lead with its limits, rather than // being dropped, which would remove whatever it was restricting. const conflicted = mergeConsultationAnswerPolicies([ { can_answer_direction: true, can_answer_precise_timing: true, claim_ceiling: "direction_only" }, { can_answer_direction: true, can_answer_precise_timing: true, claim_ceiling: "structure_only" }, ]); assert.deepEqual(conflicted.unresolved_policy_fields, ["claim_ceiling"]); assert.equal(conflicted.should_lead_with_limitations, true); assert.equal("claim_ceiling" in conflicted, false); }); test("the merged contract exposes every policy field a single domain exposes", async () => { const options: WorkflowOptions = { leadWithLimitations: false, limitation: "边界说明。", chart: natalChart, }; const single = await runDomainPlan(["career"], (theme) => workflow(theme, options)); const multi = await runDomainPlan(["career", "wealth"], (theme) => workflow(theme, options)); // Guards drift: a field added to the per-domain projection without being // merged would silently vanish from the multi-domain contract. for (const key of Object.keys(single.result.evidence_contract.answer_policy)) { assert.equal(key in multi.result.evidence_contract.answer_policy, true, `merged policy is missing ${key}`); } for (const key of Object.keys(single.result.evidence_contract)) { assert.equal(key in multi.result.evidence_contract, true, `merged contract is missing ${key}`); } assert.equal(multi.result.evidence_contract.answer_policy.should_lead_with_limitations, false); }); test("the identical natal projection is carried once instead of per domain", async () => { const { result } = await runDomainPlan(["career", "wealth", "timing"], (theme) => workflow(theme, { chart: natalChart })); assert.deepEqual(result.claim_cards.map((card) => card.category), ["natal_foundation"]); assert.equal( result.consultations.every((item) => item.claim_cards.every((card) => card.category !== "natal_foundation")), true, ); assert.equal(result.consultations.some((item) => item.claim_cards.length > 0), true); // When the domains genuinely disagree, nothing is presented as shared. const differing = await runDomainPlan(["career", "wealth"], (theme) => workflow(theme, { chart: theme === "career" ? natalChart : { ...natalChart, ascendant: { sign: "Virgo", degree: 1 } }, })); assert.deepEqual(differing.result.claim_cards, []); assert.equal( differing.result.consultations.every((item) => item.claim_cards.some((card) => card.category === "natal_foundation")), true, ); }); test("the domain cap is what the run budget can actually pay for", () => { // 原值:MAX=3,按 21s/领域 // 新值:MAX=2,按实测 31s/领域(110s-45s 写作预留) // 原因:BUG-944/946,工具实测约 31s/领域,21s 会把写作预算吃光。 assert.equal(MAX_CONSULTATION_DOMAINS, 2); assert.equal(AGENT_TIMEOUT_MS, 110_000); assert.equal(CONSULTATION_DOMAIN_WALL_CLOCK_MS, 65_000); assert.ok(MAX_CONSULTATION_DOMAINS * 31_000 <= CONSULTATION_DOMAIN_WALL_CLOCK_MS); assert.ok(6 * 31_000 > AGENT_TIMEOUT_MS); assert.deepEqual( executableDomainPlan(["career", "wealth", "timing", "marriage", "health"]), { domains: ["career", "wealth"], omittedDomains: ["timing", "marriage", "health"] }, ); assert.deepEqual(executableDomainPlan(["career"]), { domains: ["career"], omittedDomains: [] }); assert.equal(domainFitsRunBudget(0, 0), true); assert.equal(domainFitsRunBudget(31_000, 1), true); assert.equal(domainFitsRunBudget(62_000, 2), false); assert.equal(domainFitsRunBudget(60_000, 2), false); assert.equal(domainFitsRunBudget(40_000, 1), false); }); test("a plan larger than the execution cap is truncated not refused", async () => { // 原值:4 个领域 schema 失败、execute 0 次、无 omitted_domains // 新值:schema 接受最多 6 个,execute ≥1 次,omitted_domains 非空 // 原因:BUG-945,描述承诺截断,zod 却整次拒绝。 let calls = 0; const state = createConsultationRuntimeState(); const tool = createConsultationTools({ userId: "u", sessionId: "s", requestId: "r-cap", consultationMode: "verified_chart", serverChart, state, runWorkflow: async (input) => { calls += 1; return workflow(input.theme); }, })["run-jyotish-consultation"]; const inputSchema = tool.inputSchema as unknown as { safeParse: (value: unknown) => { success: boolean } }; assert.equal(inputSchema.safeParse({ question: "测试", domains: ["career", "wealth", "timing"] }).success, true); assert.equal(inputSchema.safeParse({ question: "测试", domains: ["career", "wealth", "timing", "marriage"] }).success, true); assert.equal(inputSchema.safeParse({ question: "测试", domains: ["career", "wealth", "timing", "marriage", "health", "education", "family"], }).success, false); const result = await tool.execute!( modelInput({ question: "全都看看", domains: ["career", "wealth", "timing", "marriage", "health"] }), toolContext, ) as PlanResult; assert.ok(calls >= 1); assert.ok((result.omitted_domains ?? []).length > 0); assert.doesNotMatch(JSON.stringify(result), /Tool input validation failed/); assert.equal(state.consultationToolStarted, true); }); test("a plan that runs long stops early and discloses the domains it dropped", async () => { let clock = 0; const executed: string[] = []; const state = createConsultationRuntimeState(); const tool = createConsultationTools({ userId: "u", sessionId: "s", requestId: "r-slow", consultationMode: "verified_chart", serverChart, state, now: () => clock, runWorkflow: async (input) => { executed.push(input.theme); clock += 40_000; return workflow(input.theme, { chart: natalChart }); }, })["run-jyotish-consultation"]; const result = await tool.execute!( modelInput({ question: "三个领域", domains: ["career", "wealth", "timing"] }), toolContext, ) as PlanResult; // 40s each cannot fit a second domain inside the loop's share of the budget, // so the run answers what it has instead of aborting mid-loop and losing it. assert.deepEqual(executed, ["career"]); assert.deepEqual(result.domains, ["career"]); assert.deepEqual(result.omitted_domains, ["wealth", "timing"]); assert.equal(result.status, "degraded"); assert.equal(state.consultationToolCompleted, true); assert.equal(state.consultationToolSuccessCount, 1); assert.equal(state.consultationToolDurationMs, 40_000); // The single executed domain still has to carry the full top-level contract. for (const key of ["packet_version", "route", "status", "evidence_contract", "claim_cards", "rectification"]) { assert.equal(key in result, true, `truncated result is missing ${key}`); } }); test("the advertised domain limit matches the enforced one", () => { const tool = createConsultationTools({ userId: "u", sessionId: "s", requestId: "r-description", consultationMode: "verified_chart", serverChart, state: createConsultationRuntimeState(), runWorkflow: async (input) => workflow(input.theme), })["run-jyotish-consultation"]; const description = tool.description ?? ""; assert.match(description, /up to 6/); assert.match(description, /omitted_domains/); assert.match(description, new RegExp(`about ${MAX_CONSULTATION_DOMAINS}`)); assert.match(description, /top-level answer contract/); }); test("domain plan rejects unknown and product domains before any workflow runs", async () => { for (const domain of ["unknown", "prashna", "muhurta", "rectification", "compatibility"]) { let calls = 0; const state = createConsultationRuntimeState(); const tool = createConsultationTools({ userId: "u", sessionId: "s", requestId: `r-${domain}`, consultationMode: "verified_chart", serverChart, state, runWorkflow: async () => { calls += 1; return workflow(); }, })["run-jyotish-consultation"]; // Refused by the enumerated schema before execute, so Mastra resolves with a // validation envelope instead of the tool throwing from the registry check. const rejected = await tool.execute!( modelInput({ question: "测试", domains: ["career", domain] }), { observe: { span: async (_n: string, fn: () => Promise) => fn(), log() {} } } as never, ) as { error?: unknown }; assert.equal(rejected.error, true, domain); assert.equal(calls, 0); assert.equal(state.consultationToolCompleted, false); } }); test("the model-facing schema names the domain vocabulary it accepts", async () => { const state = createConsultationRuntimeState(); const tool = createConsultationTools({ userId: "u", sessionId: "s", requestId: "r-vocabulary", consultationMode: "verified_chart", serverChart, state, runWorkflow: async () => workflow(), })["run-jyotish-consultation"]; const inputSchema = tool.inputSchema as unknown as { safeParse: (value: unknown) => { success: boolean }; shape: { domains: { unwrap: () => { element: { options?: readonly string[] } } } }; }; // The skill's methodology names strict-workflow checklists, and while this was // a free-form string those labels passed validation and died inside the call. // Enumerating the values is what puts the vocabulary in front of the model. assert.equal(inputSchema.safeParse({ question: "测试", domains: ["event-timing-strict"] }).success, false); assert.equal(inputSchema.safeParse({ question: "测试", domains: ["wealth-timing-strict"] }).success, false); assert.equal(inputSchema.safeParse({ question: "测试", domains: ["career"] }).success, true); // Aliases stay accepted: enumerating states the vocabulary, it does not narrow it. assert.equal(inputSchema.safeParse({ question: "测试", domains: ["finance"] }).success, true); assert.equal(inputSchema.safeParse({ question: "测试", domains: ["感情"] }).success, true); // Enumerable, so the JSON schema handed to the model carries the values rather // than an opaque string. A wrapper that hid them would pass the checks above. const options = inputSchema.shape.domains.unwrap().element.options; assert.deepEqual(consultationDomainPlanValues, options); for (const id of consultationDomainIds) assert.ok(options?.includes(id), id); }); test("domain plan enforces the raw plan upper bound and one input mode", async () => { let calls = 0; const state = createConsultationRuntimeState(); const tool = createConsultationTools({ userId: "u", sessionId: "s", requestId: "r-limit", consultationMode: "verified_chart", serverChart, state, runWorkflow: async () => { calls += 1; return workflow(); }, })["run-jyotish-consultation"]; const context = { observe: { span: async (_n: string, fn: () => Promise) => fn(), log() {} } } as never; const inputSchema = tool.inputSchema as unknown as { safeParse: (value: unknown) => { success: boolean } }; assert.equal(inputSchema.safeParse({ question: "测试", domains: ["career", "career", "career", "career", "career", "career", "career"], }).success, false); assert.equal(calls, 0); // The model can only express a domain plan one way. The pair that used to be // representable, and cost a model step to be told was invalid, is now refused // by the schema before the tool body runs. assert.equal(inputSchema.safeParse({ question: "测试", domains: ["career"] }).success, true); assert.equal(inputSchema.safeParse({ question: "测试" }).success, true); assert.equal(inputSchema.safeParse({ question: "测试", theme: "career" }).success, false); assert.equal(inputSchema.safeParse({ question: "测试", domains: ["career"], theme: "career" }).success, false); const rejectedState = createConsultationRuntimeState(); const secondTool = createConsultationTools({ userId: "u", sessionId: "s", requestId: "r-modes", consultationMode: "verified_chart", serverChart, state: rejectedState, runWorkflow: async () => { calls += 1; return workflow(); }, })["run-jyotish-consultation"]; const refused = await secondTool.execute!( rejectedByInputSchema({ question: "测试", domains: ["career"], theme: "career" }), context, ) as Record; // Nothing about the run advances: no calculation, no counted attempt, and no // consultation payload the model could mistake for a result. assert.equal(calls, 0); assert.equal("domains" in refused, false); assert.equal(rejectedState.consultationToolStarted, false); assert.equal(rejectedState.consultationToolCallCount, 0); assert.deepEqual(runSteps(rejectedState), []); }); test("the single-value domain form stays available to callers without the model schema", () => { const plan = createConsultationPlan({ userIntent: "事业如何", theme: "career", consultationMode: "verified_chart", modelCreditCost: 1, }); // A single-value theme must never override the route-selected server domain. assert.deepEqual(canonicalDomainPlan({ theme: "timing" }, { plan, theme: "career" }), ["career"]); assert.deepEqual(canonicalDomainPlan({}, { plan, theme: "career" }), ["career"]); assert.deepEqual(canonicalDomainPlan({ theme: "marriage" }, {}), ["marriage"]); assert.deepEqual(canonicalDomainPlan({ domains: ["career", "finance"] }, {}), ["career", "wealth"]); assert.deepEqual(canonicalDomainPlan({ domains: ["career", "finance", "career", "home"] }, {}), ["career", "wealth", "migration"]); assert.deepEqual(canonicalDomainPlan({ domains: ["timing"] }, { plan, theme: "career" }), ["timing"]); assert.deepEqual( canonicalDomainPlan({ domains: ["general"] }, { plan, theme: "timing", entrypoint: "daily_starlanguage", }), ["timing"], ); assert.deepEqual( canonicalDomainPlan({ domains: ["general"] }, { plan, theme: "family", entrypoint: "guided_topic", }), ["family"], ); assert.throws( () => canonicalDomainPlan({ domains: ["career"], theme: "career" }, {}), /invalid_consultation_domain_plan/, ); assert.deepEqual( canonicalDomainPlan({ domains: ["career"], theme: "career" }, { theme: "timing", entrypoint: "daily_starlanguage", }), ["timing"], ); assert.deepEqual( canonicalDomainPlan({ domains: ["not-a-domain"] }, { theme: "timing", entrypoint: "birth_time_rectification", }), ["timing"], ); assert.throws(() => canonicalDomainPlan({}, {}), /invalid_consultation_domain_plan/); assert.throws(() => canonicalDomainPlan({ theme: "prashna" }, {}), /unsupported_consultation_domain/); }); test("daily entrypoint ignores a model domain rewrite instead of executing it", async () => { const calls: string[] = []; const state = createConsultationRuntimeState(); const plan = createConsultationPlan({ userIntent: "深入看今日", theme: "timing", consultationMode: "verified_chart", modelCreditCost: 1, }); const result = await createConsultationTools({ userId: "u", sessionId: "s", requestId: "r-daily-pin", consultationMode: "verified_chart", plan, theme: "timing", entrypoint: "daily_starlanguage", serverChart, state, runWorkflow: async (input) => { calls.push(input.theme); return workflow(input.theme); }, })["run-jyotish-consultation"].execute!( modelInput({ question: "深入看今日", domains: ["general"] }), toolContext, ) as PlanResult; assert.deepEqual(calls, ["timing"]); assert.deepEqual(result.domains, ["timing"]); assert.equal(state.planOverrideIgnored, true); assert.equal(state.workflowReceipt?.planOverrideIgnored, true); assert.deepEqual(state.thinkingPlan?.map((section) => section.heading), [ DAILY_HEADING.trend, DAILY_HEADING.actAvoid, DAILY_HEADING.action, ]); }); test("guided-topic entrypoint ignores a model domain rewrite instead of executing it", async () => { const calls: string[] = []; const state = createConsultationRuntimeState(); const plan = createConsultationPlan({ userIntent: "请帮我看看我家庭关系的整体模式和特点", theme: "family", consultationMode: "verified_chart", modelCreditCost: 1, }); const result = await createConsultationTools({ userId: "u", sessionId: "s", requestId: "r-family-pin", consultationMode: "verified_chart", plan, theme: "family", entrypoint: "guided_topic", serverChart, state, runWorkflow: async (input) => { calls.push(input.theme); return workflow(input.theme); }, })["run-jyotish-consultation"].execute!( modelInput({ question: "请帮我看看我家庭关系的整体模式和特点", domains: ["general"] }), toolContext, ) as PlanResult; assert.deepEqual(calls, ["family"]); assert.deepEqual(result.domains, ["family"]); assert.equal(state.planOverrideIgnored, true); assert.equal(state.workflowReceipt?.planOverrideIgnored, true); }); 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 撤回 assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0 }), { activeTools: ["run-jyotish-consultation"], toolChoice: "auto", }); assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 1 }), { 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 撤回 assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 0 }), { activeTools: ["run-jyotish-window-consultation"], toolChoice: "auto", }); assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 1 }), { toolChoice: "auto", }); }); test("matching the pinned theme does not record a plan override", async () => { const state = createConsultationRuntimeState(); await createConsultationTools({ userId: "u", sessionId: "s", requestId: "r-daily-match", consultationMode: "verified_chart", theme: "timing", entrypoint: "daily_starlanguage", serverChart, state, runWorkflow: async (input) => workflow(input.theme), })["run-jyotish-consultation"].execute!( modelInput({ question: "深入看今日", domains: ["timing"] }), toolContext, ); assert.equal(state.planOverrideIgnored, undefined); assert.equal(state.workflowReceipt?.planOverrideIgnored, undefined); assert.deepEqual(state.workflowReceipt?.domains, ["timing"]); }); test("invalid model input does not poison a later valid contract retry", async () => { let calls = 0; const state = createConsultationRuntimeState(); const tool = createConsultationTools({ userId: "u", sessionId: "s", requestId: "r-retry", consultationMode: "verified_chart", serverChart, state, runWorkflow: async (input) => { calls += 1; return workflow(input.theme); }, })["run-jyotish-consultation"]; const context = { observe: { span: async (_n: string, fn: () => Promise) => fn(), log() {} } } as never; // BUG-205: a bad domain must be refused before the request-scoped calculation // cache is written. The refusal now happens at the schema, one layer earlier // than the registry check it used to reach, because the domain ids are // enumerated in the schema the model is handed. Mastra reports that refusal by // resolving with a validation envelope instead of throwing, so this asserts the // envelope rather than a rejection. const rejected = await tool.execute!( modelInput({ question: "先给出错误参数", domains: ["career", "unknown"] }), context, ) as { error?: unknown; message?: unknown }; assert.equal(rejected.error, true); // The envelope has to name the legal ids: it is the only correction the model // gets, and an unnamed vocabulary is what produced the invalid call. assert.match(String(rejected.message), /'career'/); assert.match(String(rejected.message), /'timing'/); assert.equal(calls, 0); assert.equal(state.consultationToolCallCount, 0); assert.equal(state.consultationToolSuccessCount, 0); const result = await tool.execute!(modelInput({ question: "改用合法参数", domains: ["timing"] }), context) as { domains: string[] }; assert.equal(calls, 1); assert.deepEqual(result.domains, ["timing"]); assert.equal(state.consultationToolCallCount, 1); assert.equal(state.consultationToolSuccessCount, 1); assert.equal(state.consultationToolCompleted, true); }); test("a rejected workflow promise is cleared before a later tool call", async () => { let calls = 0; const state = createConsultationRuntimeState(); const tool = createConsultationTools({ userId: "u", sessionId: "s", requestId: "r-rejected-promise", consultationMode: "verified_chart", serverChart, state, runWorkflow: async (input) => { calls += 1; if (calls === 1) throw new Error("workflow_temporarily_failed"); return workflow(input.theme); }, })["run-jyotish-consultation"]; const context = { observe: { span: async (_n: string, fn: () => Promise) => fn(), log() {} } } as never; await assert.rejects( tool.execute!(modelInput({ question: "第一次计算", domains: ["career"] }), context), /workflow_temporarily_failed/, ); const result = await tool.execute!(modelInput({ question: "重新计算", domains: ["timing"] }), context) as { domains: string[] }; assert.equal(calls, 2); assert.deepEqual(result.domains, ["timing"]); assert.equal(state.consultationToolCallCount, 2); assert.equal(state.consultationToolSuccessCount, 1); }); test("a failed calculation records why it failed and forwards the request id", async () => { const state = createConsultationRuntimeState(); const seen: Array = []; const tool = createConsultationTools({ userId: "u", sessionId: "s", requestId: "req-correlation", consultationMode: "verified_chart", serverChart, state, runWorkflow: async (_input, options) => { seen.push(options?.requestId); throw new ConsultationWorkflowError("workflow_queue_full", "Async job queue is full"); }, })["run-jyotish-consultation"]; await assert.rejects( tool.execute!(modelInput({ question: "队列满时的表现", domains: ["career"] }), { observe: { span: async (_n: string, fn: () => Promise) => fn(), log() {} } } as never), /Async job queue is full/, ); assert.deepEqual(seen, ["req-correlation"]); const failedStep = state.steps.find((step) => step.status === "failed"); assert.equal(failedStep?.name, "run-jyotish-consultation"); assert.equal(failedStep?.failureCode, "workflow_queue_full"); }); test("workflow failure codes classify transport and contract faults", () => { assert.equal(consultationWorkflowFailureCode(new ConsultationWorkflowError("workflow_rate_limited", "x")), "workflow_rate_limited"); assert.equal(consultationWorkflowFailureCode(new DOMException("slow", "TimeoutError")), "workflow_timeout"); assert.equal(consultationWorkflowFailureCode(new DOMException("stop", "AbortError")), "workflow_aborted"); assert.equal(consultationWorkflowFailureCode(new Error("anything else")), undefined); }); test("every tool failure resolves to a code, not an absent field", () => { // The workflow classifier returns undefined for anything it does not own, and // the append site omitted the field when it was undefined, so the failures // raised inside the tool reached the log as the one record with no reason. assert.equal(consultationToolFailureCode(new ConsultationWorkflowError("workflow_rate_limited", "x")), "workflow_rate_limited"); assert.equal(consultationToolFailureCode(new DOMException("stop", "AbortError")), "workflow_aborted"); assert.equal(consultationToolFailureCode(new Error("invalid_consultation_domain_plan")), "invalid_domain_plan"); assert.equal(consultationToolFailureCode(new Error("unsupported_consultation_domain")), "invalid_domain_plan"); assert.equal(consultationToolFailureCode(new Error("anything else")), "unexpected_error"); assert.equal(consultationToolFailureCode("not an error"), "unexpected_error"); }); test("a call rejected before the tool body runs still appears in the receipt", async () => { // Observed on staging: the model's arguments were refused against the tool's // strict input schema, so `execute` never ran. The client saw tool.failed // while the receipt showed no failed step and the budget counted no call. const state = createConsultationRuntimeState(); async function* chunks() { yield { type: "tool-call", payload: { toolCallId: "call-1", toolName: "run-jyotish-consultation" } }; yield { type: "tool-error", payload: { toolCallId: "call-1", toolName: "run-jyotish-consultation", error: new Error("bad arguments") } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), onError: () => {}, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); const failedStep = state.steps.find((step) => step.kind === "tool" && step.status === "failed"); assert.equal(failedStep?.name, "run-jyotish-consultation"); assert.equal(failedStep?.failureCode, "tool_call_rejected"); // 原值: used 2(skill + 被拒的 tool) // 新值: 3(再加 runtime-contract-incomplete) // 原因: BUG-955 assert.equal(consultationStepBudgetReceipt(state).used, 3); // The client learns a step failed; the classification stays server-side. const failed = events.find((event) => (event as { type?: string }).type === "run.failed") as { receipt?: { steps: Array<{ status: string; name: string }> }; }; // 原值: ["completed", "failed"](skill + 被拒的 tool) // 新值: 多一拍 validation runtime-contract-incomplete failed // 原因: BUG-955 合同未完成必须有独立回执步骤,不能只靠公开码 assert.deepEqual(failed.receipt?.steps.map((step) => step.status), ["completed", "failed", "failed"]); assert.equal(failed.receipt?.steps.at(-1)?.name, "runtime-contract-incomplete"); assert.doesNotMatch(JSON.stringify(failed), /tool_call_rejected/); }); test("a failure the tool already recorded is not recorded twice", async () => { // The tool records what it can see, with the duration and cause it alone // knows. The stream must only fill the gap, never double-count. const state = createConsultationRuntimeState(); appendConsultationRuntimeStep(state, { kind: "tool", name: "run-jyotish-consultation", status: "failed", durationMs: 20936, failureCode: "workflow_queue_full", }); async function* chunks() { yield { type: "tool-call", payload: { toolCallId: "call-1", toolName: "run-jyotish-consultation" } }; yield { type: "tool-error", payload: { toolCallId: "call-1", toolName: "run-jyotish-consultation", error: new Error("boom") } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), onError: () => {}, }); await response.text(); const failedTools = state.steps.filter((step) => step.kind === "tool" && step.status === "failed"); // 原值: 全部 failed 步 === 1 // 新值: tool failed 仍为 1;另有 runtime-contract-incomplete // 原因: BUG-955 合同未完成要记独立校验步,但不能把已记录的 tool 失败再记一次 assert.equal(failedTools.length, 1); assert.equal(failedTools[0]?.failureCode, "workflow_queue_full"); assert.equal(state.steps.filter((step) => step.name === "runtime-contract-incomplete").length, 1); }); test("the public receipt never carries the internal failure classification", () => { const state = createConsultationRuntimeState(); appendConsultationRuntimeStep(state, { kind: "tool", name: "run-jyotish-consultation", status: "failed", durationMs: 12, failureCode: "workflow_server_error", }); const steps = publicConsultationRuntimeSteps(state); assert.equal(steps.every((step) => !("failureCode" in step)), true); assert.equal(state.steps.find((step) => step.kind === "tool")?.failureCode, "workflow_server_error"); // A strict receipt schema would reject the internal field, so this also // guards the run from failing while building a successful response. const receipt = agentExecutionReceiptSchema.parse({ runId: "run", runtime: "mastra-agentic", skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0, methodologySections: 0 }, steps, workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }, }); assert.equal(receipt.steps.length, 2); assert.throws(() => agentExecutionReceiptSchema.parse({ runId: "run", runtime: "mastra-agentic", skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0, methodologySections: 0 }, steps: state.steps, workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }, })); }); test("the public receipt never carries the model step budget diagnostics", () => { const state = createConsultationRuntimeState(); state.modelStepCount = 8; state.modelFinishReason = "tool-calls"; appendConsultationRuntimeStep(state, { kind: "skill", name: "jyotish-vedic-astrology", status: "completed" }); assert.deepEqual( consultationModelStepTelemetry(state), { modelStepCount: 8, skillReferenceReads: 0, methodologySections: 0, modelFinishReason: "tool-calls" }, ); assert.deepEqual( consultationModelStepTelemetry(createConsultationRuntimeState()), { modelStepCount: 0, skillReferenceReads: 0, methodologySections: 0 }, ); // The client receipt schema is strict, so leaking either field would make a // successful run fail while serializing its own answer. const receipt = agentExecutionReceiptSchema.parse({ runId: "run", runtime: "mastra-agentic", skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0, methodologySections: 0 }, steps: publicConsultationRuntimeSteps(state), stepBudget: consultationStepBudgetReceipt(state), workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }, }); assert.doesNotMatch(JSON.stringify(receipt), /modelStepCount|modelFinishReason|tool-calls/); assert.throws(() => agentExecutionReceiptSchema.parse({ runId: "run", runtime: "mastra-agentic", skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0, methodologySections: 0 }, steps: publicConsultationRuntimeSteps(state), workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }, ...consultationModelStepTelemetry(state), })); }); test("the receipt reports how many reference documents the model opened", () => { const state = createConsultationRuntimeState(); const hooks = createConsultationRuntimeHooks(state); // The method is bound before the model runs, so a run starts with it in hand and // with nothing read. Having the method says nothing about the model having gone // past it to a reference of its own, which is the number this counts. assert.equal(state.jyotishSkillBound, true); assert.equal(state.skillReferenceReadCount, 0); hooks.afterToolCall({ toolName: "skill_read" }); hooks.afterToolCall({ toolName: "read_file" }); hooks.afterToolCall({ toolName: "skill_read", error: new Error("denied") }); assert.equal(state.skillReferenceReadCount, 2); // Reference reads are not runtime steps, so the step list cannot answer this on its own. assert.equal(state.steps.filter((step) => step.kind === "skill").length, 1); assert.equal(agentExecutionReceiptSchema.parse(receipt(state)).skill.referenceReads, 2); }); test("the receipt separates method the server delivered from method the model went looking for", () => { const state = createConsultationRuntimeState(); // A run where the model opened nothing is no longer a run composed without method: the strict // checklist for the route travels with the evidence, so the two counts have to be readable apart. state.methodologySectionCount = 3; const parsed = agentExecutionReceiptSchema.parse(receipt(state)); assert.equal(parsed.skill.referenceReads, 0); assert.equal(parsed.skill.methodologySections, 3); assert.equal(consultationModelStepTelemetry(state).methodologySections, 3); }); test("personal Agent exposes the Jyotish Skill and named server tool", async () => { const { getJyotishAgent } = await import("../src/mastra/index.ts"); const state = createConsultationRuntimeState(); const agent = getJyotishAgent({ id: "personal-agent-probe", label: "Probe", description: "", creditCost: 1, isDefault: false, mode: "openai", model: "openai/gpt-5-mini", } as never, { userId: "u", sessionId: "s", requestId: "r", consultationMode: "verified_chart", serverChart, state, } as never); const skills = await agent.listSkills(); const toolNames = Object.keys(await agent.getToolsForExecution({ runId: "r" })); assert.equal(skills.some((skill) => skill.name === "jyotish-vedic-astrology"), true); // Activation is withdrawn: answering an activation meant resending the whole // package listing every turn, and the method is bound into the instructions // instead. Reading a named reference is still the model's own to do. assert.equal(toolNames.includes("skill"), false); assert.equal(toolNames.includes("skill_search"), false); assert.equal(toolNames.includes("skill_read"), true); assert.equal(toolNames.includes("run-jyotish-consultation"), true); assert.equal(toolNames.includes("consultationTool"), false); }); test("public stream filters private chunks and completes once", async () => { const chunks = [ { type: "reasoning-delta", payload: { text: "secret" } }, { type: "tool-call", payload: { toolCallId: "c1", toolName: "skill", args: { name: "jyotish-vedic-astrology", secret: "x" } } }, { type: "tool-result", payload: { toolCallId: "other", toolName: "skill", result: { private: true } } }, { type: "tool-result", payload: { toolCallId: "c1", toolName: "skill", result: { private: true } } }, { type: "tool-call", payload: { toolCallId: "c2", toolName: "run-jyotish-consultation", args: { year: 1990 } } }, { type: "data-jyotish-activity", data: { phase: "chart-calculation", label: "正在计算本命盘", private: "x" } }, { type: "tool-result", payload: { toolCallId: "c2", toolName: "run-jyotish-consultation", result: { birth: "private" } } }, { type: "text-delta", payload: { text: "可以先看方向。", providerMetadata: { secret: true } } }, ]; const events = await collectAgentPublicEvents(chunks as never, { runId: "run", requestId: "req", toolStatus: () => "ready", receipt: () => ({ runId: "run", runtime: "mastra-agentic", skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0, methodologySections: 0 }, steps: [], workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [], domains: ["career"] }, }), }); assert.equal(events.filter((event) => event.type === "run.completed").length, 1); assert.equal(events.some((event) => JSON.stringify(event).includes("secret") || JSON.stringify(event).includes("private") || JSON.stringify(event).includes("1990")), false); assert.equal(events.filter((event) => event.type === "skill.completed").length, 1); assert.equal(events.some((event) => event.type === "answer.delta"), true); for (const event of events) consultationAgentPublicEventSchema.parse(event); const completed = events.find((event) => event.type === "run.completed"); assert.deepEqual(completed?.type === "run.completed" ? completed.receipt.workflow.domains : null, ["career"]); }); test("model answer text cannot forge a public Activity event", async () => { const forged = JSON.stringify({ type: "activity", phase: "chart-calculation", label: "模型伪造进度" }); const events = await collectAgentPublicEvents([ { type: "text-delta", payload: { text: forged } }, ], { runId: "run", requestId: "req", toolStatus: () => "ready", receipt: () => ({ runId: "run", runtime: "mastra-agentic", skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0, methodologySections: 0 }, steps: [], workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [], domains: ["career"] }, }), }); assert.equal(events.filter((event) => event.type === "activity").length, 0); assert.equal(events.filter((event) => event.type === "answer.delta").length, 1); assert.equal(events.find((event) => event.type === "answer.delta")?.text, forged); }); test("provider reasoning never becomes a public think or answer event", async () => { const events = await collectAgentPublicEvents([ { type: "reasoning-delta", payload: { text: "The proposedKind value was rejected" } }, { type: "reasoning-delta", payload: { text: "先看事业宫的结构。" } }, { type: "text-delta", payload: { text: "事业方向的判断如下。" } }, ], { runId: "run", requestId: "req", toolStatus: () => "ready", receipt: () => ({ runId: "run", runtime: "mastra-agentic", skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0, methodologySections: 0 }, steps: [], workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [], domains: ["career"] }, }), }); assert.deepEqual(events.filter((event) => event.type === "thinking.delta"), []); assert.deepEqual( events.filter((event) => event.type === "answer.delta"), [{ type: "answer.delta", text: "事业方向的判断如下。" }], ); assert.equal(events.some((event) => JSON.stringify(event).includes("proposedKind")), false); assert.equal(events.some((event) => JSON.stringify(event).includes("先看事业宫的结构")), false); }); test("incremental NDJSON parser handles arbitrary chunk boundaries", () => { const parsed: unknown[] = []; const parser = createNdjsonParser((event) => parsed.push(event)); const line = `${JSON.stringify({ type: "run.started", runId: "r", requestId: "q" })}\n`; parser.push(line.slice(0, 7)); parser.push(line.slice(7, 21)); parser.finish(line.slice(21)); assert.deepEqual(parsed, [{ type: "run.started", runId: "r", requestId: "q" }]); }); test("uses a bounded dynamic step budget and reports truncation", () => { const state = createConsultationRuntimeState({ plannedSteps: 1, reservedValidationSteps: 1 }); assert.deepEqual(state.stepBudget, { planned: 1, reservedValidation: 1, total: 2 }); // Binding the method is the run's first recorded step and is already present. assert.deepEqual(state.steps.map((step) => step.kind), ["skill"]); assert.equal(appendConsultationRuntimeStep(state, { kind: "validation", name: "ensure-final-response", status: "completed" }), true); assert.equal(appendConsultationRuntimeStep(state, { kind: "tool", name: "unexpected-extra-step", status: "completed" }), false); assert.equal(state.steps.length, 2); assert.equal(state.stepsTruncated, true); assert.deepEqual(consultationStepBudgetReceipt(state), { planned: 2, used: 2, remaining: 0, truncated: true }); }); /** * The steps a run took, without the method binding every run starts with. The * binding is recorded when the state is created, so a test asking whether a run * advanced has to say which steps it means. */ function runSteps(state: ReturnType) { return state.steps.filter((step) => step.kind !== "skill"); } function receipt(state: ReturnType) { return { runId: "run", runtime: "mastra-agentic" as const, skill: { name: "jyotish-vedic-astrology" as const, loaded: state.jyotishSkillBound, referenceReads: state.skillReferenceReadCount, methodologySections: state.methodologySectionCount, }, steps: state.steps, stepBudget: consultationStepBudgetReceipt(state), workflow: state.workflowReceipt ?? { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }, }; } test("text written before the contract completes is dropped, not released later", async () => { const state = createConsultationRuntimeState(); let completed = 0; async function* chunks() { // Production shape (run a5f4409e): between rejected calls the model narrates // its own tool errors. Holding that text meant the eventual success released // it as the visible answer, so a recovered run read as the model explaining // itself and never answering the question. yield { type: "text-delta", payload: { text: "域名单有误,我改为不指定域。" } }; yield { type: "tool-call", payload: { toolCallId: "skill-1", toolName: "skill", args: { name: "jyotish-vedic-astrology" } } }; state.jyotishSkillBound = true; yield { type: "tool-result", payload: { toolCallId: "skill-1", toolName: "skill", result: {} } }; yield { type: "tool-call", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", args: {} } }; state.consultationToolCallCount = 1; state.consultationToolSuccessCount = 1; state.consultationToolCompleted = true; state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }; yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; yield { type: "text-delta", payload: { text: "这是真正的回答。" } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), onComplete: () => { completed += 1; }, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); assert.equal(completed, 1); assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1); const answer = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text) .join(""); assert.equal(answer, "这是真正的回答。"); assert.doesNotMatch(JSON.stringify(events), /域名单有误/); }); test("a run that only narrated its failures is not delivered or billed as an answer", async () => { const state = createConsultationRuntimeState(); let completed = 0; async function* chunks() { yield { type: "tool-call", payload: { toolCallId: "skill-1", toolName: "skill", args: { name: "jyotish-vedic-astrology" } } }; state.jyotishSkillBound = true; yield { type: "tool-result", payload: { toolCallId: "skill-1", toolName: "skill", result: {} } }; yield { type: "text-delta", payload: { text: "两次域名单都不被服务端接受,我改为不指定域。" } }; yield { type: "tool-call", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", args: {} } }; state.consultationToolCallCount = 1; state.consultationToolSuccessCount = 1; state.consultationToolCompleted = true; state.workflowReceipt = { route: "general", status: "ready", preciseTiming: "allowed", missingLayers: [] }; yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), onComplete: () => { completed += 1; }, onError: () => {}, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); // Presenting the narration as the reading is dishonest, and so is charging for // a fixed apology that says there is nothing to say. assert.equal(completed, 0); assert.equal(events.some((event) => (event as { type?: string }).type === "answer.delta"), false); const failure = events.find((event) => (event as { type?: string }).type === "run.failed") as { code: string }; assert.equal(failure.code, "empty_answer"); assert.doesNotMatch(JSON.stringify(events), /不被服务端接受/); }); test("a call Mastra rejected against the input schema is not reported as completed", async () => { const state = createConsultationRuntimeState({ plannedSteps: 8 }); async function* chunks() { yield { type: "tool-call", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", args: { domains: ["event-timing-strict"] } } }; // Mastra resolves rather than throws when arguments fail inputSchema, so the // tool body never runs and cannot record anything. Reported as completed this // would claim a calculation that never happened. yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: { error: true, message: "Tool input validation failed", validationErrors: { errors: [], fields: {} } }, }, }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "ready", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), onError: () => {}, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); assert.equal(events.some((event) => (event as { type?: string }).type === "tool.completed"), false); const failed = events.find((event) => (event as { type?: string }).type === "tool.failed") as { code: string }; assert.equal(failed.code, "calculation_failed"); // 原值: ["skill:completed", "tool:failed"] // 新值: 追加 validation:failed(runtime-contract-incomplete) // 原因: BUG-955 回执必须能把合同未完成和装配失败分开 assert.deepEqual( state.steps.map((step) => `${step.kind}:${step.status}`), ["skill:completed", "tool:failed", "validation:failed"], ); assert.equal(state.steps.at(-1)?.name, "runtime-contract-incomplete"); assert.equal(runSteps(state)[0]?.failureCode, "tool_call_rejected"); // The rejection reason is a server-side diagnostic; the client sees only that a step failed. assert.doesNotMatch(JSON.stringify(events), /tool_call_rejected|validationErrors/); }); test("a calculation that succeeds only after failed attempts still satisfies the contract", async () => { const state = createConsultationRuntimeState(); let completed = 0; async function* chunks() { yield { type: "tool-call", payload: { toolCallId: "skill-1", toolName: "skill", args: { name: "jyotish-vedic-astrology" } } }; state.jyotishSkillBound = true; yield { type: "tool-result", payload: { toolCallId: "skill-1", toolName: "skill", result: {} } }; // Two transient workflow failures, then one success, as observed in production. state.consultationToolCallCount = 3; state.consultationToolSuccessCount = 1; state.consultationToolCompleted = true; state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }; yield { type: "tool-result", payload: { toolCallId: "tool-3", toolName: "run-jyotish-consultation", result: {} } }; yield { type: "text-delta", payload: { text: "事业方向的判断如下。" } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), onComplete: () => { completed += 1; }, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); assert.equal(completed, 1); assert.equal(events.filter((event) => (event as { type?: string }).type === "run.failed").length, 0); assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1); assert.equal((events.find((event) => (event as { type?: string }).type === "answer.delta") as { text?: string }).text, "事业方向的判断如下。"); }); test("a second successful calculation still fails the single-calculation boundary", async () => { const state = createConsultationRuntimeState(); let failed = 0; async function* chunks() { yield { type: "tool-call", payload: { toolCallId: "skill-1", toolName: "skill", args: { name: "jyotish-vedic-astrology" } } }; state.jyotishSkillBound = true; yield { type: "tool-result", payload: { toolCallId: "skill-1", toolName: "skill", result: {} } }; state.consultationToolCallCount = 2; state.consultationToolSuccessCount = 2; state.consultationToolCompleted = true; state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }; yield { type: "text-delta", payload: { text: "不应显示" } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), onError: () => { failed += 1; }, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); assert.equal(failed, 1); assert.equal(events.some((event) => (event as { type?: string }).type === "answer.delta"), false); assert.equal(events.filter((event) => (event as { type?: string }).type === "run.failed").length, 1); }); test("incomplete runtime contract with body is delivered degraded instead of discarded (BUG-956)", async () => { // 原值: 无工具 + 有正文 → run.failed,不发 answer.delta,不保存 // 新值: 交付该正文 + 服务端降级说明,run.completed // 原因: BUG-956 合同未绿时不得静默丢弃模型正文 const state = createConsultationRuntimeState(); let completed = 0; let failed = 0; async function* chunks() { yield { type: "text-delta", payload: { text: "不能保存" } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, pass4Mode: "verified_chart", toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), onComplete: () => { completed += 1; }, onError: () => { failed += 1; }, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); assert.equal(completed, 1); assert.equal(failed, 0); const answer = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text) .join(""); assert.match(answer, /不能保存/); assert.equal(answer.includes(CONTRACT_DEGRADED_NOTE), true); assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1); assert.equal(events.filter((event) => (event as { type?: string }).type === "run.failed").length, 0); const completedEvent = events.find((event) => (event as { type?: string }).type === "run.completed") as { receipt?: { steps: Array<{ kind: string; name: string; status: string }> }; }; assert.ok(completedEvent.receipt?.steps.some((step) => step.kind === "validation" && step.name === "contract-degraded" && step.status === "failed")); }); test("incomplete runtime contract without body still fails closed (BUG-956)", async () => { const state = createConsultationRuntimeState(); let completed = 0; let failed = 0; async function* chunks() {} const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, pass4Mode: "verified_chart", toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), onComplete: () => { completed += 1; }, onError: () => { failed += 1; }, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); assert.equal(completed, 0); assert.equal(failed, 1); assert.equal(events.some((event) => (event as { type?: string }).type === "answer.delta"), false); 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, "runtime_contract_incomplete"); assert.ok(failure.receipt?.steps.some((step) => step.kind === "validation" && step.name === "runtime-contract-incomplete" && step.status === "failed")); }); test("degraded delivery drops guarantee sentences through Pass 4 (BUG-959)", async () => { const state = createConsultationRuntimeState(); let completed = 0; async function* chunks() { yield { type: "text-delta", payload: { text: "我保证你一定会升职。" } }; yield { type: "text-delta", payload: { text: "方向上可以推进。" } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, pass4Mode: "verified_chart", toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), onComplete: () => { completed += 1; }, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); assert.equal(completed, 1); const answers = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text); assert.equal(answers.some((text) => /一定会升职|我保证/.test(text)), false); assert.match(answers.join(""), /方向上可以推进/); assert.equal(answers.join("").includes(CONTRACT_DEGRADED_NOTE), true); const completedEvent = events.find((event) => (event as { type?: string }).type === "run.completed") as { receipt?: { steps: Array<{ kind: string; name: string; status: string }> }; }; assert.ok(completedEvent.receipt?.steps.some((step) => step.kind === "validation" && step.name === "contract-degraded" && step.status === "failed")); assert.ok(completedEvent.receipt?.steps.some((step) => step.kind === "validation" && step.name === "pass4-reject:guarantee" && step.status === "failed")); }); test("degraded delivery that Pass 4 empties stays incomplete instead of a note-only answer (BUG-959)", async () => { const state = createConsultationRuntimeState(); let completed = 0; let failed = 0; async function* chunks() { yield { type: "text-delta", payload: { text: "我保证你一定会升职。" } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, pass4Mode: "verified_chart", toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), onComplete: () => { completed += 1; }, onError: () => { failed += 1; }, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); assert.equal(completed, 0); assert.equal(failed, 1); const answer = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text) .join(""); assert.equal(answer.includes(CONTRACT_DEGRADED_NOTE), false); const failure = events.find((event) => (event as { type?: string }).type === "run.failed") as { code: string; receipt?: { steps: Array<{ name: string }> }; }; assert.equal(failure.code, "runtime_contract_incomplete"); assert.ok(failure.receipt?.steps.some((step) => step.name === "pass4-reject:guarantee")); assert.ok(failure.receipt?.steps.some((step) => step.name === "runtime-contract-incomplete")); }); test("degraded delivery uses the refusal when Pass 4 drops every general-mode sentence (BUG-959)", async () => { const state = createConsultationRuntimeState(); async function* chunks() { yield { type: "text-delta", payload: { text: "你的上升是巨蟹座。" } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, pass4Mode: "general_no_birth_time", toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); const answer = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text) .join(""); assert.match(answer, new RegExp(GENERAL_NO_BIRTH_TIME_REFUSAL)); assert.equal(answer.includes(CONTRACT_DEGRADED_NOTE), true); assert.doesNotMatch(answer, /你的上升是巨蟹座/); assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1); }); test("degraded delivery keeps only the last attempt body (BUG-960)", async () => { const state = createConsultationRuntimeState(); async function* first() { yield { type: "text-delta", payload: { text: "第一段。" } }; } async function* second() { yield { type: "text-delta", payload: { text: "第二段。" } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: first(), requireTool: true, pass4Mode: "verified_chart", retry: async () => second(), toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); const answer = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text) .join(""); assert.match(answer, /第二段/); assert.doesNotMatch(answer, /第一段/); assert.equal(answer.includes(CONTRACT_DEGRADED_NOTE), true); assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1); }); test("degraded delivery does not start a compose pass (BUG-961)", async () => { const state = createConsultationRuntimeState(); let composeCalls = 0; async function* chunks() { yield { type: "text-delta", payload: { text: "方向上可以推进。" } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, pass4Mode: "verified_chart", toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), composeAnswer: async () => { composeCalls += 1; async function* composed() { yield { type: "text-delta", payload: { text: "不该出现的 compose。" } }; yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } return composed(); }, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); assert.equal(composeCalls, 0); assert.equal(events.some((event) => { const item = event as { type?: string; phase?: string }; return item.type === "phase.started" && item.phase === "compose"; }), false); const answer = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text) .join(""); assert.match(answer, /方向上可以推进/); assert.doesNotMatch(answer, /不该出现的 compose/); assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1); }); test("skill-binding abort is a distinct receipt step from a missing tool call (BUG-955)", async () => { const bindingState = createConsultationRuntimeState(); let bindingError = ""; let retried = 0; async function* tripwire() { yield { type: "tripwire", payload: { reason: "Jyotish skill method is not bound into the system prompt for jyotish-vedic-astrology", processorId: "jyotish-skill-bound", }, }; } const bindingResponse = streamAgentResponse({ runId: "run", requestId: "bind-req", state: bindingState, stream: tripwire(), requireTool: true, toolStatus: () => "blocked", receipt: () => ({ ...receipt(bindingState), steps: publicConsultationRuntimeSteps(bindingState) }), retry: async () => { retried += 1; return tripwire(); }, onError: (error) => { bindingError = error instanceof Error ? error.message : String(error); }, }); const bindingEvents: unknown[] = []; const bindingParser = createNdjsonParser((event) => bindingEvents.push(event)); bindingParser.finish(await bindingResponse.text()); const missingToolState = createConsultationRuntimeState(); async function* empty() {} const missingToolResponse = streamAgentResponse({ runId: "run", requestId: "tool-req", state: missingToolState, stream: empty(), requireTool: true, toolStatus: () => "blocked", receipt: () => ({ ...receipt(missingToolState), steps: publicConsultationRuntimeSteps(missingToolState) }), onError: () => {}, }); const missingToolEvents: unknown[] = []; const missingToolParser = createNdjsonParser((event) => missingToolEvents.push(event)); missingToolParser.finish(await missingToolResponse.text()); const bindingFailure = bindingEvents.find((event) => (event as { type?: string }).type === "run.failed") as { code: string; receipt?: { steps: Array<{ kind: string; name: string; status: string }> }; }; const missingToolFailure = missingToolEvents.find((event) => (event as { type?: string }).type === "run.failed") as { code: string; receipt?: { steps: Array<{ kind: string; name: string; status: string }> }; }; assert.equal(bindingFailure.code, "runtime_contract_incomplete"); assert.equal(missingToolFailure.code, "runtime_contract_incomplete"); assert.equal(bindingError, "skill_binding_failed"); assert.equal(retried, 0); assert.ok(bindingFailure.receipt?.steps.some((step) => step.kind === "validation" && step.name === "skill-binding-abort" && step.status === "failed")); assert.ok(missingToolFailure.receipt?.steps.some((step) => step.kind === "validation" && step.name === "runtime-contract-incomplete" && step.status === "failed")); assert.equal( bindingFailure.receipt?.steps.some((step) => step.name === "runtime-contract-incomplete"), false, ); assert.equal( missingToolFailure.receipt?.steps.some((step) => step.name === "skill-binding-abort"), false, ); assert.doesNotMatch(JSON.stringify(bindingEvents), /not bound into the system prompt/); assert.doesNotMatch(JSON.stringify(bindingEvents), /Jyotish skill method/); }); 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; state.consultationToolCallCount = 1; state.consultationToolSuccessCount = 1; state.consultationToolCompleted = true; state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }; return state; } test("a calculation the model never wrote up is asked again instead of apologised for", async () => { // Production shape: the tool succeeded in 63.5s and the model then produced no // text at all. That used to be answered with a fixed apology and billed as a // completed consultation, so the user paid for a sentence saying nothing. const state = toolOnlyRunState(); let completedOutput = ""; let retries = 0; async function* chunks() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; } async function* answerChunks() { yield { type: "text-delta", payload: { text: "事业方向的判断如下。" } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), retryForAnswer: async () => { retries += 1; return answerChunks(); }, onComplete: (output) => { completedOutput = output; }, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); assert.equal(retries, 1); assert.equal(completedOutput, "事业方向的判断如下。"); assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1); assert.equal(state.steps.at(-1)?.name, "answer-retry"); }); test("a calculation still unanswered after the retry fails the run rather than billing it", async () => { const state = toolOnlyRunState(); let completed = 0; async function* chunks() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; } async function* silentChunks() { yield { type: "step-finish", payload: { stepResult: { reason: "tool-calls" } } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), retryForAnswer: async () => silentChunks(), onComplete: () => { completed += 1; }, onError: () => {}, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); assert.equal(completed, 0); assert.equal(events.some((event) => (event as { type?: string }).type === "answer.delta"), false); const failure = events.find((event) => (event as { type?: string }).type === "run.failed") as { code: string; message: string }; assert.equal(failure.code, "empty_answer"); assert.match(failure.message, /不会扣点/); }); test("a length-limited answer is not billed or delivered as a completed consultation", async () => { // Staging persisted a 253-character pinch that ended mid-heading, then treated // the run as completed. The model had finished with reason `length`; the public // stream still emitted `run.completed`, so the composer unlocked as if the // reading were done. const state = toolOnlyRunState(); const pinchedHeading = "**先看命盘结构(Lahiri岁差、均交点口径"; let completed = 0; let failed = 0; async function* chunks() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; yield { type: "text-delta", payload: { text: pinchedHeading } }; yield { type: "finish", payload: { stepResult: { reason: "length" }, output: { usage: {}, steps: [{}, {}] } } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), onComplete: () => { completed += 1; }, onError: () => { failed += 1; }, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); assert.equal(completed, 0); assert.equal(failed, 1); assert.equal(state.modelFinishReason, "length"); assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 0); const answer = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text) .join(""); assert.equal(answer, pinchedHeading); const failure = events.find((event) => (event as { type?: string }).type === "run.failed") as { code: string; message: string; }; assert.equal(failure.code, "answer_truncated"); assert.match(failure.message, /回答未完成/); assert.match(failure.message, /不会扣点/); assert.doesNotMatch(JSON.stringify(failure), /modelFinishReason|"length"/); }); test("a length-limited answer with remaining text continues once and can complete", async () => { const state = toolOnlyRunState(); const first = "## 事业\n方向稳定。\n"; const rest = "## 技法审计表\n| 技法 | 状态 |\n## 现代生活\n先把合同写清楚。\n"; let completed = 0; let continues = 0; async function* chunks() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; yield { type: "text-delta", payload: { text: first } }; yield { type: "finish", payload: { stepResult: { reason: "length" }, output: { usage: {}, steps: [{}, {}] } } }; } async function* continueChunks() { yield { type: "text-delta", payload: { text: rest } }; yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), continueAfterLength: async (output) => { continues += 1; assert.equal(output, first); return continueChunks(); }, onComplete: () => { completed += 1; }, onError: () => {}, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); assert.equal(continues, 1); assert.equal(completed, 1); assert.equal(state.steps.at(-1)?.name, "answer-continue"); const answer = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text) .join(""); assert.equal(answer, `${first}${rest}`); assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1); }); test("a length continue that does not grow still fails as truncated", async () => { const state = toolOnlyRunState(); const first = "## 事业\n方向稳定。\n"; async function* chunks() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; yield { type: "text-delta", payload: { text: first } }; yield { type: "finish", payload: { stepResult: { reason: "length" }, output: { usage: {}, steps: [{}] } } }; } async function* emptyContinue() { yield { type: "finish", payload: { stepResult: { reason: "length" }, output: { usage: {}, steps: [{}] } } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), continueAfterLength: async () => emptyContinue(), onComplete: () => {}, onError: () => {}, }); 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, "answer_truncated"); assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 0); }); test("a calculated run publishes thinking.section without tool ids", async () => { const state = toolOnlyRunState(); state.thinkingPlan = natalConsultationThinkingPlan({ domains: ["career", "wealth"] }); async function* chunks() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; yield { type: "text-delta", payload: { text: "## 事业\n方向稳定。" } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); const sections = events.filter((event) => (event as { type?: string }).type === "thinking.section") as Array<{ title: string; heading: string; }>; assert.ok(sections.some((section) => section.heading === REPORT_HEADING.question)); assert.ok(sections.some((section) => section.heading === REPORT_HEADING.support)); assert.ok(JSON.stringify(sections).includes("事业") || JSON.stringify(sections).includes("财富")); assert.doesNotMatch(JSON.stringify(sections), /run-jyotish/); }); test("composeAnswer drains leftover first-stream text and writes one body", async () => { const state = toolOnlyRunState(); state.thinkingPlan = natalConsultationThinkingPlan({ domains: ["career", "wealth"] }); async function* leftover() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; yield { type: "text-delta", payload: { text: "## 事业\n整篇都写了" } }; yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } let composed = 0; const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: leftover(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), composeAnswer: async () => { composed += 1; async function* body() { yield { type: "reasoning-delta", payload: { text: "The user asked about career." } }; yield { type: "text-delta", payload: { text: `## ${REPORT_HEADING.question}\n外松内紧。\n` } }; yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } return body(); }, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); assert.equal(composed, 1); const answer = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text) .join(""); assert.doesNotMatch(answer, /整篇都写了/); assert.match(answer, new RegExp(`## ${REPORT_HEADING.question}`)); assert.equal(events.some((event) => (event as { type?: string }).type === "think.plan"), true); assert.equal(events.filter((event) => (event as { type?: string }).type === "thinking.delta").length, 0); }); test("empty composeAnswer falls through to answer-retry once", async () => { const state = toolOnlyRunState(); state.thinkingPlan = dailyConsultationThinkingPlan(); let answerRetries = 0; let composed = 0; async function* first() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: first(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), retryForAnswer: async () => { answerRetries += 1; async function* body() { yield { type: "text-delta", payload: { text: `## ${DAILY_HEADING.trend}\n补写。\n` } }; yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } return body(); }, composeAnswer: async () => { composed += 1; async function* empty() { yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } return empty(); }, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); const answer = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text) .join(""); assert.equal(composed, 1); assert.equal(answerRetries, 1); assert.equal(state.steps.some((step) => step.name === "section-empty-retry"), false); assert.equal(state.steps.some((step) => step.name === "answer-retry"), true); assert.match(answer, /补写/); assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1); }); test("composeAnswer that stays empty still asks for a full answer", async () => { const state = toolOnlyRunState(); state.thinkingPlan = dailyConsultationThinkingPlan(); let answerRetries = 0; async function* first() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: first(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), retryForAnswer: async () => { answerRetries += 1; async function* body() { yield { type: "text-delta", payload: { text: `## ${DAILY_HEADING.trend}\n补写。\n` } }; yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } return body(); }, composeAnswer: async () => { async function* empty() { yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } return empty(); }, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); const answer = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text) .join(""); assert.equal(answerRetries, 1); assert.equal(state.steps.some((step) => step.name === "section-empty-retry"), false); assert.equal(state.steps.at(-1)?.name, "answer-retry"); assert.match(answer, /补写/); }); test("composeAnswer length continue finishes the same body", async () => { const state = toolOnlyRunState(); state.thinkingPlan = natalConsultationThinkingPlan({ domains: ["career"] }); async function* first() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } let continues = 0; const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: first(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), composeAnswer: async () => { async function* pinched() { yield { type: "text-delta", payload: { text: `## ${REPORT_HEADING.question}\n岁差` } }; yield { type: "finish", payload: { stepResult: { reason: "length" }, output: { usage: {}, steps: [{}] } } }; } return pinched(); }, continueAfterLength: async (output) => { continues += 1; assert.match(output, new RegExp(`## ${REPORT_HEADING.question}`)); async function* rest() { yield { type: "text-delta", payload: { text: " Lahiri。\n" } }; yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } return rest(); }, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); assert.equal(continues, 1); const answer = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text) .join(""); assert.match(answer, /岁差 Lahiri/); assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1); }); test("pass4 holds verified dates and records pass4-observe without rewriting", async () => { // 原值:三个日期句 hold 成 1 条 answer.delta // 新值:每句闭合即发,≥3 条 answer.delta,日期不改写 // 原因:BUG-950 按句放行;exact-timing 是 observe,不得阻塞发送。 const state = toolOnlyRunState(); async function* chunks() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; yield { type: "text-delta", payload: { text: "第一句先说方向。" } }; yield { type: "text-delta", payload: { text: "Rahu 大运为 2013年11月21日。" } }; yield { type: "text-delta", payload: { text: "第三句把区间说完。" } }; yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, pass4Mode: "verified_chart", toolStatus: () => "ready", receipt: () => receipt(state), }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); const answers = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text); assert.ok(answers.length >= 3, `expected ≥3 answer.delta, got ${answers.length}`); assert.match(answers.join(""), /2013年11月21日/); assert.doesNotMatch(answers.join(""), /具体时间已省略/); assert.equal(state.steps.some((step) => step.name === "pass4-observe:exact-timing"), true); }); test("pass4 retries compose once on guarantee then drops leftover clauses", async () => { // 原值:整段 hold,compose 两次后一次发出 // 新值:按句放行,保证句从不出现在任何 answer.delta;已发出过句子不再整篇重写 // 原因:BUG-950,「不闪两次」只约束已发出的不撤回。 const state = toolOnlyRunState(); async function* first() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } let composed = 0; const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: first(), requireTool: true, pass4Mode: "verified_chart", toolStatus: () => "ready", receipt: () => receipt(state), composeAnswer: async () => { composed += 1; async function* body() { yield { type: "text-delta", payload: { text: "方向可以推进。" } }; yield { type: "text-delta", payload: { text: "我保证你一定会升职。" } }; yield { type: "text-delta", payload: { text: "第三句照常。" } }; yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } return body(); }, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); assert.equal(composed, 1); const answers = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text); assert.ok(answers.length >= 2, `expected ≥2 answer.delta, got ${answers.length}`); assert.equal(answers.some((text) => /一定会升职|我保证/.test(text)), false); assert.match(answers.join(""), /方向可以推进/); assert.match(answers.join(""), /第三句照常/); assert.equal(state.steps.some((step) => step.name === "pass4-reject:guarantee"), true); }); test("pass4 general mode second-pass replaces personal chart claims with the refusal", async () => { // 原值:一句个人盘断言把整段换成拒绝句 // 新值:混合文本按句丢弃,知识句发出,个人盘句不发;全丢才用兜底句 // 原因:BUG-951。 const state = createConsultationRuntimeState(); state.jyotishSkillBound = true; async function* chunks() { yield { type: "text-delta", payload: { text: "第七宫在占星概念中常与关系相关。" } }; yield { type: "text-delta", payload: { text: "你的上升是巨蟹座。" } }; yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: false, pass4Mode: "general_no_birth_time", toolStatus: () => "ready", receipt: () => receipt(state), }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); const answers = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text); assert.match(answers.join(""), /第七宫在占星概念中常与关系相关/); assert.equal(answers.some((text) => /你的上升是巨蟹座/.test(text)), false); assert.doesNotMatch(answers.join(""), new RegExp(GENERAL_NO_BIRTH_TIME_REFUSAL)); assert.equal(state.steps.some((step) => step.name === "pass4-reject:personal-chart"), true); }); test("pass4 releases each closed sentence and never emits a rejected clause", async () => { const state = toolOnlyRunState(); async function* chunks() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; yield { type: "text-delta", payload: { text: "第一句话。" } }; yield { type: "text-delta", payload: { text: "第二句话。" } }; yield { type: "text-delta", payload: { text: "第三句话。" } }; yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, pass4Mode: "verified_chart", toolStatus: () => "ready", receipt: () => receipt(state), }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); const answers = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text); assert.ok(answers.length >= 3, `expected ≥3 answer.delta, got ${answers.length}`); assert.equal(answers.join(""), "第一句话。第二句话。第三句话。"); }); test("pass4 general mode uses the refusal only after every sentence is dropped", async () => { const state = createConsultationRuntimeState(); state.jyotishSkillBound = true; async function* chunks() { yield { type: "text-delta", payload: { text: "你的上升是巨蟹座。" } }; yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: false, pass4Mode: "general_no_birth_time", toolStatus: () => "ready", receipt: () => receipt(state), }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); const answer = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text) .join(""); assert.equal(answer, GENERAL_NO_BIRTH_TIME_REFUSAL); assert.equal(state.steps.some((step) => step.name === "pass4-reject:personal-chart"), true); }); test("pass4 general mode observes dates while streaming the sentence", async () => { const state = createConsultationRuntimeState(); state.jyotishSkillBound = true; async function* chunks() { yield { type: "text-delta", payload: { text: "2026年8月适合观察方向。" } }; yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: false, pass4Mode: "general_no_birth_time", toolStatus: () => "ready", receipt: () => receipt(state), }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); const answers = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text); assert.match(answers.join(""), /2026年8月适合观察方向/); assert.equal(state.steps.some((step) => step.name === "pass4-observe:exact-timing"), true); }); test("natal tool success stores a Chinese thinking plan", async () => { const { state } = await runDomainPlan(["career", "wealth"], () => workflow()); const encoded = JSON.stringify(state.thinkingPlan ?? []); assert.ok((state.thinkingPlan ?? []).some((section) => section.heading === REPORT_HEADING.support)); assert.ok(JSON.stringify(state.thinkingPlan ?? []).includes("事业")); assert.ok(JSON.stringify(state.thinkingPlan ?? []).includes("财富")); assert.equal(encoded.includes("run-jyotish"), false); }); test("a timeout after partial visible text is the same truncation, not a successful answer", async () => { const state = toolOnlyRunState(); const pinchedHeading = "**先看命盘结构(Lahiri岁差、均交点口径"; let completed = 0; async function* chunks() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; yield { type: "text-delta", payload: { text: pinchedHeading } }; throw new DOMException("The operation was aborted due to timeout", "TimeoutError"); } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), onComplete: () => { completed += 1; }, onError: () => {}, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); assert.equal(completed, 0); const answer = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text) .join(""); assert.equal(answer, pinchedHeading); const failure = events.find((event) => (event as { type?: string }).type === "run.failed") as { code: string }; assert.equal(failure.code, "answer_truncated"); }); test("consult generation reserves spoken-answer tokens and enables a separate thinking channel", () => { const settings = consultationGenerationSettings("deepseek"); assert.equal(CONSULTATION_MAX_OUTPUT_TOKENS, 16_384); assert.equal(settings.modelSettings.maxOutputTokens, 16_384 + 8_192); assert.deepEqual(settings.providerOptions.openai, { thinking: { type: "enabled" } }); assert.deepEqual(settings.providerOptions.deepseek, { thinking: { type: "enabled" } }); const slice = consultationSliceGenerationSettings("deepseek"); assert.equal(slice.modelSettings.maxOutputTokens, 8_192 + 2_048); assert.deepEqual(slice.providerOptions.deepseek, { thinking: { type: "enabled", reasoningEffort: "low" } }); assert.equal(AGENT_MAX_STEPS, 8); }); test("provider reasoning stays off the spoken answer and off the public think channel", async () => { const state = createConsultationRuntimeState(); state.jyotishSkillBound = true; state.consultationToolCallCount = 1; state.consultationToolSuccessCount = 1; state.consultationToolCompleted = true; state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }; async function* chunks() { yield { type: "reasoning-delta", payload: { text: "The proposedKind value was rejected" } }; yield { type: "reasoning-delta", payload: { text: "先看事业宫的结构。" } }; yield { type: "text-delta", payload: { text: "事业方向的判断如下。" } }; } let completedThinking: string | undefined; const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), onComplete: (_output, _receipt, thinkingText) => { completedThinking = thinkingText; }, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); assert.deepEqual( events.filter((event) => (event as { type?: string }).type === "thinking.delta"), [], ); const answer = events .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta") .map((event) => event.text) .join(""); assert.equal(answer, "事业方向的判断如下。"); assert.equal(completedThinking, undefined); assert.doesNotMatch(JSON.stringify(events), /proposedKind/); assert.doesNotMatch(JSON.stringify(events), /先看事业宫的结构/); assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1); }); test("a completed run records the finish reason and the authoritative step count", async () => { const state = createConsultationRuntimeState(); state.jyotishSkillBound = true; state.consultationToolCallCount = 1; state.consultationToolSuccessCount = 1; state.consultationToolCompleted = true; state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }; async function* chunks() { yield { type: "step-finish", payload: { stepResult: { reason: "tool-calls" } } }; yield { type: "text-delta", payload: { text: "事业方向的判断如下。" } }; yield { type: "step-finish", payload: { stepResult: { reason: "stop" } } }; // The terminal chunk carries the runtime's own step list, which wins over // the chunks we counted. yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}, {}, {}] } } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), }); await response.text(); assert.equal(state.modelFinishReason, "stop"); assert.equal(state.modelStepCount, 3); }); test("a run that stops while still wanting tools records the exhausted step budget", async () => { const state = createConsultationRuntimeState({ plannedSteps: 8 }); state.jyotishSkillBound = true; state.consultationToolCallCount = 1; state.consultationToolSuccessCount = 1; state.consultationToolCompleted = true; state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }; // The production shape: the calculation succeeded, the model never wrote an // answer, and only the fallback text reached the user. Nothing in the public // event stream said the step budget ran out. A model that stopped while it // still wanted tool calls is the reading of `tool-calls` here, and it is what // the answer retry exists to recover from. async function* chunks() { yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; yield { type: "step-finish", payload: { stepResult: { reason: "tool-calls" } } }; yield { type: "step-finish", payload: { stepResult: { reason: "tool-calls" } } }; yield { type: "finish", payload: { stepResult: { reason: "tool-calls" }, output: { usage: {} } } }; } let completed = 0; const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), onComplete: () => { completed += 1; }, onError: () => {}, }); await response.text(); assert.equal(completed, 0); assert.equal(state.modelFinishReason, "tool-calls"); assert.equal(state.modelStepCount, 2); }); test("a retry accumulates model steps and reports the latest finish reason", async () => { const state = createConsultationRuntimeState({ plannedSteps: 8 }); async function* firstAttempt() { yield { type: "step-finish", payload: { stepResult: { reason: "tool-calls" } } }; yield { type: "finish", payload: { stepResult: { reason: "tool-calls" }, output: { usage: {} } } }; } async function* retriedAttempt() { state.jyotishSkillBound = true; state.consultationToolCallCount = 1; state.consultationToolSuccessCount = 1; state.consultationToolCompleted = true; state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }; yield { type: "text-delta", payload: { text: "补齐后的回答。" } }; yield { type: "step-finish", payload: { stepResult: { reason: "stop" } } }; yield { type: "finish", payload: { stepResult: { reason: "unrecognized-provider-reason" }, output: { usage: {} } } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: firstAttempt(), requireTool: true, retry: async () => retriedAttempt(), toolStatus: () => "ready", receipt: () => receipt(state), }); await response.text(); assert.equal(state.modelStepCount, 2); assert.equal(state.modelFinishReason, "unknown"); }); test("a failed run reports the same allowlisted receipt a completed run does", async () => { const state = createConsultationRuntimeState({ plannedSteps: 8 }); state.modelFinishReason = "tool-calls"; state.modelStepCount = 8; appendConsultationRuntimeStep(state, { kind: "tool", name: "run-jyotish-consultation", status: "failed", durationMs: 20936, failureCode: "workflow_queue_full", }); async function* chunks() { yield { type: "tool-error", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", error: new Error("boom") } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }), onError: () => {}, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); const failed = events.find((event) => (event as { type?: string }).type === "run.failed") as { code: string; receipt?: { steps: Array<{ durationMs?: number; name: string }>; stepBudget?: { used: number } }; }; assert.equal(failed.code, "runtime_contract_incomplete"); // Without this the caller learned only the code: no step durations, no budget, // exactly when the run needed explaining most. // Binding costs no time, so it reports no duration where the old activation // reported a round trip. // 原值: [undefined, 20936](skill + 失败的 tool) // 新值: 多一拍合同未完成校验,无 duration // 原因: BUG-955 回执步骤名必须能区分装配失败与合同未完成 assert.deepEqual(failed.receipt?.steps.map((step) => step.durationMs), [undefined, 20936, undefined]); assert.equal(failed.receipt?.steps.at(-1)?.name, "runtime-contract-incomplete"); // 原值: used 2 // 新值: 3 // 原因: BUG-955 合同未完成校验步 assert.equal(failed.receipt?.stepBudget?.used, 3); // The internal classification and the model loop diagnostics stay server-side. assert.doesNotMatch(JSON.stringify(failed), /workflow_queue_full|modelFinishReason|modelStepCount|tool-calls/); }); test("a receipt that cannot be built still leaves a failure event", async () => { const state = createConsultationRuntimeState(); async function* chunks() { yield { type: "text-delta", payload: { text: "不能保存" } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "blocked", receipt: () => { throw new Error("receipt_unavailable"); }, onError: () => {}, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); const failed = events.filter((event) => (event as { type?: string }).type === "run.failed"); assert.equal(failed.length, 1); assert.equal("receipt" in (failed[0] as object), false); }); test("persistence failure emits run.failed instead of run.completed", async () => { const state = createConsultationRuntimeState(); state.jyotishSkillBound = true; state.consultationToolCallCount = 1; state.consultationToolSuccessCount = 1; state.consultationToolCompleted = true; state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }; let failed = 0; async function* chunks() { yield { type: "text-delta", payload: { text: "不能在持久化失败后标记完成。" } }; } const response = streamAgentResponse({ runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, toolStatus: () => "ready", receipt: () => receipt(state), onComplete: () => { throw new Error("persistence failed"); }, onError: () => { failed += 1; }, }); const events: unknown[] = []; const parser = createNdjsonParser((event) => events.push(event)); parser.finish(await response.text()); assert.equal(failed, 1); assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 0); assert.equal(events.filter((event) => (event as { type?: string }).type === "run.failed").length, 1); });