1955ba8cef
A staging consultation submitted a three-domain plan, calculated all three successfully in 62.9s, and returned nothing but the ensureFinalResponseText fallback. The step budget was barely touched, so this is not the exhaustionc8d9ec64fixed. toModelDomainPlanContext returns two different shapes: a single domain flattens the evidence packet to the top level, several domains return only success, domains and consultations. Every hard output rule in jyotishInstructions is written against those top-level paths — evidence_contract.answer_policy, hard_blockers, rectification.boundary, status. None of them resolve in the multi-domain shape, and under a policy that forbids stating anything the server evidence does not support, silence is what the instructions ask for. Merge the packets into one top-level contract shaped exactly like the single domain one. Merging may only restrict: status takes the worst of ready > degraded > blocked, hard_blockers and missing_route_layers take the union, permission booleans need every domain to agree while limitation booleans need only one, and a field the domains genuinely disagree on is reported as unresolved rather than decided. available_layers is the one permission-shaped union, because a layer really was computed for some domain and denying it would deny real evidence. The natal projection is the same chart for every domain, so it is hoisted to one copy when the domains agree and left per-domain when they do not. The domain cap was six, advertised as six, and could never be paid for. Domains run sequentially at ~21s each against a cumulative 110s abort signal, so six is ~126s and four leaves nothing to write the answer with. Concurrency is not available: the Python API is a single GIL-bound ThreadingHTTPServer whose async work already sits behind a two-worker bounded queue that answers 503 when full. Derive the cap from the clock instead of choosing it — 110s minus a 45s answer reserve, divided by 21s, is three — and let the model-facing schema carry that bound so an unpayable plan is unrepresentable. A caller that builds a plan without that schema is truncated rather than refused, the loop stops early when the measured pace says the next domain will not fit, and either way the dropped domains are disclosed through omitted_domains and the receipt while status degrades, so a partial answer cannot be presented as complete. run.failed carried a code and nothing else, so the step durations, step budget and workflow route recorded byc8d9ec64were unavailable exactly when a run needed explaining. Send the same allowlisted receipt run.completed sends, built through publicConsultationRuntimeSteps so the internal failure code and model loop diagnostics stay server-side, and never let building it replace the failure event with a silent close. An agentic run that fails before streamAgentResponse exists never reached the settle-and-log path either, so the request-level catch now goes through the same entry point. Refs BUG-256, BUG-257, BUG-258. Co-authored-by: Cursor <cursoragent@cursor.com>
1058 lines
53 KiB
TypeScript
1058 lines
53 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,
|
|
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("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 },
|
|
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 },
|
|
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, modelFinishReason: "tool-calls" });
|
|
assert.deepEqual(
|
|
consultationModelStepTelemetry(createConsultationRuntimeState()),
|
|
{ modelStepCount: 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 },
|
|
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 },
|
|
steps: publicConsultationRuntimeSteps(state),
|
|
workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] },
|
|
...consultationModelStepTelemetry(state),
|
|
}));
|
|
});
|
|
|
|
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 },
|
|
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 },
|
|
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 },
|
|
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);
|
|
});
|