/** * Pure transcript helpers of the rectification chat surface: persisted turns * to render messages, the local "answered" mark on a question and its undo, * and the persisted receipt view. Moved out of rectification-agentic-chat.tsx * unchanged (TASK-rectification-code-split-20260926); no React hooks here. */ import { activityTraceFromReceipt } from "@/lib/rectification-activity-labels"; import { createRectificationActivityReceiptState, receiptFromRectificationActivityState, reduceRectificationActivityReceipt, type CompletedActivityReceiptView, } from "@/lib/rectification-activity-receipt"; import { isPublicRectificationMethod, isPublicRectificationTool, } from "@/lib/rectification-agentic/v9/public-receipt"; import { isIncompleteRunBanner } from "@/lib/rectification-agentic/v9/run-diagnostic"; import { isStructuredChoiceUserText } from "@/lib/rectification-agentic/v9/choice-action"; import type { ChoiceKey } from "@/lib/rectification-agentic/v9/choice-card"; import { parseRecordedEvidence, parseTurnQuestion, questionIsAnswered } from "@/lib/rectification-agentic/v9/turn-question"; import type { RenderMessage } from "@/components/rectification-message-entry"; import { parseSegmentSummary } from "./rectification-agentic/core/segment-summary.ts"; export type PersistedTurn = Readonly<{ id: string; role: "user" | "assistant"; text: string | null; status: string; question?: unknown; offer_result_id?: string | null; segment_consistency?: unknown; recorded_evidence?: unknown; receipt?: Readonly<{ status: string; phases: readonly string[]; tool_activities?: readonly Readonly<{ tool: string; status: string; methods?: readonly string[]; started_at?: string | null; elapsed_ms?: number | null; detail?: Readonly> | null; }>[]; tools: readonly string[]; methods?: readonly string[]; skill_name?: string; skill_version?: string; }> | null; }>; export function markQuestionAnswered( current: RenderMessage[], focusId: string, selected: ChoiceKey | "stop" | "skip_probe" | "typed", ): RenderMessage[] { return current.map((message) => { const question = message.question; if (!question || question.focus_id !== focusId || questionIsAnswered(question)) return message; return { ...message, question: { ...question, status: "resolved", answer_option: selected === "typed" ? null : selected, }, }; }); } /** * BUG-1046: undo `markQuestionAnswered` when the answer never reached the * server, so the question is active again with no option picked. Only the mark * this action made is undone; a server-resolved question is left alone. */ export function withdrawAnswerMark( current: RenderMessage[], focusId: string, selected: ChoiceKey | "stop" | "skip_probe" | "typed", ): RenderMessage[] { const marked = selected === "typed" ? null : selected; return current.map((message) => { const question = message.question; if (!question || question.focus_id !== focusId) return message; if (question.status !== "resolved" || question.answer_option !== marked) return message; return { ...message, question: { ...question, status: "active", answer_option: null } }; }); } export function isSettledAssistantMessage(message: RenderMessage): boolean { return message.role === "assistant" && message.state === "settled" && !message.failed; } export function snapshotTurns(payload: { turns?: unknown } | null | undefined): readonly unknown[] { return Array.isArray(payload?.turns) ? payload.turns : []; } export function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView { if (!receipt) return { steps: [], methods: [] }; if (Array.isArray(receipt.tool_activities)) { let state = createRectificationActivityReceiptState(); for (const activity of receipt.tool_activities) { if (!isPublicRectificationTool(activity.tool) || (activity.status !== "completed" && activity.status !== "failed")) continue; state = reduceRectificationActivityReceipt(state, { tool: activity.tool, status: activity.status, methods: activity.status === "completed" && Array.isArray(activity.methods) ? activity.methods.filter(isPublicRectificationMethod) : [], }); } return receiptFromRectificationActivityState(state); } return { steps: [...new Set((receipt.tools ?? []).filter(isPublicRectificationTool))], methods: [...new Set((receipt.methods ?? []).filter(isPublicRectificationMethod))], }; } export function hasActivityReceipt(receipt: CompletedActivityReceiptView): boolean { return receipt.steps.length > 0 || receipt.methods.length > 0 || Boolean(receipt.failedTool); } export function persistedTurnFailed(turn: PersistedTurn): boolean { return turn.status === "failed" || turn.status === "retryable" || turn.receipt?.status === "failed" || turn.receipt?.status === "degraded" || turn.receipt?.status === "blocked"; } export function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessage[] { return initialTurns.flatMap((turn, index): RenderMessage[] => { const key = `persisted-${turn.id}-${index}`; if (turn.role === "assistant") { const failed = persistedTurnFailed(turn); const raw = failed ? "" : turn.text ?? ""; if (failed && !raw) return []; if (isIncompleteRunBanner(raw)) return []; const completedReceipt = completedReceiptFromPersisted(turn.receipt); return [{ role: "assistant", text: raw, renderKey: key, state: turn.status === "completed" || failed ? "settled" : "thinking", completedReceipt, activityTrace: activityTraceFromReceipt(completedReceipt), failed, turnId: turn.id, question: parseTurnQuestion(turn.question) ?? undefined, segmentConsistency: parseSegmentSummary(turn.segment_consistency) ?? undefined, recordedEvidence: parseRecordedEvidence(turn.recorded_evidence), candidateOffer: typeof turn.offer_result_id === "string" ? { resultId: turn.offer_result_id } : undefined, }]; } if (isIncompleteRunBanner(turn.text ?? "")) return []; if (isStructuredChoiceUserText(turn.text)) return []; return [{ role: "user", text: turn.text ?? "", renderKey: key, state: "settled", }]; }); }