b5bcbaed73
staging 手测 run 951a841e 第一次工具调用发出 tool.failed 后重试成功,但回执里 steps 只有 skill 与那次成功的 tool,stepBudget.used 为 2——失败的那次完全不存在。 客户端看见失败过一次,回执说没有,两边都查不到为什么。 工具的 inputSchema 是 strict 的,模型参数不合法时 Mastra 在调用 execute 之前就拒了, 于是工具体内一切都没跑:调用不计数、失败步不记录、连 chart-calculation 活动事件都没 发出(这也是本次定位的证据——失败那次没有任何 activity,重试那次有)。工具无法记录 一次它从未收到的调用。叠加两处:safeToolError 把非超时非取消的错误全塌成 calculation_failed,而即使失败落进工具体的 catch,consultationWorkflowFailureCode 对非 ConsultationWorkflowError 返回 undefined、append 处又写成可选省略,于是最需要 解释的那条记录恰好是唯一没有原因的记录。 改为在流层补记:流是唯一能观测到全部工具失败的位置,无论失败在 schema 这侧还是 execute 那侧,且它持有 startedAt 因而能给出时长。tool-error 分支比对「流已见的错误数」 与「state 里已有的失败 tool 步数」,只在前者更多时补一条,工具仍记录它能看见的失败, 两者不重复计。另新增 consultationToolFailureCode,令每个错误都解析出一个码。 failureCode 刻意仍不进公开回执:白名单与「the public receipt never carries the internal failure classification」是刻意约束,workflow_rate_limited 这类后端内情不该 上线到客户端。原因走可观测日志的 toolCalls[].failureCode。客户端能看到「有一步失败」, 运维能在日志里看到为什么。 另记入 BUG-268 的线上实测值:单领域 referenceReads 两次均为 2,多领域为 0——不是 从不读方法,而是最需要方法的多领域路径一份都没打开。 Co-authored-by: Cursor <cursoragent@cursor.com>
1158 lines
59 KiB
TypeScript
1158 lines
59 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import {
|
|
AGENT_TIMEOUT_MS,
|
|
mergeConsultationAnswerPolicies,
|
|
CONSULTATION_DOMAIN_WALL_CLOCK_MS,
|
|
MAX_CONSULTATION_DOMAINS,
|
|
appendConsultationRuntimeStep,
|
|
canonicalDomainPlan,
|
|
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 { getJyotishAgent } from "../src/mastra/index.ts";
|
|
import { consultationAgentPublicEventSchema, createNdjsonParser } from "../src/lib/consultation-agent-events.ts";
|
|
import { createConsultationPlan } from "../src/lib/consultation-plan.ts";
|
|
import { collectAgentPublicEvents, ensureFinalResponseText, streamAgentResponse } from "../src/lib/stream-agent-response.ts";
|
|
|
|
const serverChart = {
|
|
name: "测试",
|
|
toolInput: { year: 1990, month: 1, day: 2, hour: 3, minute: 4, city: "台北", lat: 25.03, lon: 121.56, tz: 8 },
|
|
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);
|
|
},
|
|
});
|
|
// Aliases, not repetitions: the array bound is now the executable domain cap,
|
|
// so a duplicate spends one of the slots the clock can actually pay for.
|
|
// canonicalDomainPlan keeps the de-duplication coverage for longer raw lists.
|
|
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[]; consultations: Array<{ domain: string }> };
|
|
|
|
assert.deepEqual(calls, [
|
|
{ theme: "career", question: "事业、财富和迁居怎么一起规划" },
|
|
{ theme: "wealth", question: "事业、财富和迁居怎么一起规划" },
|
|
{ theme: "migration", question: "事业、财富和迁居怎么一起规划" },
|
|
]);
|
|
assert.deepEqual(result.domains, ["career", "wealth", "migration"]);
|
|
assert.deepEqual(result.consultations.map((item) => item.domain), ["career", "wealth", "migration"]);
|
|
assert.deepEqual(state.workflowReceipt, {
|
|
route: "multi-domain",
|
|
status: "degraded",
|
|
preciseTiming: "allowed",
|
|
missingLayers: ["D11"],
|
|
domains: ["career", "wealth", "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 () => {
|
|
const { result, state } = await runDomainPlan(["career", "timing", "wealth"], (theme) => workflow(theme, {
|
|
// One domain forbidding precise timing must forbid it for the whole answer.
|
|
preciseTiming: theme !== "timing",
|
|
status: theme === "wealth" ? "degraded" : "ready",
|
|
missingLayers: theme === "wealth" ? ["D11"] : [],
|
|
hardBlockers: theme === "timing" ? ["negative_holdout_gate"] : [],
|
|
leadWithLimitations: theme === "timing",
|
|
limitation: theme === "wealth" ? "财富层证据不完整。" : 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", () => {
|
|
// 21s per sequential domain against the 110s run budget, minus the reserve a
|
|
// three-domain staging run actually left for composing the answer.
|
|
assert.equal(MAX_CONSULTATION_DOMAINS, 3);
|
|
assert.equal(AGENT_TIMEOUT_MS, 110_000);
|
|
assert.equal(CONSULTATION_DOMAIN_WALL_CLOCK_MS, 65_000);
|
|
assert.ok(MAX_CONSULTATION_DOMAINS * 21_000 <= CONSULTATION_DOMAIN_WALL_CLOCK_MS);
|
|
// Six domains, the previous cap, could never finish inside the deadline.
|
|
assert.ok(6 * 21_000 > AGENT_TIMEOUT_MS);
|
|
|
|
assert.deepEqual(
|
|
executableDomainPlan(["career", "wealth", "timing", "marriage", "health"]),
|
|
{ domains: ["career", "wealth", "timing"], omittedDomains: ["marriage", "health"] },
|
|
);
|
|
assert.deepEqual(executableDomainPlan(["career"]), { domains: ["career"], omittedDomains: [] });
|
|
|
|
// The first domain always runs; after that the next one has to be projected
|
|
// to finish, judged by how long the executed ones really took.
|
|
assert.equal(domainFitsRunBudget(0, 0), true);
|
|
assert.equal(domainFitsRunBudget(21_000, 1), true);
|
|
assert.equal(domainFitsRunBudget(42_000, 2), true);
|
|
assert.equal(domainFitsRunBudget(60_000, 2), false);
|
|
assert.equal(domainFitsRunBudget(40_000, 1), false);
|
|
});
|
|
|
|
test("a plan larger than the cap cannot be expressed and never starts a calculation", async () => {
|
|
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, false);
|
|
|
|
// Mastra rejects the over-budget plan before the tool body runs, so it costs
|
|
// one correctable step and nothing about the run advances.
|
|
const refused = await tool.execute!(
|
|
{ question: "全都看看", domains: ["career", "wealth", "timing", "marriage", "health"] } as never,
|
|
toolContext,
|
|
) as Record<string, unknown>;
|
|
assert.equal(calls, 0);
|
|
assert.equal("domains" in refused, false);
|
|
assert.equal(state.consultationToolStarted, false);
|
|
assert.deepEqual(state.steps, []);
|
|
});
|
|
|
|
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, new RegExp(`at most ${MAX_CONSULTATION_DOMAINS} allowlisted`));
|
|
assert.doesNotMatch(description, /up to six|six allowlisted/);
|
|
assert.match(description, /omitted_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"];
|
|
await assert.rejects(
|
|
tool.execute!(
|
|
modelInput({ question: "测试", domains: ["career", domain] }),
|
|
{ observe: { span: async (_n: string, fn: () => Promise<unknown>) => fn(), log() {} } } as never,
|
|
),
|
|
/unsupported_consultation_domain/,
|
|
);
|
|
assert.equal(calls, 0);
|
|
assert.equal(state.consultationToolCompleted, false);
|
|
}
|
|
});
|
|
|
|
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(rejectedState.steps, []);
|
|
});
|
|
|
|
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.throws(
|
|
() => canonicalDomainPlan({ domains: ["career"], theme: "career" }, {}),
|
|
/invalid_consultation_domain_plan/,
|
|
);
|
|
assert.throws(() => canonicalDomainPlan({}, {}), /invalid_consultation_domain_plan/);
|
|
assert.throws(() => canonicalDomainPlan({ theme: "prashna" }, {}), /unsupported_consultation_domain/);
|
|
});
|
|
|
|
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: an input the schema accepts but the domain registry rejects must
|
|
// still be refused before the request-scoped calculation cache is written.
|
|
await assert.rejects(
|
|
tool.execute!(modelInput({ question: "先给出错误参数", domains: ["career", "unknown"] }), context),
|
|
/unsupported_consultation_domain/,
|
|
);
|
|
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();
|
|
state.jyotishSkillLoaded = true;
|
|
appendConsultationRuntimeStep(state, { kind: "skill", name: "jyotish-vedic-astrology", status: "completed", durationMs: 370 });
|
|
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");
|
|
assert.equal(consultationStepBudgetReceipt(state).used, 2);
|
|
// 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 }> };
|
|
};
|
|
assert.deepEqual(failed.receipt?.steps.map((step) => step.status), ["completed", "failed"]);
|
|
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();
|
|
state.jyotishSkillLoaded = true;
|
|
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();
|
|
|
|
assert.equal(state.steps.filter((step) => step.status === "failed").length, 1);
|
|
assert.equal(state.steps[0]?.failureCode, "workflow_queue_full");
|
|
});
|
|
|
|
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",
|
|
});
|
|
appendConsultationRuntimeStep(state, { kind: "skill", name: "jyotish-vedic-astrology", status: "completed" });
|
|
|
|
const steps = publicConsultationRuntimeSteps(state);
|
|
assert.equal(steps.every((step) => !("failureCode" in step)), true);
|
|
assert.equal(state.steps[0]?.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 },
|
|
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 },
|
|
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, modelFinishReason: "tool-calls" },
|
|
);
|
|
assert.deepEqual(
|
|
consultationModelStepTelemetry(createConsultationRuntimeState()),
|
|
{ modelStepCount: 0, skillReferenceReads: 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 },
|
|
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 },
|
|
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);
|
|
const load = { toolName: "skill", input: { name: "jyotish-vedic-astrology" } };
|
|
|
|
hooks.beforeToolCall(load);
|
|
hooks.afterToolCall(load);
|
|
// Loading the skill supplies its instructions plus a listing of reference filenames. Opening a
|
|
// listed document is a separate call, so a loaded skill says nothing about method being consulted.
|
|
assert.equal(state.jyotishSkillLoaded, true);
|
|
assert.equal(state.skillReferenceReadCount, 0);
|
|
|
|
hooks.afterToolCall({ toolName: "skill_read", input: { skillName: "jyotish-vedic-astrology", path: "references/a.md" } });
|
|
hooks.afterToolCall({ toolName: "read_file", input: { path: "references/b.md" } });
|
|
hooks.afterToolCall({ toolName: "skill_read", input: { skillName: "jyotish-vedic-astrology", path: "references/c.md" }, 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("personal Agent exposes the Jyotish Skill and named server tool", async () => {
|
|
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);
|
|
assert.equal(toolNames.includes("skill"), 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 },
|
|
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 },
|
|
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("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 });
|
|
assert.equal(appendConsultationRuntimeStep(state, { kind: "skill", name: "jyotish-vedic-astrology", status: "completed" }), true);
|
|
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 });
|
|
});
|
|
|
|
function receipt(state: ReturnType<typeof createConsultationRuntimeState>) {
|
|
return {
|
|
runId: "run",
|
|
runtime: "mastra-agentic" as const,
|
|
skill: {
|
|
name: "jyotish-vedic-astrology" as const,
|
|
loaded: state.jyotishSkillLoaded,
|
|
referenceReads: state.skillReferenceReadCount,
|
|
},
|
|
steps: state.steps,
|
|
stepBudget: consultationStepBudgetReceipt(state),
|
|
workflow: state.workflowReceipt ?? { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] },
|
|
};
|
|
}
|
|
|
|
test("holds answer text until the Skill and server tool contract completes", async () => {
|
|
const state = createConsultationRuntimeState();
|
|
let completed = 0;
|
|
async function* chunks() {
|
|
yield { type: "text-delta", payload: { text: "只在合同完成后显示。" } };
|
|
yield { type: "tool-call", payload: { toolCallId: "skill-1", toolName: "skill", args: { name: "jyotish-vedic-astrology" } } };
|
|
state.jyotishSkillLoaded = 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: {} } };
|
|
}
|
|
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);
|
|
assert.equal(events.filter((event) => (event as { type?: string }).type === "answer.delta").length, 1);
|
|
assert.equal((events.find((event) => (event as { type?: string }).type === "answer.delta") as { text?: string }).text, "只在合同完成后显示。");
|
|
});
|
|
|
|
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.jyotishSkillLoaded = 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.jyotishSkillLoaded = 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 fails without saving a successful answer", 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,
|
|
toolStatus: () => "blocked", 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(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("ensures a controlled final response after a successful tool-only run", async () => {
|
|
const state = createConsultationRuntimeState();
|
|
state.jyotishSkillLoaded = true;
|
|
state.consultationToolCallCount = 1;
|
|
state.consultationToolSuccessCount = 1;
|
|
state.consultationToolCompleted = true;
|
|
state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] };
|
|
let completedOutput = "";
|
|
async function* chunks() {
|
|
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: (output) => { completedOutput = output; },
|
|
});
|
|
const events: unknown[] = [];
|
|
const parser = createNdjsonParser((event) => events.push(event));
|
|
parser.finish(await response.text());
|
|
assert.equal(ensureFinalResponseText("", true), completedOutput);
|
|
assert.match(completedOutput, /计算已完成/);
|
|
assert.equal(events.filter((event) => (event as { type?: string }).type === "answer.delta").length, 1);
|
|
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
|
|
assert.equal(state.steps.at(-1)?.name, "ensure-final-response");
|
|
});
|
|
|
|
|
|
test("a completed run records the finish reason and the authoritative step count", async () => {
|
|
const state = createConsultationRuntimeState();
|
|
state.jyotishSkillLoaded = 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.jyotishSkillLoaded = 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.
|
|
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 completedOutput = "";
|
|
const response = streamAgentResponse({
|
|
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
|
|
toolStatus: () => "ready", receipt: () => receipt(state),
|
|
onComplete: (output) => { completedOutput = output; },
|
|
});
|
|
await response.text();
|
|
|
|
assert.equal(completedOutput, ensureFinalResponseText("", true));
|
|
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.jyotishSkillLoaded = 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.jyotishSkillLoaded = true;
|
|
state.modelFinishReason = "tool-calls";
|
|
state.modelStepCount = 8;
|
|
appendConsultationRuntimeStep(state, { kind: "skill", name: "jyotish-vedic-astrology", status: "completed", durationMs: 900 });
|
|
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.
|
|
assert.deepEqual(failed.receipt?.steps.map((step) => step.durationMs), [900, 20936]);
|
|
assert.equal(failed.receipt?.stepBudget?.used, 2);
|
|
// 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.jyotishSkillLoaded = 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);
|
|
});
|