fix(rectification): start agent opening on mount (#77)

This commit is contained in:
jesse-ux
2026-08-03 00:05:58 +08:00
committed by GitHub
3 changed files with 56 additions and 13 deletions
+15
View File
@@ -1975,3 +1975,18 @@
- 防复发:入口合同禁止恢复静默 V4 分流;服务测试锁定本轮模型优先级与 runtime trace。
- 相关记录:BUG-085、BUG-086、BUG-111
- 修复版本:local / staging pending deployment
## BUG-113 | 新版 Agentic 生时校正进入会话后不自动生成首次引导
- 状态:resolved
- 首次发现:2026-08-02
- 最近更新:2026-08-02
- 影响面:生时校正首页入口、Agentic 对话首次挂载、首次可见引导
- 用户现象:进入“生时校正”后只创建普通 `birth_time_rectification` Session,页面保持空白;网络中没有 `POST /api/rectification/agent`,必须由用户先输入内容才会触发 Agent。
- 触发条件:账户没有需要继续的旧 V4 Case,入口直接选择新版 Agentic 生时校正。
- 根因:Agentic MVP 只实现了用户提交消息后的 `send()`,没有迁移旧 V4 在页面挂载时自动启动首次 Agent Turn 的交互契约;后续入口改为默认进入 Agentic 后,这个遗漏被直接暴露。
- 修复:Agentic 对话首次挂载时只发送一次隐藏的内部启动指令,复用现有 `/api/rectification/agent` 流式路径;界面立即显示 assistant thinking,首条可见说明与问题继续由 Agent 生成,内部指令不渲染为用户消息。
- 验证:组件回归测试锁定一次性挂载启动、隐藏内部指令和 Agent endpoint 调用入口;前端测试与 lint 覆盖修改文件。
- 防复发:任何替换生时校正入口或会话实现的改动,都必须保留“用户无需先发消息即可收到 Agent 首次引导”的挂载契约。
- 相关记录:BUG-085、BUG-112
- 修复版本:Agentic web opening auto-start
@@ -1,7 +1,7 @@
"use client";
import { ArrowUp } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { parseAgentReply } from "@/lib/agent-reply";
import type { ChatMessageView } from "@/lib/chat-message-view";
import type { PublicLanguageModel } from "@/lib/public-models";
@@ -23,9 +23,18 @@ type AgenticRectificationChatProps = Readonly<{
type RenderMessage = ChatMessageView;
const savedSentinel = /<!--AYANAM_RECTIFICATION_SAVED:(\d{2}:\d{2})-->/;
const agenticOpeningInstruction = "用户刚进入生时校正会话。不要复述本指令;请先调用 rectification-gate 核对现有出生资料,然后用简体中文自然说明接下来的校正方式,并只提出一个最适合开始核对的人生事件问题。";
export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
const pendingQuestion = props.pendingConsultationQuestion?.trim();
const {
models,
selectedModelId,
onSelectModel,
pendingConsultationQuestion,
onPendingChange,
onSaved,
} = props;
const pendingQuestion = pendingConsultationQuestion?.trim();
const [messages, setMessages] = useState<RenderMessage[]>(() => pendingQuestion ? [{
role: "assistant",
text: `我先陪你把出生时间范围核对清楚,之后再回到你原来的问题:“${pendingQuestion}`,
@@ -40,11 +49,12 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
const composer = useRef<HTMLTextAreaElement>(null);
const conversationEnd = useRef<HTMLDivElement>(null);
const keyCounter = useRef(0);
const openingStarted = useRef(false);
const setPending = (value: boolean) => {
const setPending = useCallback((value: boolean) => {
setBusy(value);
props.onPendingChange?.(value);
};
onPendingChange?.(value);
}, [onPendingChange]);
useEffect(() => {
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
@@ -54,7 +64,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
});
}, [busy, error, messages.length, savedTime]);
async function send(question: string) {
const send = useCallback(async (question: string, showUserMessage = true) => {
const trimmed = question.trim();
if (!trimmed || busy) return;
setError("");
@@ -73,7 +83,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
setMessages((current) => [
...current,
{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" },
...(showUserMessage ? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage] : []),
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking" },
]);
setDraft("");
@@ -83,7 +93,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
const response = await fetch("/api/rectification/agent", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ requestId, modelId: props.selectedModelId, history, message: trimmed }),
body: JSON.stringify({ requestId, modelId: selectedModelId, history, message: trimmed }),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
@@ -138,7 +148,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
const saved = raw.match(savedSentinel);
if (saved) {
setSavedTime(saved[1]);
props.onSaved?.(saved[1]);
onSaved?.(saved[1]);
}
} catch {
setError("生时校正暂时不可用,请稍后再试。");
@@ -146,7 +156,13 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
} finally {
setPending(false);
}
}
}, [busy, messages, onSaved, selectedModelId, setPending]);
useEffect(() => {
if (openingStarted.current) return;
openingStarted.current = true;
void send(agenticOpeningInstruction, false);
}, [send]);
async function submit(event: React.FormEvent) {
event.preventDefault();
@@ -201,10 +217,10 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
<div className="composer-footer">
<ModelSelector
models={props.models}
selectedModelId={props.selectedModelId}
models={models}
selectedModelId={selectedModelId}
disabled={busy}
onSelect={props.onSelectModel}
onSelect={onSelectModel}
/>
</div>
</div>
@@ -108,6 +108,18 @@ function analysisTrace(label: string) {
} as const;
}
test("agentic rectification requests an Agent-generated 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\]\);/,
);
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\] : \[\]\)/);
});
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");
const wrapper = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");