fix(rectification): replace v4 entry with agent opening
This commit is contained in:
@@ -1990,3 +1990,20 @@
|
||||
- 防复发:任何替换生时校正入口或会话实现的改动,都必须保留“用户无需先发消息即可收到 Agent 首次引导”的挂载契约。
|
||||
- 相关记录:BUG-085、BUG-112
|
||||
- 修复版本:Agentic web opening auto-start
|
||||
|
||||
## BUG-114 | Agentic 生时校正启动指令伪装成用户消息且未知时间被误判为资料缺失
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-03
|
||||
- 最近更新:2026-08-03
|
||||
- 影响面:首页生时校正入口、`/api/rectification/agent` 首次启动合同、出生资料门、候选范围与确认写入安全门
|
||||
- 用户现象:进入生时校正后浏览器发送一段“用户刚进入生时校正会话……”的隐藏 `message`;服务端随后返回“出生日期、时间或出生地点资料不完整”。同时产品入口仍可能恢复或创建 V4 Case,与当前 Agentic 工具链并存。
|
||||
- 触发条件:用户从首页进入生时校正;资料使用合法的“只知道时段”或“完全不知道时间”声明,或客户端与服务端出生资料状态不同步。
|
||||
- 根因:BUG-113 用伪装成用户消息的字符串补上自动启动,没有建立服务端拥有的 opening operation;产品 wrapper 仍保留 V4 active-case 分流;Agentic profile loader 又把没有具体分钟一律当成缺失,因此合法的不确定时间无法进入最新流程。
|
||||
- 修复:产品 wrapper 只挂载 `AgenticRectificationChat`,不再调用或恢复 V4 Case;首次请求改为 `action: "opening"`,服务端把 opening context 注入 Agent Turn,客户端不再发送或渲染隐藏用户指令;首页在创建 Session 前复用 onboarding 资料门,服务端以 `profile_incomplete` 明确回退;`period_only` 使用已声明时段,`unknown` 使用 `00:00–23:59`,跨午夜范围保持原样;gate 返回服务端候选范围,score、diagnostics、features、confirm 拒绝 Agent 自行发明范围,全天宽范围 scan 延后而不伪造中午出生时间。
|
||||
- 验证:Agentic 入口、Session、工具、首页入口与组件合同测试覆盖无 V4 产品分流、opening operation、资料回退、时段/未知时间、跨午夜、宽范围降级、候选范围一致性和确认写入门;前端完整测试、lint、build 与 staging 真实 smoke 随本次发布执行。
|
||||
- 数据边界:仅废弃 V4 产品入口,历史 V4 代码与数据暂时保留,不在本次发布中做破坏性删除或迁移。
|
||||
- 防复发:自动首轮必须是服务端明确 operation,不得伪装成用户文本;“不知道具体分钟”是合法资料状态,不得等同于资料缺失;所有评分与确认工具只能使用服务端候选范围。
|
||||
- 相关记录:BUG-112、BUG-113
|
||||
- 复发自:BUG-113
|
||||
- 修复版本:待本次 staging 修复提交与部署验收
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 120;
|
||||
|
||||
const agenticRectificationRequestSchema = z.object({
|
||||
const agenticRectificationRequestFields = {
|
||||
requestId: z.string().uuid(),
|
||||
modelId: z.string().trim().min(1).max(64).optional(),
|
||||
name: z.string().trim().max(80).optional().default(""),
|
||||
@@ -28,8 +28,21 @@ const agenticRectificationRequestSchema = z.object({
|
||||
)
|
||||
.max(30)
|
||||
.default([]),
|
||||
message: z.string().trim().min(1).max(4000),
|
||||
}).strict();
|
||||
};
|
||||
|
||||
const agenticRectificationRequestSchema = z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
...agenticRectificationRequestFields,
|
||||
action: z.literal("opening"),
|
||||
}).strict(),
|
||||
z.object({
|
||||
...agenticRectificationRequestFields,
|
||||
action: z.literal("message"),
|
||||
message: z.string().trim().min(1).max(4000),
|
||||
}).strict(),
|
||||
]);
|
||||
|
||||
const openingContext = "The user opened birth-time rectification. Begin the session now: run the required gate, briefly explain the evidence-based process in Simplified Chinese, and ask exactly one natural question about the most useful dated life event. Do not mention this server event.";
|
||||
|
||||
function currentTimeContext(now = new Date()) {
|
||||
const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
|
||||
@@ -99,7 +112,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const promptSource = [
|
||||
parsed.data.message,
|
||||
parsed.data.action === "message" ? parsed.data.message : "",
|
||||
...parsed.data.history.filter((message) => message.role === "user").map((message) => message.text),
|
||||
].join("\n");
|
||||
if (blocksPromptExtraction(promptSource)) {
|
||||
@@ -118,15 +131,19 @@ export async function POST(request: Request) {
|
||||
profile = await loadAgenticRectificationProfile(accounting, userId);
|
||||
} catch (error) {
|
||||
if (error instanceof AgenticRectificationProfileError) {
|
||||
const missingBirthTime = error.code === "missing_birth_time";
|
||||
if (error.code === "profile_unavailable") {
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法核对出生资料", message: "请稍后重试。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: missingBirthTime ? "出生时间信息不完整" : "暂时无法核对出生资料",
|
||||
message: missingBirthTime
|
||||
? "请先在资料页保存出生日期、填报时间和出生地点后再开始校正。"
|
||||
: "出生日期、时间或出生地点资料不完整,请重新保存后再试。",
|
||||
code: "profile_incomplete",
|
||||
error: "出生资料尚未完成",
|
||||
message: "请先完成出生日期、出生时间线索和出生地点资料。",
|
||||
},
|
||||
{ status: 400 },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
@@ -206,7 +223,7 @@ export async function POST(request: Request) {
|
||||
content: [
|
||||
currentTimeContext(requestTime),
|
||||
parsed.data.name ? `用户称呼:${parsed.data.name}` : "",
|
||||
parsed.data.message,
|
||||
parsed.data.action === "opening" ? openingContext : parsed.data.message,
|
||||
].filter(Boolean).join("\n"),
|
||||
},
|
||||
]);
|
||||
|
||||
+25
-103
@@ -15,7 +15,6 @@ import {
|
||||
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
|
||||
import { AppLoadingIndicator } from "@/components/app-loading-indicator";
|
||||
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
|
||||
import type { RectificationV4Continuation } from "@/components/rectification-v4-panel";
|
||||
import { ChatMessageContent } from "@/components/chat-message-content";
|
||||
import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row";
|
||||
import { ModelSelector } from "@/components/model-selector";
|
||||
@@ -54,7 +53,6 @@ import {
|
||||
type RectificationCardAction,
|
||||
} from "@/lib/birth-time-consultation-consent";
|
||||
import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode";
|
||||
import { claimRectificationV4Handoff } from "@/lib/rectification-v4/client";
|
||||
import {
|
||||
createRectificationQuestionHandoffCoordinator,
|
||||
} from "@/lib/rectification-question-handoff";
|
||||
@@ -946,11 +944,9 @@ export default function Home() {
|
||||
createBirthTimeConsultationConsentState,
|
||||
);
|
||||
const [rectificationSessionId, setRectificationSessionId] = useState<string | null>(null);
|
||||
const [rectificationReturnSessionId, setRectificationReturnSessionId] = useState<string | null>(null);
|
||||
const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState<string | null>(null);
|
||||
const [rectificationLoading, setRectificationLoading] = useState(false);
|
||||
const [rectificationMutationPending, setRectificationMutationPending] = useState(false);
|
||||
const [rectificationContinuationPending, setRectificationContinuationPending] = useState(false);
|
||||
const [rectificationError, setRectificationError] = useState("");
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [profileSaving, setProfileSaving] = useState(false);
|
||||
@@ -990,7 +986,6 @@ export default function Home() {
|
||||
const rectificationQuestionHandoff = useRef(createRectificationQuestionHandoffCoordinator<Theme>());
|
||||
const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined);
|
||||
const rectificationOpenInFlight = useRef(false);
|
||||
const rectificationContinuationInFlight = useRef(false);
|
||||
const uiPreview = useRef(false);
|
||||
const uiPreviewMode = useRef<string | null>(null);
|
||||
const birthTimeRevisionPending = useRef(false);
|
||||
@@ -1016,7 +1011,6 @@ export default function Home() {
|
||||
|| cancellationPending
|
||||
|| creatingSession
|
||||
|| rectificationMutationPending
|
||||
|| rectificationContinuationPending
|
||||
|| !account
|
||||
|| !modelCatalog;
|
||||
const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : "";
|
||||
@@ -1041,8 +1035,7 @@ export default function Home() {
|
||||
|| activeSession.id === rectificationSessionId
|
||||
|| rectificationLoading
|
||||
|| rectificationMutationPending
|
||||
|| rectificationContinuationPending
|
||||
|| creatingSession
|
||||
|| creatingSession
|
||||
|| rectificationError) return;
|
||||
resumeRectificationSession.current(activeSession);
|
||||
}, [
|
||||
@@ -1051,7 +1044,6 @@ export default function Home() {
|
||||
creatingSession,
|
||||
hydrated,
|
||||
modelCatalog,
|
||||
rectificationContinuationPending,
|
||||
rectificationError,
|
||||
rectificationLoading,
|
||||
rectificationMutationPending,
|
||||
@@ -2032,7 +2024,15 @@ export default function Home() {
|
||||
sourceSessionOverride: ChatSession | null = null,
|
||||
) {
|
||||
if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current
|
||||
|| rectificationMutationPending || rectificationContinuationInFlight.current) return;
|
||||
|| rectificationMutationPending) return;
|
||||
const missingStep = missingProfileStep(profile);
|
||||
if (missingStep) {
|
||||
setRectificationSessionId(null);
|
||||
setRectificationPendingQuestion(null);
|
||||
setOnboardingStep(missingStep);
|
||||
setComposerNotice("请先完成出生资料,再开始生时校正。");
|
||||
return;
|
||||
}
|
||||
const sourceSession = sourceSessionOverride ?? activeSession;
|
||||
if (!sourceSession) return;
|
||||
const existing = sourceSession.sessionType === "birth_time_rectification"
|
||||
@@ -2048,7 +2048,6 @@ export default function Home() {
|
||||
setDraft("");
|
||||
setDraftTheme(null);
|
||||
setDraftEntrypoint(null);
|
||||
if (sourceSession.id !== rectificationSession.id) setRectificationReturnSessionId(sourceSession.id);
|
||||
setRectificationSessionId(rectificationSession.id);
|
||||
activeSessionIdRef.current = rectificationSession.id;
|
||||
setActiveSessionId(rectificationSession.id);
|
||||
@@ -2073,6 +2072,20 @@ export default function Home() {
|
||||
void openBirthTimeRectification(null, session);
|
||||
};
|
||||
|
||||
function handleRectificationProfileIncomplete() {
|
||||
setRectificationSessionId(null);
|
||||
setRectificationPendingQuestion(null);
|
||||
const missingStep = missingProfileStep(profile);
|
||||
if (missingStep) {
|
||||
setOnboardingStep(missingStep);
|
||||
setComposerNotice("请先完成出生资料,再开始生时校正。");
|
||||
return;
|
||||
}
|
||||
openAccountDialog("profile");
|
||||
setProfileNotice("服务端未能读取完整出生资料,请重新确认并保存。");
|
||||
void refreshAccount();
|
||||
}
|
||||
|
||||
async function draftSynastryQuestionFromChart(record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) {
|
||||
if (record.role !== "other") return;
|
||||
if (synastryPendingId) return;
|
||||
@@ -2585,96 +2598,6 @@ export default function Home() {
|
||||
}
|
||||
|
||||
|
||||
async function continueRectificationOriginalQuestion(continuation: RectificationV4Continuation) {
|
||||
const question = continuation.question;
|
||||
if (rectificationContinuationInFlight.current || rectificationMutationPending
|
||||
|| rectificationLoading || !activeSession || !account) 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();
|
||||
}
|
||||
const localHandoff = rectificationQuestionHandoff.current.peek();
|
||||
const returnSession = (localHandoff
|
||||
? sessions.find((session) => session.id === localHandoff.sessionId)
|
||||
: null)
|
||||
?? (rectificationReturnSessionId
|
||||
? sessions.find((session) => session.id === rectificationReturnSessionId)
|
||||
: null)
|
||||
?? sessions.find((session) => session.sessionType === "consultation")
|
||||
?? null;
|
||||
if (!returnSession) {
|
||||
setComposerNotice("没有找到原问题所在的会话,请从会话列表打开原问题后重试。");
|
||||
return;
|
||||
}
|
||||
|
||||
rectificationContinuationInFlight.current = true;
|
||||
setRectificationContinuationPending(true);
|
||||
setRectificationError("");
|
||||
try {
|
||||
const durableClaim = await claimRectificationV4Handoff({
|
||||
caseId: continuation.caseId,
|
||||
caseVersion: continuation.caseVersion,
|
||||
question,
|
||||
});
|
||||
if (durableClaim.status === "in_progress") {
|
||||
setComposerNotice("原问题正在另一设备继续回答;完成后刷新即可查看,不会重复扣点。");
|
||||
return;
|
||||
}
|
||||
if (durableClaim.status === "consumed") {
|
||||
activeSessionIdRef.current = returnSession.id;
|
||||
setActiveSessionId(returnSession.id);
|
||||
setRectificationPendingQuestion(null);
|
||||
setComposerNotice("原问题已经继续回答,不会再次发送或扣点。");
|
||||
return;
|
||||
}
|
||||
if (durableClaim.status !== "claimed") {
|
||||
setComposerNotice("原问题仍保留,请刷新校正状态后重试。");
|
||||
return;
|
||||
}
|
||||
const completed = await rectificationQuestionHandoff.current.continueOriginalQuestion(
|
||||
question,
|
||||
{ sessionId: returnSession.id, theme: returnSession.theme },
|
||||
async (context) => {
|
||||
activeSessionIdRef.current = context.sessionId;
|
||||
setActiveSessionId(context.sessionId);
|
||||
setBirthTimeConsultationConsent((current) => clearBirthTimeConsultationConsent(
|
||||
current,
|
||||
context.sessionId,
|
||||
));
|
||||
return send(
|
||||
context.question,
|
||||
context.theme,
|
||||
null,
|
||||
null,
|
||||
context.sessionId,
|
||||
{
|
||||
protocol: "rectification-evidence-v4",
|
||||
caseId: durableClaim.caseId,
|
||||
caseVersion: durableClaim.caseVersion,
|
||||
claimActionId: durableClaim.claimActionId,
|
||||
requestId: durableClaim.requestId,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
if (completed) {
|
||||
setRectificationPendingQuestion(null);
|
||||
setComposerNotice("已按候选范围边界继续回答原问题。");
|
||||
} else {
|
||||
setComposerNotice("原问题仍保留,可再次点击继续回答。");
|
||||
}
|
||||
} catch {
|
||||
setComposerNotice("原问题仍保留,可再次点击继续回答。");
|
||||
} finally {
|
||||
rectificationContinuationInFlight.current = false;
|
||||
setRectificationContinuationPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
useGSAP(() => {
|
||||
if (!starterHomeVisible || !starterWorkbench.current) return;
|
||||
const motion = gsap.matchMedia();
|
||||
@@ -3035,9 +2958,8 @@ export default function Home() {
|
||||
selectedModelId={activeSession?.modelId ?? ""}
|
||||
onSelectModel={(modelId) => void selectSessionModel(modelId)}
|
||||
pendingConsultationQuestion={rectificationPendingQuestion}
|
||||
continuationPending={rectificationContinuationPending}
|
||||
onPendingChange={setRectificationMutationPending}
|
||||
onContinueOriginalQuestion={(continuation) => void continueRectificationOriginalQuestion(continuation)}
|
||||
onProfileIncomplete={handleRectificationProfileIncomplete}
|
||||
onSaved={() => void refreshAccount()}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,119 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { loadActiveRectificationV4, transitionRectificationV4 } from "../lib/rectification-v4/client.ts";
|
||||
import type { RectificationV4ApiResponse } from "../lib/rectification-v4/contracts.ts";
|
||||
import type { PublicLanguageModel } from "../lib/public-models.ts";
|
||||
import { AgenticRectificationChat } from "./rectification-agentic-chat.tsx";
|
||||
import { ChatMessageRow } from "./chat-message-row.tsx";
|
||||
import {
|
||||
RectificationV4Panel,
|
||||
type RectificationV4Continuation,
|
||||
} from "./rectification-v4-panel.tsx";
|
||||
|
||||
export type ConversationalBirthTimeRectificationProps = Readonly<{
|
||||
models: readonly PublicLanguageModel[];
|
||||
selectedModelId: string;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
pendingConsultationQuestion?: string | null;
|
||||
continuationPending?: boolean;
|
||||
onPendingChange?: (pending: boolean) => void;
|
||||
onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void;
|
||||
onProfileIncomplete?: () => void;
|
||||
onSaved?: (time: string) => void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Birth-time rectification surface.
|
||||
*
|
||||
* Lets the user explicitly continue or end an existing v4 evidence case, and
|
||||
* otherwise opens the agentic chat where the LLM drives the full Jyotish
|
||||
* rectification methodology with the engine as its computation layer.
|
||||
*/
|
||||
export function ConversationalBirthTimeRectification(props: ConversationalBirthTimeRectificationProps) {
|
||||
const [mode, setMode] = useState<"loading" | "choice" | "v4" | "agentic">("loading");
|
||||
const [existing, setExisting] = useState<RectificationV4ApiResponse | null>(null);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [switchError, setSwitchError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
void (async () => {
|
||||
const existing = await loadActiveRectificationV4().catch(() => null);
|
||||
if (mounted) {
|
||||
setExisting(existing);
|
||||
setMode(existing ? "choice" : "agentic");
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
if (mode === "loading") {
|
||||
return (
|
||||
<section className="conversation" aria-label="生时校正对话" aria-busy>
|
||||
<div className="message-list" aria-live="polite">
|
||||
<ChatLoadingRow />
|
||||
<div />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "choice" && existing) {
|
||||
const startAgentic = async () => {
|
||||
setSwitching(true);
|
||||
setSwitchError("");
|
||||
try {
|
||||
await transitionRectificationV4(existing.case.id, existing.case.version, "abandon");
|
||||
setMode("agentic");
|
||||
} catch {
|
||||
setSwitchError("无法结束旧版校正,请稍后再试。");
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<section className="conversation" aria-label="生时校正版本选择" aria-busy={switching}>
|
||||
<div className="message-list" aria-live="polite">
|
||||
<ChatMessageRow
|
||||
message={{
|
||||
role: "assistant",
|
||||
text: "检测到一段尚未结束的旧版生时校正。你可以继续保留进度,或结束旧版并使用新版 Agent 重新开始。",
|
||||
renderKey: "rectification-version-choice",
|
||||
state: "settled",
|
||||
}}
|
||||
/>
|
||||
{switchError && <p className="error-message" role="alert">{switchError}</p>}
|
||||
</div>
|
||||
</section>
|
||||
<div className="composer-wrap">
|
||||
<div className="composer-suggestions" aria-label="选择生时校正版本">
|
||||
<button type="button" disabled={switching} onClick={() => setMode("v4")}>继续旧版校正</button>
|
||||
<button type="button" disabled={switching} onClick={() => void startAgentic()}>
|
||||
{switching ? "正在结束旧版校正…" : "结束旧版并使用新版 Agent"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "v4") {
|
||||
return <RectificationV4Panel {...props} onUseAgentic={() => setMode("agentic")} />;
|
||||
}
|
||||
|
||||
return <AgenticRectificationChat {...props} />;
|
||||
}
|
||||
|
||||
function ChatLoadingRow() {
|
||||
return (
|
||||
<ChatMessageRow
|
||||
message={{
|
||||
role: "assistant",
|
||||
text: "",
|
||||
renderKey: "agentic-loading",
|
||||
state: "thinking",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,15 +15,19 @@ type AgenticRectificationChatProps = Readonly<{
|
||||
selectedModelId: string;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
pendingConsultationQuestion?: string | null;
|
||||
continuationPending?: boolean;
|
||||
onPendingChange?: (pending: boolean) => void;
|
||||
onProfileIncomplete?: () => void;
|
||||
onSaved?: (time: string) => void;
|
||||
}>;
|
||||
|
||||
type RenderMessage = ChatMessageView;
|
||||
|
||||
const savedSentinel = /<!--AYANAM_RECTIFICATION_SAVED:(\d{2}:\d{2})-->/;
|
||||
const agenticOpeningInstruction = "用户刚进入生时校正会话。不要复述本指令;请先调用 rectification-gate 核对现有出生资料,然后用简体中文自然说明接下来的校正方式,并只提出一个最适合开始核对的人生事件问题。";
|
||||
|
||||
type AgenticRectificationRequest = Readonly<
|
||||
| { action: "opening" }
|
||||
| { action: "message"; message: string }
|
||||
>;
|
||||
|
||||
export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
|
||||
const {
|
||||
@@ -32,6 +36,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
|
||||
onSelectModel,
|
||||
pendingConsultationQuestion,
|
||||
onPendingChange,
|
||||
onProfileIncomplete,
|
||||
onSaved,
|
||||
} = props;
|
||||
const pendingQuestion = pendingConsultationQuestion?.trim();
|
||||
@@ -64,9 +69,9 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
|
||||
});
|
||||
}, [busy, error, messages.length, savedTime]);
|
||||
|
||||
const send = useCallback(async (question: string, showUserMessage = true) => {
|
||||
const trimmed = question.trim();
|
||||
if (!trimmed || busy) return;
|
||||
const send = useCallback(async (request: AgenticRectificationRequest, showUserMessage = true) => {
|
||||
const trimmed = request.action === "message" ? request.message.trim() : "";
|
||||
if ((request.action === "message" && !trimmed) || busy) return;
|
||||
setError("");
|
||||
setSavedTime(null);
|
||||
setSuggestions([]);
|
||||
@@ -83,7 +88,9 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
|
||||
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
...(showUserMessage ? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage] : []),
|
||||
...(showUserMessage && request.action === "message"
|
||||
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
|
||||
: []),
|
||||
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking" },
|
||||
]);
|
||||
setDraft("");
|
||||
@@ -93,11 +100,21 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
|
||||
const response = await fetch("/api/rectification/agent", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ requestId, modelId: selectedModelId, history, message: trimmed }),
|
||||
body: JSON.stringify({
|
||||
requestId,
|
||||
modelId: selectedModelId,
|
||||
history,
|
||||
action: request.action,
|
||||
...(request.action === "message" ? { message: trimmed } : {}),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
const message = payload?.message || payload?.error || `请求失败(${response.status})`;
|
||||
if (payload?.code === "profile_incomplete") {
|
||||
onProfileIncomplete?.();
|
||||
return;
|
||||
}
|
||||
if (response.status === 402) setError(`咨询点数不足:${message}`);
|
||||
else if (response.status === 401) setError("请先登录。");
|
||||
else setError(message);
|
||||
@@ -156,17 +173,17 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, [busy, messages, onSaved, selectedModelId, setPending]);
|
||||
}, [busy, messages, onProfileIncomplete, onSaved, selectedModelId, setPending]);
|
||||
|
||||
useEffect(() => {
|
||||
if (openingStarted.current) return;
|
||||
openingStarted.current = true;
|
||||
void send(agenticOpeningInstruction, false);
|
||||
void send({ action: "opening" }, false);
|
||||
}, [send]);
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
await send(draft);
|
||||
await send({ action: "message", message: draft });
|
||||
}
|
||||
|
||||
const canSend = !busy;
|
||||
@@ -190,7 +207,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
|
||||
{suggestions.length > 0 && !busy && (
|
||||
<div className="composer-suggestions" aria-label="推荐继续提问">
|
||||
{suggestions.map((question) => (
|
||||
<button key={question} type="button" onClick={() => void send(question)}>{question}</button>
|
||||
<button key={question} type="button" onClick={() => void send({ action: "message", message: question })}>{question}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -25,7 +25,8 @@ export class AgenticRectificationProfileError extends Error {
|
||||
|
||||
export type AgenticRectificationProfile = Readonly<{
|
||||
birth_date: string;
|
||||
reported_time: string;
|
||||
reported_time: string | null;
|
||||
candidateRange: AgenticRectificationContext["candidateRange"];
|
||||
lat: number;
|
||||
lon: number;
|
||||
tz: number;
|
||||
@@ -35,10 +36,59 @@ export type AgenticRectificationProfile = Readonly<{
|
||||
}>;
|
||||
|
||||
const timeValue = (value: unknown): string | null => {
|
||||
if (typeof value !== "string" || !value) return null;
|
||||
return value.length >= 5 ? value.slice(0, 5) : null;
|
||||
const time = typeof value === "string" ? value.slice(0, 5) : "";
|
||||
return /^([01]\d|2[0-3]):[0-5]\d$/.test(time) ? time : null;
|
||||
};
|
||||
|
||||
const periodRanges = {
|
||||
early_morning: { start_time: "04:00", end_time: "07:59" },
|
||||
morning: { start_time: "08:00", end_time: "11:59" },
|
||||
afternoon: { start_time: "12:00", end_time: "17:59" },
|
||||
evening: { start_time: "18:00", end_time: "22:59" },
|
||||
late_night: { start_time: "23:00", end_time: "03:59" },
|
||||
} as const;
|
||||
|
||||
function shiftedTime(time: string, offsetMinutes: number): string {
|
||||
const [hour = 0, minute = 0] = time.split(":").map(Number);
|
||||
const normalized = ((hour * 60 + minute + offsetMinutes) % 1_440 + 1_440) % 1_440;
|
||||
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function candidateRangeFrom(input: {
|
||||
activeTime: string | null;
|
||||
reportedTime: string | null;
|
||||
source: string;
|
||||
period: unknown;
|
||||
uncertaintyBefore: number | null;
|
||||
uncertaintyAfter: number | null;
|
||||
}): AgenticRectificationContext["candidateRange"] {
|
||||
const referenceTime = input.activeTime ?? input.reportedTime;
|
||||
if (referenceTime) {
|
||||
const fallback = input.source === "hospital_record" || input.source === "hospital"
|
||||
? 2
|
||||
: input.source === "family_exact" || input.source === "family_clear"
|
||||
? 15
|
||||
: input.source === "approximate" || input.source === "family_vague"
|
||||
? 60
|
||||
: 2;
|
||||
return {
|
||||
start_time: shiftedTime(referenceTime, -(input.uncertaintyBefore ?? fallback)),
|
||||
end_time: shiftedTime(referenceTime, input.uncertaintyAfter ?? fallback),
|
||||
};
|
||||
}
|
||||
if (input.source === "period_only" || input.source === "legacy_import") {
|
||||
const period = typeof input.period === "string"
|
||||
? periodRanges[input.period as keyof typeof periodRanges]
|
||||
: null;
|
||||
if (period) return period;
|
||||
if (input.source === "period_only") throw new AgenticRectificationProfileError("missing_birth_time_period");
|
||||
}
|
||||
if (input.source === "unknown" || input.source === "legacy_import") {
|
||||
return { start_time: "00:00", end_time: "23:59" };
|
||||
}
|
||||
throw new AgenticRectificationProfileError("missing_birth_time");
|
||||
}
|
||||
|
||||
function declaredAccuracyFrom(uncertaintyBefore: number | null, uncertaintyAfter: number | null, timeSource: string | null): AgenticRectificationContext["declaredAccuracy"] {
|
||||
const before = uncertaintyBefore ?? 0;
|
||||
const after = uncertaintyAfter ?? 0;
|
||||
@@ -50,16 +100,21 @@ function declaredAccuracyFrom(uncertaintyBefore: number | null, uncertaintyAfter
|
||||
return "unknown";
|
||||
}
|
||||
switch (timeSource) {
|
||||
case "hospital": return "minute";
|
||||
case "family_clear": return "15min";
|
||||
case "family_vague": return "1hour";
|
||||
case "hospital":
|
||||
case "hospital_record": return "minute";
|
||||
case "family_clear":
|
||||
case "family_exact": return "15min";
|
||||
case "family_vague":
|
||||
case "approximate": return "1hour";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function timeSourceFrom(value: unknown): AgenticRectificationContext["timeSource"] {
|
||||
const source = typeof value === "string" ? value.trim() : "";
|
||||
if (source === "hospital" || source === "family_clear" || source === "family_vague") return source;
|
||||
if (source === "hospital" || source === "hospital_record") return "hospital";
|
||||
if (source === "family_clear" || source === "family_exact") return "family_clear";
|
||||
if (source === "family_vague" || source === "approximate" || source === "period_only") return "family_vague";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
@@ -82,10 +137,8 @@ export async function loadAgenticRectificationProfile(
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(birthDate)) {
|
||||
throw new AgenticRectificationProfileError("missing_birth_date");
|
||||
}
|
||||
const reportedTime = timeValue(data.active_birth_time ?? data.reported_birth_time);
|
||||
if (!reportedTime || !/^\d{2}:\d{2}$/.test(reportedTime)) {
|
||||
throw new AgenticRectificationProfileError("missing_birth_time");
|
||||
}
|
||||
const activeTime = timeValue(data.active_birth_time);
|
||||
const reportedTime = timeValue(data.reported_birth_time);
|
||||
const lat = numberOrNull(data.latitude);
|
||||
const lon = numberOrNull(data.longitude);
|
||||
const tz = numberOrNull(data.timezone_offset);
|
||||
@@ -95,16 +148,25 @@ export async function loadAgenticRectificationProfile(
|
||||
const timeSource = timeSourceFrom(data.birth_time_source);
|
||||
const uncertaintyBefore = numberOrNull(data.uncertainty_before_minutes);
|
||||
const uncertaintyAfter = numberOrNull(data.uncertainty_after_minutes);
|
||||
const candidateRange = candidateRangeFrom({
|
||||
activeTime,
|
||||
reportedTime,
|
||||
source: typeof data.birth_time_source === "string" ? data.birth_time_source.trim() : "",
|
||||
period: data.birth_time_period,
|
||||
uncertaintyBefore,
|
||||
uncertaintyAfter,
|
||||
});
|
||||
|
||||
return {
|
||||
birth_date: birthDate,
|
||||
reported_time: reportedTime,
|
||||
reported_time: activeTime ?? reportedTime,
|
||||
candidateRange,
|
||||
lat,
|
||||
lon,
|
||||
tz,
|
||||
declaredAccuracy: declaredAccuracyFrom(uncertaintyBefore, uncertaintyAfter, data.birth_time_source),
|
||||
timeSource,
|
||||
baselineActiveTime: timeValue(data.active_birth_time),
|
||||
baselineActiveTime: activeTime,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,6 +184,7 @@ export function createAgenticRectificationContext(
|
||||
lon: profile.lon,
|
||||
tz: profile.tz,
|
||||
},
|
||||
candidateRange: profile.candidateRange,
|
||||
declaredAccuracy: profile.declaredAccuracy,
|
||||
timeSource: profile.timeSource,
|
||||
async applyConfirmedBirthTime(time) {
|
||||
|
||||
@@ -12,7 +12,7 @@ Write in concise Simplified Chinese as a natural conversation. Acknowledge what
|
||||
METHODOLOGY
|
||||
- Load and follow the jyotish-vedic-astrology skill before every substantive step. Its references (birth-time-rectification-advanced.md, birth-time-rectification-decision-tree.md, oracle overlays) are your method source.
|
||||
- ALL computation goes through the provided engine tools: rectification-gate, rectification-scan, rectification-score, rectification-diagnostics, rectification-candidate-features, rectification-confirm. Never invent a candidate time, score, date, divisional-chart fact, or birth minute in prose.
|
||||
- Workflow: run rectification-gate first to learn the starting accuracy and which dated events are most valuable. Then collect dated life events conversationally (the user narrates; ask for a date when the event is not dated, but do not press endlessly). Then run rectification-scan to see how layers change minute-to-minute, rectification-score to see candidate minutes, rectification-diagnostics to see what is weak, and ask one or two natural follow-ups to fill the weakest domain or the most unstable event. Re-score. When the candidate is stable across events and domains, run rectification-confirm.
|
||||
- Workflow: run rectification-gate first to learn the server-owned candidate_range, starting accuracy, and which dated events are most valuable. Always reuse that exact candidate_range in later tools; never create or widen one yourself. Then collect dated life events conversationally (the user narrates; ask for a date when the event is not dated, but do not press endlessly). Then run rectification-scan when available, rectification-score to see candidate minutes, rectification-diagnostics to see what is weak, and ask one or two natural follow-ups to fill the weakest domain or the most unstable event. Re-score. When the candidate is stable across events and domains, run rectification-confirm.
|
||||
- Use the decision tree: Dasha plus dated events establish the frame; D9 and D10 are core for relationship and career; D4/D24/D2/D11/D7/D30 are topic-specific; D60 is reference-only and never drives a conclusion.
|
||||
- Keep event ids stable: reuse the same id for the same life event in every tool call.
|
||||
|
||||
|
||||
@@ -23,16 +23,22 @@ const timePattern = /^\d{2}:\d{2}$/;
|
||||
/** Birth fields supplied by the server from the user profile (never by the LLM). */
|
||||
export type AgenticRectificationBirth = Readonly<{
|
||||
birth_date: string;
|
||||
reported_time: string;
|
||||
reported_time: string | null;
|
||||
lat: number;
|
||||
lon: number;
|
||||
tz: number;
|
||||
}>;
|
||||
|
||||
export type AgenticRectificationCandidateRange = Readonly<{
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
}>;
|
||||
|
||||
export type AgenticRectificationContext = Readonly<{
|
||||
userId: string;
|
||||
engineBase?: string;
|
||||
birth: AgenticRectificationBirth;
|
||||
candidateRange: AgenticRectificationCandidateRange;
|
||||
declaredAccuracy?: "minute" | "15min" | "1hour" | "unknown";
|
||||
timeSource?: "hospital" | "family_clear" | "family_vague" | "unknown";
|
||||
applyConfirmedBirthTime: (time: string) => Promise<Readonly<{
|
||||
@@ -61,6 +67,33 @@ export const candidateRangeSchema = z.object({
|
||||
end_time: z.string().regex(timePattern),
|
||||
});
|
||||
|
||||
function clockMinute(value: string): number {
|
||||
const [hour = 0, minute = 0] = value.split(":").map(Number);
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
function rangeScanWindow(range: AgenticRectificationCandidateRange) {
|
||||
const start = clockMinute(range.start_time);
|
||||
let end = clockMinute(range.end_time);
|
||||
if (end < start) end += 1_440;
|
||||
const width = end - start;
|
||||
const center = Math.round((start + end) / 2) % 1_440;
|
||||
return {
|
||||
width,
|
||||
centerTime: `${String(Math.floor(center / 60)).padStart(2, "0")}:${String(center % 60).padStart(2, "0")}`,
|
||||
uncertaintyMinutes: Math.max(1, Math.ceil(width / 2)),
|
||||
};
|
||||
}
|
||||
|
||||
function requireServerCandidateRange(
|
||||
requested: AgenticRectificationCandidateRange,
|
||||
serverOwned: AgenticRectificationCandidateRange,
|
||||
) {
|
||||
if (requested.start_time !== serverOwned.start_time || requested.end_time !== serverOwned.end_time) {
|
||||
throw new Error(`candidate_range_mismatch: use ${serverOwned.start_time}-${serverOwned.end_time}`);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultEventKind: Record<RectificationDomain, string> = {
|
||||
education: "education_milestone",
|
||||
relocation: "relocation",
|
||||
@@ -226,6 +259,24 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
|
||||
time_source: z.enum(["hospital", "family_clear", "family_vague", "unknown"]).optional(),
|
||||
}).strict(),
|
||||
execute: async (input) => {
|
||||
if (!ctx.birth.reported_time) {
|
||||
return {
|
||||
endpoint: "server_owned_rectification_preflight",
|
||||
effective_accuracy: ctx.declaredAccuracy ?? "unknown",
|
||||
candidate_range: ctx.candidateRange,
|
||||
lagna_boundary: { is_sensitive: null, note: "requires_dated_event_scoring_across_candidate_range" },
|
||||
enabled_vargas: {},
|
||||
summary: {
|
||||
headline: "broad_candidate_range",
|
||||
enabled: [],
|
||||
warned: ["candidate_range_requires_event_scoring"],
|
||||
disabled: ["single_minute_precision_claims"],
|
||||
confidence_floor: "low",
|
||||
recommended_events: ["education", "career", "relationship", "relocation"],
|
||||
next_action: "collect one clearly dated life event",
|
||||
},
|
||||
};
|
||||
}
|
||||
const body = {
|
||||
year: Number(ctx.birth.birth_date.slice(0, 4)),
|
||||
month: Number(ctx.birth.birth_date.slice(5, 7)),
|
||||
@@ -245,6 +296,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
|
||||
return {
|
||||
endpoint: data.endpoint,
|
||||
effective_accuracy: data.effective_accuracy,
|
||||
candidate_range: ctx.candidateRange,
|
||||
lagna_boundary: data.lagna_boundary,
|
||||
enabled_vargas: data.enabled_vargas,
|
||||
summary: {
|
||||
@@ -263,22 +315,30 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
|
||||
const scanTool = createTool({
|
||||
id: "rectification-scan",
|
||||
description:
|
||||
"Scan how chart layers (D1/D4/D9/D10/D24/D30 ascendants, arudhas, KP cusps) change minute-to-minute across the candidate window around the reported birth time. Use this to understand which layers are sensitive and where transitions happen.",
|
||||
"Scan how chart layers change minute-to-minute across the server-owned candidate range. Wide ranges are deferred until dated-event scoring narrows the evidence.",
|
||||
inputSchema: z.object({
|
||||
uncertainty_minutes: z.number().int().min(1).max(180).optional(),
|
||||
step_minutes: z.number().int().min(1).max(30).optional(),
|
||||
}).strict(),
|
||||
execute: async (input) => {
|
||||
const scanWindow = rangeScanWindow(ctx.candidateRange);
|
||||
if (scanWindow.width > 360) {
|
||||
return {
|
||||
scope: "candidate_time_sensitivity_scan",
|
||||
status: "deferred_wide_range",
|
||||
candidate_range: ctx.candidateRange,
|
||||
boundary: "Collect dated events and score the server-owned full range before running a local sensitivity scan.",
|
||||
};
|
||||
}
|
||||
const body = {
|
||||
year: Number(ctx.birth.birth_date.slice(0, 4)),
|
||||
month: Number(ctx.birth.birth_date.slice(5, 7)),
|
||||
day: Number(ctx.birth.birth_date.slice(8, 10)),
|
||||
hour: Number(ctx.birth.reported_time.slice(0, 2)),
|
||||
minute: Number(ctx.birth.reported_time.slice(3, 5)),
|
||||
hour: Number(scanWindow.centerTime.slice(0, 2)),
|
||||
minute: Number(scanWindow.centerTime.slice(3, 5)),
|
||||
lat: ctx.birth.lat,
|
||||
lon: ctx.birth.lon,
|
||||
tz: ctx.birth.tz,
|
||||
time_uncertainty_minutes: input.uncertainty_minutes,
|
||||
time_uncertainty_minutes: scanWindow.uncertaintyMinutes,
|
||||
step_minutes: input.step_minutes,
|
||||
};
|
||||
const data = await postEngine(base, "/api/rectification/sensitivity_scan", body);
|
||||
@@ -286,6 +346,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
|
||||
return {
|
||||
scope: data.scope,
|
||||
status: data.status,
|
||||
candidate_range: ctx.candidateRange,
|
||||
center_time: data.center_time,
|
||||
uncertainty_minutes: data.uncertainty_minutes,
|
||||
step_minutes: data.step_minutes,
|
||||
@@ -326,6 +387,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
|
||||
events: z.array(agenticRectificationEventSchema).min(1).max(40),
|
||||
}).strict(),
|
||||
execute: async (input) => {
|
||||
requireServerCandidateRange(input.candidate_range, ctx.candidateRange);
|
||||
const data = await postEngine(base, "/api/rectification/v5/score", v5Request(ctx, input.candidate_range, input.events));
|
||||
return compactScoreResult(data);
|
||||
},
|
||||
@@ -340,6 +402,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
|
||||
events: z.array(agenticRectificationEventSchema).min(1).max(40),
|
||||
}).strict(),
|
||||
execute: async (input) => {
|
||||
requireServerCandidateRange(input.candidate_range, ctx.candidateRange);
|
||||
const data = await postEngine(base, "/api/rectification/v5/diagnostics", v5Request(ctx, input.candidate_range, input.events));
|
||||
const diagnostics = (data.diagnostics && typeof data.diagnostics === "object")
|
||||
? data.diagnostics as Record<string, unknown>
|
||||
@@ -375,6 +438,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
|
||||
candidate_range: candidateRangeSchema,
|
||||
}).strict(),
|
||||
execute: async (input) => {
|
||||
requireServerCandidateRange(input.candidate_range, ctx.candidateRange);
|
||||
const data = await postEngine(base, "/api/rectification/v5/candidate-features", {
|
||||
birth_date: ctx.birth.birth_date,
|
||||
start_time: input.candidate_range.start_time,
|
||||
@@ -408,6 +472,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
|
||||
events: z.array(agenticRectificationEventSchema).min(1).max(40),
|
||||
}).strict(),
|
||||
execute: async (input) => {
|
||||
requireServerCandidateRange(input.candidate_range, ctx.candidateRange);
|
||||
const body = {
|
||||
birth_date: ctx.birth.birth_date,
|
||||
start_time: input.candidate_range.start_time,
|
||||
|
||||
@@ -78,35 +78,35 @@ test("ordinary product drafts keep the public question and clear hidden routing
|
||||
assert.match(source, /setDraft\(pending\.question\);[\s\S]*?setDraftTheme\(pending\.theme\);[\s\S]*?setDraftEntrypoint\(pending\.entrypoint\);/);
|
||||
});
|
||||
|
||||
test("homepage birth-time card opens the v4 evidence surface instead of ordinary consultation", () => {
|
||||
test("homepage birth-time card opens the latest Agentic surface instead of ordinary consultation", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /function openBirthTimeRectification/);
|
||||
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /<ConversationalBirthTimeRectification/);
|
||||
assert.match(component, /<RectificationV4Panel/);
|
||||
assert.match(component, /<AgenticRectificationChat \{\.\.\.props\} \/>/);
|
||||
assert.doesNotMatch(component, /RectificationV4Panel|loadActiveRectificationV4|transitionRectificationV4/);
|
||||
assert.match(source, /pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
|
||||
assert.doesNotMatch(source.slice(
|
||||
source.indexOf("async function openBirthTimeRectification"),
|
||||
source.indexOf("function handleConversationalRectificationTurn"),
|
||||
), /sendConversationalRectificationCommand/);
|
||||
), /sendConversationalRectificationCommand|rectification\/v4/);
|
||||
assert.doesNotMatch(source, /chooseSuggestedQuestion\([\s\S]{0,180}"birth_time_rectification"/);
|
||||
assert.doesNotMatch(source, /draftBirthTimeRectificationQuestion/);
|
||||
});
|
||||
|
||||
test("homepage opens the v4 panel without invoking the retired v3 start command", () => {
|
||||
test("homepage mounts the Agentic surface without invoking retired rectification starters", () => {
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
|
||||
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
|
||||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||||
const start = page.indexOf("async function openBirthTimeRectification");
|
||||
const end = page.indexOf("function handleConversationalRectificationTurn", start);
|
||||
const handler = page.slice(start, end);
|
||||
|
||||
assert.doesNotMatch(handler, /sendConversationalRectificationCommand/);
|
||||
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(handler, /sendConversationalRectificationCommand|loadRectificationV4Handoff|createRectificationV4/);
|
||||
assert.match(page, /<ConversationalBirthTimeRectification/);
|
||||
assert.match(component, /<RectificationV4Panel/);
|
||||
assert.match(hook, /await loadRectificationV4Handoff\(\)/);
|
||||
assert.match(hook, /await createRectificationV4\(\)/);
|
||||
assert.match(component, /<AgenticRectificationChat/);
|
||||
assert.match(chat, /void send\(\{ action: "opening" \}, false\)/);
|
||||
});
|
||||
|
||||
test("a stale v4 mutation refreshes the same case after a 409", () => {
|
||||
@@ -117,7 +117,7 @@ test("a stale v4 mutation refreshes the same case after a 409", () => {
|
||||
assert.match(hook, /loadRectificationV4\(caseId\)/);
|
||||
});
|
||||
|
||||
test("homepage birth-time card opens its dedicated session before v4 data loads", () => {
|
||||
test("homepage birth-time card opens its dedicated session before the Agent starts", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const start = source.indexOf("async function openBirthTimeRectification");
|
||||
const end = source.indexOf("function handleConversationalRectificationTurn", start);
|
||||
@@ -132,30 +132,32 @@ test("homepage birth-time card opens its dedicated session before v4 data loads"
|
||||
assert.ok(handler.indexOf("setSessions((current) => [") < firstAwait);
|
||||
assert.match(handler, /rectificationOpenInFlight\.current/);
|
||||
assert.match(handler, /rectificationOpenInFlight\.current = true;[\s\S]*?finally \{[\s\S]*?rectificationOpenInFlight\.current = false;/);
|
||||
assert.match(handler, /setRectificationReturnSessionId\(sourceSession\.id\)/);
|
||||
assert.doesNotMatch(handler, /onNarrativeDelta|sendConversationalRectificationCommand/);
|
||||
assert.match(source, /const rectificationSurfaceOpen = activeRectificationSession\s*&& activeSession\.id === rectificationSessionId/);
|
||||
assert.match(source, /rectificationSurfaceOpen && \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
|
||||
});
|
||||
|
||||
test("the v4 panel owns case recovery while the page persists only the dedicated session shell", () => {
|
||||
test("the page persists only the dedicated session shell while the Agent owns opening", () => {
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
|
||||
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
|
||||
const start = page.indexOf("async function openBirthTimeRectification");
|
||||
const end = page.indexOf("function handleConversationalRectificationTurn", start);
|
||||
const handler = page.slice(start, end);
|
||||
|
||||
assert.match(handler, /persistSession\(rectificationSession, "create"\)/);
|
||||
assert.match(hook, /const existingHandoff = await loadRectificationV4Handoff\(\)/);
|
||||
assert.match(hook, /existingHandoff[\s\S]*?loadRectificationV4\(existingHandoff\.caseId\)[\s\S]*?createRectificationV4\(\)/);
|
||||
assert.match(component, /<AgenticRectificationChat/);
|
||||
assert.doesNotMatch(component, /loadRectificationV4Handoff|createRectificationV4/);
|
||||
});
|
||||
|
||||
test("a direct homepage start restores any active v4 case before creating another", () => {
|
||||
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
|
||||
test("a direct homepage start does not restore or create a V4 case", () => {
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
|
||||
const start = page.indexOf("async function openBirthTimeRectification");
|
||||
const end = page.indexOf("function handleConversationalRectificationTurn", start);
|
||||
const handler = page.slice(start, end);
|
||||
|
||||
assert.match(hook, /const existingHandoff = await loadRectificationV4Handoff\(\)/);
|
||||
assert.match(hook, /existingHandoff\s*\? await loadRectificationV4\(existingHandoff\.caseId\)\s*:\s*await createRectificationV4\(\)/);
|
||||
assert.doesNotMatch(hook, /sendConversationalRectificationCommand/);
|
||||
assert.doesNotMatch(handler, /loadRectificationV4Handoff|createRectificationV4|rectification\/v4/);
|
||||
assert.doesNotMatch(component, /loadRectificationV4Handoff|createRectificationV4|RectificationV4Panel/);
|
||||
});
|
||||
|
||||
test("rectification cards render only inside the active rectification session", () => {
|
||||
@@ -181,28 +183,28 @@ test("selecting a rectification session resumes it without an intermediate confi
|
||||
assert.match(source, /<ConversationalBirthTimeRectification/);
|
||||
});
|
||||
|
||||
test("homepage reuses the dedicated rectification session while v4 restores the active case", () => {
|
||||
test("homepage reuses the dedicated rectification session for the Agentic surface", () => {
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
|
||||
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
|
||||
const start = page.indexOf("async function openBirthTimeRectification");
|
||||
const end = page.indexOf("function handleConversationalRectificationTurn", start);
|
||||
const handler = page.slice(start, end);
|
||||
|
||||
assert.match(handler, /sessions\.find\(\(session\) => session\.sessionType === "birth_time_rectification"\)/);
|
||||
assert.match(handler, /existing \?\? createSession/);
|
||||
assert.match(hook, /loadRectificationV4Handoff|createRectificationV4/);
|
||||
assert.match(component, /<AgenticRectificationChat/);
|
||||
});
|
||||
|
||||
test("a bound rectification session and a homepage restart share the v4 active-case loader", () => {
|
||||
test("a bound rectification session and homepage restart share the same Agentic session shell", () => {
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
|
||||
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
|
||||
const start = page.indexOf("async function openBirthTimeRectification");
|
||||
const end = page.indexOf("function handleConversationalRectificationTurn", start);
|
||||
const handler = page.slice(start, end);
|
||||
|
||||
assert.match(handler, /sourceSession\.sessionType === "birth_time_rectification"[\s\S]*?sourceSession[\s\S]*?sessions\.find/);
|
||||
assert.match(hook, /loadRectificationV4Handoff\(\)/);
|
||||
assert.match(hook, /loadRectificationV4\(existingHandoff\.caseId\)/);
|
||||
assert.match(component, /<AgenticRectificationChat/);
|
||||
assert.doesNotMatch(component, /loadRectificationV4Handoff|loadRectificationV4/);
|
||||
});
|
||||
|
||||
test("rectify-first suggestions hand the source question to a dedicated rectification session", () => {
|
||||
@@ -219,17 +221,14 @@ test("rectify-first suggestions hand the source question to a dedicated rectific
|
||||
assert.match(source, /onClick=\{\(\) => chooseConversationSuggestion\(question\)\}/);
|
||||
});
|
||||
|
||||
test("completed handoffs return only after the user clicks and target the source session", () => {
|
||||
test("rectify-first handoffs stay as Agent context without a V4 continuation claim", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.doesNotMatch(source, /automaticRectificationContinuation/);
|
||||
assert.match(source, /const returnSession = \(localHandoff/);
|
||||
assert.match(source, /session\.sessionType === "consultation"/);
|
||||
assert.match(source, /onContinueOriginalQuestion=\{\(continuation\) => void continueRectificationOriginalQuestion\(continuation\)\}/);
|
||||
assert.match(source, /claimRectificationV4Handoff\(\{[\s\S]*?caseId: continuation\.caseId,[\s\S]*?caseVersion: continuation\.caseVersion,[\s\S]*?question/);
|
||||
assert.match(source, /sessionId: returnSession\.id/);
|
||||
assert.match(source, /setActiveSessionId\(context\.sessionId\)/);
|
||||
assert.match(source, /clearBirthTimeConsultationConsent\([\s\S]*?context\.sessionId/);
|
||||
assert.match(source, /pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
|
||||
assert.match(chat, /pendingConsultationQuestion\?\.trim\(\)/);
|
||||
assert.match(chat, /之后再回到你原来的问题/);
|
||||
assert.doesNotMatch(source, /onContinueOriginalQuestion|continueRectificationOriginalQuestion|claimRectificationV4Handoff/);
|
||||
});
|
||||
|
||||
test("ordinary consultation uses current birth data without a rectification notice", () => {
|
||||
|
||||
@@ -108,56 +108,34 @@ function analysisTrace(label: string) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
test("agentic rectification requests an Agent-generated opening when the surface mounts", () => {
|
||||
test("agentic rectification requests a server-owned opening when the surface mounts", () => {
|
||||
const component = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(component, /const openingStarted = useRef\(false\)/);
|
||||
assert.match(
|
||||
component,
|
||||
/useEffect\(\(\) => \{[\s\S]*?openingStarted\.current = true;[\s\S]*?void send\(agenticOpeningInstruction, false\);[\s\S]*?\}, \[send\]\);/,
|
||||
/useEffect\(\(\) => \{[\s\S]*?openingStarted\.current = true;[\s\S]*?void send\(\{ action: "opening" \}, false\);[\s\S]*?\}, \[send\]\);/,
|
||||
);
|
||||
assert.match(component, /const send = useCallback\(async \(question: string, showUserMessage = true\) =>/);
|
||||
assert.match(component, /\.\.\.\(showUserMessage \? \[\{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" \} satisfies RenderMessage\] : \[\]\)/);
|
||||
assert.match(component, /const send = useCallback\(async \(request: AgenticRectificationRequest, showUserMessage = true\) =>/);
|
||||
assert.match(component, /showUserMessage && request\.action === "message"/);
|
||||
assert.doesNotMatch(component, /agenticOpeningInstruction|用户刚进入生时校正会话/);
|
||||
});
|
||||
|
||||
test("v4 rectification reuses the ordinary session message list, composer, and model selector", () => {
|
||||
const component = readFileSync(new URL("../src/components/rectification-v4-panel.tsx", import.meta.url), "utf8");
|
||||
test("latest Agentic rectification reuses the ordinary session message list, composer, and model selector", () => {
|
||||
const component = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||||
const wrapper = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
assert.match(
|
||||
component,
|
||||
/<>\s*<section className="conversation"[\s\S]*?<\/section>\s*\{showControls && \(\s*<div className="composer-wrap"/,
|
||||
);
|
||||
assert.match(component, /<section className="conversation"/);
|
||||
assert.match(component, /className="message-list"/);
|
||||
assert.match(component, /<ChatMessageRow/);
|
||||
assert.match(component, /className="composer-wrap"/);
|
||||
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, /aria-label="重新生成回答"/);
|
||||
assert.match(component, /<details className="rectification-analysis">/);
|
||||
assert.match(component, /<span>分析过程<\/span>/);
|
||||
assert.match(
|
||||
component,
|
||||
/<RectificationAnalysisDetails trace=\{message\.analysisTrace\} \/>[\s\S]*?<RectificationMessageRow[\s\S]*?<div className="rectification-message-actions"/,
|
||||
);
|
||||
assert.match(component, /caseValue\?\.deploymentMode === "v5_agent"/);
|
||||
assert.match(component, /controller\.regenerate\(\)/);
|
||||
assert.match(component, /controller\.answer\(answer, props\.selectedModelId \|\| null\)/);
|
||||
assert.doesNotMatch(wrapper, /existing \? "v4" : "agentic"/);
|
||||
assert.match(wrapper, /继续旧版校正/);
|
||||
assert.match(wrapper, /结束旧版并使用新版 Agent/);
|
||||
assert.match(wrapper, /transitionRectificationV4\(existing\.case\.id, existing\.case\.version, "abandon"\)/);
|
||||
assert.match(wrapper, /<RectificationV4Panel \{\.\.\.props\} onUseAgentic=/);
|
||||
assert.match(component, /controller\.abandon\(\)/);
|
||||
assert.match(component, /Runtime/);
|
||||
assert.match(component, /Deployment SHA/);
|
||||
assert.match(component, /fallbackCode/);
|
||||
assert.match(wrapper, /return <AgenticRectificationChat \{\.\.\.props\} \/>/);
|
||||
assert.doesNotMatch(wrapper, /RectificationV4Panel|继续旧版校正|transitionRectificationV4/);
|
||||
assert.match(page, /\{!rectificationSurfaceOpen && \(\s*<div className=\{`conversation/);
|
||||
assert.match(page, /\{rectificationSurfaceOpen && \(\s*<ConversationalBirthTimeRectification/);
|
||||
assert.doesNotMatch(page, /is-rectification/);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const component = readFileSync(
|
||||
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const chat = readFileSync(
|
||||
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const route = readFileSync(
|
||||
new URL("../src/app/api/rectification/agent/route.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("birth-time rectification entry no longer routes through V4", () => {
|
||||
assert.doesNotMatch(component, /loadActiveRectificationV4|RectificationV4Panel|transitionRectificationV4/);
|
||||
assert.match(component, /return <AgenticRectificationChat \{\.\.\.props\} \/>/);
|
||||
});
|
||||
|
||||
test("opening is a server-owned operation rather than a hidden user prompt", () => {
|
||||
assert.doesNotMatch(chat, /agenticOpeningInstruction|用户刚进入生时校正会话/);
|
||||
assert.match(chat, /action: "opening"/);
|
||||
assert.match(route, /z\.literal\("opening"\)/);
|
||||
assert.match(route, /parsed\.data\.action === "opening"/);
|
||||
});
|
||||
|
||||
test("incomplete profiles stay in the shared onboarding flow", () => {
|
||||
const opening = page.slice(
|
||||
page.indexOf("async function openBirthTimeRectification"),
|
||||
page.indexOf("resumeRectificationSession.current ="),
|
||||
);
|
||||
assert.match(opening, /const missingStep = missingProfileStep\(profile\)/);
|
||||
assert.match(opening, /setOnboardingStep\(missingStep\)/);
|
||||
assert.ok(
|
||||
opening.indexOf("const missingStep = missingProfileStep(profile)")
|
||||
< opening.indexOf("setRectificationSessionId(rectificationSession.id)"),
|
||||
);
|
||||
assert.match(chat, /payload\?\.code === "profile_incomplete"/);
|
||||
});
|
||||
@@ -52,6 +52,7 @@ test("loadAgenticRectificationProfile derives birth fields, accuracy and baselin
|
||||
const profile = await loadAgenticRectificationProfile(client as never, userId);
|
||||
assert.equal(profile.birth_date, "1990-05-12");
|
||||
assert.equal(profile.reported_time, "14:31");
|
||||
assert.deepEqual(profile.candidateRange, { start_time: "14:21", end_time: "14:41" });
|
||||
assert.equal(profile.lat, 31.23);
|
||||
assert.equal(profile.lon, 121.47);
|
||||
assert.equal(profile.tz, 8);
|
||||
@@ -68,6 +69,43 @@ test("loadAgenticRectificationProfile treats hospital source as minute accuracy"
|
||||
const profile = await loadAgenticRectificationProfile(client as never, userId);
|
||||
assert.equal(profile.declaredAccuracy, "minute");
|
||||
assert.equal(profile.timeSource, "hospital");
|
||||
assert.deepEqual(profile.candidateRange, { start_time: "09:03", end_time: "09:07" });
|
||||
});
|
||||
|
||||
test("loadAgenticRectificationProfile accepts a period-only declaration without a fake minute", async () => {
|
||||
const { client } = fakeAccounting(fakeProfileRow({
|
||||
reported_birth_time: null,
|
||||
active_birth_time: null,
|
||||
birth_time_source: "period_only",
|
||||
birth_time_period: "late_night",
|
||||
}));
|
||||
const profile = await loadAgenticRectificationProfile(client as never, userId);
|
||||
assert.equal(profile.reported_time, null);
|
||||
assert.deepEqual(profile.candidateRange, { start_time: "23:00", end_time: "03:59" });
|
||||
assert.equal(profile.declaredAccuracy, "unknown");
|
||||
});
|
||||
|
||||
test("loadAgenticRectificationProfile accepts an unknown time as the full day", async () => {
|
||||
const { client } = fakeAccounting(fakeProfileRow({
|
||||
reported_birth_time: null,
|
||||
active_birth_time: null,
|
||||
birth_time_source: "unknown",
|
||||
}));
|
||||
const profile = await loadAgenticRectificationProfile(client as never, userId);
|
||||
assert.equal(profile.reported_time, null);
|
||||
assert.deepEqual(profile.candidateRange, { start_time: "00:00", end_time: "23:59" });
|
||||
});
|
||||
|
||||
test("loadAgenticRectificationProfile preserves a cross-midnight uncertainty range", async () => {
|
||||
const { client } = fakeAccounting(fakeProfileRow({
|
||||
reported_birth_time: "00:10:00",
|
||||
active_birth_time: null,
|
||||
birth_time_source: "approximate",
|
||||
uncertainty_before_minutes: 30,
|
||||
uncertainty_after_minutes: 30,
|
||||
}));
|
||||
const profile = await loadAgenticRectificationProfile(client as never, userId);
|
||||
assert.deepEqual(profile.candidateRange, { start_time: "23:40", end_time: "00:40" });
|
||||
});
|
||||
|
||||
test("loadAgenticRectificationProfile rejects a missing birth date", async () => {
|
||||
|
||||
@@ -52,6 +52,7 @@ function makeCtx(applyConfirmedBirthTime?: AgenticRectificationContext["applyCon
|
||||
userId: "user-1",
|
||||
engineBase: "http://engine.test",
|
||||
birth,
|
||||
candidateRange: { start_time: "14:00", end_time: "15:00" },
|
||||
declaredAccuracy: "15min",
|
||||
timeSource: "family_clear",
|
||||
applyConfirmedBirthTime: applyConfirmedBirthTime ?? (async (time) => ({ ok: true as const, saved_time: time })),
|
||||
@@ -196,7 +197,7 @@ test("score tool normalizes year-precision events into the V5 date range contrac
|
||||
engine.restore();
|
||||
});
|
||||
|
||||
test("scan tool posts the reported time and uncertainty to /api/rectification/sensitivity_scan", async () => {
|
||||
test("scan tool derives its center and uncertainty from the server-owned range", async () => {
|
||||
const engine = installEngine([
|
||||
{
|
||||
path: "/api/rectification/sensitivity_scan",
|
||||
@@ -223,7 +224,7 @@ test("scan tool posts the reported time and uncertainty to /api/rectification/se
|
||||
},
|
||||
]);
|
||||
const tools = createAgenticRectificationTools(makeCtx());
|
||||
const result = await runTool(tools, "rectification-scan", { uncertainty_minutes: 30 });
|
||||
const result = await runTool(tools, "rectification-scan", {});
|
||||
const sent = requestBody(engine);
|
||||
assert.equal(sent.hour, 14);
|
||||
assert.equal(sent.minute, 30);
|
||||
@@ -233,6 +234,48 @@ test("scan tool posts the reported time and uncertainty to /api/rectification/se
|
||||
engine.restore();
|
||||
});
|
||||
|
||||
test("gate keeps period-only profiles on the server-owned range without inventing a minute", async () => {
|
||||
const engine = installEngine([]);
|
||||
const tools = createAgenticRectificationTools({
|
||||
...makeCtx(),
|
||||
birth: { ...birth, reported_time: null },
|
||||
candidateRange: { start_time: "23:00", end_time: "03:59" },
|
||||
declaredAccuracy: "unknown",
|
||||
});
|
||||
const result = await runTool(tools, "rectification-gate", {});
|
||||
assert.equal(engine.calls.length, 0);
|
||||
assert.equal(result.endpoint, "server_owned_rectification_preflight");
|
||||
assert.deepEqual(result.candidate_range, { start_time: "23:00", end_time: "03:59" });
|
||||
engine.restore();
|
||||
});
|
||||
|
||||
test("scan defers an unknown full-day range instead of using a fake noon birth time", async () => {
|
||||
const engine = installEngine([]);
|
||||
const tools = createAgenticRectificationTools({
|
||||
...makeCtx(),
|
||||
birth: { ...birth, reported_time: null },
|
||||
candidateRange: { start_time: "00:00", end_time: "23:59" },
|
||||
});
|
||||
const result = await runTool(tools, "rectification-scan", {});
|
||||
assert.equal(engine.calls.length, 0);
|
||||
assert.equal(result.status, "deferred_wide_range");
|
||||
engine.restore();
|
||||
});
|
||||
|
||||
test("score rejects an Agent-invented range", async () => {
|
||||
const engine = installEngine([]);
|
||||
const tools = createAgenticRectificationTools(makeCtx());
|
||||
await assert.rejects(
|
||||
() => runTool(tools, "rectification-score", {
|
||||
candidate_range: { start_time: "14:15", end_time: "14:45" },
|
||||
events: sampleEvents,
|
||||
}),
|
||||
/candidate_range_mismatch/,
|
||||
);
|
||||
assert.equal(engine.calls.length, 0);
|
||||
engine.restore();
|
||||
});
|
||||
|
||||
test("save tool rejects before a confirmation gate exists", async () => {
|
||||
const engine = installEngine([]);
|
||||
const applied: string[] = [];
|
||||
|
||||
Reference in New Issue
Block a user