Same-id focus returns instead of making the model retry; kind_hint targetKind is ignored. Failed receipts keep a redacted original error and known codes. Host fallback recap no longer duplicates dates. Co-authored-by: Cursor <cursoragent@cursor.com>
276 lines
9.3 KiB
TypeScript
276 lines
9.3 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import { isToolInputRejection } from "../src/lib/rectification-agentic/v9/host-fallback.ts";
|
|
import { mapStreamChunkToActivity } from "../src/lib/rectification-agentic/v9/stream-mapping.ts";
|
|
import {
|
|
engineMessageForReceipt,
|
|
parseToolActivityDetail,
|
|
RectificationToolServiceError,
|
|
safeToolErrorCode,
|
|
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
|
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
|
|
import {
|
|
CASE_ID,
|
|
FOCUS_ID,
|
|
TURN_ID,
|
|
USER_ID,
|
|
activeFocusFixture,
|
|
conversationSummaryFixture,
|
|
dossierFixture,
|
|
fakeAccounting,
|
|
receiptHandlers,
|
|
} from "./rectification-v9-test-support.ts";
|
|
|
|
type ExecutableTool<T = unknown> = {
|
|
execute(input: unknown): Promise<T>;
|
|
inputSchema?: { safeParse(value: unknown): { success: boolean } };
|
|
};
|
|
|
|
function asExecutable<T = unknown>(tool: unknown): ExecutableTool<T> {
|
|
return tool as ExecutableTool<T>;
|
|
}
|
|
|
|
const INVITE_QUESTION_ID = "collect:invite:more";
|
|
const INVITE_PROMPT = "还有吗?把能想起来的都说一声。";
|
|
const INVITE_SPOKEN = "还有别的记得起来的事情吗?";
|
|
const OTHER_CASE_ID = "21111111-1111-4111-8111-111111111111";
|
|
|
|
function toolsFor(accounting: ReturnType<typeof fakeAccounting>) {
|
|
return createRectificationV9Tools({
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
turnId: TURN_ID,
|
|
accounting: accounting.client as never,
|
|
});
|
|
}
|
|
|
|
function inviteFocus() {
|
|
return {
|
|
...activeFocusFixture({
|
|
questionId: INVITE_QUESTION_ID,
|
|
intent: "collect_method_evidence",
|
|
targetDomain: "other",
|
|
expectedAnswerSchema: {
|
|
prompt: INVITE_PROMPT,
|
|
collect: true,
|
|
collect_kind: "invite_more",
|
|
},
|
|
}),
|
|
target_kind: null,
|
|
};
|
|
}
|
|
|
|
function inviteDossier() {
|
|
return dossierFixture({
|
|
evidenceCount: 2,
|
|
evidence: [
|
|
{
|
|
id: "44444444-4444-4444-8444-444444444445",
|
|
source_turn_id: TURN_ID,
|
|
subject: "self",
|
|
event_kind: "education_start",
|
|
domain: "education",
|
|
occurred_from: "2016-09-01",
|
|
occurred_to: "2016-09-01",
|
|
date_precision: "month",
|
|
summary: "2016年9月上大学",
|
|
status: "confirmed",
|
|
supersedes_evidence_id: null,
|
|
created_at: "2026-09-12T05:00:00.000Z",
|
|
},
|
|
{
|
|
id: "44444444-4444-4444-8444-444444444446",
|
|
source_turn_id: TURN_ID,
|
|
subject: "self",
|
|
event_kind: "education_completion",
|
|
domain: "education",
|
|
occurred_from: "2020-06-01",
|
|
occurred_to: "2020-06-01",
|
|
date_precision: "month",
|
|
summary: "2020年6月毕业",
|
|
status: "confirmed",
|
|
supersedes_evidence_id: null,
|
|
created_at: "2026-09-12T05:00:01.000Z",
|
|
},
|
|
],
|
|
conversationSummary: conversationSummaryFixture({ activeFocus: inviteFocus() }),
|
|
});
|
|
}
|
|
|
|
function failedReceipts(accounting: ReturnType<typeof fakeAccounting>) {
|
|
return accounting.calls.filter((call) =>
|
|
call.fn === "insert_agentic_rectification_tool_receipt"
|
|
&& call.args.p_status === "failed"
|
|
);
|
|
}
|
|
|
|
function setFocusArgs(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
caseId: CASE_ID,
|
|
questionId: INVITE_QUESTION_ID,
|
|
intent: "collect_method_evidence",
|
|
spokenPrompt: INVITE_SPOKEN,
|
|
targetKind: "invite_more",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function invoke(tool: ExecutableTool, input: unknown) {
|
|
try {
|
|
return { result: await tool.execute(input), error: null };
|
|
} catch (error) {
|
|
return { result: null, error };
|
|
}
|
|
}
|
|
|
|
test("T0/T2: same invite focus is idempotent even when the RPC would conflict", async () => {
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => inviteDossier(),
|
|
set_agentic_rectification_conversation_focus: () => {
|
|
throw new Error("agentic_rectification_focus_idempotency_conflict");
|
|
},
|
|
});
|
|
const execute = asExecutable<Record<string, unknown>>(toolsFor(accounting)["rectification-set-focus"]).execute;
|
|
const result = await execute(setFocusArgs());
|
|
assert.equal(result.error, undefined);
|
|
assert.equal(result.status, "active");
|
|
assert.equal(result.idempotent, true);
|
|
assert.equal(result.question_id, INVITE_QUESTION_ID);
|
|
assert.equal(result.focus_id, FOCUS_ID);
|
|
assert.equal(result.target_kind, null);
|
|
assert.equal(failedReceipts(accounting).length, 0);
|
|
});
|
|
|
|
test("targetKind kind_hint is accepted and clamped to null", async () => {
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => inviteDossier(),
|
|
set_agentic_rectification_conversation_focus: () => {
|
|
throw new Error("set-focus must not reject invite_more by writing a new row");
|
|
},
|
|
});
|
|
const tools = toolsFor(accounting);
|
|
const setFocus = asExecutable<Record<string, unknown>>(tools["rectification-set-focus"]);
|
|
assert.equal(setFocus.inputSchema?.safeParse(setFocusArgs()).success, true);
|
|
assert.equal(setFocus.inputSchema?.safeParse(setFocusArgs({ targetKind: "targeted:family" })).success, true);
|
|
const result = await setFocus.execute(setFocusArgs({ targetKind: "invite_more" }));
|
|
assert.equal(result.idempotent, true);
|
|
assert.equal(result.target_kind, null);
|
|
});
|
|
|
|
test("tool-internal throws write distinguishable failed receipts; unknown stays tool_failed", async () => {
|
|
assert.equal(
|
|
safeToolErrorCode(new RectificationToolServiceError("invalid_case_id")),
|
|
"invalid_case_id",
|
|
);
|
|
assert.equal(
|
|
safeToolErrorCode(new RectificationToolServiceError("invalid_domain")),
|
|
"invalid_domain",
|
|
);
|
|
assert.equal(
|
|
safeToolErrorCode(new RectificationToolServiceError("invalid_event_kind")),
|
|
"invalid_event_kind",
|
|
);
|
|
assert.equal(safeToolErrorCode(new Error("connection refused")), "tool_failed");
|
|
assert.equal(
|
|
engineMessageForReceipt(new Error(`boom ${CASE_ID} user@example.com`)),
|
|
"boom [id] [email]",
|
|
);
|
|
|
|
const warns: string[] = [];
|
|
const original = console.warn;
|
|
console.warn = (...args: unknown[]) => {
|
|
warns.push(args.map(String).join(" "));
|
|
};
|
|
try {
|
|
const caseAccounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => inviteDossier(),
|
|
});
|
|
const setFocus = asExecutable(toolsFor(caseAccounting)["rectification-set-focus"]);
|
|
const schemaRejected = await invoke(setFocus, setFocusArgs({ caseId: "not-a-uuid" }));
|
|
assert.equal(isToolInputRejection(schemaRejected.result), true);
|
|
assert.equal(failedReceipts(caseAccounting).length, 0);
|
|
|
|
const wrongCase = await invoke(setFocus, setFocusArgs({ caseId: OTHER_CASE_ID }));
|
|
assert.equal(
|
|
wrongCase.error instanceof RectificationToolServiceError && wrongCase.error.code,
|
|
"invalid_case_id",
|
|
);
|
|
const caseFailed = failedReceipts(caseAccounting);
|
|
assert.equal(caseFailed[0]?.args.p_safe_error_code, "invalid_case_id");
|
|
assert.match(String(caseFailed[0]?.args.p_result_fingerprint), /invalid_case_id/);
|
|
assert.match(String(caseFailed[0]?.args.p_result_fingerprint), /engine_message/);
|
|
assert.match(warns.join("\n"), /rectification_tool_failed/);
|
|
assert.match(warns.join("\n"), /invalid_case_id/);
|
|
|
|
const domainAccounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => inviteDossier(),
|
|
});
|
|
const domainFocus = asExecutable(toolsFor(domainAccounting)["rectification-set-focus"]);
|
|
const domainResult = await invoke(domainFocus, setFocusArgs({ targetDomain: "not_a_domain" }));
|
|
assert.equal(
|
|
domainResult.error instanceof RectificationToolServiceError && domainResult.error.code,
|
|
"invalid_domain",
|
|
);
|
|
assert.equal(failedReceipts(domainAccounting)[0]?.args.p_safe_error_code, "invalid_domain");
|
|
|
|
const kindAccounting = fakeAccounting({ ...receiptHandlers });
|
|
const propose = asExecutable(toolsFor(kindAccounting)["rectification-propose-evidence"]);
|
|
const kindRejected = await invoke(propose, {
|
|
caseId: CASE_ID,
|
|
quote: "2016年9月上大学",
|
|
proposedKind: "invite_more",
|
|
subject: "self",
|
|
domain: "education",
|
|
datePrecision: "month",
|
|
occurredFrom: "2016-09",
|
|
summary: "2016年9月上大学",
|
|
});
|
|
assert.equal(isToolInputRejection(kindRejected.result), true);
|
|
} finally {
|
|
console.warn = original;
|
|
}
|
|
|
|
const detail = parseToolActivityDetail({
|
|
error: "tool_failed",
|
|
result_fingerprint: JSON.stringify({
|
|
safe_error_code: "tool_failed",
|
|
engine_message: "connection refused",
|
|
}),
|
|
});
|
|
assert.equal(detail?.safe_error_code, "tool_failed");
|
|
assert.equal(detail?.engine_message, "connection refused");
|
|
});
|
|
|
|
test("tool-error activity keeps invalid_case_id instead of collapsing to tool_failed", () => {
|
|
assert.deepEqual(
|
|
mapStreamChunkToActivity({
|
|
type: "tool-error",
|
|
payload: {
|
|
toolName: "rectification-set-focus",
|
|
error: new Error("Rectification tool service error: invalid_case_id"),
|
|
},
|
|
} as never),
|
|
{
|
|
type: "tool.activity",
|
|
tool: "rectification-set-focus",
|
|
status: "failed",
|
|
code: "invalid_case_id",
|
|
},
|
|
);
|
|
assert.equal(
|
|
mapStreamChunkToActivity({
|
|
type: "tool-error",
|
|
payload: {
|
|
toolName: "rectification-set-focus",
|
|
error: new Error("Rectification tool service error: invalid_event_kind"),
|
|
},
|
|
} as never)?.code,
|
|
"invalid_event_kind",
|
|
);
|
|
});
|