Files
Jyotisha/frontend/tests/consultation-smalltalk.test.ts
T

243 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { classifyConsultationTurn, consultationTurnSchema, smalltalkHistoryPair, type SmalltalkObservation } from "../src/lib/consultation-smalltalk.ts";
import { streamSmalltalkResponse } from "../src/lib/stream-smalltalk-response.ts";
import { consultationAgentPublicEventSchema } from "../src/lib/consultation-agent-events.ts";
import { settledChatMessageViews, streamingChatMessageView } from "../src/lib/chat-message-view.ts";
import { productConversationVoice, natalSpokenReportContract } from "../src/mastra/product-voice.ts";
import type { ResolvedLanguageModel } from "../src/mastra/model.ts";
import type { LanguageModelV2 } from "@ai-sdk/provider";
// Transport tests inject a generator; the adapter regression uses a local fake.
// Neither resolves credentials nor calls a real provider.
const model = { id: "fake-model", model: "openai/fake" } as ResolvedLanguageModel;
const source = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
for (const question of ["你好", "在吗", "谢谢", "晚安", "哈哈", "我回来了"]) {
test(`smalltalk fake transport: ${question}`, async () => {
let calls = 0;
assert.deepEqual(await classifyConsultationTurn({ model, question, history: [], generate: async () => {
calls++; return { object: { kind: "smalltalk", reply: "在呢" } };
} }), { kind: "smalltalk", reply: "在呢" });
assert.equal(calls, 1);
});
}
for (const question of ["你好,帮我看看事业", "?", "你在说什么鬼", "今年怎么样", ""]) {
test(`consult fake transport: ${question || "blank"}`, async () => {
assert.deepEqual(await classifyConsultationTurn({ model, question, history: [], generate: async () => ({ object: { kind: "consult" } }) }), { kind: "consult" });
});
}
for (const object of [null, {}, { kind: "smalltalk", reply: "" }, { kind: "smalltalk", reply: "字".repeat(21) }, { kind: "smalltalk", reply: "你好。" }, { kind: "smalltalk", reply: "你好\n再见" }, { kind: "smalltalk", reply: "你好", extra: true }, { kind: "consult", reply: "你好" }]) {
test(`strict schema fails open: ${JSON.stringify(object)}`, async () => {
assert.equal(consultationTurnSchema.safeParse(object).success, false);
assert.deepEqual(await classifyConsultationTurn({ model, question: "test", history: [], generate: async () => ({ object }) }), { kind: "consult" });
});
}
test("default Mastra adapter uses one tool-free bounded model call and returns real usage", async () => {
const calls: Parameters<LanguageModelV2["doGenerate"]>[0][] = [];
const text = '{"kind":"smalltalk","reply":"你好"}';
const usage = { inputTokens: 15, outputTokens: 10, totalTokens: 25 };
const fake: LanguageModelV2 = {
specificationVersion: "v2", provider: "fake", modelId: "fake", supportedUrls: {},
async doGenerate(options) {
calls.push(options);
return { content: [{ type: "text", text }], finishReason: "stop", usage, warnings: [] };
},
async doStream(options) {
calls.push(options);
return { stream: new ReadableStream({ start(controller) {
controller.enqueue({ type: "stream-start", warnings: [] });
controller.enqueue({ type: "text-start", id: "1" });
controller.enqueue({ type: "text-delta", id: "1", delta: text });
controller.enqueue({ type: "text-end", id: "1" });
controller.enqueue({ type: "finish", finishReason: "stop", usage });
controller.close();
} }) };
},
};
const observations: SmalltalkObservation[] = [];
const result = await classifyConsultationTurn({
model: { ...model, model: fake }, question: "你好", history: [],
onObservation: (observation) => observations.push(observation),
});
assert.deepEqual(result, { kind: "smalltalk", reply: "你好" });
assert.equal(calls.length, 1);
assert.equal(calls[0]?.maxOutputTokens, 96);
assert.equal(calls[0]?.tools?.length ?? 0, 0);
assert.equal(observations[0]?.usage?.inputTokens, 15);
assert.equal(observations[0]?.usage?.outputTokens, 10);
});
for (const scenario of ["invalid_schema", "bad_json", "provider_error"] as const) {
test(`default Mastra adapter protects privacy and usage on ${scenario}`, async (t) => {
const sentinel = "FICTIONAL_PRIVATE_SENTINEL";
const text = scenario === "bad_json" ? sentinel : JSON.stringify({ kind: "smalltalk", reply: `${sentinel}。` });
const usage = { inputTokens: 23, outputTokens: 7, totalTokens: 30 };
const calls: Parameters<LanguageModelV2["doGenerate"]>[0][] = [];
const fake: LanguageModelV2 = {
specificationVersion: "v2", provider: "fake", modelId: "fake", supportedUrls: {},
async doGenerate(options) {
calls.push(options);
if (scenario === "provider_error") throw new Error(sentinel);
return { content: [{ type: "text", text }], finishReason: "stop", usage, warnings: [] };
},
async doStream(options) {
calls.push(options);
if (scenario === "provider_error") throw new Error(sentinel);
return { stream: new ReadableStream({ start(controller) {
controller.enqueue({ type: "stream-start", warnings: [] });
controller.enqueue({ type: "text-start", id: "1" });
controller.enqueue({ type: "text-delta", id: "1", delta: text });
controller.enqueue({ type: "text-end", id: "1" });
controller.enqueue({ type: "finish", finishReason: "stop", usage });
controller.close();
} }) };
},
};
const logs: string[] = [];
const observations: SmalltalkObservation[] = [];
let result;
try {
for (const method of ["log", "info", "warn", "error", "debug"] as const) {
t.mock.method(console, method, (...values: unknown[]) => { logs.push(JSON.stringify(values)); });
}
const stdoutWrite = process.stdout.write.bind(process.stdout);
const stderrWrite = process.stderr.write.bind(process.stderr);
t.mock.method(process.stdout, "write", ((
chunk: string | Uint8Array,
encoding?: BufferEncoding | ((error?: Error | null) => void),
callback?: (error?: Error | null) => void,
) => {
logs.push(String(chunk));
if (typeof encoding === "function") return stdoutWrite(chunk, encoding);
return stdoutWrite(chunk, encoding, callback);
}) as typeof process.stdout.write);
t.mock.method(process.stderr, "write", ((
chunk: string | Uint8Array,
encoding?: BufferEncoding | ((error?: Error | null) => void),
callback?: (error?: Error | null) => void,
) => {
logs.push(String(chunk));
if (typeof encoding === "function") return stderrWrite(chunk, encoding);
return stderrWrite(chunk, encoding, callback);
}) as typeof process.stderr.write);
result = await classifyConsultationTurn({ model: { ...model, model: fake }, question: sentinel, history: [], onObservation: (o) => observations.push(o) });
} finally { t.mock.restoreAll(); }
assert.deepEqual(result, { kind: "consult" });
assert.equal(calls.length, 1);
assert.equal(calls[0]?.maxOutputTokens, 96);
assert.equal(calls[0]?.tools?.length ?? 0, 0);
assert.equal(logs.join(" ").includes(sentinel), false, "SDK must not log private text");
assert.equal(JSON.stringify(observations).includes(sentinel), false);
if (scenario === "provider_error") {
assert.equal(observations[0]?.outcome, "provider_error");
assert.equal(observations[0]?.usage, undefined, "unknown usage must not become a fake zero");
} else {
assert.equal(observations[0]?.outcome, "invalid_output");
assert.equal(observations[0]?.usage?.inputTokens, 23);
assert.equal(observations[0]?.usage?.outputTokens, 7);
}
});
}
test("invalid JSON and provider errors fail open without leaking errors", async () => {
for (const generate of [async () => ({ text: "not json" }), async () => { throw new Error("private provider payload"); }]) {
const observations: SmalltalkObservation[] = [];
assert.deepEqual(await classifyConsultationTurn({ model, question: "test", history: [], generate, onObservation: (o) => observations.push(o) }), { kind: "consult" });
assert.equal(JSON.stringify(observations).includes("private"), false);
}
});
test("timeout is bounded even when provider ignores abort, and late usage remains observable", async () => {
let resolve!: (value: { object: unknown; usage: { inputTokens: number } }) => void;
let signal: AbortSignal | undefined;
const observations: SmalltalkObservation[] = [];
const started = Date.now();
const result = await classifyConsultationTurn({ model, question: "test", history: [], onObservation: (o) => observations.push(o), generate: async (_, s) => {
signal = s; return new Promise((done) => { resolve = done; });
} });
assert.deepEqual(result, { kind: "consult" });
assert.ok(Date.now() - started >= 2900 && Date.now() - started < 4500);
assert.equal(signal?.aborted, true);
assert.equal(observations[0]?.outcome, "timeout");
resolve({ object: { kind: "smalltalk", reply: "你好" }, usage: { inputTokens: 12 } });
await new Promise((done) => setImmediate(done));
assert.equal(observations[1]?.late, true);
assert.equal(observations[1]?.usage?.inputTokens, 12);
});
test("pre-cancelled input never calls the model or causes an unhandled rejection", async () => {
const c = new AbortController(); c.abort();
let calls = 0;
assert.deepEqual(await classifyConsultationTurn({ model, question: "test", history: [], signal: c.signal, generate: async () => { calls++; return {}; } }), { kind: "consult" });
assert.equal(calls, 0);
await new Promise((done) => setImmediate(done));
});
test("input projects only visible question, name and last complete history pair", async () => {
const history = [
{ role: "user", text: "older", requestId: "old" }, { role: "assistant", text: "old reply", requestId: "old" },
{ role: "user", text: "previous", requestId: "pair", birth: "excluded" },
{ role: "assistant", text: "visible previous reply", requestId: "pair", thinkingText: "excluded", workflowReceipt: {} },
{ role: "user", text: "incomplete" },
];
assert.deepEqual(smalltalkHistoryPair(history), [{ role: "user", text: "previous" }, { role: "assistant", text: "visible previous reply" }]);
assert.deepEqual(smalltalkHistoryPair([{ role: "user", text: "unanswered" }]), []);
assert.deepEqual(smalltalkHistoryPair([{ role: "user", text: "a", requestId: "a" }, { role: "assistant", text: "b", requestId: "b" }]), []);
await classifyConsultationTurn({ model, question: "current", name: "虚构称呼", history, generate: async (content) => {
assert.deepEqual(JSON.parse(content), { question: "current", name: "虚构称呼", history: smalltalkHistoryPair(history) });
return { object: { kind: "consult" } };
} });
const classifier = source("../src/lib/consultation-smalltalk.ts");
assert.doesNotMatch(classifier, /tools:|memory:|getJyotishAgent|skillBinding/);
});
test("smalltalk stream persists first and emits only answer plus receipt-free completion", async () => {
let persisted = false;
const response = streamSmalltalkResponse({ requestId: "fake", reply: "你好", complete: async () => { persisted = true; }, onError: async () => assert.fail() });
const events = (await response.text()).trim().split("\n").map((line) => consultationAgentPublicEventSchema.parse(JSON.parse(line)));
assert.equal(persisted, true);
assert.deepEqual(events, [{ type: "answer.delta", text: "你好" }, { type: "run.completed", responseKind: "smalltalk" }]);
assert.equal(consultationAgentPublicEventSchema.safeParse({ type: "run.completed" }).success, false);
});
test("failed free persistence never emits answer or completion", async () => {
let cancelled = 0;
const response = streamSmalltalkResponse({ requestId: "fake", reply: "你好", complete: async () => { throw new Error("failed"); }, onError: async () => { cancelled++; } });
const text = await response.text();
assert.equal(cancelled, 1);
assert.match(text, /run.failed/);
assert.doesNotMatch(text, /answer.delta|run.completed|你好/);
});
test("disconnect does not cancel the server-owned free completion", async () => {
let finish!: () => void;
let persisted = false;
const response = streamSmalltalkResponse({ requestId: "fake", reply: "你好", complete: async () => { await new Promise<void>((done) => { finish = done; }); persisted = true; }, onError: async () => assert.fail() });
await response.body!.cancel(); finish();
await new Promise((done) => setImmediate(done));
assert.equal(persisted, true);
});
test("smalltalk views never reconstruct an invented settled execution timeline", () => {
assert.equal(settledChatMessageViews([{ role: "assistant", text: "你好", responseKind: "smalltalk" }])[0]?.timeline, undefined);
assert.equal(streamingChatMessageView([{ role: "user", text: "你好" }], true, "你好", undefined, undefined, undefined, [], "smalltalk")?.responseKind, "smalltalk");
// 原值: /const quiet = smalltalk \|\| awaitingClassification/(分类回来前整行静默)
// 新值: /const quiet = smalltalk;/(只有寒暄回复静默;分类前显示排队行)
// 原因: TASK-consult-first-frame-and-pacing-20260928 D1 / BUG-1074
assert.match(source("../src/components/chat-message-row.tsx"), /const quiet = smalltalk;/);
assert.doesNotMatch(source("../src/components/chat-message-row.tsx"), /awaitingClassification/);
assert.match(source("../src/lib/home-cloud-sync.ts"), /stored.responseKind === "smalltalk"/);
});
test("route gates all special entries before a single selected-model classifier and keeps tool contracts", () => {
const route = source("../src/app/api/consult/route.ts");
assert.match(route, /parsed.data.entrypoint === undefined\s*\? await classifyConsultationTurn/);
assert.match(route, /classifyConsultationTurn\(\{\s*model: selectedModel,\s*question: visibleQuestion/);
assert.equal((route.match(/requireTool: true/g) ?? []).length, 2);
const smalltalk = route.slice(route.indexOf('if (turn.kind === "smalltalk")'), route.indexOf("const expectedTitle"));
assert.match(smalltalk, /complete_consultation_free/);
assert.doesNotMatch(smalltalk, /run-jyotish-|streamAgentResponse\(/);
});
test("all three agent voices share the exemption, natal domain skeleton stays mandatory", () => {
assert.match(productConversationVoice, /只是打招呼、道谢、告别时,回一句话,不要套开场形状/);
assert.doesNotMatch(natalSpokenReportContract, /Short chit-chat/);
// 原值: /One natal career\/wealth\/marriage\/family question still uses the opener-plus-skeleton/
// 新值: 首轮仍用开场 + 骨架;追问轮按用户轮里的指令直接回答(骨架对首轮仍是强制的)
// 原因: TASK-consult-answer-the-question-20260927 D3 / BUG-1071:追问轮不再重开整套骨架
assert.match(natalSpokenReportContract, /The first natal career\/wealth\/marriage\/family answer of a session uses the opener-plus-skeleton above/);
assert.match(natalSpokenReportContract, /a follow-up turn follows the shape instruction carried in the user turn/);
const index = source("../src/mastra/index.ts");
assert.ok((index.match(/\$\{productConversationVoice\}/g) ?? []).length >= 3);
});