Files
Jyotisha/frontend/src/lib/rectification-chat-accept-run.ts
T
Jesse_ChenandClaude Opus 5.5 282455cc83
Independent Staging Quality Gate / validate (push) Successful in 13m59s
Independent Staging Quality Gate / publish (push) Successful in 3m49s
fix(rectification): post-adopt check gets its own message and keeps its buttons; segment bubble copy (BUG-1146, BUG-1147)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-01 16:59:32 +08:00

201 lines
7.4 KiB
TypeScript

/**
* 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<number>;
runAbort: MutableRefObject<AbortController | null>;
lastRunOutcome: MutableRefObject<RectificationRunOutcome>;
conversationAnchor: Readonly<{ pinLatestTurn: () => void }>;
setError: Dispatch<SetStateAction<string>>;
setMessages: Dispatch<SetStateAction<RenderMessage[]>>;
setAcceptingCandidateId: Dispatch<SetStateAction<string | null>>;
setCandidateResult: Dispatch<SetStateAction<CandidateResult>>;
setSavedTime: Dispatch<SetStateAction<string | null>>;
setSavedStatus: Dispatch<SetStateAction<"accepted" | "confirmed" | null>>;
setPending: (value: boolean) => void;
beginLiveRun: (baseLabel: string) => void;
loadCaseSnapshot: RectificationSnapshotLoader;
send: (
action: RectificationTurnAction,
messageText: string,
continuation?: RectificationTurnContinuation,
) => Promise<void>;
onCompleted?: () => void;
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
}>;
export async function runRectificationCandidateAccept(
deps: RectificationCandidateAcceptDeps,
candidateId: string,
): Promise<void> {
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);
}
}