fix(rectification): stop re-parsing settled messages on every stream frame
Independent Staging Quality Gate / validate (push) Failing after 6m18s
Independent Staging Quality Gate / publish (push) Skipped

This commit is contained in:
jesse-ux
2026-09-16 07:13:12 +08:00
parent 53a37ce944
commit 58ccafb67c
25 changed files with 827 additions and 213 deletions
@@ -5,7 +5,7 @@ import { useQueuedMessage } from "@/hooks/use-queued-message";
import { appendQueuedText, queuedDraftSettleAction } from "@/lib/queued-draft";
import { createPortal } from "react-dom";
import { parseAgentReply } from "@/lib/agent-reply";
import { nextActivityView, type AgentActivityView, type ChatMessage, type ChatMessageView } from "@/lib/chat-message-view";
import { nextActivityView, type AgentActivityView, type ChatMessage } from "@/lib/chat-message-view";
import { createStreamFrameBuffer } from "@/lib/stream-frame-buffer";
import {
completeActivityTrace,
@@ -44,7 +44,6 @@ import {
diffRectificationBoard,
RECTIFICATION_BOARD_SPLIT_MIN_PX,
} from "@/lib/rectification-board-model";
import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter";
import {
rectificationAdoptingLabel,
RECTIFICATION_EMPTY_ACTION_LABEL,
@@ -59,7 +58,6 @@ import {
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
RECTIFICATION_COLLECT_WAITING_PLACEHOLDER,
RECTIFICATION_DELIVERED_COPY,
RECTIFICATION_STOPPED_NOTICE,
isAbortError,
rectificationConversationState,
rectificationInitialLiveLabel,
@@ -81,7 +79,6 @@ import {
buildRectificationTimeline,
type RectificationTimelineStage,
} from "@/lib/rectification-timeline-scale";
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
import {
isPublicRectificationActivity,
isPublicRectificationMethod,
@@ -99,19 +96,20 @@ import {
} from "@/lib/rectification-agentic/v9/choice-action";
import { postAdoptVerifyDoneCopy } from "@/lib/rectification-agentic/user-copy";
import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status";
import { CHOICE_MODE, CHOICE_SKIP_QUESTION_LABEL, CHOICE_SKIP_QUESTION_MESSAGE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
import { isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
import type { PublicLanguageModel } from "@/lib/public-models";
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
import { CharacterRemaining } from "./character-remaining";
import { ChatComposer } from "./chat-composer";
import { ChatMessageRow } from "./chat-message-row";
import { ConsultationTimelineLiveRow } from "./consultation-run-timeline";
import { JumpToLatestButton } from "./jump-to-latest-button";
import { toggleChatMessageFeedback } from "./chat-message-actions";
import {
ChatMessageActions,
toggleChatMessageFeedback,
} from "./chat-message-actions";
RectificationMessageEntry,
type RectificationMessageActions,
type RenderMessage,
} from "./rectification-message-entry";
import { ModelSelector } from "./model-selector";
import { RectificationBoard, RectificationBoardPeek } from "./rectification-board";
import { RectificationChoiceCard } from "./rectification-choice-card";
@@ -124,7 +122,6 @@ import {
questionIsAnswered,
resolveSelectionCardMessageKey,
type SelectionCardLock,
type TurnQuestion,
} from "@/lib/rectification-agentic/v9/turn-question";
import { Button } from "@/components/ui/button";
import {
@@ -239,17 +236,6 @@ type RectificationAgenticChatProps = Readonly<{
headerSlot: HTMLElement | null;
}>;
type RenderMessage = ChatMessageView & {
renderKey: string;
completedReceipt?: CompletedActivityReceiptView;
failed?: boolean;
stopped?: boolean;
turnId?: string;
activityTrace?: readonly AgentActivityTraceItem[];
question?: TurnQuestion;
candidateOffer?: Readonly<{ resultId: string }>;
};
function markQuestionAnswered(
current: RenderMessage[],
focusId: string,
@@ -273,37 +259,6 @@ function snapshotTurns(payload: { turns?: unknown } | null | undefined): readonl
return Array.isArray(payload?.turns) ? payload.turns : [];
}
function choiceCardFromQuestion(
question: TurnQuestion,
live: ChoiceCardModel | null,
): ChoiceCardModel | null {
if (live && live.focus_id === question.focus_id && live.options.length === 4) {
return { ...live, prompt: question.prompt };
}
if (!question.options || question.options.length !== 4) return null;
return {
question_id: question.question_id,
method_id: "",
prompt: question.prompt,
why: "",
varga: null,
choice_mode: CHOICE_MODE,
options: question.options.map((option) => ({
key: option.key,
label: option.label,
answer_class: "unsure",
role: "primary",
})),
stop_label: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_LABEL : CHOICE_STOP_LABEL,
stop_message: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_MESSAGE : CHOICE_STOP_MESSAGE,
scoring: question.kind !== "reverse_verify",
probe_id: question.probe_id,
case_revision: null,
focus_id: question.focus_id,
...(live?.skip_this_probe ? { skip_this_probe: true } : {}),
};
}
function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView {
if (!receipt) return { steps: [], methods: [] };
if (Array.isArray(receipt.tool_activities)) {
@@ -478,6 +433,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
const [choiceNonce, setChoiceNonce] = useState(0);
const messageActionsRef = useRef<RectificationMessageActions>({
submitChoice() {},
submitStop() {},
copyMessage() {},
regenerateMessage() {},
onFeedback() {},
});
const conversation = useRef<HTMLElement>(null);
const workspace = useRef<HTMLDivElement>(null);
const composer = useRef<HTMLTextAreaElement>(null);
@@ -1649,6 +1611,21 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
void submitStructuredChoice(STOP_ACTION, "stop");
}
useEffect(() => {
messageActionsRef.current = {
submitChoice,
submitStop,
copyMessage,
regenerateMessage,
onFeedback(renderKey, requested) {
setFeedback((current) => ({
...current,
[renderKey]: toggleRectificationFeedback(current[renderKey], requested),
}));
},
};
});
const boardPeek = compactBoard && !boardOpen ? (
<RectificationBoardPeek
result={candidateResult}
@@ -1689,118 +1666,31 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</div>
)}
{messages.map((message) => {
const showActions = message.role === "assistant"
&& message.state === "settled"
&& !message.failed
&& Boolean(message.text);
const regenerating = regeneratingMessageKey === message.renderKey;
const canRegenerate = message.renderKey === latestRegeneratableKey
&& !busy
&& !readonly
&& regeneratingMessageKey === null;
// Both surfaces render the same step timeline: the tool trace and receipt
// are projected onto ConsultationRunTimeline rows. A regenerating reply shows
// the queued row until real events fill it in, never a staged label.
const displayedMessage: RenderMessage = regenerating
? {
...message,
text: "",
thinkingText: undefined,
state: "thinking" as const,
activity: undefined,
activityTrace: emptyActivityTrace(),
completedReceipt: undefined,
timeline: [],
}
: {
...message,
timeline: rectificationTimelineRows({
trace: message.activityTrace,
receipt: message.completedReceipt,
activity: message.activity,
settled: message.state === "settled",
}),
};
const vargaSentence = !message.failed
? vargaSentenceFromMethods(message.completedReceipt?.methods)
: null;
const question = regenerating ? undefined : message.question;
const liveQuestion = Boolean(
question
&& currentQuestion?.focus_id === question.focus_id
&& !questionIsAnswered(question)
&& !busy
&& !readonly
&& regeneratingMessageKey === null
&& (
question.kind === "collect_spoken"
|| (
choiceCard
&& choiceCard.focus_id === question.focus_id
&& (question.kind === "choice" || question.kind === "reverse_verify")
)
),
);
const unansweredDeadChoice = Boolean(
question
&& !questionIsAnswered(question)
&& !liveQuestion
&& (question.kind === "choice" || question.kind === "reverse_verify")
);
const embeddedCard = question && !unansweredDeadChoice
? choiceCardFromQuestion(question, liveQuestion ? choiceCard : null)
: null;
const afterAnswer = question && displayedMessage.state === "settled"
? (
<div className="rectification-message-question">
<p className="rectification-message-question__prompt">{question.prompt}</p>
{embeddedCard && (
<RectificationChoiceCard
key={`${embeddedCard.question_id}:${question.answer_option ?? choiceNonce}:${question.status}`}
variant="embedded"
card={embeddedCard}
pending={busy && liveQuestion}
disabled={!liveQuestion}
selectedKey={question.answer_option ?? ""}
onSelect={submitChoice}
onStop={submitStop}
/>
)}
{question.status === "skipped" && (
<p className="rectification-question-skipped" role="status">
{savedTime ? `已跳过(已采用 ${savedTime}` : "已跳过"}
</p>
)}
</div>
)
: undefined;
return (
<div
key={message.renderKey}
className="rectification-message-wrap rectification-message-entry"
>
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
<ChatMessageRow
message={displayedMessage}
showActivity={displayedMessage.state !== "settled"}
vargaSentence={vargaSentence}
afterAnswer={afterAnswer}
stoppedNotice={message.stopped ? RECTIFICATION_STOPPED_NOTICE : undefined}
/>
)}
{showActions && !regenerating && (
<ChatMessageActions
feedback={feedback[message.renderKey]}
copied={copiedMessageKey === message.renderKey}
canRegenerate={canRegenerate}
onFeedback={(requested) => setFeedback((current) => ({
...current,
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], requested),
}))}
onCopy={() => void copyMessage(message)}
onRegenerate={() => void regenerateMessage(message)}
/>
)}
<RectificationMessageEntry
message={message}
busy={busy}
readonly={readonly}
regenerating={regenerating}
canRegenerate={canRegenerate}
currentQuestionFocusId={currentQuestion?.focus_id ?? null}
interactive={!busy && !readonly && regeneratingMessageKey === null}
liveChoiceCard={choiceCard}
choiceNonce={choiceNonce}
savedTime={savedTime}
copied={copiedMessageKey === message.renderKey}
feedback={feedback[message.renderKey]}
actionsRef={messageActionsRef}
/>
{showSelectionCards && candidateResult && message.renderKey === selectionCardMessageKey && (
<>
<RectificationRangeDelivery