Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
201 lines
7.4 KiB
TypeScript
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);
|
|
}
|
|
}
|