fix(rectification): make every gap a live row with retries, give an empty Case a start, and say why a run stopped

The chat used to show "当前没有可回答的问题,正在等待服务端更新" / "当前问题暂时无法显示"
/ "题目加载失败,请刷新" as bare copy above the composer. The gap between a
settled turn and its next question is now a state: `preparing` renders the
same timeline live row the reply uses ("正在准备下一个问题…") and refetches the
Case on a 2s timer up to two retries; `unavailable` renders one line and a
44px reload button. A snapshot that never arrived at hydration is the same
gap. A resumed Case with no turns and no server-started opening shows
"这段校正还没有开始。" with one 开始提问 action. Stopping a run keeps what
streamed and says so instead of "暂时不可用"; the opening turn's live row
names what is happening; a 402 explains itself for 600ms before leaving.

BUG-505, BUG-507

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
Jesse_Chen
2026-09-03 06:56:17 +00:00
parent 53017d4153
commit 360989ca2c
6 changed files with 169 additions and 50 deletions
@@ -133,8 +133,12 @@ test("one assistant article owns the stem and options; the slot class is gone",
assert.doesNotMatch(chat, /rectification-question-slot/);
assert.doesNotMatch(chat, /composeCollectSpokenAssistantText/);
assert.match(chat, /role="status"/);
assert.match(chat, /当前没有可回答的问题,正在等待服务端更新/);
assert.match(chat, /题目加载失败,请刷新/);
// Was: /当前没有可回答的问题,正在等待服务端更新/ and /题目加载失败,请刷新/ — bare copy
// that told the reader to wait for the server with nothing happening. The gap is
// now a live timeline row with timed refetches, then copy and a reload (BUG-505).
assert.doesNotMatch(chat, /等待服务端更新|题目加载失败,请刷新/);
assert.match(chat, /RECTIFICATION_QUESTION_PREPARING_LABEL/);
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
assert.match(chat, /question_source/);
});
@@ -144,12 +144,17 @@ test("collect_spoken stem lives on turn.question inside the same assistant artic
test("missing current_question is explicit only for resumable cases", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(chat, /const showMissingQuestion = Boolean\([\s\S]*caseSnapshotLoaded[\s\S]*resumableCase[\s\S]*currentQuestion === null/);
// Was: `const showMissingQuestion = Boolean(... caseSnapshotLoaded ... resumableCase ...
// currentQuestion === null` plus the copy /当前没有可回答的问题,正在等待服务端更新/ and
// /题目加载失败,请刷新/. Those were bare waiting sentences; the same inputs now feed
// `rectificationQuestionGapState`, which yields a live row with retries and then a
// reload button, and is still gated on a resumable case (BUG-505).
assert.match(chat, /const questionGap = rectificationQuestionGapState\(\{[\s\S]*questionMissing: currentQuestion === null,[\s\S]*snapshotLoaded: caseSnapshotLoaded,[\s\S]*resumableCase,/);
assert.match(chat, /const resumableCase = caseStatus !== null && isResumableStatus\(caseStatus\)/);
assert.match(chat, /当前没有可回答的问题,正在等待服务端更新/);
assert.match(chat, /题目加载失败,请刷新/);
assert.doesNotMatch(chat, /等待服务端更新|题目加载失败,请刷新/);
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
assert.match(chat, /readonly && \(/);
assert.doesNotMatch(chat, /showMissingQuestion[\s\S]*caseStatus.*TERMINAL/);
assert.doesNotMatch(chat, /questionGap[\s\S]*caseStatus.*TERMINAL/);
});
test("choice options render inside the same assistant message, not a sibling slot", () => {