diff --git a/CHANGELOG.md b/CHANGELOG.md
index e1e65f03..5b7304d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
+## 2026-09-17 — 校正会话上不再出现可用的普通输入框
+
+在生时校正会话里,普通对话的输入框不再能用:打开校正面之前是禁用的「正在打开生时校正…」;打开失败时可以点「重新打开生时校正」。打在那里的字不会再发到普通咨询。Skill 版本不变。
+
## 2026-09-17 — 星盘页、星历页去掉三句说明
打开星盘后,Tab 行下面不再写「主盘直接算 · 分盘按需 · 不消耗点数」。基础信息里也不再写「下面是词条式释义,不是对你个人的判断。」星历页底部不再写「这一页是天象本身,不是对你的判断。」盘还是主盘先算、分盘点到才算、不扣点;行星卡仍是度数和星宿;星历仍只写天象事实,不是运势判断。Skill 版本不变。
diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md
index eda81136..f78a682b 100644
--- a/docs/BUG_HISTORY.md
+++ b/docs/BUG_HISTORY.md
@@ -12034,3 +12034,35 @@
- 相关记录:BUG-214、BUG-286、BUG-922
- 复发自:无
- 修复版本:待发布
+
+## BUG-924 | 校正会话上普通输入框可用,问题发到 `/api/consult`
+
+- 状态:resolved
+- 首次发现:2026-09-17
+- 最近更新:2026-09-17
+- 影响面:`use-consultation-run.ts` `send()`、`page.tsx` 普通 `ChatComposer`、`use-session-management.ts` 回退路径
+- 用户现象:在生时校正会话里打了一个普通问题,请求打到咨询接口,提示「咨询会话不存在」。
+- 触发条件:校正会话已激活但校正面未开(刷新揭幕超时、打开失败不重试、删除或回退落到列表第一条校正会话)。
+- 根因:发送链路以「校正面没开」推断「当前是普通会话」;`send()` 不看 `sessionType`。BUG-505 只覆盖了 `selectSession`。
+- 修复:`send()` 对校正会话不发 `/api/consult`,保留草稿并打开校正面。普通输入框在校正会话上只有禁用态。删除 / popstate / `sessions[0]` 回退不再直接 `setActiveSessionId` 到校正会话。
+- 验证:`rectification-session-composer-guard.test.ts`、`rectification-surface-contract.test.ts`。
+- 防复发:校正会话不得发出 `/api/consult`;普通输入框在校正会话上不得可用。
+- 相关记录:BUG-505、BUG-925
+- 复发自:无
+- 修复版本:待发布
+
+## BUG-925 | 咨询接口把校正会话和「会话不存在」都回 404
+
+- 状态:resolved
+- 首次发现:2026-09-17
+- 最近更新:2026-09-17
+- 影响面:`api/consult/route.ts`、`use-consultation-run.ts` 错误映射
+- 用户现象:校正会话误发咨询时只看到「咨询会话不存在」。
+- 触发条件:`POST /api/consult` 的 `sessionId` 指向 `session_type !== consultation`。
+- 根因:查不到与类型不对共用一个 404。
+- 修复:查不到仍 404「咨询会话不存在」;类型不对 409 `session_not_consultation`。前端收到该码不显示错误、不扣点,改走打开校正面。
+- 验证:`chat-session-authority.test.ts`、`rectification-session-composer-guard.test.ts`。
+- 防复发:不得把两种失败再合成一个 404。
+- 相关记录:BUG-924
+- 复发自:无
+- 修复版本:待发布
diff --git a/docs/tasks/PROGRESS-rectification-session-composer-guard-20260917.md b/docs/tasks/PROGRESS-rectification-session-composer-guard-20260917.md
new file mode 100644
index 00000000..43b282aa
--- /dev/null
+++ b/docs/tasks/PROGRESS-rectification-session-composer-guard-20260917.md
@@ -0,0 +1,33 @@
+# PROGRESS · 校正会话输入框守卫(2026-09-17)
+
+工作树:`.worktrees/rectification-session-composer-guard-20260917`
+分支:`codex/rectification-session-composer-guard-20260917`
+基线:`origin/staging` @ `8eb1de1f`
+本机 Windows。
+
+Skill **未 bump**。未改 Python、迁移、Skill、校正面组件、校正 Agent。
+
+## 任务状态
+
+| 任务 | 状态 | 说明 |
+| --- | --- | --- |
+| T1 send() 会话类型守卫 BUG-924 | 完成 | 校正会话不发 `/api/consult`,保留草稿,打开校正面;pending 指向校正会话则丢弃 |
+| T4 错误码 BUG-925 | 完成 | 查不到 404;类型不对 409 `session_not_consultation`;前端不显示错误、走 T1 |
+| T2 普通输入框禁用态 | 完成 | placeholder「正在打开生时校正…」;失败时「重新打开生时校正」 |
+| T3 回退走 selectSession | 完成 | `activateFallbackSession`;popstate 用 `fallbackSessionId`;`sessions[0]` 改为第一条非校正 |
+| T5 记录 | 完成 | BUG-924/925、CHANGELOG、DESIGN、VOICE、真人清单 |
+
+## 让步
+
+`/api/consult/cancel` 与 `status` 按 requestId 工作,没有 session_type 判断,未改。
+
+## 测试
+
+| 项 | 结果 |
+| --- | --- |
+| `tsc --noEmit` | 0 错 |
+| `npm run lint` | 0 error / 116 warning(与基线同;本轮文件 0 条) |
+| 定向 | composer-guard / surface-contract / chat-session-authority / bootstrap-reveal / home-shell-growth **34 passed / 0 failed** |
+| `page.tsx` 行数 | 开工 1835 → 现在 ≤ 1835 |
+
+无登录态、无 Chrome:走查见 `docs/testing/rectification-session-composer-guard-20260917.md`。
diff --git a/docs/tasks/README.md b/docs/tasks/README.md
index c8c9d6b0..2212ac2c 100644
--- a/docs/tasks/README.md
+++ b/docs/tasks/README.md
@@ -106,7 +106,7 @@
| `TASK-rectification-dead-d9-choice-fix-20260916.md` | `PROGRESS-rectification-dead-d9-choice-fix-20260916.md` | **验收修复单**:自建分盘探针照 `withNakshatraBoundaryProbe` 模式在每个读 state 的地方(盖戳 / 答题 / `previousInferenceFromReceipt` / GET 投影 / idle 过期判定 / 计划承接)从 receipt 确定性重建(BUG-915);测试改走生产路径、删手工塞探针的 fixture;顺带 host 前置挪到会话校验之后、登记 phase(BUG-916);死卡不得配 `collect_waiting` 占位(BUG-917);**产品决策 1c**:风格题默认不作全额计分(沿用 09-09 ±1 拍板),无事件探针就走下一条线。**staging 现状:风格题答不了,先跳过**。§1d 已记 **产品决策 (b)** | 已验收 | `5113d457`(BUG-915~917,产品拍板 (b) 风格题不计分)。Claude 独立验收:生产路径测试(receipt 不含自建探针)答题 applied / GET 出卡 / idle 不误伤;合并候选门禁 tsc 0 / lint 0 error / npm test 3427 条 31 红与基线逐条相同 / `/` Static / 首屏 gzip 620,107→620,193(+0.01%)/ pytest 63 绿 / 快速门 Python 798 绿。真机清单 `docs/testing/rectification-dead-d9-choice-fix-20260916.md` |
| `TASK-rectification-mobile-timeline-readout-20260917.md` | `PROGRESS-rectification-mobile-timeline-readout-20260917.md` | 手机截图:时间轴读数第四项被裁成「已…」(nowrap + inset 内边距,BUG-918);「跳到最新」浮层压住选项 C(BUG-919)。灰卡与「再说一件」矛盾归修复单 BUG-915/917 | 已验收 | `e8e98bbd`(合入时重放为本分支提交,BUG-918/919)。时间轴相关 71 条 + 全量同上;手机上隐藏「已对照 N 件」符合 DESIGN §10;桌面是否也移除待产品拍板。真机清单 `docs/testing/rectification-mobile-timeline-readout-20260917.md` |
| `TASK-mobile-viewport-scroll-lock-20260917.md` | `PROGRESS-mobile-viewport-scroll-lock-20260917.md` | iPhone 上键盘收起 / 刷新后整页上移、顶栏点不到(BUG-920):iOS 不支持 `interactive-widget`,键盘弹出时 Safari 滚动 window,`html/body overflow: hidden` 让用户拉不回来,reload 又还原 `scrollY`;代码里无任何 window 级复位。补 `ViewportScrollLock`(`scrollRestoration=manual` + `visualViewport` 复位)。与今天两单无关,既有缺陷。**T3 顶栏积分块「有点扁」(BUG-921)**:两枚芯片 44px 塞在 46px 顶栏里、字号/内边距/圆角各一套、积分块强制 64px 最小宽度;统一尺寸并把手机顶栏放到 52px | 待验收 | `codex/mobile-viewport-scroll-lock-20260917` |
-| `TASK-rectification-session-composer-guard-20260917.md` | — | 校正会话激活但校正面未开(刷新揭幕超时 / 打开失败不重试 / 删除与 popstate 回退落到列表第一条)时露出可用的普通输入框,问题发到 `/api/consult` 回 404「咨询会话不存在」;`send()` 无会话类型守卫;服务端无独立错误码。关联 BUG-505。BUG 段 924 起 | 待领取 | — |
+| `TASK-rectification-session-composer-guard-20260917.md` | `PROGRESS-rectification-session-composer-guard-20260917.md` | 校正会话激活但校正面未开(刷新揭幕超时 / 打开失败不重试 / 删除与 popstate 回退落到列表第一条)时露出可用的普通输入框,问题发到 `/api/consult` 回 404「咨询会话不存在」;`send()` 无会话类型守卫;服务端无独立错误码。关联 BUG-505。BUG 段 924 起 | 待验收 | `codex/rectification-session-composer-guard-20260917` |
### 聊天主链路与首页
diff --git a/docs/testing/rectification-session-composer-guard-20260917.md b/docs/testing/rectification-session-composer-guard-20260917.md
new file mode 100644
index 00000000..bca1fb13
--- /dev/null
+++ b/docs/testing/rectification-session-composer-guard-20260917.md
@@ -0,0 +1,24 @@
+# 真机清单 · 校正会话普通输入框守卫(2026-09-17)
+
+执行环境无登录态、无 Chrome。下列条目留给有真实账号的人在 staging 走查。截图不要带姓名、出生资料或会话正文。
+
+## 刷新后立刻打字
+
+1. 打开一条进行中的生时校正会话。
+2. 刷新页面。揭幕后立刻在底部输入框打字。
+3. **看:** 输入框是禁用的,占位「正在打开生时校正…」。没有转圈。
+4. 几秒后校正面出现。刚才打的字还在草稿里(不要发到普通咨询)。
+
+## 打开失败后重新打开
+
+1. 断网(或开发者工具离线)。
+2. 点侧栏一条校正会话。
+3. **看:** 错误文案 + 「重新打开生时校正」按钮。普通输入框仍禁用。
+4. 恢复网络,点按钮。
+5. **看:** 校正面打开。
+
+## 删除后落到校正会话
+
+1. 当前是一条普通对话,侧栏下一条(或第一条剩下的)是校正会话。
+2. 删除当前普通对话。
+3. **看:** 不会出现可用的普通输入框。要么打开校正面,要么输入框保持禁用。
diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index 211b9208..9a894e4d 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -218,7 +218,7 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and
- **Structure:** a column, not a row — warm canvas field on top, the composer's own bottom row beneath it. The row carries the model picker on the left and the send/stop control on the right, with the character count inline beside it.
- **No strip below the composer.** `.composer-footer` used to sit under the field at a permanent `min-height: 44px`, holding the model picker and a count that only appears near the cap — so most of the time it was 44px of empty chrome on every screen. The row is inside the box now and only renders what it has.
-- **States:** default, hover, focus with deep-brown ring, disabled only for structural reasons (readonly, session loading, onboarding, the other surface open), invalid. Generating does not disable the textarea; the send control becomes stop.
+- **States:** default, hover, focus with deep-brown ring, disabled only for structural reasons (readonly, session loading, onboarding, the other surface open, **a rectification session whose surface is not yet open**), invalid. Generating does not disable the textarea; the send control becomes stop. On a rectification session the ordinary composer is only a disabled field with placeholder「正在打开生时校正…」; if opening failed, a「重新打开生时校正」button sits above it. No spinner.
- **Queue:** one pending card (`.composer-queue`) sits above the field while an answer is in flight. Copy: “已排队,回答结束后发出”, plus the text and a 44px “撤回”. A second Enter appends to the same card with a newline. Successful settlement sends it; stop, failure, or recovery puts the text back in the field. The send button is disabled while a card is waiting. No spinner.
- **Accessibility:** persistent label where practical; composer has an explicit accessible label.
- **One composer:** the rectification surface renders the same `ChatComposer` as the main chat; there is no second composer. A surface that owns its own draft passes it as `value`; the main chat reads the draft store. Both count down from the same 500-character ceiling through `CharacterRemaining`, which the composer renders inline and only once `characterRemainingVisible()` is true — it no longer has a permanent container. Controls that belong to the composer go through the `toolbar` slot; nothing is stacked below it.
diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md
index 40974467..85d68154 100644
--- a/frontend/docs/VOICE.md
+++ b/frontend/docs/VOICE.md
@@ -66,6 +66,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 打开即有 / 直接计算 · 打开即有 · 不消耗点数 / 主盘直接算 · 分盘按需 · 不消耗点数 | (不写) | 星盘页不写成本、速度、计费说明。失败页也不写。忙(429)说「算盘的服务正忙,稍等几秒再打开就好。」;其它失败说「这张盘算不出来,我们已经记录下来了。」不要说「过一会儿再打开」。 |
| 下面是词条式释义,不是对你个人的判断。 | (不写) | 基础信息 Tab 只放行星卡。词条本身不得写成运势,但不另印这句边界说明。 |
| 这一页是天象本身,不是对你的判断。 | (不写) | 星历页只放日期、五要素、行运、九十天事件。「带这天去提问」在顶栏。不另印定性句。 |
+| (校正会话上普通输入框仍可打字) | 正在打开生时校正… | 校正会话上普通输入框只有禁用态。打开失败时按钮写「重新打开生时校正」。 |
## 服务端探针 → Agent 题干
diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts
index f473b000..bdad37a5 100644
--- a/frontend/src/app/api/consult/route.ts
+++ b/frontend/src/app/api/consult/route.ts
@@ -305,12 +305,22 @@ export async function POST(request: Request) {
{ status: 503 },
);
}
- if (!chatSession || chatSession.session_type !== "consultation") {
+ if (!chatSession) {
return NextResponse.json(
{ error: "咨询会话不存在", message: "请重新进入咨询。" },
{ status: 404 },
);
}
+ if (chatSession.session_type !== "consultation") {
+ return NextResponse.json(
+ {
+ error: "这是生时校正会话",
+ code: "session_not_consultation",
+ message: "请在生时校正里继续。",
+ },
+ { status: 409 },
+ );
+ }
if (!chatSession.model_id || chatSession.model_id !== parsed.data.modelId) {
return NextResponse.json(
{ error: "会话模型已经变化", message: "请刷新会话后重新发送,本次不会扣点。" },
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index d72cc0c7..5bf12403 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -21,6 +21,11 @@ import {
rectificationEntryLabels,
resolveRectificationEntryAction,
} from "@/lib/rectification-entry";
+import {
+ dropRectificationStoredPending,
+ ordinaryComposerPlaceholder,
+ rectificationOrdinaryComposerReopen,
+} from "@/lib/rectification-session-composer-guard";
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
import {
declaredBirthTime,
@@ -34,6 +39,7 @@ import { ChatTranscript, type ChatTranscriptActions } from "@/components/chat-tr
import { ModelSelector } from "@/components/model-selector";
import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall";
import { ChatComposer } from "@/components/chat-composer";
+import { RectificationComposerReopen } from "@/components/rectification-composer-reopen";
import { JumpToLatestButton } from "@/components/jump-to-latest-button";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
@@ -166,7 +172,6 @@ import {
type Profile,
type ReplyOutcome,
type RequestError,
- type StoredPendingConsultation,
type StreamingReply,
type Theme,
} from "@/lib/home-types";
@@ -376,10 +381,12 @@ export default function Home() {
onEditBirthTimeDetails: () => editDeclaredBirthTimeDetails(),
});
- const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0];
+ const activeSession = sessions.find((session) => session.id === activeSessionId)
+ ?? sessions.find((session) => session.sessionType !== "birth_time_rectification");
const activeRectificationSession = activeSession?.sessionType === "birth_time_rectification";
- const rectificationSurfaceOpen = activeRectificationSession
- && activeSession.id === rectificationSessionId;
+ const rectificationSurfaceOpen = Boolean(activeRectificationSession && activeSession && activeSession.id === rectificationSessionId);
+ const rectificationComposerLocked = Boolean(activeRectificationSession && !rectificationSurfaceOpen);
+ const rectificationComposerReopen = rectificationOrdinaryComposerReopen({ activeRectificationSession, error: rectificationError, errorSessionId: rectificationErrorSessionId, sessionId: activeSession?.id });
const activeError = requestError && requestError.sessionId === activeSession?.id ? requestError.message : "";
const isLoading = pendingSessionId === activeSession?.id;
const productEntrypointsDisabled = !hydrated
@@ -460,10 +467,8 @@ export default function Home() {
});
const {
- setRectificationHeaderSlot,
- rectificationPanel,
- resumeRectificationSession,
- openRectificationFromHomepage,
+ setRectificationHeaderSlot, rectificationPanel, resumeRectificationSession,
+ openRectificationFromHomepage, openRectificationSession, reopenRectificationSession,
} = useRectificationSurface({
account, activeChartId, activeSessionIdRef, chartLibrary, creatingSession, modelCatalog,
persistSession, profile, rectification, setRectification,
@@ -699,7 +704,7 @@ export default function Home() {
&& presetMessageFinished
&& !rectificationSurfaceOpen
&& Boolean(activeSession?.messagesHydrated)
- && !activeSession.messages.length;
+ && !activeSession?.messages.length;
const sessionMessagesLoading = Boolean(
activeSession
&& activeSession.sessionType === "consultation"
@@ -766,8 +771,7 @@ export default function Home() {
startNewChat,
continueInNewChat,
refreshAccount,
- openAccountDialog,
- openRectificationFromHomepage,
+ openAccountDialog, openRectificationFromHomepage, openRectificationSession,
});
const jumpToLatestVisible = !rectificationSurfaceOpen
&& !starterHomeVisible
@@ -939,13 +943,11 @@ export default function Home() {
nextSessions = applyLegacySessionControls(nextAccount.user.id, nextSessions);
let reservedConsultation: ConsultationStatus | null = null;
- const storedPending: StoredPendingConsultation | null = readStoredPendingConsultation(
- sessionStorage.getItem(pendingConsultationStorageKey),
- nextSessions.map((session) => session.id),
+ const storedPending = dropRectificationStoredPending(
+ readStoredPendingConsultation(sessionStorage.getItem(pendingConsultationStorageKey), nextSessions.map((session) => session.id)),
+ nextSessions,
);
- if (!storedPending && sessionStorage.getItem(pendingConsultationStorageKey)) {
- sessionStorage.removeItem(pendingConsultationStorageKey);
- }
+ if (!storedPending) sessionStorage.removeItem(pendingConsultationStorageKey);
if (storedPending) {
try {
@@ -1747,22 +1749,15 @@ export default function Home() {
{jumpToLatestVisible && (
)}
+ {rectificationComposerReopen && activeSession ? void reopenRectificationSession(activeSession.id)} /> : null}
void;
+}) {
+ return (
+
+
{message}
+
+
+ );
+}
diff --git a/frontend/src/hooks/use-consultation-run.ts b/frontend/src/hooks/use-consultation-run.ts
index ec0aa22f..f3f65e1b 100644
--- a/frontend/src/hooks/use-consultation-run.ts
+++ b/frontend/src/hooks/use-consultation-run.ts
@@ -62,6 +62,10 @@ import {
} from "@/lib/home-cloud-sync";
import { completedOnboardingTranscript, isProfileComplete, selectedBirthPlace } from "@/lib/home-profile";
import { pendingConsultationStorageKey, timestamp } from "@/lib/home-types";
+import {
+ consultSendBlockedByRectificationSession,
+ SESSION_NOT_CONSULTATION_CODE,
+} from "@/lib/rectification-session-composer-guard";
import type {
Account,
AccountDialog,
@@ -139,6 +143,7 @@ export type ConsultationRunParams = {
refreshAccount: () => Promise;
openAccountDialog: (dialog: AccountDialog, options?: HTMLButtonElement | null | OpenAccountDialogOptions) => void;
openRectificationFromHomepage: (pendingConsultationQuestion?: string | null) => Promise;
+ openRectificationSession: (exactSessionId: string) => Promise | void;
};
export function useConsultationRun(params: ConsultationRunParams) {
@@ -196,6 +201,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
refreshAccount,
openAccountDialog,
openRectificationFromHomepage,
+ openRectificationSession,
} = params;
const queued = useQueuedMessage();
@@ -219,6 +225,14 @@ export function useConsultationRun(params: ConsultationRunParams) {
if (pendingConsultation.current) return;
const lastMessage = session.messages.at(-1);
const storedQuestion = stored?.question?.trim() ?? "";
+ if (consultSendBlockedByRectificationSession(session)) {
+ try {
+ sessionStorage.removeItem(pendingConsultationStorageKey);
+ } catch {
+ // Private-mode storage must not block recovery.
+ }
+ return;
+ }
const lastIsQuestion = lastMessage?.role === "user"
&& (!storedQuestion || lastMessage.text === storedQuestion);
const question = lastIsQuestion && lastMessage ? lastMessage.text : storedQuestion;
@@ -499,6 +513,26 @@ export function useConsultationRun(params: ConsultationRunParams) {
: activeSession;
const currentSession = options.sessionOverride ?? liveSession;
if (!question || !currentSession || !modelCatalog || !account) return false;
+ if (consultSendBlockedByRectificationSession(currentSession)) {
+ if (resuming) {
+ pendingConsultation.current = null;
+ setPendingSessionId(null);
+ setPendingRequestId(null);
+ setConsultationPhase(null);
+ setStreamingReply(null);
+ try {
+ sessionStorage.removeItem(pendingConsultationStorageKey);
+ } catch {
+ // Private-mode storage must not block the guard.
+ }
+ }
+ setDraft(originalQuestion);
+ setDraftTheme(null);
+ setDraftEntrypoint(null);
+ await openRectificationSession(currentSession.id);
+ setDraft(originalQuestion);
+ return false;
+ }
const rollbackSession = options.restoreOnFailure ?? liveSession ?? currentSession;
if (!resuming && (pendingSessionId || cancellationInFlight.current || pendingConsultation.current)) return false;
if (resuming) {
@@ -960,6 +994,17 @@ export function useConsultationRun(params: ConsultationRunParams) {
const ownsInterface = pendingConsultation.current?.requestId === requestId;
const partialReply = latestPartialReply;
if (!cancelled && ownsInterface && pendingConsultation.current && caught instanceof ConsultationResponseError) {
+ if (caught.code === SESSION_NOT_CONSULTATION_CODE) {
+ updateSession(sessionId, () => rollbackSession);
+ setOnboardingJustCompleted(previousOnboardingState);
+ completeConsultationInterface(requestId);
+ setDraft(originalQuestion);
+ setDraftTheme(null);
+ setDraftEntrypoint(null);
+ await openRectificationSession(currentSession.id);
+ setDraft(originalQuestion);
+ return false;
+ }
if (caught.code === "session_full") {
updateSession(sessionId, () => rollbackSession);
setOnboardingJustCompleted(previousOnboardingState);
diff --git a/frontend/src/hooks/use-rectification-surface.ts b/frontend/src/hooks/use-rectification-surface.ts
index ea1b4af7..f8cc3678 100644
--- a/frontend/src/hooks/use-rectification-surface.ts
+++ b/frontend/src/hooks/use-rectification-surface.ts
@@ -335,6 +335,12 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
await openRectificationCase("session", exactSessionId, null);
}
+ async function reopenRectificationSession(exactSessionId: string) {
+ setRectificationError("");
+ setRectificationErrorSessionId(null);
+ await openRectificationSession(exactSessionId);
+ }
+
async function startNewRectification() {
await openRectificationCase("new", null, null);
}
@@ -404,6 +410,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
openRectificationCase,
openRectificationFromHomepage,
openRectificationSession,
+ reopenRectificationSession,
startNewRectification,
handleRectificationProfileIncomplete,
handleRectificationMessagesChange,
diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts
index fc19b6ba..da129656 100644
--- a/frontend/src/hooks/use-session-management.ts
+++ b/frontend/src/hooks/use-session-management.ts
@@ -12,6 +12,7 @@ import {
writeSessionUrl,
} from "@/lib/chat-session-url";
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
+import { fallbackSessionId } from "@/lib/rectification-session-composer-guard";
import { sortSessions } from "@/lib/session-groups";
import { invalidateSidebarCache } from "@/lib/sidebar-data-cache";
import {
@@ -235,9 +236,7 @@ export function useSessionManagement(params: SessionManagementParams) {
setSessions(nextSessions);
setBirthTimeConsultationConsent((current) => clearBirthTimeConsultationConsent(current, session.id));
if (activeSessionId === session.id) {
- const fallbackId = nextSessions[0]?.id ?? "";
- setActiveSessionId(fallbackId);
- if (!uiPreview.current) writeSessionUrl(fallbackId || null, "replace");
+ activateFallbackSession(nextSessions);
}
try {
const response = await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" });
@@ -269,9 +268,7 @@ export function useSessionManagement(params: SessionManagementParams) {
const nextArchivedAt = restoring ? null : new Date().toISOString();
updateSession(sessionId, (current) => ({ ...current, archivedAt: nextArchivedAt }));
if (!restoring && activeSessionId === sessionId) {
- const fallbackId = visibleSessions.find((item) => item.id !== sessionId)?.id ?? "";
- setActiveSessionId(fallbackId);
- if (!uiPreview.current) writeSessionUrl(fallbackId || null, "replace");
+ activateFallbackSession(visibleSessions.filter((item) => item.id !== sessionId));
}
setComposerNotice(restoring ? "已恢复到聊天记录。" : "已归档,可在左侧归档中恢复。");
void writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update").then(invalidateSidebarCache).catch((caught) => {
@@ -392,6 +389,22 @@ export function useSessionManagement(params: SessionManagementParams) {
sessionSelectionSource.current = "user";
}
+ function activateFallbackSession(candidates: ChatSession[]) {
+ const fallbackId = fallbackSessionId(candidates);
+ if (!fallbackId) {
+ setActiveSessionId("");
+ if (!uiPreview.current) writeSessionUrl(null, "replace");
+ return;
+ }
+ const fallback = candidates.find((session) => session.id === fallbackId);
+ if (fallback?.sessionType === "birth_time_rectification") {
+ selectSession(fallbackId);
+ return;
+ }
+ setActiveSessionId(fallbackId);
+ if (!uiPreview.current) writeSessionUrl(fallbackId, "replace");
+ }
+
async function loadMoreSessions() {
if (!beginSessionPageLoad(loadMoreInFlight, sessionsCursor)) return;
try {
@@ -431,7 +444,7 @@ export function useSessionManagement(params: SessionManagementParams) {
if (uiPreview.current) return;
const listed = sessionsRef.current;
const query = parseSessionUrlQuery(search);
- const fallbackId = listed[0]?.id ?? "";
+ const fallbackId = fallbackSessionId(listed);
const requestedId = query.sessionId;
if (query.present && !requestedId) {
writeSessionUrl(null, "replace");
diff --git a/frontend/src/lib/rectification-session-composer-guard.ts b/frontend/src/lib/rectification-session-composer-guard.ts
new file mode 100644
index 00000000..da5a8196
--- /dev/null
+++ b/frontend/src/lib/rectification-session-composer-guard.ts
@@ -0,0 +1,67 @@
+export const RECTIFICATION_OPENING_COMPOSER_PLACEHOLDER = "正在打开生时校正…";
+export const RECTIFICATION_REOPEN_LABEL = "重新打开生时校正";
+export const SESSION_NOT_CONSULTATION_CODE = "session_not_consultation";
+
+export function isRectificationChatSession(
+ session: { sessionType?: string } | null | undefined,
+): boolean {
+ return session?.sessionType === "birth_time_rectification";
+}
+
+/** First non-rectification session, else the first session, else empty. */
+export function fallbackSessionId(
+ sessions: readonly T[],
+): string {
+ return sessions.find((session) => session.sessionType !== "birth_time_rectification")?.id
+ ?? sessions[0]?.id
+ ?? "";
+}
+
+export function consultSendBlockedByRectificationSession(
+ session: { sessionType?: string } | null | undefined,
+): boolean {
+ return isRectificationChatSession(session);
+}
+
+export function dropRectificationStoredPending<
+ S extends { sessionId: string },
+ T extends { id: string; sessionType: string },
+>(
+ stored: S | null,
+ sessions: readonly T[],
+): S | null {
+ if (!stored) return null;
+ const session = sessions.find((item) => item.id === stored.sessionId);
+ if (session?.sessionType === "birth_time_rectification") return null;
+ return stored;
+}
+
+export function ordinaryComposerPlaceholder(input: {
+ locked: boolean;
+ hasAccount: boolean;
+ profileComplete: boolean;
+ onboardingName: boolean;
+ presetMessageFinished: boolean;
+ creditsEmpty: boolean;
+}): string {
+ if (input.locked) return RECTIFICATION_OPENING_COMPOSER_PLACEHOLDER;
+ if (!input.hasAccount) return "正在读取账户…";
+ if (!input.profileComplete) {
+ if (!input.onboardingName) return "请先完成上方资料";
+ return input.presetMessageFinished ? "输入你的称呼" : "Jyotisha 正在输入…";
+ }
+ if (input.creditsEmpty) return "余额不足,可在账户与点数中补充";
+ return "例如:未来半年是否适合换工作?";
+}
+
+export function rectificationOrdinaryComposerReopen(input: {
+ activeRectificationSession: boolean;
+ error: string;
+ errorSessionId: string | null;
+ sessionId: string | undefined;
+}): boolean {
+ if (!input.activeRectificationSession || !input.error || input.error === "profile_incomplete") {
+ return false;
+ }
+ return !input.errorSessionId || input.errorSessionId === input.sessionId;
+}
diff --git a/frontend/tests/chat-session-authority.test.ts b/frontend/tests/chat-session-authority.test.ts
index 1f7efd51..bc196527 100644
--- a/frontend/tests/chat-session-authority.test.ts
+++ b/frontend/tests/chat-session-authority.test.ts
@@ -35,6 +35,19 @@ test("session list GET omits messages while detail GET returns them", () => {
assert.match(page, /messagesHydrated: messagesPresent/);
});
+test("consult POST on a rectification session is 409, a missing id is 404", () => {
+ // 原值:!chatSession || session_type !== consultation 一律 404「咨询会话不存在」
+ // 新值:查不到 404;类型不对 409 session_not_consultation
+ // 原因:BUG-925 前端无法区分不存在与类型不对
+ assert.match(consultRoute, /if \(!chatSession\) \{[\s\S]*status: 404/);
+ assert.match(consultRoute, /code: "session_not_consultation"/);
+ assert.match(consultRoute, /status: 409/);
+ assert.doesNotMatch(
+ consultRoute,
+ /if \(!chatSession \|\| chatSession\.session_type !== "consultation"\)/,
+ );
+});
+
test("consult reads stored history and never applies the client history field", () => {
assert.match(consultRoute, /history: z\s*\.array\([\s\S]*?\)\s*\.max\(20\)\s*\.optional\(\)\s*\.default\(\[\]\)/);
assert.match(consultRoute, /const storedHistory = historyWindow\.tail/);
diff --git a/frontend/tests/rectification-session-composer-guard.test.ts b/frontend/tests/rectification-session-composer-guard.test.ts
new file mode 100644
index 00000000..c8ff0825
--- /dev/null
+++ b/frontend/tests/rectification-session-composer-guard.test.ts
@@ -0,0 +1,106 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+import { homeSurface as page } from "./home-surface.ts";
+import {
+ consultSendBlockedByRectificationSession,
+ dropRectificationStoredPending,
+ fallbackSessionId,
+ RECTIFICATION_OPENING_COMPOSER_PLACEHOLDER,
+ RECTIFICATION_REOPEN_LABEL,
+ rectificationOrdinaryComposerReopen,
+ SESSION_NOT_CONSULTATION_CODE,
+} from "../src/lib/rectification-session-composer-guard.ts";
+
+const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
+const run = read("../src/hooks/use-consultation-run.ts");
+const sessions = read("../src/hooks/use-session-management.ts");
+const consultRoute = read("../src/app/api/consult/route.ts");
+const pageSource = read("../src/app/page.tsx");
+const sendFn = run.slice(run.indexOf("async function send("), run.indexOf('await fetch("/api/consult"'));
+
+test("a rectification session blocks consult send and keeps the draft", () => {
+ assert.equal(consultSendBlockedByRectificationSession({ sessionType: "birth_time_rectification" }), true);
+ assert.equal(consultSendBlockedByRectificationSession({ sessionType: "consultation" }), false);
+ assert.match(sendFn, /consultSendBlockedByRectificationSession\(currentSession\)/);
+ assert.match(sendFn, /setDraft\(originalQuestion\)/);
+ assert.match(sendFn, /openRectificationSession\(currentSession\.id\)/);
+ assert.doesNotMatch(sendFn, /fetch\("\/api\/consult"/);
+});
+
+test("pending consult replay pointing at a rectification session is dropped", () => {
+ assert.equal(
+ dropRectificationStoredPending(
+ { sessionId: "r1" },
+ [{ id: "r1", sessionType: "birth_time_rectification" }],
+ ),
+ null,
+ );
+ assert.deepEqual(
+ dropRectificationStoredPending(
+ { sessionId: "c1" },
+ [{ id: "c1", sessionType: "consultation" }],
+ ),
+ { sessionId: "c1" },
+ );
+ assert.match(run, /if \(consultSendBlockedByRectificationSession\(session\)\) \{[\s\S]*sessionStorage\.removeItem\(pendingConsultationStorageKey\)/);
+});
+
+test("consult POST splits missing session from wrong session type", () => {
+ assert.match(consultRoute, /if \(!chatSession\) \{[\s\S]*咨询会话不存在[\s\S]*status: 404/);
+ assert.match(
+ consultRoute,
+ /if \(chatSession\.session_type !== "consultation"\) \{[\s\S]*session_not_consultation[\s\S]*status: 409/,
+ );
+ assert.match(consultRoute, /这是生时校正会话/);
+ assert.match(consultRoute, /请在生时校正里继续。/);
+ assert.equal(SESSION_NOT_CONSULTATION_CODE, "session_not_consultation");
+ assert.match(run, /caught\.code === SESSION_NOT_CONSULTATION_CODE/);
+});
+
+test("ordinary composer is locked on a rectification session until the surface opens", () => {
+ assert.equal(RECTIFICATION_OPENING_COMPOSER_PLACEHOLDER, "正在打开生时校正…");
+ assert.equal(RECTIFICATION_REOPEN_LABEL, "重新打开生时校正");
+ assert.match(pageSource, /inputDisabled=\{[^}]*activeRectificationSession/);
+ assert.match(pageSource, /submitBlocked=\{[^}]*activeRectificationSession/);
+ assert.match(pageSource, /ordinaryComposerPlaceholder\(/);
+ assert.match(pageSource, /RectificationComposerReopen/);
+ assert.equal(
+ rectificationOrdinaryComposerReopen({
+ activeRectificationSession: true,
+ error: "生时校正会话暂时无法打开,请稍后重试。",
+ errorSessionId: "s1",
+ sessionId: "s1",
+ }),
+ true,
+ );
+ assert.equal(
+ rectificationOrdinaryComposerReopen({
+ activeRectificationSession: true,
+ error: "",
+ errorSessionId: null,
+ sessionId: "s1",
+ }),
+ false,
+ );
+});
+
+test("delete and popstate fallbacks do not setActiveSessionId onto a rectification session", () => {
+ assert.equal(
+ fallbackSessionId([
+ { id: "r1", sessionType: "birth_time_rectification" },
+ { id: "c1", sessionType: "consultation" },
+ ]),
+ "c1",
+ );
+ assert.equal(
+ fallbackSessionId([{ id: "r1", sessionType: "birth_time_rectification" }]),
+ "r1",
+ );
+ assert.match(sessions, /function activateFallbackSession/);
+ assert.match(sessions, /if \(fallback\?\.sessionType === "birth_time_rectification"\) \{\s*selectSession\(fallbackId\);/);
+ assert.match(sessions, /if \(activeSessionId === session.id\) \{\s*activateFallbackSession\(nextSessions\);/);
+ assert.match(sessions, /const fallbackId = fallbackSessionId\(listed\);/);
+ assert.match(page, /sessions.find\(\(session\) => session.sessionType !== "birth_time_rectification"\)/);
+});
diff --git a/frontend/tests/rectification-surface-contract.test.ts b/frontend/tests/rectification-surface-contract.test.ts
index ea7b7036..c12d1676 100644
--- a/frontend/tests/rectification-surface-contract.test.ts
+++ b/frontend/tests/rectification-surface-contract.test.ts
@@ -65,6 +65,17 @@ test("a rectification session named in the URL is hydrated before the reveal; ba
assert.match(sessions, /sessionSelectionSource\.current = "history";\s*selectSession\(requestedId\);/);
});
+test("an ordinary composer on a rectification session is disabled until the surface opens", () => {
+ // 原值:inputDisabled / submitBlocked 只看 rectificationSurfaceOpen
+ // 新值:另加 activeRectificationSession;placeholder 为「正在打开生时校正…」
+ // 原因:BUG-924 校正面未开时普通输入框仍可用,问题被发到 /api/consult
+ assert.match(page, /inputDisabled=\{[^}]*activeRectificationSession/);
+ assert.match(page, /ordinaryComposerPlaceholder\(/);
+ assert.match(page, /RectificationComposerReopen/);
+ assert.match(page, /reopenRectificationSession/);
+ assert.doesNotMatch(page, /正在打开生时校正[\s\S]*InlineSpinner/);
+});
+
test("entry feedback is static: card and sidebar row say 正在打开, and nothing spins after the reveal", () => {
assert.match(page, /const rectificationCardLabel = rectificationLoading\s*\? RECTIFICATION_OPENING_LABEL/);
assert.match(starter, /data-opening=\{rectificationLoading \? "true" : undefined\}/);