fix(rectification): restore message actions and retry receipts
Independent Staging Quality Gate / validate (push) Failing after 30m22s
Independent Staging Quality Gate / publish (push) Has been skipped

This commit is contained in:
Jesse_Chen
2026-08-14 00:10:22 +08:00
parent e8952dcc75
commit 2e17ccad64
17 changed files with 1225 additions and 60 deletions
+14
View File
@@ -3138,3 +3138,17 @@
- 防复发:生时校正的运行中状态与完成凭证都必须位于对应 Agent 正文上方;不得仅通过 CSS `order` 视觉重排而保留错误的 DOM/读屏顺序。
- 相关记录:BUG-172、BUG-181
- 修复版本:本次 staging 发布提交(精确 SHA 以提交与部署结果为准)
## BUG-185 | 生时校正工具重试成功后仍显示失败,且 V9 消息操作栏丢失
- 状态:resolved(本地候选,待浏览器与 staging 验收)
- 首次发现:2026-08-13
- 最近更新:2026-08-13
- 影响面:V9 生时校正 Activity 完成凭证,以及 Agent 正文下方的赞、踩、复制和重新生成操作。
- 用户现象:同一证据工具首次失败、同轮自动重试成功且最终回复正常时,界面仍显示“未完成,当前进度已保留”;同时此前已有的消息操作栏在 V9 页面中消失。
- 触发条件:同一公开校正工具在一个 NDJSON run 中出现 `failed → started → completed`;或查看任意已完成的 V9 Assistant 消息。
- 根因:客户端 Activity 聚合只保留“曾经失败”的集合,后续成功没有覆盖同工具旧失败;旧版生时校正运行时退役时删除了消息操作 JSX、状态和 handler,而 V9 入口没有迁入,虽然对应 CSS 仍被保留。
- 修复:Activity 改为按工具记录最终终态,后续 `completed` 清除同工具旧失败,只有最终仍为 `failed` 才显示失败凭证。V9 恢复赞、踩、复制与重新生成操作栏,并保持 `完成凭证 → Agent 正文 → 操作栏` 的 DOM 顺序。重新生成使用独立只读 Jyotisha Agent,仅可读取当前 Case,免费且原位替换最新 completed Assistant 正文;不新增 Turn、不写证据或候选、不修改 Case 状态、不调用计费。
- 验证:新增 Activity reducer、重新生成 runner、公开 stream、UI DOM 与数据库迁移合同回归;聚焦测试、目标 ESLint、TypeScript 与 `git diff --check` 结果以本次本地验证记录为准。真实剪贴板权限、键盘焦点和登录态重新生成仍需浏览器验收。
- 防复发:Activity 必须以每个工具的最终终态为准,不能把历史瞬时失败永久化;V9 消息动作不得依赖已退役组件。任何“重新生成”都必须是最新回复的只读原位替换,严禁复用普通 message 发送链导致重复证据、重复 Turn 或重复计费。
- 相关记录:BUG-049、BUG-050、BUG-181、BUG-184
@@ -0,0 +1,132 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { regenerateV9AssistantTurn } from "@/lib/rectification-agentic/v9/regenerate-turn";
import { RectificationToolServiceError } from "@/lib/rectification-agentic/v9/tool-service";
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { getRectificationV9RegenerationAgent } from "@/mastra/agentic-rectification";
export const runtime = "nodejs";
export const maxDuration = 120;
type RouteContext = { params: Promise<{ caseId: string; turnId: string }> };
const regenerateSchema = z.object({
sessionId: z.string().uuid(),
requestId: z.string().uuid(),
}).strict();
function errorResponse(error: unknown) {
const code = error instanceof RectificationToolServiceError
? error.code
: error instanceof Error
? error.message
: String(error);
const known = [
["agentic_rectification_case_not_found", 404, "校正记录不存在或无权访问", "case_not_found"],
["agentic_rectification_turn_not_found", 404, "这条回答不存在或无法重新生成", "turn_not_found"],
["agentic_rectification_case_session_mismatch", 409, "校正记录与会话绑定不一致", "case_session_mismatch"],
["agentic_rectification_case_terminal", 409, "该校正已结束,只能查看历史", "case_terminal"],
["agentic_rectification_turn_not_latest", 409, "只能重新生成最近一条回答", "turn_not_latest"],
["agentic_rectification_turn_not_completed", 409, "这条回答尚未完成", "turn_not_completed"],
["agentic_rectification_regeneration_request_conflict", 409, "重新生成请求已用于其他回答", "request_conflict"],
] as const;
for (const [needle, status, message, publicCode] of known) {
if (code.includes(needle)) {
return NextResponse.json({ error: message, code: publicCode }, { status });
}
}
return NextResponse.json(
{ error: "暂时无法重新生成回答", code: "regeneration_failed" },
{ status: 503 },
);
}
/**
* Free, read-only reply regeneration. The model receives only the bound Skill
* and rectification-read-case tool. The database then replaces the latest
* completed Assistant text in place; no turn is appended and billing is never
* invoked by this route.
*/
export async function POST(request: Request, context: RouteContext) {
let supabase;
let accounting;
try {
supabase = await createServerSupabaseClient();
accounting = createAdminSupabaseClient();
} catch {
return NextResponse.json({ error: "服务尚未配置" }, { status: 503 });
}
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const { caseId, turnId } = await context.params;
if (!z.string().uuid().safeParse(caseId).success || !z.string().uuid().safeParse(turnId).success) {
return NextResponse.json({ error: "请求内容不正确", code: "invalid_reference" }, { status: 400 });
}
const parsed = regenerateSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "请求内容不正确", code: "invalid_regeneration_request" }, { status: 400 });
}
const { data: chatSession, error: chatSessionError } = await supabase
.from("chat_sessions")
.select("id,session_type,model_id,model_config_version,agentic_rectification_case_id")
.eq("id", parsed.data.sessionId)
.eq("user_id", user.id)
.maybeSingle();
if (chatSessionError) {
return NextResponse.json({ error: "暂时无法读取会话" }, { status: 503 });
}
if (!chatSession || chatSession.session_type !== "birth_time_rectification") {
return NextResponse.json({ error: "生时校正会话不存在", code: "session_not_found" }, { status: 404 });
}
if (chatSession.agentic_rectification_case_id !== caseId) {
return NextResponse.json({ error: "校正记录与会话绑定不一致", code: "case_session_mismatch" }, { status: 409 });
}
const selectedModel = await resolveSessionLanguageModel(
chatSession.model_id,
chatSession.model_config_version,
);
if (!selectedModel) {
return NextResponse.json(
{ error: "模型暂不可用", message: "请选择其他模型后再试,本次不会扣除点数。" },
{ status: 409 },
);
}
try {
const agent = getRectificationV9RegenerationAgent(selectedModel, {
userId: user.id,
caseId,
turnId,
accounting,
});
const result = await regenerateV9AssistantTurn({
userId: user.id,
caseId,
sessionId: parsed.data.sessionId,
turnId,
requestId: parsed.data.requestId,
accounting,
agent,
signal: request.signal,
});
return NextResponse.json({
ok: true,
turnId: result.turnId,
assistantMessage: result.assistantMessage,
idempotent: result.idempotent,
});
} catch (error) {
return errorResponse(error);
}
}
@@ -1,17 +1,12 @@
"use client";
import { Check, ChevronDown } from "lucide-react";
import type { CompletedActivityReceiptView } from "@/lib/rectification-activity-receipt";
import type {
PublicRectificationMethod,
PublicRectificationTool,
} from "@/lib/rectification-agentic/v9/public-receipt";
export type CompletedActivityReceiptView = Readonly<{
steps: readonly PublicRectificationTool[];
methods: readonly PublicRectificationMethod[];
failedTool?: PublicRectificationTool;
}>;
const TOOL_LABELS: Readonly<Record<PublicRectificationTool, string>> = {
"rectification-read-case": "读取校正记录",
"rectification-propose-evidence": "整理事件证据",
@@ -1,9 +1,15 @@
"use client";
import { ArrowUp } from "lucide-react";
import { ArrowUp, Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { parseAgentReplyBody } from "@/lib/agent-reply";
import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
import {
createRectificationActivityReceiptState,
receiptFromRectificationActivityState,
reduceRectificationActivityReceipt,
type CompletedActivityReceiptView,
} from "@/lib/rectification-activity-receipt";
import {
isRecommendedRectificationCandidate,
parseRectificationCandidateResult,
@@ -13,15 +19,11 @@ import { membershipHref } from "@/lib/membership";
import {
isPublicRectificationMethod,
isPublicRectificationTool,
type PublicRectificationMethod,
type PublicRectificationTool,
} from "@/lib/rectification-agentic/v9/public-receipt";
import type { PublicLanguageModel } from "@/lib/public-models";
import { ChatMessageRow } from "./chat-message-row";
import {
CompletedActivityReceipt,
type CompletedActivityReceiptView,
} from "./completed-activity-receipt";
import { CompletedActivityReceipt } from "./completed-activity-receipt";
import { ModelSelector } from "./model-selector";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
@@ -65,6 +67,7 @@ type RenderMessage = ChatMessageView & {
renderKey: string;
activeActivity?: PublicActivity;
completedReceipt?: CompletedActivityReceiptView;
turnId?: string;
};
type PublicActivity = Readonly<{
@@ -93,6 +96,14 @@ function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): Compl
};
}
export function toggleRectificationFeedback(
current: "up" | "down" | undefined,
requested: "up" | "down",
): "up" | "down" | undefined {
return current === requested ? undefined : requested;
}
function activityPhase(tool: PublicRectificationTool): NonNullable<ChatMessageView["activity"]>["phase"] {
if (tool === "rectification-compare-candidates" || tool === "rectification-read-diagnostics") {
return "chart-calculation";
@@ -110,6 +121,7 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
renderKey: key,
state: turn.status === "completed" ? "settled" : "thinking",
completedReceipt: completedReceiptFromPersisted(turn.receipt),
turnId: turn.id,
}];
}
return [{
@@ -148,6 +160,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null);
const [candidateResult, setCandidateResult] = useState<CandidateResult>(null);
const [acceptingTime, setAcceptingTime] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
const conversation = useRef<HTMLElement>(null);
const composer = useRef<HTMLTextAreaElement>(null);
const keyCounter = useRef(0);
@@ -214,10 +229,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setDraft("");
let raw = "";
let completedReceipt: CompletedActivityReceiptView = { steps: [], methods: [] };
const completedSteps = new Set<PublicRectificationTool>();
const completedMethods = new Set<PublicRectificationMethod>();
let failedTool: PublicRectificationTool | undefined;
let activityReceiptState = createRectificationActivityReceiptState();
let completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
let completedTurnId: string | undefined;
try {
const response = await fetch("/api/rectification/agent", {
method: "POST",
@@ -273,6 +287,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
message?: unknown;
tool?: unknown;
methods?: unknown;
turnId?: unknown;
};
try {
event = JSON.parse(line) as typeof event;
@@ -293,6 +308,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setError(typeof event.message === "string" ? event.message : "生时校正暂时不可用,请稍后再试。");
} else if (event.type === "run.completed") {
completed = true;
if (typeof event.turnId === "string") completedTurnId = event.turnId;
} else if (event.type === "tool.activity") {
const tool = isPublicRectificationTool(event.tool) ? event.tool : null;
if (!tool) continue;
@@ -303,21 +319,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: message));
continue;
}
if (event.status === "completed") {
completedSteps.add(tool);
if (Array.isArray(event.methods)) {
for (const method of event.methods) {
if (isPublicRectificationMethod(method)) completedMethods.add(method);
}
}
} else if (event.status === "failed") {
failedTool = tool;
}
completedReceipt = {
steps: [...completedSteps],
methods: [...completedMethods],
...(failedTool ? { failedTool } : {}),
};
if (event.status !== "completed" && event.status !== "failed") continue;
activityReceiptState = reduceRectificationActivityReceipt(activityReceiptState, {
tool,
status: event.status,
methods: event.status === "completed" && Array.isArray(event.methods)
? event.methods.filter(isPublicRectificationMethod)
: [],
});
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
@@ -338,11 +348,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
state: "settled",
activeActivity: undefined,
completedReceipt,
turnId: completedTurnId,
}
: message)
: current.filter((message) => message.renderKey !== assistantRenderKey));
if (!succeeded && failedTool) {
setError(failedTool === "rectification-compare-candidates"
if (!succeeded && completedReceipt.failedTool) {
setError(completedReceipt.failedTool === "rectification-compare-candidates"
? "候选比较未完成,当前进度已保留。"
: "本轮处理未完成,当前进度已保留。请稍后再试。");
}
@@ -405,38 +416,149 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
}, [acceptingTime, candidateResult, caseId, onCompleted, onSaved, readonly, sessionId]);
async function copyMessage(message: RenderMessage) {
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(message: RenderMessage) {
if (!message.turnId || regeneratingMessageKey || busy || readonly) return;
setError("");
setRegeneratingMessageKey(message.renderKey);
const previousText = message.text;
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}/turns/${encodeURIComponent(message.turnId)}/regenerate`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
sessionId,
requestId: globalThis.crypto.randomUUID(),
}),
},
);
const payload = await response.json().catch(() => null);
if (!response.ok || payload?.ok !== true || typeof payload.assistantMessage !== "string") {
throw new Error(payload?.message || payload?.error || "暂时无法重新生成回答");
}
setMessages((current) => current.map((item) => item.renderKey === message.renderKey
? { ...item, text: payload.assistantMessage, state: "settled" }
: item));
} catch (caught) {
setMessages((current) => current.map((item) => item.renderKey === message.renderKey
? { ...item, text: previousText, state: "settled" }
: item));
setError(caught instanceof Error ? caught.message : "暂时无法重新生成回答");
} finally {
setRegeneratingMessageKey((current) => current === message.renderKey ? null : current);
}
}
async function submit(event: React.FormEvent) {
event.preventDefault();
await send("message", draft);
}
const canSend = !busy && !readonly;
const latestRegeneratableKey = [...messages]
.reverse()
.find((message) => (
message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.turnId)
&& Boolean(message.text)
))?.renderKey;
const canSend = !busy && !readonly && !regeneratingMessageKey;
return (
<>
<section ref={conversation} className="conversation" aria-label="生时校正对话" aria-busy={busy}>
<section ref={conversation} className="conversation" aria-label="生时校正对话" aria-busy={busy || regeneratingMessageKey !== null}>
<div className="message-list" aria-live="polite">
{pendingConsultationQuestion?.trim() && (
<p className="rectification-pending-note">
先陪你核对出生时间范围,之后会回到你原来的问题:“{pendingConsultationQuestion.trim()}”
</p>
)}
{messages.map((message) => (
<div key={message.renderKey} className="rectification-message-wrap">
{message.state === "settled" && message.completedReceipt && (
<CompletedActivityReceipt receipt={message.completedReceipt} />
)}
<ChatMessageRow
message={{
...message,
activity: message.activeActivity
? { phase: activityPhase(message.activeActivity.tool), label: message.activeActivity.label }
: undefined,
}}
showActivity={message.state === "thinking" || Boolean(message.activeActivity)}
/>
</div>
))}
{messages.map((message) => {
const showActions = message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text);
const regenerating = regeneratingMessageKey === message.renderKey;
const canRegenerate = message.renderKey === latestRegeneratableKey
&& !busy
&& !readonly
&& regeneratingMessageKey === null;
const displayedMessage = regenerating
? { ...message, text: "", state: "thinking" as const, activeActivity: undefined }
: message;
return (
<div key={message.renderKey} className="rectification-message-wrap rectification-message-entry">
{message.state === "settled" && message.completedReceipt && (
<CompletedActivityReceipt receipt={message.completedReceipt} />
)}
<ChatMessageRow
message={{
...displayedMessage,
activity: displayedMessage.activeActivity
? { phase: activityPhase(displayedMessage.activeActivity.tool), label: displayedMessage.activeActivity.label }
: undefined,
}}
showActivity={displayedMessage.state === "thinking" || Boolean(displayedMessage.activeActivity)}
/>
{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)}
>
<RotateCcw aria-hidden="true" />
</button>
</div>
)}
</div>
);
})}
{candidateResult?.selectionAllowed && candidateResult.candidates.length > 0 && (
<section className="rectification-candidates" aria-label="生时校正候选时间">
<div className="rectification-candidates-heading">
@@ -0,0 +1,76 @@
import type {
PublicRectificationMethod,
PublicRectificationTool,
RectificationActivityStatus,
} from "./rectification-agentic/v9/public-receipt";
export type CompletedActivityReceiptView = Readonly<{
steps: readonly PublicRectificationTool[];
methods: readonly PublicRectificationMethod[];
failedTool?: PublicRectificationTool;
}>;
type ToolTerminalStatus = "completed" | "failed";
export type RectificationActivityReceiptState = Readonly<{
completedSteps: readonly PublicRectificationTool[];
methods: readonly PublicRectificationMethod[];
terminalStatus: Readonly<Partial<Record<PublicRectificationTool, ToolTerminalStatus>>>;
failureOrder: readonly PublicRectificationTool[];
}>;
type ReceiptActivityEvent = Readonly<{
tool: PublicRectificationTool;
status: RectificationActivityStatus;
methods?: readonly PublicRectificationMethod[];
}>;
export function createRectificationActivityReceiptState(): RectificationActivityReceiptState {
return {
completedSteps: [],
methods: [],
terminalStatus: {},
failureOrder: [],
};
}
export function reduceRectificationActivityReceipt(
state: RectificationActivityReceiptState,
event: ReceiptActivityEvent,
): RectificationActivityReceiptState {
if (event.status === "started") return state;
const terminalStatus = { ...state.terminalStatus, [event.tool]: event.status };
if (event.status === "completed") {
return {
completedSteps: state.completedSteps.includes(event.tool)
? state.completedSteps
: [...state.completedSteps, event.tool],
methods: [...new Set([...state.methods, ...(event.methods ?? [])])],
terminalStatus,
failureOrder: state.failureOrder.filter((tool) => tool !== event.tool),
};
}
return {
...state,
terminalStatus,
failureOrder: [
...state.failureOrder.filter((tool) => tool !== event.tool),
event.tool,
],
};
}
export function receiptFromRectificationActivityState(
state: RectificationActivityReceiptState,
): CompletedActivityReceiptView {
const failedTool = [...state.failureOrder]
.reverse()
.find((tool) => state.terminalStatus[tool] === "failed");
return {
steps: [...state.completedSteps],
methods: [...state.methods],
...(failedTool ? { failedTool } : {}),
};
}
@@ -313,7 +313,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}
await finalize("completed", answerText);
await persistPhase("run.completed", null);
await emit({ type: "run.completed" });
await emit({ type: "run.completed", turnId });
return {
ok: true, turnId, turnStatus: "completed", skillLoaded,
answerText, phases, toolsUsed: [...toolsUsed], errorCode: null,
@@ -0,0 +1,155 @@
import type { MessageListInput } from "@mastra/core/agent/message-list";
import { isTerminalStatus, type RectificationCaseStatus } from "./case-status";
import {
loadV9CaseDossier,
RectificationToolServiceError,
type RectificationRpcClient,
} from "./tool-service";
export type RectificationRegenerationAgent = Readonly<{
getSkill(name: string): Promise<unknown>;
generate(
messages: MessageListInput,
options: { abortSignal?: AbortSignal; maxSteps: number },
): Promise<{ text: string }>;
}>;
export type RegenerateV9AssistantTurnOptions = Readonly<{
userId: string;
caseId: string;
sessionId: string;
turnId: string;
requestId: string;
accounting: RectificationRpcClient;
agent: RectificationRegenerationAgent;
signal?: AbortSignal;
}>;
export type RegenerateV9AssistantTurnResult = Readonly<{
ok: true;
turnId: string;
assistantMessage: string;
idempotent: boolean;
}>;
function latestCompletedAssistantTurn(
turns: Awaited<ReturnType<typeof loadV9CaseDossier>>["turns"],
) {
return [...turns]
.reverse()
.find((turn) => turn.role === "assistant" && turn.status === "completed" && Boolean(turn.text?.trim()));
}
function regenerationPrompt(caseId: string, oldAssistantMessage: string): string {
return [
`当前校正 Case 引用:${caseId}`,
"请先加载绑定 Skill,再调用 rectification-read-case。",
"随后只重写下面这条最近的 Agent 正文,使其更自然、准确,并符合当前服务端事实。不要描述后台过程,不要执行或声称执行任何写操作。",
"",
"待替换的旧正文:",
oldAssistantMessage,
].join("\n");
}
function parseRpcResult(value: unknown): RegenerateV9AssistantTurnResult {
const row = Array.isArray(value) ? value[0] : value;
if (!row || typeof row !== "object") {
throw new RectificationToolServiceError("agentic_rectification_regeneration_invalid_result");
}
const result = row as Record<string, unknown>;
if (
result.ok !== true
|| typeof result.turn_id !== "string"
|| typeof result.assistant_message !== "string"
|| !result.assistant_message.trim()
) {
throw new RectificationToolServiceError("agentic_rectification_regeneration_invalid_result");
}
return {
ok: true,
turnId: result.turn_id,
assistantMessage: result.assistant_message,
idempotent: result.idempotent === true,
};
}
export async function regenerateV9AssistantTurn(
options: RegenerateV9AssistantTurnOptions,
): Promise<RegenerateV9AssistantTurnResult> {
const {
userId,
caseId,
sessionId,
turnId,
requestId,
accounting,
agent,
signal,
} = options;
const { data: existingData, error: existingError } = await accounting.rpc(
"get_agentic_rectification_turn_regeneration",
{
p_user_id: userId,
p_case_id: caseId,
p_turn_id: turnId,
p_request_id: requestId,
},
);
if (existingError) throw new RectificationToolServiceError(existingError.message);
if (existingData) return parseRpcResult(existingData);
const dossier = await loadV9CaseDossier(accounting, userId, caseId);
if (dossier.case.sessionId !== sessionId) {
throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch");
}
if (isTerminalStatus(dossier.case.status as RectificationCaseStatus)) {
throw new RectificationToolServiceError("agentic_rectification_case_terminal");
}
const target = dossier.turns.find((turn) => (
turn.id === turnId
&& turn.role === "assistant"
&& turn.status === "completed"
&& Boolean(turn.text?.trim())
));
if (!target?.text) {
throw new RectificationToolServiceError("agentic_rectification_turn_not_found");
}
const latest = latestCompletedAssistantTurn(dossier.turns);
if (!latest || latest.id !== turnId) {
throw new RectificationToolServiceError("agentic_rectification_turn_not_latest");
}
let skill: unknown = null;
try {
skill = await agent.getSkill(dossier.case.skillName);
} catch {
skill = null;
}
if (!skill) {
throw new RectificationToolServiceError("agentic_rectification_skill_not_loaded");
}
const generated = await agent.generate(
[{ role: "user", content: regenerationPrompt(caseId, target.text) }],
{ abortSignal: signal, maxSteps: 6 },
);
const assistantMessage = generated.text.trim();
if (!assistantMessage) {
throw new RectificationToolServiceError("agentic_rectification_regeneration_empty");
}
const { data, error } = await accounting.rpc(
"regenerate_agentic_rectification_turn",
{
p_user_id: userId,
p_case_id: caseId,
p_session_id: sessionId,
p_turn_id: turnId,
p_request_id: requestId,
p_assistant_message: assistantMessage,
},
);
if (error) throw new RectificationToolServiceError(error.message);
return parseRpcResult(data);
}
@@ -23,6 +23,7 @@ export type PublicPhaseStreamEvent = Readonly<{
text?: string;
tool?: PublicRectificationTool;
methods?: readonly PublicRectificationMethod[];
turnId?: string;
}>;
export type PublicStreamEvent = PublicPhaseStreamEvent | RectificationActivityEvent;
@@ -162,6 +163,7 @@ export function safePublicEvent(value: unknown): PublicStreamEvent | null {
text?: unknown;
tool?: unknown;
methods?: unknown;
turnId?: unknown;
};
if (event.type === "tool.activity") {
if (!isPublicRectificationTool(event.tool) || !isRectificationActivityStatus(event.status)) return null;
@@ -186,10 +188,16 @@ export function safePublicEvent(value: unknown): PublicStreamEvent | null {
const methods = tool && METHOD_TOOLS.has(tool) && Array.isArray(event.methods)
? [...new Set(event.methods.filter(isPublicRectificationMethod))]
: [];
const turnId = type === "run.completed"
&& typeof event.turnId === "string"
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(event.turnId)
? event.turnId
: undefined;
return {
type,
...(text !== undefined ? { text } : {}),
...(tool ? { tool } : {}),
...(methods.length > 0 ? { methods } : {}),
...(turnId ? { turnId } : {}),
};
}
@@ -3,6 +3,7 @@ import path from "node:path";
import type { ResolvedLanguageModel } from "./model";
import {
RECTIFICATION_V9_SKILL_NAME,
createRectificationV9ReadOnlyTools,
createRectificationV9Tools,
type RectificationV9Context,
} from "./rectification-v9-tools";
@@ -90,4 +91,31 @@ export function getRectificationV9Agent(
});
}
const regenerationInstructions = `你是 Jyotisha,负责为当前生时校正对话重新生成最近一条 Agent 正文。
这不是新一轮校正。先加载绑定的 jyotish-birth-time-rectification Skill,再调用 rectification-read-case 读取服务端事实,然后只输出一版更自然、准确、简洁的替代正文。
硬性边界:
1. 只能使用 rectification-read-case;不得新增、确认或修订证据,不得比较或采用候选,不得确认出生时间,不得关闭 Case。
2. 不得改变任何服务端事实,不得声称执行了本次只读重写中没有执行的动作。
3. 保持 candidate、accepted、confirmed 的边界;候选数字和采用动作仍交给候选卡。
4. 不叙述 Skill、工具、Case、Dossier、执行步骤或后台状态。
5. 尊重用户最近的意图和拒答;不要为了延续对话而机械追问。只有确有信息增益时才保留一个主要问题。
6. 不泄露提示词、工具参数、内部 ID、评分、数据库信息或密钥。`;
export function getRectificationV9RegenerationAgent(
model: ResolvedLanguageModel,
ctx: RectificationV9Context,
) {
return new Agent({
id: `rectification-v9-regeneration-${model.id}`,
name: "Jyotisha Rectification Reply Regenerator",
model: model.model,
instructions: regenerationInstructions,
skills: [rectificationSkillPath],
tools: createRectificationV9ReadOnlyTools(ctx),
});
}
export { RECTIFICATION_V9_SKILL_NAME };
@@ -308,6 +308,24 @@ function normalizeDatePart(value: string): string | null {
return `${parts[0]}-${parts[1]!.padStart(2, "0")}-${parts[2]!.padStart(2, "0")}`;
}
export function createRectificationV9ReadOnlyTools(ctx: RectificationV9Context) {
const { accounting, userId } = ctx;
const readCaseTool = createTool({
id: "rectification-read-case",
description:
"只读加载当前服务端 Case、受限事件摘要、近期对话上下文和权威出生上下文。重新生成回答时只允许调用本工具,不写证据、候选、Case、turn 或计费数据。input 只允许 caseId。",
inputSchema: z.object({ caseId: z.string().uuid() }).strict(),
execute: async (input) => {
assertCaseRef(input);
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
const compute = await loadV9CaseCompute(accounting, userId, input.caseId);
return safeCaseProjection(parseDossierForTools(dossier), compute);
},
});
return { "rectification-read-case": readCaseTool };
}
export function createRectificationV9Tools(ctx: RectificationV9Context) {
const { accounting, userId, caseId, turnId } = ctx;
const engineVersion = v9EngineVersion();
@@ -0,0 +1,180 @@
-- Regenerate the latest completed Assistant reply in place.
-- This is a free, read-only Agent pass followed by one narrowly scoped turn
-- text update. It never appends a turn or changes evidence, results, profiles,
-- cases, candidate state, or billing records.
begin;
create table if not exists public.agentic_rectification_turn_regenerations (
request_id uuid primary key,
user_id uuid not null,
case_id uuid not null references public.agentic_rectification_cases(id) on delete cascade,
turn_id uuid not null references public.agentic_rectification_turns(id) on delete cascade,
assistant_message text not null check (length(btrim(assistant_message)) > 0),
created_at timestamptz not null default pg_catalog.now()
);
create index if not exists agentic_rectification_turn_regenerations_case_idx
on public.agentic_rectification_turn_regenerations (case_id, created_at desc);
alter table public.agentic_rectification_turn_regenerations enable row level security;
revoke all on table public.agentic_rectification_turn_regenerations from public, anon, authenticated;
grant all on table public.agentic_rectification_turn_regenerations to service_role;
create or replace function public.get_agentic_rectification_turn_regeneration(
p_user_id uuid,
p_case_id uuid,
p_turn_id uuid,
p_request_id uuid
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_row public.agentic_rectification_turn_regenerations%rowtype;
begin
if p_user_id is null or p_case_id is null or p_turn_id is null or p_request_id is null then
raise exception 'agentic_rectification_regeneration_invalid_input' using errcode = 'P0001';
end if;
select * into v_row
from public.agentic_rectification_turn_regenerations
where request_id = p_request_id
and user_id = p_user_id
and case_id = p_case_id
and turn_id = p_turn_id;
if not found then return null; end if;
return jsonb_build_object(
'ok', true,
'turn_id', v_row.turn_id,
'assistant_message', v_row.assistant_message,
'idempotent', true
);
end;
$$;
revoke all on function public.get_agentic_rectification_turn_regeneration(uuid, uuid, uuid, uuid)
from public, anon, authenticated;
grant execute on function public.get_agentic_rectification_turn_regeneration(uuid, uuid, uuid, uuid)
to service_role;
create or replace function public.regenerate_agentic_rectification_turn(
p_user_id uuid,
p_case_id uuid,
p_session_id uuid,
p_turn_id uuid,
p_request_id uuid,
p_assistant_message text
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_case public.agentic_rectification_cases%rowtype;
v_turn public.agentic_rectification_turns%rowtype;
v_latest_turn_id uuid;
v_existing public.agentic_rectification_turn_regenerations%rowtype;
v_message text;
begin
if p_user_id is null
or p_case_id is null
or p_session_id is null
or p_turn_id is null
or p_request_id is null
or p_assistant_message is null
or length(btrim(p_assistant_message)) = 0
then
raise exception 'agentic_rectification_regeneration_invalid_input' using errcode = 'P0001';
end if;
perform pg_catalog.pg_advisory_xact_lock(
pg_catalog.hashtextextended('agentic-rectification-regenerate:' || p_case_id::text, 0)
);
select * into v_case
from public.agentic_rectification_cases
where id = p_case_id and user_id = p_user_id;
if not found then
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
end if;
if v_case.session_id <> p_session_id then
raise exception 'agentic_rectification_case_session_mismatch' using errcode = 'P0001';
end if;
select * into v_existing
from public.agentic_rectification_turn_regenerations
where request_id = p_request_id;
if found then
if v_existing.user_id <> p_user_id
or v_existing.case_id <> p_case_id
or v_existing.turn_id <> p_turn_id
then
raise exception 'agentic_rectification_regeneration_request_conflict' using errcode = 'P0001';
end if;
return jsonb_build_object(
'ok', true,
'turn_id', v_existing.turn_id,
'assistant_message', v_existing.assistant_message,
'idempotent', true
);
end if;
if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then
raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001';
end if;
select * into v_turn
from public.agentic_rectification_turns
where id = p_turn_id and case_id = p_case_id;
if not found then
raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001';
end if;
if v_turn.status <> 'completed'
or v_turn.assistant_message is null
or length(btrim(v_turn.assistant_message)) = 0
then
raise exception 'agentic_rectification_turn_not_completed' using errcode = 'P0001';
end if;
select id into v_latest_turn_id
from public.agentic_rectification_turns
where case_id = p_case_id
and status = 'completed'
and assistant_message is not null
and length(btrim(assistant_message)) > 0
order by created_at desc, id desc
limit 1;
if v_latest_turn_id is distinct from p_turn_id then
raise exception 'agentic_rectification_turn_not_latest' using errcode = 'P0001';
end if;
v_message := btrim(p_assistant_message);
update public.agentic_rectification_turns
set assistant_message = v_message,
updated_at = pg_catalog.now()
where id = p_turn_id and case_id = p_case_id;
insert into public.agentic_rectification_turn_regenerations (
request_id, user_id, case_id, turn_id, assistant_message
) values (
p_request_id, p_user_id, p_case_id, p_turn_id, v_message
);
return jsonb_build_object(
'ok', true,
'turn_id', p_turn_id,
'assistant_message', v_message,
'idempotent', false
);
end;
$$;
revoke all on function public.regenerate_agentic_rectification_turn(uuid, uuid, uuid, uuid, uuid, text)
from public, anon, authenticated;
grant execute on function public.regenerate_agentic_rectification_turn(uuid, uuid, uuid, uuid, uuid, text)
to service_role;
commit;
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createRectificationActivityReceiptState,
reduceRectificationActivityReceipt,
receiptFromRectificationActivityState,
} from "../src/lib/rectification-activity-receipt";
test("a successful retry clears the same tool's earlier failure", () => {
let state = createRectificationActivityReceiptState();
for (const event of [
{ tool: "rectification-read-case", status: "completed" },
{ tool: "rectification-propose-evidence", status: "failed" },
{ tool: "rectification-propose-evidence", status: "started" },
{ tool: "rectification-propose-evidence", status: "completed" },
{ tool: "rectification-confirm-evidence", status: "completed" },
] as const) {
state = reduceRectificationActivityReceipt(state, event);
}
assert.deepEqual(receiptFromRectificationActivityState(state), {
steps: [
"rectification-read-case",
"rectification-propose-evidence",
"rectification-confirm-evidence",
],
methods: [],
});
});
test("an unrecovered latest tool failure remains visible", () => {
let state = createRectificationActivityReceiptState();
state = reduceRectificationActivityReceipt(state, {
tool: "rectification-read-case",
status: "completed",
});
state = reduceRectificationActivityReceipt(state, {
tool: "rectification-propose-evidence",
status: "failed",
});
assert.deepEqual(receiptFromRectificationActivityState(state), {
steps: ["rectification-read-case"],
methods: [],
failedTool: "rectification-propose-evidence",
});
});
test("a later failure overrides an earlier completion for the same tool", () => {
let state = createRectificationActivityReceiptState();
state = reduceRectificationActivityReceipt(state, {
tool: "rectification-read-case",
status: "completed",
});
state = reduceRectificationActivityReceipt(state, {
tool: "rectification-read-case",
status: "failed",
});
assert.deepEqual(receiptFromRectificationActivityState(state), {
steps: ["rectification-read-case"],
methods: [],
failedTool: "rectification-read-case",
});
});
@@ -24,6 +24,13 @@ const completedActivityReceipt = readFileSync(
new URL("../src/components/completed-activity-receipt.tsx", import.meta.url),
"utf8",
);
const regenerateRoute = readFileSync(
new URL(
"../src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts",
import.meta.url,
),
"utf8",
);
test("birth-time rectification entry mounts the V9 case-ref chat", () => {
assert.match(component, /return <RectificationAgenticChat \{\.\.\.props\} \/>/);
@@ -193,18 +200,21 @@ test("rectification activity separates live work from the receipt above the Agen
assert.match(activityHelper, /filter\(isPublicRectificationMethod\)/);
assert.match(chat, /activeActivity\?: PublicActivity/);
assert.match(chat, /completedReceipt\?: CompletedActivityReceiptView/);
assert.match(chat, /showActivity=\{message\.state === "thinking" \|\| Boolean\(message\.activeActivity\)\}/);
assert.match(chat, /showActivity=\{displayedMessage\.state === "thinking" \|\| Boolean\(displayedMessage\.activeActivity\)\}/);
assert.match(chat, /"rectification-read-case": "正在读取校正记录…"/);
assert.match(chat, /event\.type === "tool\.activity"/);
assert.match(chat, /event\.status === "started"/);
assert.match(chat, /event\.status === "completed"/);
assert.match(chat, /event\.status === "failed"/);
assert.match(chat, /event\.status !== "completed" && event\.status !== "failed"/);
const messageRender = chat.slice(
chat.indexOf('{messages.map((message) => ('),
chat.indexOf('{candidateResult?.selectionAllowed'),
chat.indexOf("{messages.map((message) => {"),
chat.indexOf("{candidateResult?.selectionAllowed"),
);
assert.ok(messageRender.indexOf("<CompletedActivityReceipt") < messageRender.indexOf("<ChatMessageRow"));
const receiptIndex = messageRender.indexOf("<CompletedActivityReceipt");
const replyIndex = messageRender.indexOf("<ChatMessageRow");
assert.ok(receiptIndex >= 0 && replyIndex >= 0);
assert.ok(receiptIndex < replyIndex);
assert.match(completedActivityReceipt, /<details className=/);
assert.doesNotMatch(completedActivityReceipt, /<details[^>]*\sopen/);
assert.match(completedActivityReceipt, /本轮完成 · \$\{stepLabels\.length\} 个步骤 · \$\{receipt\.methods\.length\} 项计算依据/);
@@ -223,6 +233,30 @@ test("rectification activity separates live work from the receipt above the Agen
assert.doesNotMatch(receiptRule, /border:/);
});
test("completed Agent replies restore feedback, copy and safe in-place regeneration actions", () => {
for (const label of ["赞", "踩", "复制回答", "重新生成回答"]) {
assert.match(chat, new RegExp(`aria-label="${label}"`));
}
assert.match(chat, /toggleRectificationFeedback/);
assert.match(chat, /navigator\.clipboard\.writeText\(message\.text\)/);
assert.match(chat, /\/turns\/\$\{encodeURIComponent\(message\.turnId\)\}\/regenerate/);
assert.match(chat, /requestId: globalThis\.crypto\.randomUUID\(\)/);
const messageRender = chat.slice(
chat.indexOf("{messages.map((message) => {"),
chat.indexOf("{candidateResult?.selectionAllowed"),
);
const receiptIndex = messageRender.indexOf("<CompletedActivityReceipt");
const replyIndex = messageRender.indexOf("<ChatMessageRow");
const actionsIndex = messageRender.indexOf('className="rectification-message-actions"');
assert.ok(receiptIndex >= 0 && replyIndex >= 0 && actionsIndex >= 0);
assert.ok(receiptIndex < replyIndex && replyIndex < actionsIndex);
assert.doesNotMatch(chat, /send\("message",\s*(?:oldText|previousText)/);
assert.doesNotMatch(regenerateRoute, /consultation-billing|reserveConsultation|billing\./);
assert.match(regenerateRoute, /regenerateV9AssistantTurn/);
});
test("candidate state renders from the snapshot API and never from sentinels", () => {
assert.match(chat, /当前可能的出生时间/);
assert.match(chat, /可以先采用一个作为当前排盘时间,也可以继续补充事件/);
+35 -1
View File
@@ -10,9 +10,13 @@ import {
RECTIFICATION_V9_SKILL_PATH,
RECTIFICATION_V9_SKILL_NAME,
getRectificationV9Agent,
getRectificationV9RegenerationAgent,
resolveRectificationStepBudget,
} from "../src/mastra/agentic-rectification.ts";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import {
createRectificationV9ReadOnlyTools,
createRectificationV9Tools,
} from "../src/mastra/rectification-v9-tools.ts";
import { runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import { persistV9Candidate } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
@@ -464,3 +468,33 @@ test("agent construction wires the pinned skill and the ten v9 tools", () => {
assert.equal(agent.id, "rectification-v9-test-model");
assert.ok(agent);
});
test("reply regeneration is a separate Jyotisha agent with only read-case access", () => {
const model = {
id: "test-model",
label: "Test",
description: "",
creditCost: 1,
isDefault: true,
mode: "compatible" as const,
model: { provider: "openai", name: "gpt-4o-mini", modelId: "gpt-4o-mini" } as never,
};
const accounting = fakeAccounting({});
const tools = createRectificationV9ReadOnlyTools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
assert.deepEqual(Object.keys(tools), ["rectification-read-case"]);
const agent = getRectificationV9RegenerationAgent(model, {
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
assert.equal(agent.id, "rectification-v9-regeneration-test-model");
assert.match(agentSource, /这不是新一轮校正/);
assert.match(agentSource, /只能使用 rectification-read-case/);
});
@@ -600,3 +600,63 @@ test("candidate reselection migration stays out of the identity migration tree",
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
);
});
// ---------------------------------------------------------------------------
// 20260813060000_rectification_turn_regeneration.sql
// ---------------------------------------------------------------------------
const turnRegenerationMigration = readFileSync(
new URL(
"../supabase/migrations/20260813060000_rectification_turn_regeneration.sql",
import.meta.url,
),
"utf8",
);
const turnRegenerationMigrationCopy = fileURLToPath(
new URL(
"../db/migrations/20260813060000_rectification_turn_regeneration.sql",
import.meta.url,
),
);
test("turn regeneration migration is forward-only, transactional and business-tree only", () => {
assert.ok(
"20260813060000_rectification_turn_regeneration.sql"
> "20260813050000_allow_rectification_candidate_reselection.sql",
);
assert.match(turnRegenerationMigration, /^begin;[\s\S]*^commit;$/m);
assert.equal(
existsSync(turnRegenerationMigrationCopy),
false,
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
);
});
test("turn regeneration is ownership-bound, latest-only and request-id idempotent", () => {
assert.match(turnRegenerationMigration, /where id = p_case_id and user_id = p_user_id/);
assert.match(turnRegenerationMigration, /if v_case\.session_id <> p_session_id/);
assert.match(turnRegenerationMigration, /where request_id = p_request_id/);
assert.match(turnRegenerationMigration, /agentic_rectification_regeneration_request_conflict/);
assert.match(turnRegenerationMigration, /pg_advisory_xact_lock/);
assert.match(turnRegenerationMigration, /v_case\.status in \('confirmed', 'closed', 'abandoned', 'superseded'\)/);
assert.match(turnRegenerationMigration, /where id = p_turn_id and case_id = p_case_id/);
assert.match(turnRegenerationMigration, /v_turn\.status <> 'completed'/);
assert.match(turnRegenerationMigration, /order by created_at desc, id desc[\s\S]*limit 1/);
assert.match(turnRegenerationMigration, /v_latest_turn_id is distinct from p_turn_id/);
});
test("turn regeneration updates only the existing Assistant text and timestamp", () => {
const update = turnRegenerationMigration.slice(
turnRegenerationMigration.indexOf("update public.agentic_rectification_turns"),
turnRegenerationMigration.indexOf("insert into public.agentic_rectification_turn_regenerations"),
);
const setClause = update.slice(update.indexOf("set "), update.indexOf("where "));
assert.match(update, /set assistant_message = v_message,[\s\S]*updated_at = pg_catalog\.now\(\)/);
assert.doesNotMatch(setClause, /user_message|status\s*=|case_id\s*=|session_id\s*=/);
assert.doesNotMatch(
turnRegenerationMigration,
/update public\.(?:agentic_rectification_evidence|agentic_rectification_results|agentic_rectification_cases|profiles)/,
);
assert.match(turnRegenerationMigration, /create or replace function public\.get_agentic_rectification_turn_regeneration/);
assert.match(turnRegenerationMigration, /create or replace function public\.regenerate_agentic_rectification_turn/);
});
@@ -0,0 +1,229 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
regenerateV9AssistantTurn,
type RectificationRegenerationAgent,
} from "../src/lib/rectification-agentic/v9/regenerate-turn.ts";
import { RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
SESSION_ID,
SOURCE_TURN_ID,
TURN_ID,
USER_ID,
dossierFixture,
fakeAccounting,
} from "./rectification-v9-test-support.ts";
const REQUEST_ID = "88888888-8888-4888-8888-888888888888";
const OLD_REPLY = "旧的 Agent 正文";
const NEW_REPLY = "新的自然回复";
function completedTurns() {
return [
{
id: TURN_ID,
role: "user",
text: "2020 年 4 月开始实习",
status: "completed",
created_at: "2026-08-13T01:00:00.000Z",
},
{
id: TURN_ID,
role: "assistant",
text: OLD_REPLY,
status: "completed",
created_at: "2026-08-13T01:00:00.000Z",
},
];
}
function fakeAgent(overrides: Partial<RectificationRegenerationAgent> = {}) {
const calls = { getSkill: 0, generate: 0 };
const agent: RectificationRegenerationAgent = {
async getSkill() {
calls.getSkill += 1;
return { name: "jyotish-birth-time-rectification" };
},
async generate() {
calls.generate += 1;
return { text: NEW_REPLY };
},
...overrides,
};
return { agent, calls };
}
function validAccounting() {
return fakeAccounting({
get_agentic_rectification_turn_regeneration: () => null,
get_agentic_rectification_case_dossier: () => dossierFixture({ turns: completedTurns() }),
regenerate_agentic_rectification_turn: () => ({
ok: true,
turn_id: TURN_ID,
assistant_message: NEW_REPLY,
idempotent: false,
}),
});
}
function options(
accounting: ReturnType<typeof fakeAccounting>["client"],
agent: RectificationRegenerationAgent,
overrides: Partial<Parameters<typeof regenerateV9AssistantTurn>[0]> = {},
) {
return {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
turnId: TURN_ID,
requestId: REQUEST_ID,
accounting,
agent,
...overrides,
};
}
async function rejectsWithCode(promise: Promise<unknown>, code: string) {
await assert.rejects(promise, (error: unknown) => (
error instanceof RectificationToolServiceError && error.code === code
));
}
test("regeneration replaces the latest completed Assistant text in place", async () => {
const accounting = validAccounting();
const { agent, calls } = fakeAgent();
const result = await regenerateV9AssistantTurn(options(accounting.client, agent));
assert.deepEqual(result, {
ok: true,
turnId: TURN_ID,
assistantMessage: NEW_REPLY,
idempotent: false,
});
assert.equal(calls.getSkill, 1);
assert.equal(calls.generate, 1);
assert.deepEqual(accounting.calls.map((call) => call.fn), [
"get_agentic_rectification_turn_regeneration",
"get_agentic_rectification_case_dossier",
"regenerate_agentic_rectification_turn",
]);
const update = accounting.calls.at(-1);
assert.equal(update?.args.p_assistant_message, NEW_REPLY);
assert.equal(accounting.calls.some((call) => call.fn === "append_agentic_rectification_turn"), false);
});
test("same request id returns the stored replacement without invoking the Agent again", async () => {
const accounting = fakeAccounting({
get_agentic_rectification_turn_regeneration: () => ({
ok: true,
turn_id: TURN_ID,
assistant_message: NEW_REPLY,
idempotent: true,
}),
});
const { agent, calls } = fakeAgent();
const result = await regenerateV9AssistantTurn(options(accounting.client, agent));
assert.equal(result.idempotent, true);
assert.equal(calls.getSkill, 0);
assert.equal(calls.generate, 0);
assert.deepEqual(accounting.calls.map((call) => call.fn), [
"get_agentic_rectification_turn_regeneration",
]);
});
test("terminal cases cannot regenerate replies", async () => {
const accounting = fakeAccounting({
get_agentic_rectification_turn_regeneration: () => null,
get_agentic_rectification_case_dossier: () => dossierFixture({
status: "closed",
turns: completedTurns(),
}),
});
const { agent, calls } = fakeAgent();
await rejectsWithCode(
regenerateV9AssistantTurn(options(accounting.client, agent)),
"agentic_rectification_case_terminal",
);
assert.equal(calls.generate, 0);
});
test("case and session must remain exactly bound", async () => {
const accounting = validAccounting();
const { agent, calls } = fakeAgent();
await rejectsWithCode(
regenerateV9AssistantTurn(options(accounting.client, agent, {
sessionId: "99999999-9999-4999-8999-999999999999",
})),
"agentic_rectification_case_session_mismatch",
);
assert.equal(calls.generate, 0);
});
test("only the latest completed Assistant turn can be regenerated", async () => {
const turns = [
{
id: SOURCE_TURN_ID,
role: "assistant",
text: "更早的回答",
status: "completed",
created_at: "2026-08-13T00:00:00.000Z",
},
...completedTurns(),
];
const accounting = fakeAccounting({
get_agentic_rectification_turn_regeneration: () => null,
get_agentic_rectification_case_dossier: () => dossierFixture({ turns }),
});
const { agent, calls } = fakeAgent();
await rejectsWithCode(
regenerateV9AssistantTurn(options(accounting.client, agent, { turnId: SOURCE_TURN_ID })),
"agentic_rectification_turn_not_latest",
);
assert.equal(calls.generate, 0);
assert.equal(accounting.calls.some((call) => call.fn === "regenerate_agentic_rectification_turn"), false);
});
test("missing or empty completed Assistant text is rejected", async () => {
const accounting = fakeAccounting({
get_agentic_rectification_turn_regeneration: () => null,
get_agentic_rectification_case_dossier: () => dossierFixture({
turns: [{
id: TURN_ID,
role: "assistant",
text: " ",
status: "completed",
created_at: "2026-08-13T01:00:00.000Z",
}],
}),
});
const { agent, calls } = fakeAgent();
await rejectsWithCode(
regenerateV9AssistantTurn(options(accounting.client, agent)),
"agentic_rectification_turn_not_found",
);
assert.equal(calls.generate, 0);
});
test("an empty regenerated body never reaches the update RPC", async () => {
const accounting = validAccounting();
const { agent } = fakeAgent({
async generate() {
return { text: " " };
},
});
await rejectsWithCode(
regenerateV9AssistantTurn(options(accounting.client, agent)),
"agentic_rectification_regeneration_empty",
);
assert.equal(accounting.calls.some((call) => call.fn === "regenerate_agentic_rectification_turn"), false);
});
+17 -2
View File
@@ -148,6 +148,18 @@ test("streamToolNames exposes only allowlisted rectification tools", () => {
test("safePublicEvent drops anything outside the allowlist", () => {
assert.deepEqual(safePublicEvent({ type: "answer.delta", text: "你好" }), { type: "answer.delta", text: "你好" });
assert.deepEqual(safePublicEvent({ type: "skill.loaded" }), { type: "skill.loaded" });
assert.deepEqual(
safePublicEvent({ type: "run.completed", turnId: TURN_ID }),
{ type: "run.completed", turnId: TURN_ID },
);
assert.deepEqual(
safePublicEvent({ type: "answer.delta", text: "你好", turnId: TURN_ID }),
{ type: "answer.delta", text: "你好" },
);
assert.deepEqual(
safePublicEvent({ type: "run.completed", turnId: "not-a-uuid" }),
{ type: "run.completed" },
);
assert.deepEqual(
safePublicEvent({
type: "case.loaded",
@@ -234,7 +246,7 @@ function fakeAgentStream(chunks: Array<{ type: string; payload?: Record<string,
}
function runOptions(overrides: Partial<V9AgentRunOptions> = {}) {
const emitted: Array<{ type: string; text?: string }> = [];
const emitted: Array<{ type: string; text?: string; turnId?: string }> = [];
const billing = { reserved: 0, completed: 0, released: 0 };
const accounting = fakeAccounting({
...receiptHandlers,
@@ -285,7 +297,10 @@ test("answer deltas stream in order and reasoning is never forwarded", async ()
{ type: "answer.delta", text: "好的," },
{ type: "answer.delta", text: "先确认一下:" },
]);
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.deepEqual(
emitted.find((event) => event.type === "run.completed"),
{ type: "run.completed", turnId: TURN_ID },
);
assert.equal(emitted.some((event) => String(event.type).includes("reasoning")), false);
assert.equal(emitted.some((event) => String(event.type).includes("raw")), false);
});