fix: restore rectification agent message actions

This commit is contained in:
Jesse_Chen
2026-07-30 00:41:48 +08:00
parent a5f409a500
commit 0a5b8cd239
15 changed files with 513 additions and 4 deletions
+16
View File
@@ -1676,3 +1676,19 @@
- 防复发:跨语言持久化指纹必须使用已知向量验证 JSON 数字规范化,不能只在各自语言内断言自洽。
- 相关记录:BUG-087、BUG-092
- 修复版本:`rectification-v5-matrix-scoring-1`(仅修复输入规范化,算法版本不变)
## BUG-094 | V5 Agent 消息操作栏在 V4 面板切换后消失
- 状态:resolved
- 首次发现:2026-07-29
- 最近更新:2026-07-29
- 影响面:生时校正 V5 Agent 对话、助手消息反馈与当前问题重新生成
- 用户现象:助手消息气泡下不再显示点赞、点踩、复制和重新生成操作。
- 触发条件:生时校正页面使用 `RectificationV4Panel` 渲染 V5 Agent 会话。
- 根因:旧对话组件中的消息操作栏没有迁入 V4/V5 共用面板,同时新 V4 API 没有与当前语义问题绑定的重新生成命令。
- 修复:复用现有消息操作栏样式,仅为 `v5_agent` 的稳定助手消息恢复操作;重新生成只重写当前已验证 Semantic Question Opportunity 的自然语言实现,并通过用户、Case 版本、当前目标和 action ID 原子校验,不重跑事件提取、候选评分、诊断或 Job。
- 验证:组件资格与反馈互斥测试、V4 service 幂等重放与数据不变量测试、Renderer 安全回落测试、迁移契约测试,以及 staging 精确 SHA 部署和浏览器验收。
- 防复发:测试锁定 V5 Agent 操作栏、仅当前问题可重跑、legacy/shadow 不启用新 Renderer、重跑不改变 turns/events/snapshots/profile 且 completed Job 不被改写。
- 相关记录:BUG-090、BUG-091、BUG-092、BUG-093
- 复发自:无
- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1`
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { caseActionRequestSchema } from "@/lib/rectification-v4/contracts";
import { rectificationV4Context, rectificationV4Error, requestBody, routeId } from "../../../_server";
export const runtime = "nodejs";
export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) {
try {
const body = await requestBody(request, caseActionRequestSchema);
const context = await rectificationV4Context();
const result = await context.service.regenerateQuestion({
...body,
userId: context.userId,
caseId: routeId((await params).caseId),
});
return result
? NextResponse.json(result)
: NextResponse.json({ error: "当前问题不能重新生成,请刷新后重试。" }, { status: 409 });
} catch (error) {
return rectificationV4Error(error);
}
}
@@ -1,6 +1,6 @@
"use client";
import { ArrowUp } from "lucide-react";
import { ArrowUp, Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useRectificationV4 } from "@/hooks/use-rectification-v4";
import type { ChatMessageView } from "@/lib/chat-message-view";
@@ -29,6 +29,28 @@ type RectificationV4PanelProps = Readonly<{
onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void;
}>;
export function toggleRectificationFeedback(
current: "up" | "down" | undefined,
requested: "up" | "down",
): "up" | "down" | undefined {
return current === requested ? undefined : requested;
}
export function canRegenerateRectificationMessage(input: Readonly<{
message: ChatMessageView;
currentMessageKey: string | null;
deploymentMode: RectificationV4ApiResponse["case"]["deploymentMode"] | null;
busy: boolean;
canAnswer: boolean;
}>): boolean {
return input.deploymentMode === "v5_agent"
&& input.message.role === "assistant"
&& input.message.state === "settled"
&& input.message.renderKey === input.currentMessageKey
&& !input.busy
&& input.canAnswer;
}
export function rectificationV4ChatMessages(
data: RectificationV4ApiResponse | null,
processing: boolean,
@@ -129,6 +151,9 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) {
onPendingChange: props.onPendingChange,
});
const [draft, setDraft] = useState("");
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
const composer = useRef<HTMLTextAreaElement>(null);
const conversationEnd = useRef<HTMLDivElement>(null);
const caseValue = controller.data?.case;
@@ -145,6 +170,10 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) {
&& !processing
&& !controller.pending
&& ["awaiting_answer", "range_ready"].includes(caseValue?.status ?? "");
const currentMessageKey = caseValue?.currentQuestion
? `rectification-current-${caseValue.currentQuestion.id}`
: null;
const busy = processing || controller.pending || regeneratingMessageKey !== null;
const canAcceptRange = caseValue?.status === "range_ready"
&& Boolean(caseValue.latestSnapshot?.canAcceptRange)
&& !caseValue.acceptedRange;
@@ -174,6 +203,27 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) {
if (result) setDraft("");
}
async function copyMessage(message: ChatMessageView) {
try {
await navigator.clipboard.writeText(message.text);
setCopiedMessageKey(message.renderKey);
window.setTimeout(() => setCopiedMessageKey((current) => (
current === message.renderKey ? null : current
)), 1_500);
} catch {
// Clipboard permission failures must not interrupt the conversation.
}
}
async function regenerateMessage(messageKey: string) {
setRegeneratingMessageKey(messageKey);
try {
await controller.regenerate();
} finally {
setRegeneratingMessageKey((current) => current === messageKey ? null : current);
}
}
function continueOriginalQuestion() {
if (!caseValue?.acceptedRange || !handoff) return;
props.onContinueOriginalQuestion?.({
@@ -189,7 +239,71 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) {
<>
<section className="conversation" aria-label="生时校正对话" aria-busy={processing || controller.pending}>
<div className="message-list" aria-live="polite">
{messages.map((message) => <ChatMessageRow key={message.renderKey} message={message} />)}
{messages.map((message) => {
const showActions = caseValue?.deploymentMode === "v5_agent"
&& message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text);
const regenerating = regeneratingMessageKey === message.renderKey;
const canRegenerate = canRegenerateRectificationMessage({
message,
currentMessageKey,
deploymentMode: caseValue?.deploymentMode ?? null,
busy,
canAnswer,
});
return (
<div className="rectification-message-entry" key={message.renderKey}>
<ChatMessageRow message={regenerating
? { ...message, text: "", state: "thinking" }
: message} />
{showActions && !regenerating && (
<div className="rectification-message-actions" aria-label="Agent 回答操作">
<button
aria-label="赞"
aria-pressed={feedback[message.renderKey] === "up"}
className={feedback[message.renderKey] === "up" ? "is-active" : ""}
title="赞"
type="button"
onClick={() => setFeedback((current) => ({
...current,
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], "up"),
}))}
>
<ThumbsUp aria-hidden="true" />
</button>
<button
aria-label="踩"
aria-pressed={feedback[message.renderKey] === "down"}
className={feedback[message.renderKey] === "down" ? "is-active" : ""}
title="踩"
type="button"
onClick={() => setFeedback((current) => ({
...current,
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], "down"),
}))}
>
<ThumbsDown aria-hidden="true" />
</button>
<button aria-label="复制回答" title="复制" type="button" onClick={() => void copyMessage(message)}>
{copiedMessageKey === message.renderKey
? <Check aria-hidden="true" />
: <Copy aria-hidden="true" />}
</button>
<button
aria-label="重新生成回答"
disabled={!canRegenerate}
title={canRegenerate ? "重新生成" : "只能重新生成当前问题"}
type="button"
onClick={() => void regenerateMessage(message.renderKey)}
>
<RotateCcw aria-hidden="true" />
</button>
</div>
)}
</div>
);
})}
{controller.error && <p className="error-message" role="alert">{controller.error}</p>}
<div ref={conversationEnd} />
</div>
@@ -16,6 +16,7 @@ import {
loadRectificationV4,
loadRectificationV4Handoff,
loadRectificationV4Job,
regenerateRectificationV4Question,
transitionRectificationV4,
} from "@/lib/rectification-v4/client";
@@ -136,6 +137,9 @@ export function useRectificationV4(input: {
answer: (answer: string, modelId?: string | null) => data
? mutate(() => answerRectificationV4(data.case.id, data.case.version, answer, modelId))
: Promise.resolve(null),
regenerate: () => data
? mutate(() => regenerateRectificationV4Question(data.case.id, data.case.version))
: Promise.resolve(null),
pause: () => data
? mutate(() => transitionRectificationV4(data.case.id, data.case.version, "pause"))
: Promise.resolve(null),
@@ -1,5 +1,6 @@
import path from "node:path";
import { Agent } from "@mastra/core/agent";
import { z } from "zod";
import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model";
import type { CandidateSnapshot, LifeEventRevision, PendingEvidence, RectificationV4Case } from "../rectification-v4/contracts.ts";
import { publicMessageSchema, type PublicMessage, type QuestionOpportunity, type ValidatedDecision } from "./contracts.ts";
@@ -14,6 +15,7 @@ const multiQuestionMoves = /(?:另外|还有|同时再说|并且告诉我|顺便
const cannedQuestion = /(?:承接[“\"']?.{0,80}[”\"']??请再说一件|接下来请继续讲另一件|我会顺着你的叙述继续核对)/;
const exactClockMinute = /(?:[01]?\d|2[0-3])[:][0-5]\d|(?:[零〇一二两三四五六七八九十]{1,3}|(?:[01]?\d|2[0-3]))点(?:[零〇一二两三四五六七八九十]{1,3}|[0-5]?\d)分/;
const exactMinuteClaim = /(?:唯一|准确|精确|确切|确认|确定|代表).{0,12}(?:出生|生时)?(?:时间|时刻|分钟)|(?:出生|生时)(?:时间|时刻|分钟)?.{0,12}(?:唯一|准确|精确|确切|确认|确定|代表|就是)/;
const questionRealizationSchema = z.object({ question: z.string().trim().min(1).max(1_000) }).strict();
function agentFor(modelId: string | null): { id: string; agent: Agent } | null {
const selected = (modelId ? resolveLanguageModel(modelId) : null) ?? defaultLanguageModel();
@@ -199,3 +201,42 @@ export async function renderPublicTurn(input: Readonly<{
return fallback;
}
}
export async function regenerateQuestionRealization(input: Readonly<{
caseValue: RectificationV4Case;
currentPrompt: string;
latestAnswer: string;
acceptedEvents: readonly LifeEventRevision[];
opportunity: QuestionOpportunity;
timeoutMs?: number;
}>): Promise<string> {
const selected = agentFor(input.caseValue.narrationModelId);
if (!selected) return input.currentPrompt;
try {
const result = await selected.agent.generate(JSON.stringify({
task: "Rewrite the current question naturally without changing its semantic target. Return one question only.",
currentPrompt: input.currentPrompt,
latestAnswer: input.latestAnswer,
recentEvents: input.acceptedEvents.slice(-5).map((event) => ({
summary: event.summary,
date: event.dateRange.label,
subject: event.subject,
})),
selectedOpportunity: {
kind: input.opportunity.kind,
goal: input.opportunity.goal,
requestedFields: input.opportunity.requestedFields,
anchors: input.opportunity.anchors,
contextFacts: input.opportunity.contextFacts,
forbiddenMoves: input.opportunity.forbiddenMoves,
},
}), {
abortSignal: AbortSignal.timeout(input.timeoutMs ?? 15_000),
structuredOutput: { schema: questionRealizationSchema, jsonPromptInjection: "inline" },
});
const question = questionRealizationSchema.parse(result.object).question;
return validateQuestionRealization(question, input.opportunity).valid ? question : input.currentPrompt;
} catch {
return input.currentPrompt;
}
}
@@ -8,6 +8,7 @@ import type {
import { rectificationAgentV5Protocol, rectificationV4AlgorithmVersion, rectificationV4Protocol } from "./contracts.ts";
import { selectRectificationDeploymentMode } from "../rectification-agent/feature-policy.ts";
import { CURRENT_RECTIFICATION_PROMPT_VERSION, CURRENT_RECTIFICATION_SKILL_VERSION } from "../rectification-agent/contracts.ts";
import { regenerateQuestionRealization } from "../rectification-agent/renderer-agent.ts";
import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts";
import { openingQuestion } from "./opening-question.ts";
import type { RectificationV4Store } from "./store.ts";
@@ -88,6 +89,40 @@ export function createRectificationV4CaseService(store: RectificationV4Store, op
return response(input.userId, saved.case, saved.job.id);
},
async regenerateQuestion(input: {
readonly userId: string;
readonly caseId: string;
readonly actionId: string;
readonly expectedCaseVersion: number;
}) {
const current = await store.loadCase(input.userId, input.caseId);
if (!current?.currentQuestion || current.deploymentMode !== "v5_agent") return null;
const validated = await store.loadLatestValidatedDecision(input.userId, input.caseId);
const opportunity = validated?.selectedOpportunity;
if (!opportunity) return null;
const [events, turns] = await Promise.all([
store.loadEvents(input.userId, input.caseId),
store.loadTurns(input.userId, input.caseId),
]);
const prompt = await regenerateQuestionRealization({
caseValue: current,
currentPrompt: current.currentQuestion.prompt,
latestAnswer: turns.at(-1)?.answer ?? "",
acceptedEvents: events,
opportunity,
});
const nextQuestion = {
...current.currentQuestion,
id: randomUUID(),
prompt,
};
return response(input.userId, await store.replaceCurrentQuestion({
...input,
question: nextQuestion,
now: now().toISOString(),
}));
},
async reviseEvent(input: {
readonly userId: string;
readonly caseId: string;
@@ -63,6 +63,12 @@ export function answerRectificationV4(caseId: string, expectedCaseVersion: numbe
});
}
export function regenerateRectificationV4Question(caseId: string, expectedCaseVersion: number) {
return post(`/api/rectification/v4/cases/${caseId}/regenerate`, {
actionId: globalThis.crypto.randomUUID(), expectedCaseVersion,
});
}
export function transitionRectificationV4(
caseId: string,
expectedCaseVersion: number,
@@ -69,6 +69,14 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
.filter((turn) => turn.caseId === caseId)
.sort((left, right) => left.caseVersion - right.caseVersion || left.createdAt.localeCompare(right.createdAt));
},
async loadLatestValidatedDecision(userId, caseId) {
const caseValue = cases.get(caseId);
if (!caseValue || caseValue.userId !== userId) return null;
const latest = [...agentRuns.values()]
.filter((run) => run.caseId === caseId)
.sort((left, right) => right.caseVersion - left.caseVersion || right.createdAt.localeCompare(left.createdAt))[0];
return latest ? validatedDecisions.get(latest.jobId) ?? latest.validatedDecision : null;
},
async createCase(input) {
const replay = actionResults.get(`${input.case.userId}:${input.actionId}`);
if (replay) return owned(input.case.userId, replay.caseId);
@@ -91,6 +99,29 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
actionResults.set(`${input.case.userId}:${input.actionId}`, { caseId: input.case.id, jobId: null });
return input.case;
},
async replaceCurrentQuestion(input) {
const key = `${input.userId}:${input.actionId}`;
const replay = actionResults.get(key);
if (replay) return owned(input.userId, replay.caseId);
const current = owned(input.userId, input.caseId);
if (current.version !== input.expectedCaseVersion) throw new RectificationV4StoreError("stale_version");
if (current.deploymentMode !== "v5_agent"
|| !["awaiting_answer", "range_ready"].includes(current.status)
|| !current.currentQuestion) throw new RectificationV4StoreError("invalid_state");
const updated: RectificationV4Case = {
...current,
version: current.version + 1,
currentQuestion: {
...input.question,
domain: current.currentQuestion.domain,
targetEventId: current.currentQuestion.targetEventId,
},
updatedAt: input.now,
};
cases.set(current.id, updated);
actionResults.set(key, { caseId: current.id, jobId: null });
return updated;
},
async submitAnswer(input) {
const key = `${input.userId}:${input.actionId}`;
const replay = actionResults.get(key);
@@ -45,7 +45,16 @@ export interface RectificationV4Store {
loadCase(userId: string, caseId: string): Promise<RectificationV4Case | null>;
loadEvents(userId: string, caseId: string): Promise<readonly LifeEventRevision[]>;
loadTurns(userId: string, caseId: string): Promise<readonly RectificationV4Turn[]>;
loadLatestValidatedDecision(userId: string, caseId: string): Promise<ValidatedDecision | null>;
createCase(input: { readonly case: RectificationV4Case; readonly actionId: string }): Promise<RectificationV4Case>;
replaceCurrentQuestion(input: {
readonly userId: string;
readonly caseId: string;
readonly actionId: string;
readonly expectedCaseVersion: number;
readonly question: RectificationV4Question;
readonly now: string;
}): Promise<RectificationV4Case>;
submitAnswer(input: {
readonly userId: string;
readonly caseId: string;
@@ -1,4 +1,5 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { validatedDecisionSchema, type ValidatedDecision } from "../rectification-agent/contracts.ts";
import {
candidateSnapshotSchema,
lifeEventRevisionSchema,
@@ -199,6 +200,14 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
loadCase: loadCaseById,
loadEvents: loadEventsByCase,
loadTurns: loadTurnsByCase,
async loadLatestValidatedDecision(userId, caseId): Promise<ValidatedDecision | null> {
const { data, error } = await supabase.from("birth_time_rectification_agent_runs")
.select("validated_decision_json").eq("case_id", caseId).eq("user_id", userId)
.order("case_version", { ascending: false }).order("created_at", { ascending: false })
.limit(1).maybeSingle();
if (error) throw storeError(error);
return data ? validatedDecisionSchema.parse((data as Row).validated_decision_json) : null;
},
async createCase(input) {
const id = String(await rpc("create_birth_time_rectification_v5_case", {
p_user_id: input.case.userId,
@@ -222,6 +231,19 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
if (!value) throw new RectificationV4StoreError("not_found");
return value;
},
async replaceCurrentQuestion(input) {
const id = String(await rpc("replace_birth_time_rectification_v4_current_question", {
p_user_id: input.userId,
p_case_id: input.caseId,
p_action_id: input.actionId,
p_expected_version: input.expectedCaseVersion,
p_question: input.question,
p_now: input.now,
}));
const value = await loadCaseById(input.userId, id);
if (!value) throw new RectificationV4StoreError("not_found");
return value;
},
async submitAnswer(input) {
const jobId = String(await rpc("submit_birth_time_rectification_v4_answer", {
p_user_id: input.userId,
@@ -0,0 +1,80 @@
begin;
create or replace function public.replace_birth_time_rectification_v4_current_question(
p_user_id uuid,
p_case_id uuid,
p_action_id uuid,
p_expected_version bigint,
p_question jsonb,
p_now timestamptz
) returns uuid
language plpgsql security definer set search_path = '' as $$
declare
v_case public.birth_time_rectification_v4_cases%rowtype;
v_case_id uuid;
v_question jsonb;
begin
select action.case_id into v_case_id
from public.birth_time_rectification_v4_actions action
where action.user_id = p_user_id and action.action_id = p_action_id;
if v_case_id is not null then return v_case_id; end if;
select value.* into v_case
from public.birth_time_rectification_v4_cases value
where value.id = p_case_id and value.user_id = p_user_id
for update;
if not found then raise exception 'rectification_v4_case_not_found'; end if;
select action.case_id into v_case_id
from public.birth_time_rectification_v4_actions action
where action.user_id = p_user_id and action.action_id = p_action_id;
if v_case_id is not null then return v_case_id; end if;
if v_case.version <> p_expected_version then raise exception 'stale_rectification_v4_case'; end if;
if v_case.deployment_mode <> 'v5_agent'
or v_case.status not in ('awaiting_answer', 'range_ready')
or v_case.current_question is null then
raise exception 'rectification_v4_question_not_regenerable';
end if;
if p_question is null or pg_catalog.jsonb_typeof(p_question) <> 'object' then
raise exception 'invalid_rectification_v4_question';
end if;
if nullif(pg_catalog.btrim(p_question->>'id'), '') is null
or (p_question->>'id') !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
or nullif(pg_catalog.btrim(p_question->>'prompt'), '') is null
or pg_catalog.length(pg_catalog.btrim(p_question->>'prompt')) > 1000
or p_question->>'recallCost' not in ('low', 'medium', 'high')
or nullif(pg_catalog.btrim(p_question->>'reason'), '') is null
or pg_catalog.length(pg_catalog.btrim(p_question->>'reason')) > 240 then
raise exception 'invalid_rectification_v4_question';
end if;
v_question := p_question || pg_catalog.jsonb_build_object(
'domain', v_case.current_question->'domain',
'targetEventId', v_case.current_question->'targetEventId'
);
update public.birth_time_rectification_v4_cases
set version = p_expected_version + 1,
current_question = v_question,
updated_at = p_now
where id = p_case_id;
insert into public.birth_time_rectification_v4_actions(
user_id, action_id, case_id, created_at
) values (
p_user_id, p_action_id, p_case_id, p_now
);
return p_case_id;
end;
$$;
revoke all on function public.replace_birth_time_rectification_v4_current_question(
uuid, uuid, uuid, bigint, jsonb, timestamptz
) from public, anon, authenticated, service_role;
grant execute on function public.replace_birth_time_rectification_v4_current_question(
uuid, uuid, uuid, bigint, jsonb, timestamptz
) to service_role;
commit;
@@ -1,7 +1,11 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { rectificationV4ChatMessages } from "../src/components/rectification-v4-panel.tsx";
import {
canRegenerateRectificationMessage,
rectificationV4ChatMessages,
toggleRectificationFeedback,
} from "../src/components/rectification-v4-panel.tsx";
import type { RectificationV4ApiResponse } from "../src/lib/rectification-v4/contracts.ts";
const id = "00000000-0000-4000-8000-000000000901";
@@ -87,6 +91,11 @@ test("v4 rectification reuses the ordinary session message list, composer, and m
assert.match(component, /className="composer"/);
assert.match(component, /<Textarea/);
assert.match(component, /<ModelSelector/);
assert.match(component, /aria-label="赞"/);
assert.match(component, /aria-label="踩"/);
assert.match(component, /aria-label="重新生成回答"/);
assert.match(component, /caseValue\?\.deploymentMode === "v5_agent"/);
assert.match(component, /controller\.regenerate\(\)/);
assert.match(component, /controller\.answer\(answer, props\.selectedModelId \|\| null\)/);
assert.match(wrapper, /<RectificationV4Panel \{\.\.\.props\} \/>/);
assert.match(page, /\{!rectificationSurfaceOpen && \(\s*<div className=\{`conversation/);
@@ -96,6 +105,42 @@ test("v4 rectification reuses the ordinary session message list, composer, and m
assert.doesNotMatch(css, /\.rectification-chat/);
});
test("V5 Agent feedback is mutually exclusive and regenerate is limited to the current settled assistant question", () => {
assert.equal(toggleRectificationFeedback(undefined, "up"), "up");
assert.equal(toggleRectificationFeedback("up", "up"), undefined);
assert.equal(toggleRectificationFeedback("up", "down"), "down");
const message = rectificationV4ChatMessages(response(), false).at(-1)!;
assert.equal(canRegenerateRectificationMessage({
message,
currentMessageKey: `rectification-current-${questionId}`,
deploymentMode: "v5_agent",
busy: false,
canAnswer: true,
}), true);
assert.equal(canRegenerateRectificationMessage({
message,
currentMessageKey: message.renderKey,
deploymentMode: "v5_shadow",
busy: false,
canAnswer: true,
}), false);
assert.equal(canRegenerateRectificationMessage({
message,
currentMessageKey: message.renderKey,
deploymentMode: "v5_agent",
busy: true,
canAnswer: true,
}), false);
assert.equal(canRegenerateRectificationMessage({
message: { ...message, role: "user" },
currentMessageKey: message.renderKey,
deploymentMode: "v5_agent",
busy: false,
canAnswer: true,
}), false);
});
test("turn history and the context-aware next question render as one chat timeline", () => {
const messages = rectificationV4ChatMessages(response(), false);
@@ -77,6 +77,7 @@ test("the v4 rectification chat renders persisted turns as alternating message h
assert.match(source, /for \(const turn of data\.turns\)/);
assert.match(source, /role: "assistant"[\s\S]*?text: turn\.question/);
assert.match(source, /role: "user"[\s\S]*?text: turn\.answer/);
assert.match(source, /messages\.map\(\(message\) => <ChatMessageRow/);
assert.match(source, /messages\.map\(\(message\) => \{/);
assert.match(source, /<ChatMessageRow message=/);
assert.doesNotMatch(source, /rectification-progress-details|evidenceRecap\.map/);
});
@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
const sql = readFileSync(new URL("../supabase/migrations/20260726020000_birth_time_rectification_v4.sql", import.meta.url), "utf8");
const conversationalSql = readFileSync(new URL("../supabase/migrations/20260727010000_rectification_v4_conversational_turns.sql", import.meta.url), "utf8");
const regenerationSql = readFileSync(new URL("../supabase/migrations/20260729020000_rectification_v4_current_question_regeneration.sql", import.meta.url), "utf8");
test("v4 migration creates canonical append-only storage and leased jobs", () => {
for (const table of [
@@ -44,3 +45,16 @@ test("v4 handoff SQL enforces range acceptance, leases, idempotent settlement, a
assert.match(sql, /birth_time_rectification_v4_handoff_settlements/i);
assert.doesNotMatch(sql, /update\s+public\.profiles[\s\S]*active_birth_time/i);
});
test("current-question regeneration is service-role-only, atomic, idempotent, and preserves the semantic target", () => {
assert.match(regenerationSql, /create or replace function public\.replace_birth_time_rectification_v4_current_question/i);
assert.match(regenerationSql, /where value\.id = p_case_id and value\.user_id = p_user_id[\s\S]*for update/i);
assert.match(regenerationSql, /where action\.user_id = p_user_id and action\.action_id = p_action_id[\s\S]*return v_case_id/i);
assert.match(regenerationSql, /v_case\.version <> p_expected_version[\s\S]*stale_rectification_v4_case/i);
assert.match(regenerationSql, /v_case\.deployment_mode <> 'v5_agent'/i);
assert.match(regenerationSql, /'domain', v_case\.current_question->'domain'[\s\S]*'targetEventId', v_case\.current_question->'targetEventId'/i);
assert.match(regenerationSql, /grant execute on function public\.replace_birth_time_rectification_v4_current_question\([\s\S]*to service_role/i);
assert.doesNotMatch(regenerationSql, /insert into public\.birth_time_rectification_v4_(?:turns|events|jobs|candidate_snapshots)/i);
assert.doesNotMatch(regenerationSql, /update\s+public\.profiles/i);
});
@@ -148,3 +148,72 @@ test("legacy cases are not hard-switched to V5 even when flags change later", as
assert.equal(run.fallbackReason, "deployment_mode_legacy");
});
});
test("V5 Agent regenerate rewrites only the current semantic question and replays the same action once", async () => withMode("v5_agent", async () => {
const store = createRectificationV4MemoryStore();
const service = createRectificationV4CaseService(store, { now: fixedNow });
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const queued = await service.answer({
userId,
caseId: created.case.id,
actionId: randomUUID(),
expectedCaseVersion: created.case.version,
answer: "2020年4月去石油化工研究院实习做研究员",
});
assert.ok(queued?.job);
const worker = createRectificationV4Worker({
store,
now: fixedNow,
engine: { async score() { throw new Error("engine must not run before enough events"); } },
});
assert.equal(await worker.runOnce(), true);
const before = await service.loadCase(userId, created.case.id);
assert.ok(before?.case.currentQuestion);
const actionId = randomUUID();
const regenerated = await service.regenerateQuestion({
userId,
caseId: created.case.id,
actionId,
expectedCaseVersion: before.case.version,
});
assert.ok(regenerated?.case.currentQuestion);
assert.equal(regenerated.case.version, before.case.version + 1);
assert.notEqual(regenerated.case.currentQuestion.id, before.case.currentQuestion.id);
assert.equal(regenerated.case.currentQuestion.domain, before.case.currentQuestion.domain);
assert.equal(regenerated.case.currentQuestion.targetEventId, before.case.currentQuestion.targetEventId);
assert.equal(regenerated.case.evidenceSetHash, before.case.evidenceSetHash);
assert.deepEqual(regenerated.events, before.events);
assert.deepEqual(regenerated.turns, before.turns);
assert.deepEqual(regenerated.case.latestSnapshot, before.case.latestSnapshot);
assert.equal(store.jobs.size, 1);
const replayed = await service.regenerateQuestion({
userId,
caseId: created.case.id,
actionId,
expectedCaseVersion: before.case.version,
});
assert.equal(replayed?.case.version, regenerated.case.version);
assert.equal(replayed?.case.currentQuestion?.id, regenerated.case.currentQuestion.id);
assert.equal(store.jobs.size, 1);
assert.equal(regenerated.case.latestSnapshot?.canConfirmExactMinute ?? false, false);
}));
test("legacy and shadow cases cannot call the V5 Agent question renderer", async () => {
for (const mode of ["v4_legacy", "v5_shadow"] as const) {
await withMode(mode, async () => {
const store = createRectificationV4MemoryStore();
const service = createRectificationV4CaseService(store, { now: fixedNow });
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
assert.equal(await service.regenerateQuestion({
userId,
caseId: created.case.id,
actionId: randomUUID(),
expectedCaseVersion: created.case.version,
}), null);
});
}
});