feat: add safe consultation cancellation

This commit is contained in:
Jesse_Chen
2026-07-17 11:57:28 +08:00
parent 95efa12b11
commit 2e193f594e
17 changed files with 1225 additions and 229 deletions
+53
View File
@@ -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");
});
+120
View File
@@ -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);
});