/** * Adopting a candidate minute from the range delivery card: one live row for * the accept POST and the agent turn that follows on it, the working house * table recast from the adopted minute, and the stop / fail branches. Moved * out of RectificationAgenticChat's `acceptCandidate` 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 { emptyActivityTrace } from "@/lib/agent-activity-trace"; import { natalRecastMeaning, workingRectificationHouseTable, } from "@/lib/rectification-candidate-result"; import { rectificationAdoptingLabel, isAbortError, } from "@/lib/rectification-surface-state"; import type { CandidateResult } 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"; import { messagesFromTurns, type PersistedTurn } from "@/lib/rectification-chat-messages"; import { applySnapshotTurnsToMessages } from "@/lib/rectification-snapshot-messages"; export type RectificationCandidateAcceptDeps = Readonly<{ caseId: string; sessionId: string; busy: boolean; readonly: boolean; candidateResult: CandidateResult; acceptingCandidateId: string | null; keyCounter: MutableRefObject; runAbort: MutableRefObject; lastRunOutcome: MutableRefObject; conversationAnchor: Readonly<{ pinLatestTurn: () => void }>; setError: Dispatch>; setMessages: Dispatch>; setAcceptingCandidateId: Dispatch>; setCandidateResult: Dispatch>; setSavedTime: Dispatch>; setSavedStatus: Dispatch>; setPending: (value: boolean) => void; beginLiveRun: (baseLabel: string) => void; loadCaseSnapshot: RectificationSnapshotLoader; send: ( action: RectificationTurnAction, messageText: string, continuation?: RectificationTurnContinuation, ) => Promise; onCompleted?: () => void; onSaved?: (time: string, status: "accepted" | "confirmed") => void; }>; export async function runRectificationCandidateAccept( deps: RectificationCandidateAcceptDeps, candidateId: string, ): Promise { const { caseId, sessionId, busy, readonly, candidateResult, acceptingCandidateId, keyCounter, runAbort, lastRunOutcome, conversationAnchor, setError, setMessages, setAcceptingCandidateId, setCandidateResult, setSavedTime, setSavedStatus, setPending, beginLiveRun, loadCaseSnapshot, send, onCompleted, onSaved, } = deps; if (!candidateResult || acceptingCandidateId || busy || readonly) return; setError(""); setAcceptingCandidateId(candidateId); setPending(true); // Adoption is a server round trip followed by an agent turn; the reader sees // one live row for the whole of it instead of a greyed button. const candidateTime = candidateResult.candidates.find((candidate) => candidate.candidateId === candidateId)?.time ?? ""; const adoptingLabel = rectificationAdoptingLabel(candidateTime); keyCounter.current += 1; const assistantRenderKey = `v9-adopt-assistant-${keyCounter.current}`; beginLiveRun(adoptingLabel); setMessages((current) => [ ...current, { role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activityTrace: emptyActivityTrace(), activity: { phase: "evidence-validation", label: adoptingLabel, startedAt: Date.now() }, }, ]); conversationAnchor.pinLatestTurn(); const abortController = new AbortController(); runAbort.current = abortController; try { const response = await fetch( `/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`, { method: "POST", headers: { "content-type": "application/json" }, signal: abortController.signal, body: JSON.stringify({ sessionId, resultId: candidateResult.resultId, candidateId, requestId: globalThis.crypto.randomUUID(), mode: "range", }), }, ); const payload = await response.json().catch(() => null); if (!response.ok || payload?.ok !== true || payload?.status !== "accepted") { throw new Error(payload?.error || payload?.message || "暂时无法采用该候选时间"); } setCandidateResult((current) => { if (!current) return current; const selectedTime = typeof payload.saved_time === "string" ? payload.saved_time.slice(0, 5) : current.selectedTime; const next = { ...current, selectedTime, selectionKind: "user_accepted" as const, }; const houseTable = workingRectificationHouseTable(next); const recastMeaning = natalRecastMeaning(houseTable); return { ...next, houseTable: houseTable ?? current.houseTable, natalRecast: houseTable && recastMeaning ? { time: houseTable.time, lagna: houseTable.lagna, user_meaning: recastMeaning, unique_minute_claim: false as const, confirmation_allowed: false as const, } : current.natalRecast, }; }); setSavedTime(payload.saved_time); setSavedStatus("accepted"); onSaved?.(payload.saved_time, "accepted"); onCompleted?.(); if (typeof payload.adopt_turn_id === "string" && payload.adopt_turn_id) { // BUG-1146: the server wrote the adopt message with the first post-adopt check hung on it; // show that turn in place of the live row. The read_only run would only be // already_delivered and leave the check under the previous answer. await loadCaseSnapshot((turns) => { setMessages((current) => applySnapshotTurnsToMessages( current.filter((message) => message.renderKey !== assistantRenderKey), turns, (incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]), )); }); setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey)); conversationAnchor.pinLatestTurn(); return; } await loadCaseSnapshot(); await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel }); } 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"; setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey)); setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间"); } finally { if (runAbort.current === abortController) runAbort.current = null; if (lastRunOutcome.current === null) lastRunOutcome.current = "succeeded"; setAcceptingCandidateId(null); setPending(false); } }