fix: make rectification question handoff durable
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { createRectificationHandoffService } from "@/lib/rectification-handoff-service";
|
||||
import {
|
||||
createRectificationHandoffHandlers,
|
||||
type RectificationHandoffRouteDependencies,
|
||||
} from "@/lib/rectification-handoff-route";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const productionDependencies: RectificationHandoffRouteDependencies = {
|
||||
async authenticate() {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error } = await supabase.auth.getUser();
|
||||
return error || !user ? null : { userId: user.id };
|
||||
},
|
||||
service() {
|
||||
return createRectificationHandoffService(createAdminSupabaseClient());
|
||||
},
|
||||
};
|
||||
|
||||
const handlers = createRectificationHandoffHandlers(productionDependencies);
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return await handlers.get();
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ code: "handoff_unavailable", message: "原问题交接服务暂时不可用。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
return await handlers.post(request);
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ code: "handoff_unavailable", message: "原问题交接服务暂时不可用。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,11 @@ import {
|
||||
ConsultationProfileTruthError,
|
||||
prepareConsultationRoute,
|
||||
} from "@/lib/consultation-route-service";
|
||||
import {
|
||||
createRectificationHandoffService,
|
||||
type RectificationHandoffExecution,
|
||||
type RectificationHandoffService,
|
||||
} from "@/lib/rectification-handoff-service";
|
||||
import { z } from "zod";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -51,12 +56,20 @@ const chatRequestMetadataSchema = z.object({
|
||||
.default([]),
|
||||
});
|
||||
|
||||
const rectificationHandoffSchema = z.object({
|
||||
caseId: z.string().uuid(),
|
||||
turnVersion: z.number().int().nonnegative(),
|
||||
claimActionId: z.string().uuid(),
|
||||
requestId: z.string().uuid(),
|
||||
}).strict();
|
||||
|
||||
const chartChatRequestSchema = consultationInputSchema.extend({
|
||||
...chatRequestMetadataSchema.shape,
|
||||
consultationMode: consultationBirthTimeModeSchema.exclude(["general_no_birth_time"])
|
||||
.optional()
|
||||
.default("verified_chart"),
|
||||
entrypoint: consultationEntrypointSchema.optional(),
|
||||
rectificationHandoff: rectificationHandoffSchema.optional(),
|
||||
}).strict();
|
||||
|
||||
const generalChatRequestSchema = z.object({
|
||||
@@ -157,23 +170,6 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const userControlledPrompt = [
|
||||
parsed.data.question,
|
||||
...parsed.data.history
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => message.text),
|
||||
].join("\n");
|
||||
if (blocksPromptExtraction(userControlledPrompt)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "无法处理该请求",
|
||||
message:
|
||||
"我不能提供系统提示词、技能原文或任何密钥。你可以继续询问占星相关问题。",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const requestTime = new Date();
|
||||
const resolvedQuestion = resolveConsultationQuestion({
|
||||
visibleQuestion: parsed.data.question,
|
||||
@@ -183,6 +179,97 @@ export async function POST(request: Request) {
|
||||
|
||||
const userId = user.id;
|
||||
const requestId = parsed.data.requestId;
|
||||
const handoff = "rectificationHandoff" in parsed.data
|
||||
? parsed.data.rectificationHandoff
|
||||
: undefined;
|
||||
let handoffService: RectificationHandoffService | null = null;
|
||||
let handoffExecution: RectificationHandoffExecution | null = null;
|
||||
let handoffSettlement: Promise<void> | null = null;
|
||||
|
||||
async function settleHandoff(emitted: boolean) {
|
||||
if (!handoff || !handoffService || !handoffExecution
|
||||
|| handoffExecution.status !== "ready") return;
|
||||
handoffSettlement ??= handoffService.settle({
|
||||
userId,
|
||||
caseId: handoff.caseId,
|
||||
claimActionId: handoff.claimActionId,
|
||||
requestId: handoff.requestId,
|
||||
emitted,
|
||||
}).then(() => undefined);
|
||||
await handoffSettlement;
|
||||
}
|
||||
|
||||
if (handoff) {
|
||||
if (parsed.data.consultationMode !== "verified_chart"
|
||||
|| parsed.data.entrypoint !== undefined
|
||||
|| requestId !== handoff.requestId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "原问题交接请求不一致",
|
||||
message: "请刷新校正结果后重新点击继续,本次不会扣点。",
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
handoffService = createRectificationHandoffService(accounting);
|
||||
handoffExecution = await handoffService.beginExecution({
|
||||
userId,
|
||||
caseId: handoff.caseId,
|
||||
turnVersion: handoff.turnVersion,
|
||||
claimActionId: handoff.claimActionId,
|
||||
requestId: handoff.requestId,
|
||||
question: parsed.data.question,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "原问题状态已经变化",
|
||||
message: "请刷新后查看最新状态,本次不会扣点。",
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (handoffExecution.status !== "ready") {
|
||||
const consumed = handoffExecution.status === "consumed";
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: consumed ? "原问题已经继续回答" : "原问题正在另一处继续",
|
||||
message: consumed
|
||||
? "刷新后即可查看最新状态,不会再次扣点。"
|
||||
: "请等待当前回答完成后刷新,本次不会重复扣点。",
|
||||
},
|
||||
{ status: consumed ? 410 : 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userControlledPrompt = [
|
||||
parsed.data.question,
|
||||
...parsed.data.history
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => message.text),
|
||||
].join("\n");
|
||||
if (blocksPromptExtraction(userControlledPrompt)) {
|
||||
if (handoffExecution?.status === "ready") {
|
||||
try {
|
||||
await settleHandoff(false);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法释放原问题", message: "请稍后刷新状态。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "无法处理该请求",
|
||||
message:
|
||||
"我不能提供系统提示词、技能原文或任何密钥。你可以继续询问占星相关问题。",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
let prepared;
|
||||
try {
|
||||
prepared = await prepareConsultationRoute({
|
||||
@@ -200,15 +287,34 @@ export async function POST(request: Request) {
|
||||
reserve: () => reserveConsultationModel(
|
||||
parsed.data.modelId,
|
||||
resolveLanguageModel,
|
||||
() => runCreditRpc(
|
||||
accounting,
|
||||
"begin_consultation_credit",
|
||||
userId,
|
||||
requestId,
|
||||
),
|
||||
() => handoffExecution?.billingReused
|
||||
? Promise.resolve({
|
||||
success: true,
|
||||
credits: handoffExecution.credits ?? null,
|
||||
error_code: null,
|
||||
})
|
||||
: runCreditRpc(
|
||||
accounting,
|
||||
"begin_consultation_credit",
|
||||
userId,
|
||||
requestId,
|
||||
),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
if (handoffExecution?.status === "ready") {
|
||||
try {
|
||||
await settleHandoff(false);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "暂时无法释放原问题",
|
||||
message: "请稍后刷新状态,本次不会重复扣点。",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (error instanceof ConsultationProfileTruthError) {
|
||||
const modeChanged = error.code === "mode_changed";
|
||||
return NextResponse.json(
|
||||
@@ -237,6 +343,16 @@ export async function POST(request: Request) {
|
||||
const modelSelection = prepared.reservation;
|
||||
|
||||
if (modelSelection.status === "unavailable") {
|
||||
if (handoffExecution?.status === "ready") {
|
||||
try {
|
||||
await settleHandoff(false);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法释放原问题", message: "请稍后刷新状态。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "模型暂不可用",
|
||||
@@ -250,6 +366,16 @@ export async function POST(request: Request) {
|
||||
const reserveResult = modelSelection.reservation;
|
||||
|
||||
if (!reserveResult.success) {
|
||||
if (handoffExecution?.status === "ready") {
|
||||
try {
|
||||
await settleHandoff(false);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法释放原问题", message: "请稍后刷新状态。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
const insufficient = reserveResult.error_code === "insufficient_credits";
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -263,6 +389,17 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (handoffExecution?.status === "ready") {
|
||||
try {
|
||||
await settleHandoff(false);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.name : "UnknownError";
|
||||
console.error(
|
||||
`[billing] handoff release failed request=${requestId} reason=${reason}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await runCreditRpc(
|
||||
accounting,
|
||||
@@ -279,6 +416,10 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
async function complete() {
|
||||
if (handoffExecution?.status === "ready") {
|
||||
await settleHandoff(true);
|
||||
return;
|
||||
}
|
||||
const result = await runCreditRpc(
|
||||
accounting,
|
||||
"complete_consultation_credit",
|
||||
@@ -335,6 +476,7 @@ export async function POST(request: Request) {
|
||||
"x-jyotish-missing-layers": "birth-minute",
|
||||
"x-jyotish-birth-time-mode": consultationMode,
|
||||
},
|
||||
...(handoff ? { onFirstOutput: () => settle(completeAndRecordUsage) } : {}),
|
||||
onComplete: () => settle(completeAndRecordUsage),
|
||||
onError: (_error, emitted) => settleInterrupted(emitted),
|
||||
onCancel: settleInterrupted,
|
||||
@@ -398,6 +540,7 @@ export async function POST(request: Request) {
|
||||
"x-jyotish-missing-layers": workflowReceipt.missingLayers,
|
||||
"x-jyotish-birth-time-mode": consultationMode,
|
||||
},
|
||||
...(handoff ? { onFirstOutput: () => settle(completeAndRecordUsage) } : {}),
|
||||
onComplete: () => settle(completeAndRecordUsage),
|
||||
onError: (_error, emitted) => settleInterrupted(emitted),
|
||||
onCancel: settleInterrupted,
|
||||
|
||||
+137
-37
@@ -50,7 +50,11 @@ import {
|
||||
import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode";
|
||||
import { sendConversationalRectificationCommand } from "@/lib/conversational-rectification/client";
|
||||
import type { ConversationalRectificationTurn } from "@/lib/conversational-rectification/contracts";
|
||||
import { createRectificationQuestionHandoffCoordinator } from "@/lib/rectification-question-handoff";
|
||||
import {
|
||||
createDurableRectificationQuestionHandoffClient,
|
||||
createRectificationQuestionHandoffCoordinator,
|
||||
DurableRectificationHandoffError,
|
||||
} from "@/lib/rectification-question-handoff";
|
||||
import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey";
|
||||
import {
|
||||
requestBirthTimeAssessment,
|
||||
@@ -63,6 +67,7 @@ import {
|
||||
} from "@/lib/birth-time-guided-preview";
|
||||
import { keepFocusWithin } from "@/lib/focus-trap";
|
||||
import { chatMessageViews, type ChatMessage } from "@/lib/chat-message-view";
|
||||
import { persistExistingChatSession } from "@/lib/chat-session-persistence";
|
||||
import {
|
||||
OnboardingAuthenticationError,
|
||||
type OnboardingContent,
|
||||
@@ -172,12 +177,19 @@ type PendingBirthTimeChoice = Readonly<{
|
||||
entrypoint: ConsultationEntrypoint | null;
|
||||
theme: Theme;
|
||||
}>;
|
||||
type ConsultationRectificationHandoff = Readonly<{
|
||||
caseId: string;
|
||||
turnVersion: number;
|
||||
claimActionId: string;
|
||||
requestId: string;
|
||||
}>;
|
||||
type PendingConsultation = {
|
||||
readonly requestId: string;
|
||||
readonly sessionId: string;
|
||||
readonly question: string;
|
||||
readonly entrypoint: ConsultationEntrypoint | null;
|
||||
readonly theme: Theme;
|
||||
readonly rectificationHandoff: ConsultationRectificationHandoff | null;
|
||||
readonly previousSession: ChatSession;
|
||||
readonly optimisticSession: ChatSession;
|
||||
readonly previousOnboardingState: boolean;
|
||||
@@ -784,6 +796,9 @@ export default function Home() {
|
||||
const activeOnboardingRequestIdentity = useRef("");
|
||||
const accountRefreshGuard = useRef(createLatestAccountRequestGuard());
|
||||
const rectificationQuestionHandoff = useRef(createRectificationQuestionHandoffCoordinator<Theme>());
|
||||
const durableRectificationQuestionHandoff = useRef(
|
||||
createDurableRectificationQuestionHandoffClient(),
|
||||
);
|
||||
const rectificationContinuationInFlight = useRef(false);
|
||||
const uiPreview = useRef(false);
|
||||
const uiPreviewMode = useRef<string | null>(null);
|
||||
@@ -1260,7 +1275,7 @@ export default function Home() {
|
||||
setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session)));
|
||||
}
|
||||
|
||||
async function persistSession(session: ChatSession) {
|
||||
async function persistSession(session: ChatSession, mode: "create" | "update" = "update") {
|
||||
if (!account) throw new Error("账户尚未加载完成");
|
||||
if (process.env.NODE_ENV === "development" && uiPreview.current) return;
|
||||
const supabase = createBrowserSupabaseClient();
|
||||
@@ -1271,22 +1286,26 @@ export default function Home() {
|
||||
messages: session.messages,
|
||||
updated_at: new Date(session.updatedAt).toISOString(),
|
||||
};
|
||||
const { data, error } = await supabase
|
||||
.from("chat_sessions")
|
||||
.update(values)
|
||||
.eq("id", session.id)
|
||||
.eq("user_id", account.user.id)
|
||||
.select("id")
|
||||
.maybeSingle();
|
||||
if (error) throw new Error(`云端同步失败:${error.message}`);
|
||||
if (data) return;
|
||||
if (mode === "create") {
|
||||
const { error } = await supabase.from("chat_sessions").insert({
|
||||
id: session.id,
|
||||
user_id: account.user.id,
|
||||
...values,
|
||||
});
|
||||
if (error) throw new Error(`云端同步失败:${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: insertError } = await supabase.from("chat_sessions").insert({
|
||||
id: session.id,
|
||||
user_id: account.user.id,
|
||||
...values,
|
||||
await persistExistingChatSession(async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("chat_sessions")
|
||||
.update(values)
|
||||
.eq("id", session.id)
|
||||
.eq("user_id", account.user.id)
|
||||
.select("id")
|
||||
.maybeSingle();
|
||||
return { found: Boolean(data), error: error?.message ?? null };
|
||||
});
|
||||
if (insertError) throw new Error(`云端同步失败:${insertError.message}`);
|
||||
}
|
||||
|
||||
async function renameSession(session: ChatSession) {
|
||||
@@ -1368,7 +1387,7 @@ export default function Home() {
|
||||
setComposerNotice("");
|
||||
setRequestError(null);
|
||||
try {
|
||||
await persistSession(nextSession);
|
||||
await persistSession(nextSession, "create");
|
||||
} catch (caught) {
|
||||
setSessions((current) => current.filter((session) => session.id !== nextSession.id));
|
||||
setActiveSessionId(previousSessionId);
|
||||
@@ -1810,17 +1829,47 @@ export default function Home() {
|
||||
setRectificationInitialTurn(null);
|
||||
setRectificationError("");
|
||||
setRectificationSurfaceOpen(true);
|
||||
if (action !== "resume" || !account.rectificationCase) return;
|
||||
|
||||
setRectificationLoading(true);
|
||||
try {
|
||||
const current = account.rectificationCase;
|
||||
const turn = await sendConversationalRectificationCommand({
|
||||
type: "resume",
|
||||
caseId: current.caseId,
|
||||
actionId: globalThis.crypto.randomUUID(),
|
||||
turnVersion: current.turnVersion,
|
||||
});
|
||||
if (action !== "resume" || !account.rectificationCase) {
|
||||
const durable = await durableRectificationQuestionHandoff.current.load();
|
||||
if (!durable || durable.status === "consumed") return;
|
||||
setRectificationInitialTurn(durable.turn);
|
||||
synchronizeRectificationQuestion(durable.turn);
|
||||
return;
|
||||
}
|
||||
|
||||
let current = account.rectificationCase;
|
||||
let turn: ConversationalRectificationTurn;
|
||||
if (pendingConsultationQuestion) {
|
||||
try {
|
||||
turn = await durableRectificationQuestionHandoff.current.attach({
|
||||
caseId: current.caseId,
|
||||
turnVersion: current.turnVersion,
|
||||
question: pendingConsultationQuestion,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof DurableRectificationHandoffError)
|
||||
|| error.status !== 409) throw error;
|
||||
const latest = await fetchAccount();
|
||||
if (!latest.rectificationCase
|
||||
|| latest.rectificationCase.caseId !== current.caseId) throw error;
|
||||
current = latest.rectificationCase;
|
||||
setAccount(latest);
|
||||
turn = await durableRectificationQuestionHandoff.current.attach({
|
||||
caseId: current.caseId,
|
||||
turnVersion: current.turnVersion,
|
||||
question: pendingConsultationQuestion,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
turn = await sendConversationalRectificationCommand({
|
||||
type: "resume",
|
||||
caseId: current.caseId,
|
||||
actionId: globalThis.crypto.randomUUID(),
|
||||
turnVersion: current.turnVersion,
|
||||
});
|
||||
}
|
||||
setRectificationInitialTurn(turn);
|
||||
synchronizeRectificationQuestion(turn);
|
||||
} catch (caught) {
|
||||
@@ -2039,8 +2088,10 @@ export default function Home() {
|
||||
setPendingSessionId(null);
|
||||
setConsultationPhase(null);
|
||||
setRequestError(null);
|
||||
cancellationFeedbackRequest.current = pending.requestId;
|
||||
setComposerNotice("已停止,问题已放回输入框,正在确认点数…");
|
||||
cancellationFeedbackRequest.current = pending.rectificationHandoff ? null : pending.requestId;
|
||||
setComposerNotice(pending.rectificationHandoff
|
||||
? "已停止,原问题仍由校正案例保留;正在释放本次继续操作…"
|
||||
: "已停止,问题已放回输入框,正在确认点数…");
|
||||
window.requestAnimationFrame(() => composerInput.current?.focus());
|
||||
|
||||
if (pending.phase === "undo" || isPreview) {
|
||||
@@ -2051,11 +2102,15 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
|
||||
await confirmCancellation(
|
||||
pending.requestId,
|
||||
pending.sessionId,
|
||||
"已停止,问题已放回输入框,本次未扣点。",
|
||||
);
|
||||
if (pending.rectificationHandoff) {
|
||||
setComposerNotice("已停止;原问题仍保留,可刷新校正状态后重试。");
|
||||
} else {
|
||||
await confirmCancellation(
|
||||
pending.requestId,
|
||||
pending.sessionId,
|
||||
"已停止,问题已放回输入框,本次未扣点。",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function completeConsultationInterface(requestId: string) {
|
||||
@@ -2072,6 +2127,7 @@ export default function Home() {
|
||||
entrypoint: ConsultationEntrypoint | null = null,
|
||||
consentGrantedForRequest: ConsultationBirthTimeMode | null = null,
|
||||
targetSessionId: string | null = null,
|
||||
rectificationHandoff: ConsultationRectificationHandoff | null = null,
|
||||
): Promise<boolean> {
|
||||
const originalQuestion = text;
|
||||
const question = text.trim();
|
||||
@@ -2140,7 +2196,7 @@ export default function Home() {
|
||||
messages: [...preservedMessages, { role: "user", text: question }],
|
||||
updatedAt: timestamp(),
|
||||
};
|
||||
const requestId = globalThis.crypto.randomUUID();
|
||||
const requestId = rectificationHandoff?.requestId ?? globalThis.crypto.randomUUID();
|
||||
const controller = new AbortController();
|
||||
const previousOnboardingState = onboardingJustCompleted;
|
||||
cancellationFeedbackRequest.current = null;
|
||||
@@ -2154,6 +2210,7 @@ export default function Home() {
|
||||
question: originalQuestion,
|
||||
entrypoint,
|
||||
theme,
|
||||
rectificationHandoff,
|
||||
previousSession: currentSession,
|
||||
optimisticSession: userSession,
|
||||
previousOnboardingState,
|
||||
@@ -2246,6 +2303,7 @@ export default function Home() {
|
||||
role: message.role,
|
||||
text: message.text.slice(0, 4000),
|
||||
})),
|
||||
...(rectificationHandoff ? { rectificationHandoff } : {}),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
@@ -2324,12 +2382,14 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ownsInterface && !partialReply) {
|
||||
if (ownsInterface && !partialReply && !rectificationHandoff) {
|
||||
await confirmCancellation(
|
||||
requestId,
|
||||
sessionId,
|
||||
"问题已放回输入框,本次未扣点。",
|
||||
);
|
||||
} else if (ownsInterface && !partialReply && rectificationHandoff) {
|
||||
setComposerNotice("原问题仍保留;请刷新校正状态后重试,本次不会重复扣点。");
|
||||
} else if (!cancelled && ownsInterface) {
|
||||
const interruptedSession: ChatSession = {
|
||||
...userSession,
|
||||
@@ -2373,7 +2433,15 @@ export default function Home() {
|
||||
|
||||
async function continueRectificationOriginalQuestion(question: string) {
|
||||
if (rectificationContinuationInFlight.current || rectificationMutationPending
|
||||
|| rectificationLoading || !activeSession) return;
|
||||
|| rectificationLoading || !activeSession || !account) return;
|
||||
const confirmedTurn = rectificationInitialTurn;
|
||||
if (!confirmedTurn || confirmedTurn.status !== "completed"
|
||||
|| confirmedTurn.pendingConsultationQuestion !== question
|
||||
|| !confirmedTurn.actions.includes("continue_original_question")) return;
|
||||
if (account.credits <= 0) {
|
||||
openAccountDialog("redeem", creditTrigger.current);
|
||||
return;
|
||||
}
|
||||
if (rectificationQuestionHandoff.current.peek()
|
||||
&& !sessions.some((session) => session.id === rectificationQuestionHandoff.current.peek()?.sessionId)) {
|
||||
rectificationQuestionHandoff.current.clear();
|
||||
@@ -2383,6 +2451,26 @@ export default function Home() {
|
||||
setRectificationContinuationPending(true);
|
||||
setRectificationError("");
|
||||
try {
|
||||
const durableClaim = await durableRectificationQuestionHandoff.current.claim({
|
||||
caseId: confirmedTurn.caseId,
|
||||
turnVersion: confirmedTurn.turnVersion,
|
||||
question,
|
||||
});
|
||||
if (durableClaim.status === "in_progress") {
|
||||
setComposerNotice("原问题正在另一设备继续回答;完成后刷新即可查看,不会重复扣点。");
|
||||
return;
|
||||
}
|
||||
if (durableClaim.status === "consumed") {
|
||||
setRectificationSurfaceOpen(false);
|
||||
setRectificationPendingQuestion(null);
|
||||
setRectificationInitialTurn(null);
|
||||
setComposerNotice("原问题已经继续回答,不会再次发送或扣点。");
|
||||
return;
|
||||
}
|
||||
if (durableClaim.status !== "claimed") {
|
||||
setComposerNotice("原问题仍保留,请刷新校正状态后重试。");
|
||||
return;
|
||||
}
|
||||
const completed = await rectificationQuestionHandoff.current.continueOriginalQuestion(
|
||||
question,
|
||||
{ sessionId: activeSession.id, theme: activeSession.theme },
|
||||
@@ -2393,7 +2481,19 @@ export default function Home() {
|
||||
current,
|
||||
context.sessionId,
|
||||
));
|
||||
return send(context.question, context.theme, null, null, context.sessionId);
|
||||
return send(
|
||||
context.question,
|
||||
context.theme,
|
||||
null,
|
||||
null,
|
||||
context.sessionId,
|
||||
{
|
||||
caseId: durableClaim.caseId,
|
||||
turnVersion: durableClaim.turnVersion,
|
||||
claimActionId: durableClaim.claimActionId,
|
||||
requestId: durableClaim.requestId,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
if (completed) {
|
||||
|
||||
@@ -105,7 +105,9 @@ export function ConversationalRectificationSurface({
|
||||
const restoreAbandonFocus = useRef(false);
|
||||
const focusTerminalForCase = useRef<string | null>(null);
|
||||
const turn = controller.turn;
|
||||
const pendingQuestion = turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null;
|
||||
const pendingQuestion = turn?.status === "completed"
|
||||
? turn.pendingConsultationQuestion
|
||||
: turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null;
|
||||
const abandonIdentity = turn
|
||||
? `${turn.caseId}:${turn.turnVersion}:${turn.status}`
|
||||
: null;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { forwardRef } from "react";
|
||||
import { SidebarMenuButton } from "@/components/ui/sidebar";
|
||||
import { sessionMutationMenuVisible } from "@/lib/chat-session-persistence";
|
||||
|
||||
export type SidebarSession = {
|
||||
readonly id: string;
|
||||
@@ -100,7 +101,7 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
|
||||
>
|
||||
<MoreHorizontal aria-hidden="true" />
|
||||
</button>
|
||||
{menuOpen ? (
|
||||
{sessionMutationMenuVisible(menuOpen, disabled) ? (
|
||||
<div className="session-actions" role="menu" aria-label={`${session.title} 操作`}>
|
||||
<button type="button" role="menuitem" onClick={() => runAction(onTogglePinned)}>
|
||||
{session.pinned ? <PinOff aria-hidden="true" /> : <Pin aria-hidden="true" />}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export type ChatSessionWriteResult = Readonly<{
|
||||
found: boolean;
|
||||
error: string | null;
|
||||
}>;
|
||||
|
||||
export function sessionMutationMenuVisible(menuOpen: boolean, pending: boolean) {
|
||||
return menuOpen && !pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an already-created session. A missing row is a durable deletion, not
|
||||
* an invitation to upsert: late model responses must never resurrect a chat
|
||||
* removed on another device.
|
||||
*/
|
||||
export async function persistExistingChatSession(
|
||||
write: () => PromiseLike<ChatSessionWriteResult>,
|
||||
): Promise<void> {
|
||||
const result = await write();
|
||||
if (result.error) throw new Error(`云端同步失败:${result.error}`);
|
||||
if (!result.found) {
|
||||
throw new Error("聊天记录已在另一设备删除,晚到内容不会重新创建该记录。");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
RectificationHandoffServiceError,
|
||||
type RectificationHandoffService,
|
||||
} from "./rectification-handoff-service.ts";
|
||||
|
||||
const identity = {
|
||||
caseId: z.string().uuid(),
|
||||
turnVersion: z.number().int().nonnegative(),
|
||||
actionId: z.string().uuid(),
|
||||
question: z.string().trim().min(1).max(500),
|
||||
} as const;
|
||||
|
||||
const commandSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("attach"), ...identity }).strict(),
|
||||
z.object({ type: z.literal("claim"), ...identity }).strict(),
|
||||
]);
|
||||
|
||||
type Authenticated = Readonly<{ userId: string }>;
|
||||
|
||||
export type RectificationHandoffRouteDependencies = Readonly<{
|
||||
authenticate(): Promise<Authenticated | null>;
|
||||
service(): RectificationHandoffService;
|
||||
}>;
|
||||
|
||||
function publicFailure(error: unknown) {
|
||||
if (error instanceof RectificationHandoffServiceError) {
|
||||
if (error.code === "not_found") {
|
||||
return Response.json(
|
||||
{ code: "handoff_not_found", message: "没有找到可继续的原问题。" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
if (error.code === "stale") {
|
||||
return Response.json(
|
||||
{ code: "stale_turn", message: "校正状态已经更新,请刷新后重试。" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (error.code === "conflict") {
|
||||
return Response.json(
|
||||
{ code: "handoff_conflict", message: "原问题状态已经变化,请刷新后查看。" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
return Response.json(
|
||||
{ code: "handoff_unavailable", message: "暂时无法保存或继续原问题,请稍后重试。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
export function createRectificationHandoffHandlers(
|
||||
dependencies: RectificationHandoffRouteDependencies,
|
||||
) {
|
||||
return Object.freeze({
|
||||
async get() {
|
||||
const authenticated = await dependencies.authenticate();
|
||||
if (!authenticated) {
|
||||
return Response.json(
|
||||
{ code: "authentication_required", message: "登录后才能继续原问题。" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const handoff = await dependencies.service().load({ userId: authenticated.userId });
|
||||
return handoff
|
||||
? Response.json(handoff)
|
||||
: new Response(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return publicFailure(error);
|
||||
}
|
||||
},
|
||||
|
||||
async post(request: Request) {
|
||||
const authenticated = await dependencies.authenticate();
|
||||
if (!authenticated) {
|
||||
return Response.json(
|
||||
{ code: "authentication_required", message: "登录后才能保存或继续原问题。" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
const parsed = commandSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return Response.json(
|
||||
{ code: "invalid_command", message: "原问题交接请求格式不正确。" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const service = dependencies.service();
|
||||
const input = { userId: authenticated.userId, ...parsed.data };
|
||||
const result = parsed.data.type === "attach"
|
||||
? await service.attach(input)
|
||||
: await service.claim(input);
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
return publicFailure(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { conversationalRectificationTurnSchema } from "./conversational-rectification/contracts.ts";
|
||||
import { storedCaseRowSchema } from "./conversational-rectification/persistence-contracts.ts";
|
||||
|
||||
const uuidSchema = z.string().uuid();
|
||||
const fingerprintSchema = z.string().regex(/^[0-9a-f]{64}$/);
|
||||
|
||||
const handoffProjectionSchema = z.object({
|
||||
caseId: uuidSchema,
|
||||
turnVersion: z.number().int().nonnegative(),
|
||||
question: z.string().trim().min(1).max(500),
|
||||
questionFingerprint: fingerprintSchema,
|
||||
requestId: uuidSchema,
|
||||
status: z.enum(["pending", "claimed", "in_progress", "consumed"]),
|
||||
turn: conversationalRectificationTurnSchema,
|
||||
}).strict();
|
||||
|
||||
const executionProjectionSchema = z.object({
|
||||
status: z.enum(["ready", "in_progress", "consumed", "released"]),
|
||||
requestId: uuidSchema,
|
||||
billingReused: z.boolean().optional().default(false),
|
||||
credits: z.number().int().nonnegative().nullable().optional(),
|
||||
}).strict();
|
||||
|
||||
const settlementProjectionSchema = z.object({
|
||||
status: z.enum(["pending", "consumed"]),
|
||||
requestId: uuidSchema,
|
||||
credits: z.number().int().nonnegative().nullable(),
|
||||
}).strict();
|
||||
|
||||
export type RectificationHandoffProjection = z.infer<typeof handoffProjectionSchema>;
|
||||
export type RectificationHandoffExecution = z.infer<typeof executionProjectionSchema>;
|
||||
export type RectificationHandoffSettlement = z.infer<typeof settlementProjectionSchema>;
|
||||
|
||||
export type RectificationHandoffRpcClient = Readonly<{
|
||||
rpc(
|
||||
name: string,
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
): PromiseLike<Readonly<{ data: unknown; error: unknown }>>;
|
||||
}>;
|
||||
|
||||
export class RectificationHandoffServiceError extends Error {
|
||||
readonly name = "RectificationHandoffServiceError";
|
||||
|
||||
constructor(readonly code: "not_found" | "stale" | "conflict" | "unavailable") {
|
||||
super(`Rectification handoff failed: ${code}`);
|
||||
}
|
||||
}
|
||||
|
||||
function rpcMessage(error: unknown): string {
|
||||
if (!error || typeof error !== "object") return "";
|
||||
try {
|
||||
const message = (error as { message?: unknown }).message;
|
||||
return typeof message === "string" ? message : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function mappedError(error: unknown): RectificationHandoffServiceError {
|
||||
const message = rpcMessage(error);
|
||||
if (message === "conversational_case_not_found") {
|
||||
return new RectificationHandoffServiceError("not_found");
|
||||
}
|
||||
if (message === "conversational_stale_turn") {
|
||||
return new RectificationHandoffServiceError("stale");
|
||||
}
|
||||
if (message === "conversational_action_conflict") {
|
||||
return new RectificationHandoffServiceError("conflict");
|
||||
}
|
||||
return new RectificationHandoffServiceError("unavailable");
|
||||
}
|
||||
|
||||
function single(value: unknown): unknown {
|
||||
if (!Array.isArray(value)) return value;
|
||||
if (value.length !== 1) throw new RectificationHandoffServiceError("unavailable");
|
||||
return value[0];
|
||||
}
|
||||
|
||||
async function rpc(
|
||||
client: RectificationHandoffRpcClient,
|
||||
name: string,
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
const result = await client.rpc(name, args);
|
||||
if (result.error) throw mappedError(result.error);
|
||||
return single(result.data);
|
||||
} catch (error) {
|
||||
if (error instanceof RectificationHandoffServiceError) throw error;
|
||||
throw new RectificationHandoffServiceError("unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
export function rectificationQuestionFingerprint(question: string): string {
|
||||
return createHash("sha256").update(question, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
export function createRectificationHandoffService(client: RectificationHandoffRpcClient) {
|
||||
return Object.freeze({
|
||||
async attach(input: Readonly<{
|
||||
userId: string;
|
||||
caseId: string;
|
||||
turnVersion: number;
|
||||
actionId: string;
|
||||
question: string;
|
||||
}>) {
|
||||
const question = input.question.trim();
|
||||
const parsed = storedCaseRowSchema.safeParse(await rpc(
|
||||
client,
|
||||
"attach_conversational_rectification_question",
|
||||
{
|
||||
p_user_id: input.userId,
|
||||
p_case_id: input.caseId,
|
||||
p_expected_version: input.turnVersion,
|
||||
p_action_id: input.actionId,
|
||||
p_question: question,
|
||||
p_question_fingerprint: rectificationQuestionFingerprint(question),
|
||||
},
|
||||
));
|
||||
if (!parsed.success) throw new RectificationHandoffServiceError("unavailable");
|
||||
return parsed.data.latest_turn;
|
||||
},
|
||||
|
||||
async load(input: Readonly<{ userId: string; caseId?: string }>) {
|
||||
const value = await rpc(client, "load_conversational_rectification_handoff", {
|
||||
p_user_id: input.userId,
|
||||
p_case_id: input.caseId ?? null,
|
||||
});
|
||||
if (value === null) return null;
|
||||
const parsed = handoffProjectionSchema.safeParse(value);
|
||||
if (!parsed.success) throw new RectificationHandoffServiceError("unavailable");
|
||||
return parsed.data;
|
||||
},
|
||||
|
||||
async claim(input: Readonly<{
|
||||
userId: string;
|
||||
caseId: string;
|
||||
turnVersion: number;
|
||||
actionId: string;
|
||||
question: string;
|
||||
}>) {
|
||||
const question = input.question.trim();
|
||||
const parsed = handoffProjectionSchema.safeParse(await rpc(
|
||||
client,
|
||||
"claim_conversational_rectification_handoff",
|
||||
{
|
||||
p_user_id: input.userId,
|
||||
p_case_id: input.caseId,
|
||||
p_expected_version: input.turnVersion,
|
||||
p_action_id: input.actionId,
|
||||
p_question_fingerprint: rectificationQuestionFingerprint(question),
|
||||
},
|
||||
));
|
||||
if (!parsed.success) throw new RectificationHandoffServiceError("unavailable");
|
||||
return parsed.data;
|
||||
},
|
||||
|
||||
async beginExecution(input: Readonly<{
|
||||
userId: string;
|
||||
caseId: string;
|
||||
turnVersion: number;
|
||||
claimActionId: string;
|
||||
requestId: string;
|
||||
question: string;
|
||||
}>): Promise<RectificationHandoffExecution> {
|
||||
const question = input.question.trim();
|
||||
const parsed = executionProjectionSchema.safeParse(await rpc(
|
||||
client,
|
||||
"begin_conversational_rectification_handoff_execution",
|
||||
{
|
||||
p_user_id: input.userId,
|
||||
p_case_id: input.caseId,
|
||||
p_expected_version: input.turnVersion,
|
||||
p_claim_action_id: input.claimActionId,
|
||||
p_request_id: input.requestId,
|
||||
p_question_fingerprint: rectificationQuestionFingerprint(question),
|
||||
},
|
||||
));
|
||||
if (!parsed.success) throw new RectificationHandoffServiceError("unavailable");
|
||||
return parsed.data;
|
||||
},
|
||||
|
||||
async settle(input: Readonly<{
|
||||
userId: string;
|
||||
caseId: string;
|
||||
claimActionId: string;
|
||||
requestId: string;
|
||||
emitted: boolean;
|
||||
}>): Promise<RectificationHandoffSettlement> {
|
||||
const parsed = settlementProjectionSchema.safeParse(await rpc(
|
||||
client,
|
||||
"settle_conversational_rectification_handoff",
|
||||
{
|
||||
p_user_id: input.userId,
|
||||
p_case_id: input.caseId,
|
||||
p_claim_action_id: input.claimActionId,
|
||||
p_request_id: input.requestId,
|
||||
p_emitted: input.emitted,
|
||||
},
|
||||
));
|
||||
if (!parsed.success) throw new RectificationHandoffServiceError("unavailable");
|
||||
return parsed.data;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type RectificationHandoffService = ReturnType<typeof createRectificationHandoffService>;
|
||||
@@ -1,3 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
conversationalRectificationTurnSchema,
|
||||
type ConversationalRectificationTurn,
|
||||
} from "./conversational-rectification/contracts.ts";
|
||||
|
||||
export type RectificationQuestionHandoff<Theme extends string> = Readonly<{
|
||||
question: string;
|
||||
sessionId: string;
|
||||
@@ -104,3 +110,154 @@ export function createRectificationQuestionHandoffCoordinator<Theme extends stri
|
||||
export type RectificationQuestionHandoffCoordinator<Theme extends string> = ReturnType<
|
||||
typeof createRectificationQuestionHandoffCoordinator<Theme>
|
||||
>;
|
||||
|
||||
const durableHandoffSchema = z.object({
|
||||
caseId: z.string().uuid(),
|
||||
turnVersion: z.number().int().nonnegative(),
|
||||
question: z.string().trim().min(1).max(500),
|
||||
questionFingerprint: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
requestId: z.string().uuid(),
|
||||
status: z.enum(["pending", "claimed", "in_progress", "consumed"]),
|
||||
turn: conversationalRectificationTurnSchema,
|
||||
}).strict();
|
||||
|
||||
export type DurableRectificationQuestionHandoff = z.infer<typeof durableHandoffSchema>;
|
||||
export type ClaimedRectificationQuestionHandoff = DurableRectificationQuestionHandoff & Readonly<{
|
||||
claimActionId: string;
|
||||
}>;
|
||||
|
||||
export class DurableRectificationHandoffError extends Error {
|
||||
readonly name = "DurableRectificationHandoffError";
|
||||
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string | null,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
type HandoffFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
async function handoffPayload(response: Response): Promise<unknown> {
|
||||
if (response.status === 204) return null;
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
return contentType.includes("application/json")
|
||||
? response.json().catch(() => null)
|
||||
: null;
|
||||
}
|
||||
|
||||
function errorFields(value: unknown): { code: string | null; message: string } {
|
||||
if (!value || typeof value !== "object") {
|
||||
return { code: null, message: "暂时无法保存或继续原问题,请稍后重试。" };
|
||||
}
|
||||
const record = value as { code?: unknown; message?: unknown };
|
||||
return {
|
||||
code: typeof record.code === "string" ? record.code : null,
|
||||
message: typeof record.message === "string"
|
||||
? record.message
|
||||
: "暂时无法保存或继续原问题,请稍后重试。",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser transport for the account-level handoff. A claim keeps one action id
|
||||
* until the server returns a terminal response, so a lost HTTP response replays
|
||||
* the same durable identity rather than creating another consultation attempt.
|
||||
*/
|
||||
export function createDurableRectificationQuestionHandoffClient(input: Readonly<{
|
||||
fetch?: HandoffFetch;
|
||||
createActionId?: () => string;
|
||||
}> = {}) {
|
||||
const fetcher = input.fetch ?? globalThis.fetch.bind(globalThis);
|
||||
const createActionId = input.createActionId ?? (() => globalThis.crypto.randomUUID());
|
||||
const claimActions = new Map<string, string>();
|
||||
|
||||
async function post(command: Readonly<Record<string, unknown>>): Promise<unknown> {
|
||||
const body = JSON.stringify(command);
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
const response = await fetcher("/api/birth-time-conversation/handoff", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body,
|
||||
});
|
||||
const payload = await handoffPayload(response);
|
||||
if (!response.ok) {
|
||||
const fields = errorFields(payload);
|
||||
throw new DurableRectificationHandoffError(
|
||||
response.status,
|
||||
fields.code,
|
||||
fields.message,
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (error instanceof DurableRectificationHandoffError || attempt > 0) throw error;
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async load(): Promise<DurableRectificationQuestionHandoff | null> {
|
||||
const response = await fetcher("/api/birth-time-conversation/handoff", {
|
||||
method: "GET",
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
const payload = await handoffPayload(response);
|
||||
if (response.status === 204) return null;
|
||||
if (!response.ok) {
|
||||
const fields = errorFields(payload);
|
||||
throw new DurableRectificationHandoffError(response.status, fields.code, fields.message);
|
||||
}
|
||||
return durableHandoffSchema.parse(payload);
|
||||
},
|
||||
|
||||
async attach(request: Readonly<{
|
||||
caseId: string;
|
||||
turnVersion: number;
|
||||
question: string;
|
||||
actionId?: string;
|
||||
}>): Promise<ConversationalRectificationTurn> {
|
||||
const payload = await post({
|
||||
type: "attach",
|
||||
caseId: request.caseId,
|
||||
turnVersion: request.turnVersion,
|
||||
actionId: request.actionId ?? createActionId(),
|
||||
question: request.question.trim(),
|
||||
});
|
||||
return conversationalRectificationTurnSchema.parse(payload);
|
||||
},
|
||||
|
||||
async claim(request: Readonly<{
|
||||
caseId: string;
|
||||
turnVersion: number;
|
||||
question: string;
|
||||
}>): Promise<ClaimedRectificationQuestionHandoff> {
|
||||
const identity = JSON.stringify([
|
||||
request.caseId,
|
||||
request.turnVersion,
|
||||
request.question.trim(),
|
||||
]);
|
||||
const actionId = claimActions.get(identity) ?? createActionId();
|
||||
claimActions.set(identity, actionId);
|
||||
const payload = durableHandoffSchema.parse(await post({
|
||||
type: "claim",
|
||||
caseId: request.caseId,
|
||||
turnVersion: request.turnVersion,
|
||||
actionId,
|
||||
question: request.question.trim(),
|
||||
}));
|
||||
if (payload.status !== "claimed") claimActions.delete(identity);
|
||||
return Object.freeze({ ...payload, claimActionId: actionId });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type DurableRectificationQuestionHandoffClient = ReturnType<
|
||||
typeof createDurableRectificationQuestionHandoffClient
|
||||
>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
type StreamHooks = {
|
||||
readonly onFirstOutput?: () => Promise<void>;
|
||||
readonly onComplete?: () => Promise<void>;
|
||||
readonly onError?: (error: unknown, emitted: boolean) => Promise<void>;
|
||||
readonly onCancel?: (emitted: boolean) => Promise<void>;
|
||||
@@ -157,6 +158,13 @@ export function streamTextResponse(
|
||||
let pending = "";
|
||||
let settled = false;
|
||||
let emitted = false;
|
||||
let firstOutputSettled = false;
|
||||
|
||||
async function settleFirstOutput(value: string) {
|
||||
if (!value || firstOutputSettled) return;
|
||||
firstOutputSettled = true;
|
||||
await options.onFirstOutput?.();
|
||||
}
|
||||
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
@@ -167,7 +175,10 @@ export function streamTextResponse(
|
||||
const finalText = visibleTransformer
|
||||
? visibleTransformer.finish(pending)
|
||||
: pending;
|
||||
if (finalText) controller.enqueue(encoder.encode(finalText));
|
||||
if (finalText) {
|
||||
await settleFirstOutput(finalText);
|
||||
controller.enqueue(encoder.encode(finalText));
|
||||
}
|
||||
settled = true;
|
||||
if (!emitted) {
|
||||
const error = new Error("empty_stream");
|
||||
@@ -190,6 +201,7 @@ export function streamTextResponse(
|
||||
? visibleTransformer.push(stable)
|
||||
: stable;
|
||||
if (transformed) {
|
||||
await settleFirstOutput(transformed);
|
||||
controller.enqueue(encoder.encode(transformed));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,716 @@
|
||||
begin;
|
||||
|
||||
create table if not exists public.birth_time_rectification_question_handoffs (
|
||||
case_id uuid primary key
|
||||
references public.birth_time_rectification_cases(id) on delete cascade,
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
question text not null check (
|
||||
public.conversational_rectification_text_utf16_length(question) between 1 and 500
|
||||
and public.conversational_rectification_text_is_nonblank(question)
|
||||
),
|
||||
question_fingerprint text not null check (question_fingerprint ~ '^[0-9a-f]{64}$'),
|
||||
attached_turn_version bigint not null check (attached_turn_version >= 0),
|
||||
attach_action_id uuid not null,
|
||||
state text not null check (state in ('pending', 'claimed', 'executing', 'consumed')),
|
||||
attempt integer not null default 0 check (attempt between 0 and 1000),
|
||||
request_id uuid not null,
|
||||
claim_action_id uuid,
|
||||
lease_expires_at timestamptz,
|
||||
claimed_at timestamptz,
|
||||
consumed_at timestamptz,
|
||||
created_at timestamptz not null default pg_catalog.now(),
|
||||
updated_at timestamptz not null default pg_catalog.now(),
|
||||
unique (user_id, request_id),
|
||||
check ((state in ('claimed', 'executing')) = (claim_action_id is not null)),
|
||||
check ((state in ('claimed', 'executing')) = (lease_expires_at is not null)),
|
||||
check ((state = 'consumed') = (consumed_at is not null))
|
||||
);
|
||||
|
||||
create table if not exists public.birth_time_rectification_handoff_attach_receipts (
|
||||
case_id uuid not null references public.birth_time_rectification_cases(id) on delete cascade,
|
||||
action_id uuid not null,
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
expected_turn_version bigint not null check (expected_turn_version >= 0),
|
||||
question_fingerprint text not null check (question_fingerprint ~ '^[0-9a-f]{64}$'),
|
||||
response jsonb not null,
|
||||
created_at timestamptz not null default pg_catalog.now(),
|
||||
primary key (case_id, action_id)
|
||||
);
|
||||
|
||||
create table if not exists public.birth_time_rectification_handoff_settlements (
|
||||
case_id uuid not null references public.birth_time_rectification_cases(id) on delete cascade,
|
||||
request_id uuid not null,
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
claim_action_id uuid not null,
|
||||
emitted boolean not null,
|
||||
response jsonb not null,
|
||||
created_at timestamptz not null default pg_catalog.now(),
|
||||
primary key (case_id, request_id)
|
||||
);
|
||||
|
||||
create index if not exists birth_time_rectification_handoff_owner_state_idx
|
||||
on public.birth_time_rectification_question_handoffs (user_id, state, updated_at desc);
|
||||
|
||||
alter table public.birth_time_rectification_question_handoffs enable row level security;
|
||||
alter table public.birth_time_rectification_handoff_attach_receipts enable row level security;
|
||||
alter table public.birth_time_rectification_handoff_settlements enable row level security;
|
||||
|
||||
revoke all on table public.birth_time_rectification_question_handoffs
|
||||
from public, anon, authenticated;
|
||||
revoke all on table public.birth_time_rectification_handoff_attach_receipts
|
||||
from public, anon, authenticated;
|
||||
revoke all on table public.birth_time_rectification_handoff_settlements
|
||||
from public, anon, authenticated;
|
||||
grant all on table public.birth_time_rectification_question_handoffs to service_role;
|
||||
grant all on table public.birth_time_rectification_handoff_attach_receipts to service_role;
|
||||
grant all on table public.birth_time_rectification_handoff_settlements to service_role;
|
||||
|
||||
create or replace function public.conversational_rectification_handoff_request_id(
|
||||
p_case_id uuid,
|
||||
p_attempt integer
|
||||
)
|
||||
returns uuid
|
||||
language sql
|
||||
immutable
|
||||
strict
|
||||
set search_path = ''
|
||||
as $$
|
||||
select pg_catalog.md5(
|
||||
p_case_id::text || ':ordinary-consultation-handoff:' || p_attempt::text
|
||||
)::uuid;
|
||||
$$;
|
||||
|
||||
create or replace function public.conversational_rectification_question_fingerprint(
|
||||
p_question text
|
||||
)
|
||||
returns text
|
||||
language sql
|
||||
immutable
|
||||
strict
|
||||
set search_path = ''
|
||||
as $$
|
||||
select pg_catalog.encode(
|
||||
pg_catalog.sha256(pg_catalog.convert_to(p_question, 'UTF8')),
|
||||
'hex'
|
||||
);
|
||||
$$;
|
||||
|
||||
create or replace function public.conversational_rectification_handoff_projection(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid
|
||||
)
|
||||
returns jsonb
|
||||
language sql
|
||||
stable
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
select pg_catalog.jsonb_build_object(
|
||||
'caseId', h.case_id,
|
||||
'turnVersion', c.turn_version,
|
||||
'question', h.question,
|
||||
'questionFingerprint', h.question_fingerprint,
|
||||
'requestId', h.request_id,
|
||||
'status', case
|
||||
when h.state = 'pending' then 'pending'
|
||||
when h.state in ('claimed', 'executing') then 'in_progress'
|
||||
else 'consumed'
|
||||
end,
|
||||
'turn', public.conversational_rectification_case_projection(
|
||||
p_user_id, p_case_id
|
||||
) -> 'latest_turn'
|
||||
)
|
||||
from public.birth_time_rectification_question_handoffs h
|
||||
join public.birth_time_rectification_cases c
|
||||
on c.id = h.case_id and c.user_id = h.user_id
|
||||
where h.case_id = p_case_id and h.user_id = p_user_id;
|
||||
$$;
|
||||
|
||||
create or replace function public.seed_conversational_rectification_handoff()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
if new.journey_protocol = 'conversational-evidence-v3'
|
||||
and new.pending_consultation_question is not null then
|
||||
insert into public.birth_time_rectification_question_handoffs (
|
||||
case_id, user_id, question, question_fingerprint,
|
||||
attached_turn_version, attach_action_id, state, attempt, request_id
|
||||
) values (
|
||||
new.id, new.user_id, new.pending_consultation_question,
|
||||
public.conversational_rectification_question_fingerprint(
|
||||
new.pending_consultation_question
|
||||
),
|
||||
new.turn_version, new.id, 'pending', 0,
|
||||
public.conversational_rectification_handoff_request_id(new.id, 0)
|
||||
) on conflict (case_id) do nothing;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists birth_time_rectification_seed_question_handoff
|
||||
on public.birth_time_rectification_cases;
|
||||
create trigger birth_time_rectification_seed_question_handoff
|
||||
after insert on public.birth_time_rectification_cases
|
||||
for each row execute function public.seed_conversational_rectification_handoff();
|
||||
|
||||
insert into public.birth_time_rectification_question_handoffs (
|
||||
case_id, user_id, question, question_fingerprint,
|
||||
attached_turn_version, attach_action_id, state, attempt, request_id
|
||||
)
|
||||
select
|
||||
c.id, c.user_id, c.pending_consultation_question,
|
||||
public.conversational_rectification_question_fingerprint(
|
||||
c.pending_consultation_question
|
||||
),
|
||||
c.turn_version, c.id, 'pending', 0,
|
||||
public.conversational_rectification_handoff_request_id(c.id, 0)
|
||||
from public.birth_time_rectification_cases c
|
||||
where c.journey_protocol = 'conversational-evidence-v3'
|
||||
and c.pending_consultation_question is not null
|
||||
on conflict (case_id) do nothing;
|
||||
|
||||
create or replace function public.attach_conversational_rectification_question(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid,
|
||||
p_expected_version bigint,
|
||||
p_action_id uuid,
|
||||
p_question text,
|
||||
p_question_fingerprint text
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_case public.birth_time_rectification_cases%rowtype;
|
||||
v_handoff public.birth_time_rectification_question_handoffs%rowtype;
|
||||
v_receipt public.birth_time_rectification_handoff_attach_receipts%rowtype;
|
||||
v_response jsonb;
|
||||
begin
|
||||
if p_user_id is null or p_case_id is null or p_action_id is null
|
||||
or p_expected_version is null or p_expected_version < 0
|
||||
or p_question is null or p_question_fingerprint is null
|
||||
or p_question_fingerprint !~ '^[0-9a-f]{64}$'
|
||||
or public.conversational_rectification_text_utf16_length(p_question) not between 1 and 500
|
||||
or public.conversational_rectification_text_is_nonblank(p_question) is not true
|
||||
or public.conversational_rectification_question_fingerprint(p_question)
|
||||
is distinct from p_question_fingerprint then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
perform pg_catalog.pg_advisory_xact_lock(
|
||||
pg_catalog.hashtextextended(
|
||||
p_user_id::text || ':' || p_case_id::text || ':attach-question', 0
|
||||
)
|
||||
);
|
||||
select c.* into v_case
|
||||
from public.birth_time_rectification_cases c
|
||||
where c.id = p_case_id and c.user_id = p_user_id
|
||||
for update;
|
||||
if not found or v_case.journey_protocol is distinct from 'conversational-evidence-v3' then
|
||||
raise exception 'conversational_case_not_found' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
select r.* into v_receipt
|
||||
from public.birth_time_rectification_handoff_attach_receipts r
|
||||
where r.case_id = p_case_id and r.action_id = p_action_id
|
||||
for update;
|
||||
if found then
|
||||
if v_receipt.user_id is distinct from p_user_id
|
||||
or v_receipt.expected_turn_version is distinct from p_expected_version
|
||||
or v_receipt.question_fingerprint is distinct from p_question_fingerprint then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
return v_receipt.response;
|
||||
end if;
|
||||
|
||||
if v_case.turn_version is distinct from p_expected_version then
|
||||
raise exception 'conversational_stale_turn' using errcode = 'P0001';
|
||||
end if;
|
||||
if v_case.status not in ('starting', 'active', 'paused', 'confirming') then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
select h.* into v_handoff
|
||||
from public.birth_time_rectification_question_handoffs h
|
||||
where h.case_id = p_case_id and h.user_id = p_user_id
|
||||
for update;
|
||||
if found and v_handoff.state <> 'pending' then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
insert into public.birth_time_rectification_question_handoffs (
|
||||
case_id, user_id, question, question_fingerprint,
|
||||
attached_turn_version, attach_action_id, state, attempt, request_id,
|
||||
claim_action_id, lease_expires_at, claimed_at, consumed_at, updated_at
|
||||
) values (
|
||||
p_case_id, p_user_id, p_question, p_question_fingerprint,
|
||||
p_expected_version, p_action_id, 'pending', 0,
|
||||
public.conversational_rectification_handoff_request_id(p_case_id, 0),
|
||||
null, null, null, null, pg_catalog.now()
|
||||
) on conflict (case_id) do update set
|
||||
question = excluded.question,
|
||||
question_fingerprint = excluded.question_fingerprint,
|
||||
attached_turn_version = excluded.attached_turn_version,
|
||||
attach_action_id = excluded.attach_action_id,
|
||||
state = 'pending',
|
||||
attempt = 0,
|
||||
request_id = excluded.request_id,
|
||||
claim_action_id = null,
|
||||
lease_expires_at = null,
|
||||
claimed_at = null,
|
||||
consumed_at = null,
|
||||
updated_at = pg_catalog.now();
|
||||
|
||||
update public.birth_time_rectification_cases
|
||||
set pending_consultation_question = p_question,
|
||||
turn_state = pg_catalog.jsonb_set(
|
||||
turn_state, '{pendingConsultationQuestion}', pg_catalog.to_jsonb(p_question), true
|
||||
),
|
||||
journey_snapshot = pg_catalog.jsonb_set(
|
||||
journey_snapshot, '{pendingConsultationQuestion}', pg_catalog.to_jsonb(p_question), true
|
||||
),
|
||||
updated_at = pg_catalog.now()
|
||||
where id = p_case_id and user_id = p_user_id
|
||||
and turn_version = p_expected_version;
|
||||
if not found then
|
||||
raise exception 'conversational_stale_turn' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
v_response := public.conversational_rectification_case_projection(
|
||||
p_user_id, p_case_id
|
||||
);
|
||||
insert into public.birth_time_rectification_handoff_attach_receipts (
|
||||
case_id, action_id, user_id, expected_turn_version,
|
||||
question_fingerprint, response
|
||||
) values (
|
||||
p_case_id, p_action_id, p_user_id, p_expected_version,
|
||||
p_question_fingerprint, v_response
|
||||
);
|
||||
return v_response;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.load_conversational_rectification_handoff(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid default null
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
stable
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_case_id uuid;
|
||||
begin
|
||||
if p_user_id is null then
|
||||
return null;
|
||||
end if;
|
||||
if p_case_id is not null then
|
||||
v_case_id := p_case_id;
|
||||
else
|
||||
select h.case_id into v_case_id
|
||||
from public.birth_time_rectification_question_handoffs h
|
||||
where h.user_id = p_user_id and h.state <> 'consumed'
|
||||
order by h.updated_at desc, h.created_at desc
|
||||
limit 1;
|
||||
end if;
|
||||
return public.conversational_rectification_handoff_projection(
|
||||
p_user_id, v_case_id
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.consume_conversational_rectification_handoff(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid
|
||||
)
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_actions jsonb;
|
||||
begin
|
||||
select coalesce(pg_catalog.jsonb_agg(action.value), '[]'::jsonb)
|
||||
into v_actions
|
||||
from public.birth_time_rectification_cases c
|
||||
join public.birth_time_rectification_turns t
|
||||
on t.case_id = c.id and t.turn_version = c.turn_version
|
||||
cross join lateral pg_catalog.jsonb_array_elements(t.actions) action(value)
|
||||
where c.id = p_case_id and c.user_id = p_user_id
|
||||
and action.value #>> '{}' <> 'continue_original_question';
|
||||
|
||||
update public.birth_time_rectification_turns t
|
||||
set actions = coalesce(v_actions, '[]'::jsonb)
|
||||
from public.birth_time_rectification_cases c
|
||||
where c.id = p_case_id and c.user_id = p_user_id
|
||||
and t.case_id = c.id and t.turn_version = c.turn_version;
|
||||
|
||||
update public.birth_time_rectification_cases
|
||||
set pending_consultation_question = null,
|
||||
turn_state = pg_catalog.jsonb_set(
|
||||
pg_catalog.jsonb_set(
|
||||
turn_state, '{pendingConsultationQuestion}', 'null'::jsonb, true
|
||||
),
|
||||
'{actions}', coalesce(v_actions, '[]'::jsonb), true
|
||||
),
|
||||
journey_snapshot = pg_catalog.jsonb_set(
|
||||
pg_catalog.jsonb_set(
|
||||
journey_snapshot, '{pendingConsultationQuestion}', 'null'::jsonb, true
|
||||
),
|
||||
'{actions}', coalesce(v_actions, '[]'::jsonb), true
|
||||
),
|
||||
updated_at = pg_catalog.now()
|
||||
where id = p_case_id and user_id = p_user_id;
|
||||
|
||||
update public.birth_time_rectification_question_handoffs
|
||||
set state = 'consumed', claim_action_id = null, lease_expires_at = null,
|
||||
consumed_at = coalesce(consumed_at, pg_catalog.now()),
|
||||
updated_at = pg_catalog.now()
|
||||
where case_id = p_case_id and user_id = p_user_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.claim_conversational_rectification_handoff(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid,
|
||||
p_expected_version bigint,
|
||||
p_action_id uuid,
|
||||
p_question_fingerprint text
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_case public.birth_time_rectification_cases%rowtype;
|
||||
v_handoff public.birth_time_rectification_question_handoffs%rowtype;
|
||||
v_request_status text;
|
||||
begin
|
||||
if p_user_id is null or p_case_id is null or p_action_id is null
|
||||
or p_expected_version is null or p_expected_version < 0
|
||||
or p_question_fingerprint is null
|
||||
or p_question_fingerprint !~ '^[0-9a-f]{64}$' then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
perform pg_catalog.pg_advisory_xact_lock(
|
||||
pg_catalog.hashtextextended(
|
||||
p_user_id::text || ':' || p_case_id::text || ':claim-handoff', 0
|
||||
)
|
||||
);
|
||||
select c.* into v_case
|
||||
from public.birth_time_rectification_cases c
|
||||
where c.id = p_case_id and c.user_id = p_user_id
|
||||
for update;
|
||||
if not found or v_case.journey_protocol is distinct from 'conversational-evidence-v3' then
|
||||
raise exception 'conversational_case_not_found' using errcode = 'P0001';
|
||||
end if;
|
||||
if v_case.turn_version is distinct from p_expected_version then
|
||||
raise exception 'conversational_stale_turn' using errcode = 'P0001';
|
||||
end if;
|
||||
if v_case.status is distinct from 'completed' then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
select h.* into v_handoff
|
||||
from public.birth_time_rectification_question_handoffs h
|
||||
where h.case_id = p_case_id and h.user_id = p_user_id
|
||||
for update;
|
||||
if not found
|
||||
or v_handoff.question_fingerprint is distinct from p_question_fingerprint then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
if v_handoff.state = 'consumed' then
|
||||
return public.conversational_rectification_handoff_projection(p_user_id, p_case_id);
|
||||
end if;
|
||||
if v_case.pending_consultation_question is distinct from v_handoff.question then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
if v_handoff.state in ('claimed', 'executing')
|
||||
and v_handoff.lease_expires_at > pg_catalog.now() then
|
||||
if v_handoff.state = 'claimed' and v_handoff.claim_action_id = p_action_id then
|
||||
return public.conversational_rectification_handoff_projection(p_user_id, p_case_id)
|
||||
|| pg_catalog.jsonb_build_object('status', 'claimed');
|
||||
end if;
|
||||
return public.conversational_rectification_handoff_projection(p_user_id, p_case_id)
|
||||
|| pg_catalog.jsonb_build_object('status', 'in_progress');
|
||||
end if;
|
||||
|
||||
select request.status into v_request_status
|
||||
from public.consultation_requests request
|
||||
where request.user_id = p_user_id and request.request_id = v_handoff.request_id::text
|
||||
for update;
|
||||
if v_request_status = 'completed' then
|
||||
perform public.consume_conversational_rectification_handoff(p_user_id, p_case_id);
|
||||
return public.conversational_rectification_handoff_projection(p_user_id, p_case_id);
|
||||
end if;
|
||||
if v_request_status = 'cancelled' then
|
||||
update public.birth_time_rectification_question_handoffs
|
||||
set attempt = attempt + 1,
|
||||
request_id = public.conversational_rectification_handoff_request_id(
|
||||
p_case_id, attempt + 1
|
||||
)
|
||||
where case_id = p_case_id and user_id = p_user_id;
|
||||
end if;
|
||||
|
||||
update public.birth_time_rectification_question_handoffs
|
||||
set state = 'claimed', claim_action_id = p_action_id,
|
||||
lease_expires_at = pg_catalog.now() + interval '2 minutes',
|
||||
claimed_at = pg_catalog.now(), consumed_at = null,
|
||||
updated_at = pg_catalog.now()
|
||||
where case_id = p_case_id and user_id = p_user_id;
|
||||
return public.conversational_rectification_handoff_projection(p_user_id, p_case_id)
|
||||
|| pg_catalog.jsonb_build_object('status', 'claimed');
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.begin_conversational_rectification_handoff_execution(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid,
|
||||
p_expected_version bigint,
|
||||
p_claim_action_id uuid,
|
||||
p_request_id uuid,
|
||||
p_question_fingerprint text
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_case public.birth_time_rectification_cases%rowtype;
|
||||
v_handoff public.birth_time_rectification_question_handoffs%rowtype;
|
||||
v_request_status text;
|
||||
v_credits integer;
|
||||
begin
|
||||
if p_user_id is null or p_case_id is null or p_claim_action_id is null
|
||||
or p_request_id is null or p_expected_version is null or p_expected_version < 0
|
||||
or p_question_fingerprint is null
|
||||
or p_question_fingerprint !~ '^[0-9a-f]{64}$' then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
perform pg_catalog.pg_advisory_xact_lock(
|
||||
pg_catalog.hashtextextended(
|
||||
p_user_id::text || ':' || p_case_id::text || ':execute-handoff', 0
|
||||
)
|
||||
);
|
||||
select c.* into v_case
|
||||
from public.birth_time_rectification_cases c
|
||||
where c.id = p_case_id and c.user_id = p_user_id
|
||||
for update;
|
||||
select h.* into v_handoff
|
||||
from public.birth_time_rectification_question_handoffs h
|
||||
where h.case_id = p_case_id and h.user_id = p_user_id
|
||||
for update;
|
||||
if v_case.id is null or v_handoff.case_id is null
|
||||
or v_case.journey_protocol is distinct from 'conversational-evidence-v3'
|
||||
or v_case.status is distinct from 'completed'
|
||||
or v_case.turn_version is distinct from p_expected_version
|
||||
or v_handoff.question_fingerprint is distinct from p_question_fingerprint
|
||||
or v_handoff.request_id is distinct from p_request_id then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
if v_handoff.state = 'consumed' then
|
||||
return pg_catalog.jsonb_build_object(
|
||||
'status', 'consumed', 'requestId', v_handoff.request_id
|
||||
);
|
||||
end if;
|
||||
if v_handoff.claim_action_id is distinct from p_claim_action_id
|
||||
or v_handoff.lease_expires_at <= pg_catalog.now() then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
if v_handoff.state = 'executing' then
|
||||
return pg_catalog.jsonb_build_object(
|
||||
'status', 'in_progress', 'requestId', v_handoff.request_id
|
||||
);
|
||||
end if;
|
||||
if v_handoff.state is distinct from 'claimed' then
|
||||
return pg_catalog.jsonb_build_object(
|
||||
'status', case when v_handoff.state = 'consumed' then 'consumed' else 'in_progress' end,
|
||||
'requestId', v_handoff.request_id
|
||||
);
|
||||
end if;
|
||||
|
||||
select request.status into v_request_status
|
||||
from public.consultation_requests request
|
||||
where request.user_id = p_user_id and request.request_id = p_request_id::text
|
||||
for update;
|
||||
if v_request_status = 'completed' then
|
||||
perform public.consume_conversational_rectification_handoff(p_user_id, p_case_id);
|
||||
return pg_catalog.jsonb_build_object('status', 'consumed', 'requestId', p_request_id);
|
||||
end if;
|
||||
if v_request_status = 'cancelled' then
|
||||
update public.birth_time_rectification_question_handoffs
|
||||
set state = 'pending', attempt = attempt + 1,
|
||||
request_id = public.conversational_rectification_handoff_request_id(
|
||||
p_case_id, attempt + 1
|
||||
),
|
||||
claim_action_id = null, lease_expires_at = null, updated_at = pg_catalog.now()
|
||||
where case_id = p_case_id and user_id = p_user_id;
|
||||
return pg_catalog.jsonb_build_object('status', 'released', 'requestId', p_request_id);
|
||||
end if;
|
||||
|
||||
select profile.credits into v_credits
|
||||
from public.profiles profile where profile.id = p_user_id;
|
||||
update public.birth_time_rectification_question_handoffs
|
||||
set state = 'executing',
|
||||
lease_expires_at = pg_catalog.now() + interval '2 minutes',
|
||||
updated_at = pg_catalog.now()
|
||||
where case_id = p_case_id and user_id = p_user_id;
|
||||
return pg_catalog.jsonb_build_object(
|
||||
'status', 'ready',
|
||||
'requestId', p_request_id,
|
||||
'billingReused', coalesce(v_request_status = 'reserved', false),
|
||||
'credits', v_credits
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.settle_conversational_rectification_handoff(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid,
|
||||
p_claim_action_id uuid,
|
||||
p_request_id uuid,
|
||||
p_emitted boolean
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_handoff public.birth_time_rectification_question_handoffs%rowtype;
|
||||
v_receipt public.birth_time_rectification_handoff_settlements%rowtype;
|
||||
v_success boolean;
|
||||
v_credits integer;
|
||||
v_error text;
|
||||
v_response jsonb;
|
||||
begin
|
||||
if p_user_id is null or p_case_id is null or p_claim_action_id is null
|
||||
or p_request_id is null or p_emitted is null then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
perform pg_catalog.pg_advisory_xact_lock(
|
||||
pg_catalog.hashtextextended(
|
||||
p_user_id::text || ':' || p_case_id::text || ':settle-handoff', 0
|
||||
)
|
||||
);
|
||||
select s.* into v_receipt
|
||||
from public.birth_time_rectification_handoff_settlements s
|
||||
where s.case_id = p_case_id and s.request_id = p_request_id
|
||||
for update;
|
||||
if found then
|
||||
if v_receipt.user_id is distinct from p_user_id
|
||||
or v_receipt.claim_action_id is distinct from p_claim_action_id
|
||||
or v_receipt.emitted is distinct from p_emitted then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
return v_receipt.response;
|
||||
end if;
|
||||
|
||||
select h.* into v_handoff
|
||||
from public.birth_time_rectification_question_handoffs h
|
||||
where h.case_id = p_case_id and h.user_id = p_user_id
|
||||
for update;
|
||||
if not found or v_handoff.request_id is distinct from p_request_id
|
||||
or v_handoff.claim_action_id is distinct from p_claim_action_id
|
||||
or v_handoff.state not in ('claimed', 'executing') then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
if p_emitted then
|
||||
select result.success, result.credits, result.error_code
|
||||
into v_success, v_credits, v_error
|
||||
from public.complete_consultation_credit(
|
||||
p_user_id, p_request_id::text
|
||||
) result;
|
||||
if v_success is not true then
|
||||
raise exception 'conversational_billing_failed' using errcode = 'P0001';
|
||||
end if;
|
||||
perform public.consume_conversational_rectification_handoff(p_user_id, p_case_id);
|
||||
v_response := pg_catalog.jsonb_build_object(
|
||||
'status', 'consumed', 'requestId', p_request_id, 'credits', v_credits
|
||||
);
|
||||
else
|
||||
select result.success, result.credits, result.error_code
|
||||
into v_success, v_credits, v_error
|
||||
from public.cancel_consultation_credit(
|
||||
p_user_id, p_request_id::text
|
||||
) result;
|
||||
if v_success is not true then
|
||||
raise exception 'conversational_billing_failed' using errcode = 'P0001';
|
||||
end if;
|
||||
update public.birth_time_rectification_question_handoffs
|
||||
set state = 'pending', attempt = attempt + 1,
|
||||
request_id = public.conversational_rectification_handoff_request_id(
|
||||
p_case_id, attempt + 1
|
||||
),
|
||||
claim_action_id = null, lease_expires_at = null,
|
||||
claimed_at = null, updated_at = pg_catalog.now()
|
||||
where case_id = p_case_id and user_id = p_user_id;
|
||||
v_response := pg_catalog.jsonb_build_object(
|
||||
'status', 'pending', 'requestId', p_request_id, 'credits', v_credits
|
||||
);
|
||||
end if;
|
||||
|
||||
insert into public.birth_time_rectification_handoff_settlements (
|
||||
case_id, request_id, user_id, claim_action_id, emitted, response
|
||||
) values (
|
||||
p_case_id, p_request_id, p_user_id, p_claim_action_id, p_emitted, v_response
|
||||
);
|
||||
return v_response;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.conversational_rectification_handoff_request_id(uuid, integer)
|
||||
from public, anon, authenticated, service_role;
|
||||
revoke all on function public.conversational_rectification_question_fingerprint(text)
|
||||
from public, anon, authenticated, service_role;
|
||||
revoke all on function public.conversational_rectification_handoff_projection(uuid, uuid)
|
||||
from public, anon, authenticated, service_role;
|
||||
revoke all on function public.seed_conversational_rectification_handoff()
|
||||
from public, anon, authenticated, service_role;
|
||||
revoke all on function public.consume_conversational_rectification_handoff(uuid, uuid)
|
||||
from public, anon, authenticated, service_role;
|
||||
|
||||
revoke all on function public.attach_conversational_rectification_question(
|
||||
uuid, uuid, bigint, uuid, text, text
|
||||
) from public, anon, authenticated;
|
||||
revoke all on function public.load_conversational_rectification_handoff(uuid, uuid)
|
||||
from public, anon, authenticated;
|
||||
revoke all on function public.claim_conversational_rectification_handoff(
|
||||
uuid, uuid, bigint, uuid, text
|
||||
) from public, anon, authenticated;
|
||||
revoke all on function public.begin_conversational_rectification_handoff_execution(
|
||||
uuid, uuid, bigint, uuid, uuid, text
|
||||
) from public, anon, authenticated;
|
||||
revoke all on function public.settle_conversational_rectification_handoff(
|
||||
uuid, uuid, uuid, uuid, boolean
|
||||
) from public, anon, authenticated;
|
||||
|
||||
grant execute on function public.attach_conversational_rectification_question(
|
||||
uuid, uuid, bigint, uuid, text, text
|
||||
) to service_role;
|
||||
grant execute on function public.load_conversational_rectification_handoff(uuid, uuid)
|
||||
to service_role;
|
||||
grant execute on function public.claim_conversational_rectification_handoff(
|
||||
uuid, uuid, bigint, uuid, text
|
||||
) to service_role;
|
||||
grant execute on function public.begin_conversational_rectification_handoff_execution(
|
||||
uuid, uuid, bigint, uuid, uuid, text
|
||||
) to service_role;
|
||||
grant execute on function public.settle_conversational_rectification_handoff(
|
||||
uuid, uuid, uuid, uuid, boolean
|
||||
) to service_role;
|
||||
|
||||
commit;
|
||||
@@ -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