feat: add safe consultation cancellation
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { parseAgentReply } from "../src/lib/agent-reply.ts";
|
||||
|
||||
test("extracts a model-generated session title without exposing hidden metadata", () => {
|
||||
// Given
|
||||
const response = [
|
||||
"你目前更适合先验证新的职业方向。",
|
||||
'<!--AYANAM_SUGGESTIONS:["什么时候行动?","适合什么方向?","有哪些风险?"]-->',
|
||||
"<!--AYANAM_TITLE:未来半年职业转型-->",
|
||||
].join("\n");
|
||||
|
||||
// When
|
||||
const reply = parseAgentReply(response, "career");
|
||||
|
||||
// Then
|
||||
assert.equal(reply.text, "你目前更适合先验证新的职业方向。");
|
||||
assert.equal(reply.title, "未来半年职业转型");
|
||||
});
|
||||
|
||||
test("rejects an overlong model-generated session title", () => {
|
||||
// Given
|
||||
const response = "回答正文\n<!--AYANAM_TITLE:这是一个明显超过合理长度并且不适合作为会话标题的模型输出标题-->";
|
||||
|
||||
// When
|
||||
const reply = parseAgentReply(response, "general");
|
||||
|
||||
// Then
|
||||
assert.equal(reply.text, "回答正文");
|
||||
assert.equal(reply.title, undefined);
|
||||
});
|
||||
|
||||
test("accepts a concise English model-generated session title", () => {
|
||||
// Given
|
||||
const response = "Your next step is to test the market first.\n<!--AYANAM_TITLE:Career Change Timing-->";
|
||||
|
||||
// When
|
||||
const reply = parseAgentReply(response, "career");
|
||||
|
||||
// Then
|
||||
assert.equal(reply.title, "Career Change Timing");
|
||||
});
|
||||
|
||||
test("hides an incomplete metadata block while a reply is streaming", () => {
|
||||
// Given
|
||||
const response = "回答正文\n<!--AYANAM_TITLE:未来半年";
|
||||
|
||||
// When
|
||||
const reply = parseAgentReply(response, "general");
|
||||
|
||||
// Then
|
||||
assert.equal(reply.text, "回答正文");
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { runCreditRpc } from "../src/lib/consultation-billing.ts";
|
||||
|
||||
test("returns a valid business rejection without retrying it as an RPC error", async () => {
|
||||
// Given
|
||||
let calls = 0;
|
||||
const accounting = {
|
||||
async rpc() {
|
||||
calls += 1;
|
||||
return {
|
||||
data: [{ success: false, credits: 0, error_code: "insufficient_credits" }],
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
const result = await runCreditRpc(
|
||||
accounting,
|
||||
"begin_consultation_credit",
|
||||
"00000000-0000-4000-8000-000000000001",
|
||||
"00000000-0000-4000-8000-000000000002",
|
||||
);
|
||||
|
||||
// Then
|
||||
assert.equal(calls, 1);
|
||||
assert.deepEqual(result, {
|
||||
success: false,
|
||||
credits: 0,
|
||||
error_code: "insufficient_credits",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns request_completed so the cancel route can respond with 409", async () => {
|
||||
const accounting = {
|
||||
async rpc() {
|
||||
return {
|
||||
data: { success: false, credits: 4, error_code: "request_completed" },
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const result = await runCreditRpc(
|
||||
accounting,
|
||||
"cancel_consultation_credit",
|
||||
"00000000-0000-4000-8000-000000000001",
|
||||
"00000000-0000-4000-8000-000000000003",
|
||||
);
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.error_code, "request_completed");
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { streamTextResponse } from "../src/lib/stream-text-response.ts";
|
||||
|
||||
test("charges a consultation when cancellation happens after partial output", async () => {
|
||||
// Given
|
||||
let completed = 0;
|
||||
let cancelled = 0;
|
||||
async function* reply() {
|
||||
yield "部分回答";
|
||||
yield "剩余回答";
|
||||
}
|
||||
const response = streamTextResponse(reply(), {
|
||||
mode: "mastra",
|
||||
requestId: "00000000-0000-4000-8000-000000000001",
|
||||
onComplete: async () => { completed += 1; },
|
||||
onCancel: async (emitted) => {
|
||||
if (emitted) completed += 1;
|
||||
else cancelled += 1;
|
||||
},
|
||||
});
|
||||
const reader = response.body?.getReader();
|
||||
assert.ok(reader);
|
||||
await reader.read();
|
||||
|
||||
// When
|
||||
await reader.cancel();
|
||||
|
||||
// Then
|
||||
assert.equal(cancelled, 0);
|
||||
assert.equal(completed, 1);
|
||||
});
|
||||
|
||||
test("refunds when cancellation happens before any output", async () => {
|
||||
// Given
|
||||
let completed = 0;
|
||||
let cancelled = 0;
|
||||
async function* reply() {
|
||||
yield "回答";
|
||||
}
|
||||
const response = streamTextResponse(reply(), {
|
||||
mode: "mastra",
|
||||
requestId: "00000000-0000-4000-8000-000000000003",
|
||||
onComplete: async () => { completed += 1; },
|
||||
onCancel: async (emitted) => {
|
||||
if (emitted) completed += 1;
|
||||
else cancelled += 1;
|
||||
},
|
||||
});
|
||||
const reader = response.body?.getReader();
|
||||
assert.ok(reader);
|
||||
|
||||
// When
|
||||
await reader.cancel();
|
||||
|
||||
// Then
|
||||
assert.equal(cancelled, 1);
|
||||
assert.equal(completed, 0);
|
||||
});
|
||||
|
||||
test("completes billing only after a non-empty stream finishes", async () => {
|
||||
// Given
|
||||
let completed = 0;
|
||||
async function* reply() {
|
||||
yield "完整回答";
|
||||
}
|
||||
const response = streamTextResponse(reply(), {
|
||||
mode: "mastra",
|
||||
requestId: "00000000-0000-4000-8000-000000000002",
|
||||
onComplete: async () => { completed += 1; },
|
||||
});
|
||||
|
||||
// When
|
||||
const answer = await response.text();
|
||||
|
||||
// Then
|
||||
assert.equal(answer, "完整回答");
|
||||
assert.equal(completed, 1);
|
||||
});
|
||||
|
||||
test("does not run cancellation settlement once completion has started", async () => {
|
||||
// Given
|
||||
let completed = 0;
|
||||
let cancelled = 0;
|
||||
let releaseCompletion = () => {};
|
||||
const completionGate = new Promise<void>((resolve) => {
|
||||
releaseCompletion = resolve;
|
||||
});
|
||||
let markCompletionStarted = () => {};
|
||||
const completionStarted = new Promise<void>((resolve) => {
|
||||
markCompletionStarted = resolve;
|
||||
});
|
||||
async function* reply() {
|
||||
yield "完整回答";
|
||||
}
|
||||
const response = streamTextResponse(reply(), {
|
||||
mode: "mastra",
|
||||
requestId: "00000000-0000-4000-8000-000000000004",
|
||||
onComplete: async () => {
|
||||
completed += 1;
|
||||
markCompletionStarted();
|
||||
await completionGate;
|
||||
},
|
||||
onCancel: async () => { cancelled += 1; },
|
||||
});
|
||||
const reader = response.body?.getReader();
|
||||
assert.ok(reader);
|
||||
await reader.read();
|
||||
|
||||
// When
|
||||
const finalRead = reader.read();
|
||||
await completionStarted;
|
||||
const cancellation = reader.cancel();
|
||||
releaseCompletion();
|
||||
await Promise.all([finalRead, cancellation]);
|
||||
|
||||
// Then
|
||||
assert.equal(completed, 1);
|
||||
assert.equal(cancelled, 0);
|
||||
});
|
||||
Reference in New Issue
Block a user