/** * A tapped choice (answer, stop, skip this probe) from the rectification chat: * the local answered mark and live row, the structured POST, the transcript * merge after the Case re-read, the follow-up turn on the same row, and the * roll-back when the tap did not land (BUG-1046). Moved out of * RectificationAgenticChat's `submitStructuredChoice` unchanged * (TASK-rectification-code-split-20260926). A parameter function: the * component hands in its state, setters and refs; no React hooks here. */ import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import type { ChatMessage } from "@/lib/chat-message-view"; import { emptyActivityTrace } from "@/lib/agent-activity-trace"; import { RECTIFICATION_ACTIVITY_PROGRESS_LABELS } from "@/lib/rectification-activity-labels"; import { RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY, isAbortError, } from "@/lib/rectification-surface-state"; import { CHOICE_ACTION, SKIP_PROBE_ACTION, STOP_ACTION, shouldContinueAfterStructuredChoice, type ChoiceOptionId, } from "@/lib/rectification-agentic/v9/choice-action"; import { isPersistedFocusId, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card"; import { appendUnseenAssistantTurns, applySnapshotTurnsToMessages, mergeTurnQuestions, } from "@/lib/rectification-snapshot-messages"; import { markQuestionAnswered, messagesFromTurns, withdrawAnswerMark, type PersistedTurn, } from "@/lib/rectification-chat-messages"; import type { CurrentQuestionModel } from "@/lib/rectification-chat-snapshot"; import type { RectificationRunOutcome, RectificationSnapshotLoader, RectificationTurnAction, RectificationTurnContinuation, } from "@/lib/rectification-chat-turn-run"; import type { RenderMessage } from "@/components/rectification-message-entry"; export type RectificationStructuredChoiceAction = typeof CHOICE_ACTION | typeof STOP_ACTION | typeof SKIP_PROBE_ACTION; export type RectificationStructuredChoiceOverride = Readonly<{ focusId?: string; questionId?: string | null; probeId?: string | null; caseRevision?: number | null; }>; export type RectificationStructuredChoiceDeps = Readonly<{ caseId: string; sessionId: string; busy: boolean; readonly: boolean; choiceCard: ChoiceCardModel | null; currentQuestion: CurrentQuestionModel | null; keyCounter: MutableRefObject; runAbort: MutableRefObject; lastRunOutcome: MutableRefObject; conversationAnchor: Readonly<{ pinLatestTurn: () => void }>; actionIdForChoice: (focusId: string, optionId: ChoiceOptionId) => string; setError: Dispatch>; setMessages: Dispatch>; setChoiceNonce: Dispatch>; setNextUserActionId: Dispatch>; setPending: (value: boolean) => void; beginLiveRun: (baseLabel: string) => void; loadCaseSnapshot: RectificationSnapshotLoader; send: ( action: RectificationTurnAction, messageText: string, continuation?: RectificationTurnContinuation, ) => Promise; onCompleted?: () => void; onMessagesChange?: (messages: ChatMessage[]) => void; onProfileIncomplete?: () => void; }>; export async function runRectificationStructuredChoice( deps: RectificationStructuredChoiceDeps, action: RectificationStructuredChoiceAction, optionId: ChoiceKey | "stop" | "skip_probe", override?: RectificationStructuredChoiceOverride, ): Promise { const { caseId, sessionId, busy, readonly, choiceCard, currentQuestion, keyCounter, runAbort, lastRunOutcome, conversationAnchor, actionIdForChoice, setError, setMessages, setChoiceNonce, setNextUserActionId, setPending, beginLiveRun, loadCaseSnapshot, send, onCompleted, onMessagesChange, onProfileIncomplete, } = deps; const focusId = override?.focusId ?? choiceCard?.focus_id ?? ( action === STOP_ACTION && currentQuestion?.kind === "collect_spoken" ? currentQuestion.focus_id : null ); if (busy || readonly) return; if (action !== STOP_ACTION && (!focusId || (!override && !choiceCard))) return; if (focusId && !isPersistedFocusId(focusId)) { setError("当前选择题已失效,请等待下一问。"); return; } const questionId = override?.questionId ?? choiceCard?.question_id ?? currentQuestion?.question_id ?? null; const probeId = override?.probeId ?? choiceCard?.probe_id; const expectedRevision = override?.caseRevision ?? choiceCard?.case_revision ?? 0; const actionId = actionIdForChoice(focusId ?? caseId, optionId); setError(""); keyCounter.current += 1; const turnKey = keyCounter.current; const assistantRenderKey = `v9-choice-assistant-${turnKey}`; const recordingLabel = RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer; beginLiveRun(recordingLabel); setMessages((current) => [ ...markQuestionAnswered(current, focusId ?? "", optionId), { role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activityTrace: emptyActivityTrace(), activity: { phase: "evidence-validation", label: recordingLabel, startedAt: Date.now(), }, }, ]); conversationAnchor.pinLatestTurn(); setPending(true); // BUG-1046 (D2): a tap that did not land takes back its local answered // mark and placeholder; the card key changes (answer_option → nonce), so // the card remounts clean and one tappable card is left. const rollBackFailedChoice = () => { setMessages((current) => withdrawAnswerMark( current.filter((message) => message.renderKey !== assistantRenderKey), focusId ?? "", optionId, )); setChoiceNonce((current) => current + 1); }; // The server may have moved on (stale_question, a dropped response after // it applied): re-read the Case so the card reflects what it holds. const reloadAfterFailedChoice = () => loadCaseSnapshot((turns) => { setMessages((current) => applySnapshotTurnsToMessages( current, turns, (incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]), )); }); const abortController = new AbortController(); runAbort.current = abortController; try { const response = await fetch("/api/rectification/agent", { method: "POST", headers: { "content-type": "application/json" }, signal: abortController.signal, body: JSON.stringify({ caseId, sessionId, requestId: actionId, action, actionId, ...(focusId ? { focusId } : {}), ...(questionId ? { questionId } : {}), probeId: probeId ?? null, optionId: optionId === "stop" ? undefined : optionId, expectedRevision, origin: "choice_click", clientActionId: actionId, }), }); const payload = await response.json().catch(() => null); if (!response.ok) { rollBackFailedChoice(); if (payload?.code === "profile_incomplete") { onProfileIncomplete?.(); return; } setError(RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY); await reloadAfterFailedChoice(); return; } const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload); const payloadNextActionId = typeof payload?.next_user_action?.id === "string" ? payload.next_user_action.id.trim() : ""; if (payloadNextActionId) setNextUserActionId(payloadNextActionId); onCompleted?.(); await loadCaseSnapshot((turns) => { if (willContinue) { // D4 (BUG-1046, the same gap BUG-685 closed for the tie-break entry): a turn // the server wrote while applying the tap joins the transcript, // above the row the follow-up run continues on. setMessages((current) => mergeTurnQuestions( appendUnseenAssistantTurns( current, messagesFromTurns(turns as readonly PersistedTurn[]), { beforeRenderKey: assistantRenderKey }, ), turns, )); return; } const narration = typeof payload?.narration === "string" && payload.narration.trim() ? payload.narration.trim() : "已记录你的选择。"; setMessages((current) => { const withoutPlaceholder = current.filter((message) => message.renderKey !== assistantRenderKey); const withHistory = appendUnseenAssistantTurns( mergeTurnQuestions(withoutPlaceholder, turns), messagesFromTurns(turns as readonly PersistedTurn[]), ); if (withHistory.length > withoutPlaceholder.length) return withHistory; return [ ...withHistory, { role: "assistant" as const, text: narration, renderKey: assistantRenderKey, state: "settled" as const, activity: undefined, }, ]; }); }); if (willContinue) { // The follow-up turn continues on the row already in place: no removed // row, no effect hop, and `busy` never drops in between (so the next // card cannot flash before the turn hides it). await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel }); } else { const narration = typeof payload?.narration === "string" && payload.narration.trim() ? payload.narration.trim() : "已记录你的选择。"; onMessagesChange?.([ { role: "assistant", text: narration }, ]); } } catch (caught) { if (isAbortError(caught)) { lastRunOutcome.current = "stopped"; setMessages((current) => current.map((message) => ( message.renderKey !== assistantRenderKey ? message : { ...message, state: "settled" as const, failed: false, stopped: true, activity: undefined, } ))); return; } lastRunOutcome.current = "failed"; rollBackFailedChoice(); setError(RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY); await reloadAfterFailedChoice(); } finally { if (runAbort.current === abortController) runAbort.current = null; if (lastRunOutcome.current === null) lastRunOutcome.current = "succeeded"; setPending(false); } }