Files
Jyotisha/frontend/tests/consultation-agentic-runtime.test.ts
T

354 lines
18 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import {
appendConsultationRuntimeStep,
consultationStepBudgetReceipt,
createConsultationTools,
createConsultationRuntimeState,
} from "../src/mastra/consultation-tools.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,
},
};
function workflow(
theme = "career",
options: { status?: "ready" | "degraded" | "blocked"; missingLayers?: string[]; preciseTiming?: boolean } = {},
) {
return {
success: true,
chart: {},
routing: { primary_theme: theme },
consumer_context: {
route: theme, core_status: options.status ?? "ready", available_layers: [], missing_route_layers: options.missingLayers ?? [], hard_blockers: [],
technique_truth: { status: "verified" },
answer_policy: { can_answer_direction: true, can_answer_precise_timing: options.preciseTiming ?? true },
},
};
}
test("context-bound tool keeps legacy single theme compatibility and calculates once", 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"];
assert.deepEqual(Object.keys((tool.inputSchema as unknown as { shape: object }).shape), ["question", "domains", "theme"]);
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({ question: "尝试改成精确应期", theme: "timing" }, context),
execute({ question: "尝试改成婚恋", theme: "marriage" }, 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.workflowReceipt?.preciseTiming, "blocked");
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);
},
});
const result = await tools["run-jyotish-consultation"].execute!(
{ question: "事业、财富和迁居怎么一起规划", domains: ["career", "finance", "career", "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("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!(
{ 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);
const secondTool = createConsultationTools({
userId: "u", sessionId: "s", requestId: "r-modes", consultationMode: "verified_chart",
serverChart, state: createConsultationRuntimeState(),
runWorkflow: async () => { calls += 1; return workflow(); },
})["run-jyotish-consultation"];
await assert.rejects(
secondTool.execute!({ question: "测试", domains: ["career"], theme: "career" }, context),
/invalid_consultation_domain_plan/,
);
assert.equal(calls, 0);
});
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.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("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.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("persistence failure emits run.failed instead of run.completed", async () => {
const state = createConsultationRuntimeState();
state.jyotishSkillLoaded = true;
state.consultationToolCallCount = 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);
});