354 lines
18 KiB
TypeScript
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);
|
|
});
|