fix: make rectification question handoff durable
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import {
|
||||
persistExistingChatSession,
|
||||
sessionMutationMenuVisible,
|
||||
} from "../src/lib/chat-session-persistence.ts";
|
||||
|
||||
const sql = readFileSync(new URL(
|
||||
"../supabase/migrations/20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql",
|
||||
@@ -11,3 +15,32 @@ test("chat sessions expose owner-only delete", () => {
|
||||
assert.match(sql, /create policy chat_sessions_delete_own[\s\S]*for delete[\s\S]*auth\.uid\(\).*user_id/i);
|
||||
assert.match(sql, /grant delete on table public\.chat_sessions to authenticated/i);
|
||||
});
|
||||
|
||||
test("a late response cannot insert or resurrect a session deleted on another device", async () => {
|
||||
const inserts = 0;
|
||||
let updates = 0;
|
||||
await assert.rejects(
|
||||
persistExistingChatSession(async () => {
|
||||
updates += 1;
|
||||
return { found: false, error: null };
|
||||
}),
|
||||
/另一设备删除.*不会重新创建/,
|
||||
);
|
||||
assert.equal(updates, 1);
|
||||
assert.equal(inserts, 0);
|
||||
});
|
||||
|
||||
test("an existing session update succeeds without a create fallback", async () => {
|
||||
let updates = 0;
|
||||
await persistExistingChatSession(async () => {
|
||||
updates += 1;
|
||||
return { found: true, error: null };
|
||||
});
|
||||
assert.equal(updates, 1);
|
||||
});
|
||||
|
||||
test("session mutation menu closes and cannot act while a response is pending", () => {
|
||||
assert.equal(sessionMutationMenuVisible(true, true), false);
|
||||
assert.equal(sessionMutationMenuVisible(false, true), false);
|
||||
assert.equal(sessionMutationMenuVisible(true, false), true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createRectificationHandoffService,
|
||||
rectificationQuestionFingerprint,
|
||||
} from "../src/lib/rectification-handoff-service.ts";
|
||||
import { createRectificationHandoffHandlers } from "../src/lib/rectification-handoff-route.ts";
|
||||
|
||||
const userId = "00000000-0000-4000-8000-000000002001";
|
||||
const caseId = "00000000-0000-4000-8000-000000002002";
|
||||
const actionId = "00000000-0000-4000-8000-000000002003";
|
||||
const requestId = "00000000-0000-4000-8000-000000002004";
|
||||
const question = "未来半年是否适合换工作?";
|
||||
|
||||
function turn(pendingQuestion: string | null = question) {
|
||||
return {
|
||||
caseId,
|
||||
journeyProtocol: "conversational-evidence-v3" as const,
|
||||
status: "completed" as const,
|
||||
turnVersion: 4,
|
||||
narrative: "候选时间已经确认。",
|
||||
candidate: {
|
||||
status: "confirmed" as const,
|
||||
representativeTime: "05:18",
|
||||
rangeStart: "05:16",
|
||||
rangeEnd: "05:20",
|
||||
},
|
||||
technicalReceipt: {
|
||||
calculationVersion: "rectification-v3",
|
||||
stableLayers: ["D1"],
|
||||
sensitiveLayers: ["D9"],
|
||||
candidateDifferenceRefs: ["candidate-05:18"],
|
||||
},
|
||||
evidenceRequest: null,
|
||||
evidenceRecap: [],
|
||||
actions: pendingQuestion ? ["continue_original_question" as const] : [],
|
||||
pendingConsultationQuestion: pendingQuestion,
|
||||
};
|
||||
}
|
||||
|
||||
function handoff(status: "pending" | "claimed" | "in_progress" | "consumed") {
|
||||
return {
|
||||
caseId,
|
||||
turnVersion: 4,
|
||||
question,
|
||||
questionFingerprint: rectificationQuestionFingerprint(question),
|
||||
requestId,
|
||||
status,
|
||||
turn: turn(status === "consumed" ? null : question),
|
||||
};
|
||||
}
|
||||
|
||||
test("server service binds begin and settlement to one case, claim, and request identity", async () => {
|
||||
const calls: Array<{ name: string; args: Readonly<Record<string, unknown>> }> = [];
|
||||
const service = createRectificationHandoffService({
|
||||
async rpc(name, args) {
|
||||
calls.push({ name, args });
|
||||
if (name === "begin_conversational_rectification_handoff_execution") {
|
||||
return {
|
||||
data: { status: "ready", requestId, billingReused: false, credits: 7 },
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
if (name === "settle_conversational_rectification_handoff") {
|
||||
return { data: { status: "consumed", requestId, credits: 7 }, error: null };
|
||||
}
|
||||
return { data: null, error: { message: "unexpected_rpc" } };
|
||||
},
|
||||
});
|
||||
|
||||
const execution = await service.beginExecution({
|
||||
userId,
|
||||
caseId,
|
||||
turnVersion: 4,
|
||||
claimActionId: actionId,
|
||||
requestId,
|
||||
question,
|
||||
});
|
||||
const settlement = await service.settle({
|
||||
userId,
|
||||
caseId,
|
||||
claimActionId: actionId,
|
||||
requestId,
|
||||
emitted: true,
|
||||
});
|
||||
|
||||
assert.equal(execution.status, "ready");
|
||||
assert.equal(settlement.status, "consumed");
|
||||
assert.deepEqual(calls.map((call) => call.name), [
|
||||
"begin_conversational_rectification_handoff_execution",
|
||||
"settle_conversational_rectification_handoff",
|
||||
]);
|
||||
assert.deepEqual(calls[0]?.args, {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_expected_version: 4,
|
||||
p_claim_action_id: actionId,
|
||||
p_request_id: requestId,
|
||||
p_question_fingerprint: rectificationQuestionFingerprint(question),
|
||||
});
|
||||
assert.deepEqual(calls[1]?.args, {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_claim_action_id: actionId,
|
||||
p_request_id: requestId,
|
||||
p_emitted: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("pre-output settlement releases the durable question for retry", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const service = createRectificationHandoffService({
|
||||
async rpc(name, args) {
|
||||
calls.push({ name, args });
|
||||
return { data: { status: "pending", requestId, credits: 8 }, error: null };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.settle({
|
||||
userId,
|
||||
caseId,
|
||||
claimActionId: actionId,
|
||||
requestId,
|
||||
emitted: false,
|
||||
});
|
||||
|
||||
assert.equal(result.status, "pending");
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test("handoff route authenticates before parsing and returns owner-safe claim DTO", async () => {
|
||||
let serviceCalls = 0;
|
||||
const unauthenticated = createRectificationHandoffHandlers({
|
||||
authenticate: async () => null,
|
||||
service() {
|
||||
serviceCalls += 1;
|
||||
throw new Error("must not construct");
|
||||
},
|
||||
});
|
||||
const denied = await unauthenticated.post(new Request("https://example.invalid", {
|
||||
method: "POST",
|
||||
body: "not-json",
|
||||
}));
|
||||
assert.equal(denied.status, 401);
|
||||
assert.equal(serviceCalls, 0);
|
||||
|
||||
const authenticated = createRectificationHandoffHandlers({
|
||||
authenticate: async () => ({ userId }),
|
||||
service() {
|
||||
return {
|
||||
attach: async () => turn(),
|
||||
load: async () => handoff("pending"),
|
||||
claim: async () => handoff("claimed"),
|
||||
beginExecution: async () => ({
|
||||
status: "ready" as const,
|
||||
requestId,
|
||||
billingReused: false,
|
||||
credits: 8,
|
||||
}),
|
||||
settle: async () => ({ status: "consumed" as const, requestId, credits: 8 }),
|
||||
};
|
||||
},
|
||||
});
|
||||
const response = await authenticated.post(new Request("https://example.invalid", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "claim",
|
||||
caseId,
|
||||
turnVersion: 4,
|
||||
actionId,
|
||||
question,
|
||||
}),
|
||||
}));
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), handoff("claimed"));
|
||||
});
|
||||
|
||||
test("migration keeps claim, billing recovery, settlement and ACL inside locked RPCs", () => {
|
||||
const sql = readFileSync(new URL(
|
||||
"../supabase/migrations/20260720040000_rectification_question_handoff.sql",
|
||||
import.meta.url,
|
||||
), "utf8");
|
||||
|
||||
assert.match(sql, /attach_conversational_rectification_question[\s\S]*for update[\s\S]*pending_consultation_question = p_question/i);
|
||||
assert.match(sql, /claim_conversational_rectification_handoff[\s\S]*for update[\s\S]*lease_expires_at/i);
|
||||
assert.match(sql, /begin_conversational_rectification_handoff_execution[\s\S]*billingReused[\s\S]*v_request_status = 'reserved'/i);
|
||||
assert.match(sql, /settle_conversational_rectification_handoff[\s\S]*complete_consultation_credit[\s\S]*cancel_consultation_credit/i);
|
||||
assert.match(sql, /consume_conversational_rectification_handoff[\s\S]*continue_original_question/i);
|
||||
assert.match(sql, /consume_conversational_rectification_handoff[\s\S]*pending_consultation_question = null/i);
|
||||
for (const functionName of [
|
||||
"attach_conversational_rectification_question",
|
||||
"load_conversational_rectification_handoff",
|
||||
"claim_conversational_rectification_handoff",
|
||||
"begin_conversational_rectification_handoff_execution",
|
||||
"settle_conversational_rectification_handoff",
|
||||
]) {
|
||||
assert.match(sql, new RegExp(`revoke all on function public\\.${functionName}\\([\\s\\S]*?from public, anon, authenticated`, "i"));
|
||||
assert.match(sql, new RegExp(`grant execute on function public\\.${functionName}\\([\\s\\S]*?to service_role`, "i"));
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -10,6 +9,7 @@ import type { ConversationalRectificationController } from "../src/hooks/use-con
|
||||
import { prepareConsultationRoute } from "../src/lib/consultation-route-service.ts";
|
||||
import type { ConversationalRectificationTurn } from "../src/lib/conversational-rectification/contracts.ts";
|
||||
import {
|
||||
createDurableRectificationQuestionHandoffClient,
|
||||
createRectificationQuestionHandoffCoordinator,
|
||||
} from "../src/lib/rectification-question-handoff.ts";
|
||||
|
||||
@@ -299,48 +299,131 @@ test("returning from rectification restores the composer context without consult
|
||||
assert.equal(coordinator.peek(), null);
|
||||
});
|
||||
|
||||
test("homepage wires the tested handoff coordinator without carrying hidden rectification routing", () => {
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const chooseStart = page.indexOf("function rectifyBeforePendingConsultation");
|
||||
const chooseEnd = page.indexOf("function cancelPendingBirthTimeChoice", chooseStart);
|
||||
const chooseHandler = page.slice(chooseStart, chooseEnd);
|
||||
const continuationStart = page.indexOf("async function continueRectificationOriginalQuestion");
|
||||
const continuationEnd = page.indexOf("function restoreQuestionFromRectification", continuationStart);
|
||||
const continuationHandler = page.slice(continuationStart, continuationEnd);
|
||||
const restoreStart = continuationEnd;
|
||||
const restoreEnd = page.indexOf("function useUnverifiedTimeForPendingConsultation", restoreStart);
|
||||
const restoreHandler = page.slice(restoreStart, restoreEnd);
|
||||
test("lost claim responses replay one stable action and durable request identity", async () => {
|
||||
const actionId = "00000000-0000-4000-8000-000000001099";
|
||||
const requestId = "00000000-0000-4000-8000-000000001098";
|
||||
const bodies: Array<Record<string, unknown>> = [];
|
||||
let attempt = 0;
|
||||
const client = createDurableRectificationQuestionHandoffClient({
|
||||
createActionId: () => actionId,
|
||||
async fetch(_url, init) {
|
||||
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
||||
bodies.push(body);
|
||||
attempt += 1;
|
||||
if (attempt === 1) throw new TypeError("lost response");
|
||||
return Response.json({
|
||||
caseId: confirmedTurn().caseId,
|
||||
turnVersion: confirmedTurn().turnVersion,
|
||||
question: pendingQuestion,
|
||||
questionFingerprint: "a".repeat(64),
|
||||
requestId,
|
||||
status: "claimed",
|
||||
turn: confirmedTurn(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(page, /createRectificationQuestionHandoffCoordinator/);
|
||||
assert.match(chooseHandler, /\.capture\(\{[\s\S]*question:\s*pending\.question,[\s\S]*sessionId:\s*pending\.sessionId,[\s\S]*theme:\s*pending\.theme/);
|
||||
assert.doesNotMatch(chooseHandler, /\/api\/consult|\bsend\(/);
|
||||
assert.match(continuationHandler, /continueOriginalQuestion\(/);
|
||||
assert.match(continuationHandler, /send\(context\.question, context\.theme, null, null, context\.sessionId\)/);
|
||||
assert.match(continuationHandler, /if \(completed\)[\s\S]*setRectificationSurfaceOpen\(false\)/);
|
||||
assert.match(restoreHandler, /setDraft\(handoff\.question\)/);
|
||||
assert.match(restoreHandler, /setDraftTheme\(handoff\.theme\)/);
|
||||
assert.match(restoreHandler, /setDraftEntrypoint\(null\)/);
|
||||
assert.doesNotMatch(restoreHandler, /\/api\/consult|\bsend\(/);
|
||||
assert.match(page, /continuationPending=\{rectificationContinuationPending\}/);
|
||||
assert.match(page, /onContinueOriginalQuestion=\{\(question\) => void continueRectificationOriginalQuestion\(question\)\}/);
|
||||
assert.match(page, /\? "返回并恢复原问题"\s*:\s*"返回首页"/);
|
||||
const claimed = await client.claim({
|
||||
caseId: confirmedTurn().caseId,
|
||||
turnVersion: confirmedTurn().turnVersion,
|
||||
question: pendingQuestion,
|
||||
});
|
||||
|
||||
assert.equal(claimed.claimActionId, actionId);
|
||||
assert.equal(claimed.requestId, requestId);
|
||||
assert.equal(bodies.length, 2);
|
||||
assert.equal(bodies[0]?.actionId, actionId);
|
||||
assert.deepEqual(bodies[1], bodies[0]);
|
||||
});
|
||||
|
||||
test("ordinary consult remains strict and bills the confirmed continuation through the normal route", () => {
|
||||
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
||||
const chartSchema = route.slice(
|
||||
route.indexOf("const chartChatRequestSchema"),
|
||||
route.indexOf("const generalChatRequestSchema"),
|
||||
);
|
||||
const parse = route.indexOf("chatRequestSchema.safeParse");
|
||||
const legacyRejection = route.indexOf('parsed.data.entrypoint === "birth_time_rectification"', parse);
|
||||
const prepare = route.indexOf("prepareConsultationRoute({", parse);
|
||||
const reserve = route.indexOf("reserveConsultationModel(", prepare);
|
||||
test("two independent devices cannot both claim the same confirmed question", async () => {
|
||||
let owner: string | null = null;
|
||||
let claimCalls = 0;
|
||||
const requestId = "00000000-0000-4000-8000-000000001097";
|
||||
const transport = async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const command = JSON.parse(String(init?.body)) as { actionId: string };
|
||||
claimCalls += 1;
|
||||
const status = owner === null || owner === command.actionId ? "claimed" : "in_progress";
|
||||
owner ??= command.actionId;
|
||||
return Response.json({
|
||||
caseId: confirmedTurn().caseId,
|
||||
turnVersion: confirmedTurn().turnVersion,
|
||||
question: pendingQuestion,
|
||||
questionFingerprint: "b".repeat(64),
|
||||
requestId,
|
||||
status,
|
||||
turn: confirmedTurn(),
|
||||
});
|
||||
};
|
||||
const first = createDurableRectificationQuestionHandoffClient({
|
||||
fetch: transport,
|
||||
createActionId: () => "00000000-0000-4000-8000-000000001091",
|
||||
});
|
||||
const second = createDurableRectificationQuestionHandoffClient({
|
||||
fetch: transport,
|
||||
createActionId: () => "00000000-0000-4000-8000-000000001092",
|
||||
});
|
||||
|
||||
assert.match(chartSchema, /consultationInputSchema\.extend\([\s\S]*?\)\.strict\(\);/);
|
||||
assert.doesNotMatch(route, /continue_original_question|rectificationHandoff|skipBilling/);
|
||||
assert.ok(parse >= 0 && legacyRejection > parse && prepare > legacyRejection && reserve > prepare);
|
||||
assert.match(route, /"begin_consultation_credit"/);
|
||||
assert.match(route, /"complete_consultation_credit"/);
|
||||
assert.match(route, /"cancel_consultation_credit"/);
|
||||
const [firstResult, secondResult] = await Promise.all([
|
||||
first.claim({ caseId: confirmedTurn().caseId, turnVersion: 5, question: pendingQuestion }),
|
||||
second.claim({ caseId: confirmedTurn().caseId, turnVersion: 5, question: pendingQuestion }),
|
||||
]);
|
||||
|
||||
assert.equal(firstResult.status, "claimed");
|
||||
assert.equal(secondResult.status, "in_progress");
|
||||
assert.equal(firstResult.requestId, secondResult.requestId);
|
||||
assert.equal(claimCalls, 2);
|
||||
});
|
||||
|
||||
test("refresh restores only the server-owned pending question and replacement wins", async () => {
|
||||
let durableQuestion = "旧问题";
|
||||
const client = createDurableRectificationQuestionHandoffClient({
|
||||
createActionId: () => "00000000-0000-4000-8000-000000001093",
|
||||
async fetch(_url, init) {
|
||||
if (init?.method === "GET") {
|
||||
return Response.json({
|
||||
caseId: confirmedTurn().caseId,
|
||||
turnVersion: 5,
|
||||
question: durableQuestion,
|
||||
questionFingerprint: "c".repeat(64),
|
||||
requestId: "00000000-0000-4000-8000-000000001094",
|
||||
status: "pending",
|
||||
turn: { ...confirmedTurn(), pendingConsultationQuestion: durableQuestion },
|
||||
});
|
||||
}
|
||||
const command = JSON.parse(String(init?.body)) as { question: string };
|
||||
durableQuestion = command.question;
|
||||
return Response.json({ ...confirmedTurn(), pendingConsultationQuestion: durableQuestion });
|
||||
},
|
||||
});
|
||||
|
||||
const replaced = await client.attach({
|
||||
caseId: confirmedTurn().caseId,
|
||||
turnVersion: 5,
|
||||
question: "新问题",
|
||||
});
|
||||
const refreshed = await client.load();
|
||||
|
||||
assert.equal(replaced.pendingConsultationQuestion, "新问题");
|
||||
assert.equal(refreshed?.question, "新问题");
|
||||
assert.equal(refreshed?.turn.pendingConsultationQuestion, "新问题");
|
||||
});
|
||||
|
||||
test("confirmed surface never revives an old local question after durable consumption", () => {
|
||||
const consumed = {
|
||||
...confirmedTurn(),
|
||||
pendingConsultationQuestion: null,
|
||||
actions: [] as const,
|
||||
};
|
||||
const markup = renderToStaticMarkup(React.createElement(
|
||||
ConversationalRectificationSurface,
|
||||
{
|
||||
controller: controllerFor(consumed),
|
||||
pendingConsultationQuestion: "浏览器里的旧问题",
|
||||
onContinueOriginalQuestion: () => undefined,
|
||||
},
|
||||
));
|
||||
|
||||
assert.doesNotMatch(markup, /使用新确认时间继续回答原问题/);
|
||||
assert.doesNotMatch(markup, /浏览器里的旧问题/);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,30 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { streamTextResponse } from "../src/lib/stream-text-response.ts";
|
||||
|
||||
test("durable settlement runs before the first response bytes are exposed", async () => {
|
||||
const order: string[] = [];
|
||||
async function* reply() {
|
||||
yield "第一段";
|
||||
yield "第二段";
|
||||
}
|
||||
const response = streamTextResponse(reply(), {
|
||||
mode: "mastra",
|
||||
requestId: "00000000-0000-4000-8000-000000000099",
|
||||
onFirstOutput: async () => { order.push("settled"); },
|
||||
onComplete: async () => { order.push("completed"); },
|
||||
});
|
||||
const reader = response.body?.getReader();
|
||||
assert.ok(reader);
|
||||
|
||||
const first = await reader.read();
|
||||
order.push(new TextDecoder().decode(first.value));
|
||||
while (!(await reader.read()).done) {
|
||||
// Drain so normal completion runs too.
|
||||
}
|
||||
|
||||
assert.deepEqual(order, ["settled", "第一段", "completed"]);
|
||||
});
|
||||
|
||||
test("charges a consultation when cancellation happens after partial output", async () => {
|
||||
// Given
|
||||
let completed = 0;
|
||||
|
||||
Reference in New Issue
Block a user