feat: add consultation and product domain registries

This commit is contained in:
Jesse_Chen
2026-08-15 06:04:23 +08:00
parent 5012ff7212
commit 6d192ad175
46 changed files with 1404 additions and 292 deletions
@@ -17,20 +17,23 @@ const serverChart = {
},
};
function workflow() {
function workflow(
theme = "career",
options: { status?: "ready" | "degraded" | "blocked"; missingLayers?: string[]; preciseTiming?: boolean } = {},
) {
return {
success: true,
chart: {},
routing: { primary_theme: "career" },
routing: { primary_theme: theme },
consumer_context: {
route: "career", core_status: "ready" as const, available_layers: [], missing_route_layers: [], hard_blockers: [],
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: true },
answer_policy: { can_answer_direction: true, can_answer_precise_timing: options.preciseTiming ?? true },
},
};
}
test("context-bound tool exposes only question/theme and calculates once", async () => {
test("context-bound tool keeps legacy single theme compatibility and calculates once", async () => {
let calls = 0;
let captured: unknown;
const state = createConsultationRuntimeState();
@@ -40,7 +43,7 @@ test("context-bound tool exposes only question/theme and calculates once", async
runWorkflow: async (input) => { calls += 1; captured = input; return workflow(); },
});
const tool = tools["run-jyotish-consultation"];
assert.deepEqual(Object.keys((tool.inputSchema as unknown as { shape: object }).shape), ["question", "theme"]);
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([
@@ -52,6 +55,90 @@ test("context-bound tool exposes only question/theme and calculates once", async
assert.deepEqual(captured, { ...serverChart.toolInput, entryMode: "direct_chart", question: "事业如何", theme: "career" });
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 () => {
@@ -85,7 +172,7 @@ test("public stream filters private chunks and completes once", async () => {
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: [] },
steps: [], workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [], domains: ["career"] },
}),
});
assert.equal(events.filter((event) => event.type === "run.completed").length, 1);
@@ -93,6 +180,24 @@ test("public stream filters private chunks and completes once", async () => {
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", () => {