Files
Jyotisha/frontend/tests/consultation-agentic-runtime.test.ts
T
jesse-ux 5e8b2ac112 fix(consult): 降级正文过 Pass 4,去重并停止空跑 compose(BUG-959/960/961)
降级交付复用 releasePass4Sentences;每次 attempt 清空 uncontractedText;成功降级后跳过 compose。
2026-09-18 18:45:18 +08:00

2404 lines
119 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import 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<string, unknown>;
};
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<unknown>) => fn(), log() {} } } as never;
type PlanResult = Record<string, unknown> & {
domains: string[];
omitted_domains: string[];
consultations: Array<Record<string, unknown> & { domain: string; claim_cards: Array<{ category: string }> }>;
evidence_contract: {
available_layers: string[];
missing_route_layers: string[];
hard_blockers: string[];
answer_policy: Record<string, unknown>;
user_facing_limitation?: string;
};
rectification: { boundary: string };
claim_cards: Array<{ category: string }>;
};
async function runDomainPlan(
domains: string[],
runWorkflow: (theme: string) => ReturnType<typeof workflow>,
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<unknown>) => 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
// 新值:别名仍规范化,执行上限 2migration 进 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<unknown>) => 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-946MAX=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<unknown>) => 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<unknown>) => 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<string, unknown>;
// 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 模式下的 requiredBUG-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 模式下的 requiredBUG-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<unknown>) => 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<unknown>) => 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<string | undefined> = [];
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<unknown>) => 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 2skill + 被拒的 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<typeof createConsultationRuntimeState>) {
return state.steps.filter((step) => step.kind !== "skill");
}
function receipt(state: ReturnType<typeof createConsultationRuntimeState>) {
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:failedruntime-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 () => {
// 原值:整段 holdcompose 两次后一次发出
// 新值:按句放行,保证句从不出现在任何 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);
});