236 lines
12 KiB
TypeScript
236 lines
12 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 { 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() {
|
|
return {
|
|
success: true,
|
|
chart: {},
|
|
routing: { primary_theme: "career" },
|
|
consumer_context: {
|
|
route: "career", core_status: "ready" as const, available_layers: [], missing_route_layers: [], hard_blockers: [],
|
|
technique_truth: { status: "verified" },
|
|
answer_policy: { can_answer_direction: true, can_answer_precise_timing: true },
|
|
},
|
|
};
|
|
}
|
|
|
|
test("context-bound tool exposes only question/theme and calculates once", async () => {
|
|
let calls = 0;
|
|
let captured: unknown;
|
|
const state = createConsultationRuntimeState();
|
|
const tools = createConsultationTools({
|
|
userId: "u", sessionId: "s", requestId: "r", consultationMode: "unverified_birth_time",
|
|
serverChart, state,
|
|
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"]);
|
|
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: "career" }, context),
|
|
execute({ question: "事业如何", theme: "career" }, context),
|
|
]);
|
|
assert.equal(calls, 1);
|
|
assert.deepEqual(first, second);
|
|
assert.deepEqual(captured, { ...serverChart.toolInput, entryMode: "direct_chart", question: "事业如何", theme: "career" });
|
|
assert.equal(state.consultationToolCallCount, 1);
|
|
assert.equal(state.workflowReceipt?.preciseTiming, "blocked");
|
|
});
|
|
|
|
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: [] },
|
|
}),
|
|
});
|
|
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);
|
|
});
|
|
|
|
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);
|
|
});
|