Files
Jyotisha/frontend/tests/rectification-chat-run-fixtures.ts
T
Jesse_ChenandClaude Opus 5.5 84eb23152a feat(rectification): remove the rectification regenerate button and endpoint (BUG-1056)
T2 of TASK-rectification-grounding-20260927 (product decision P1).
The rectification 「重试(重新生成)」 was a blind agent.generate rewrite that
persisted unchecked text (invented range / fit rate reproduced). Removed:
the regenerate route, regenerate-turn.ts, the regeneration agent and its
read-only tool set, the client regenerate action/state and the
regenerating/canRegenerate props. ChatMessageActions renders the regenerate
button only when onRegenerate is passed; ordinary consultation is unchanged.
The DB function regenerate_agentic_rectification_turn is kept (AGENTS §7.6,
retire in a later round). DESIGN.md / VOICE.md updated; source-contract
tests follow with 原值/新值/原因 notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-27 03:28:27 +08:00

361 lines
13 KiB
TypeScript

/**
* Fake dependencies for the rectification chat's parameter functions
* (TASK-rectification-code-split-20260926). The split moved `send`,
* `submitStructuredChoice` and `acceptCandidate` out of the component into
* `runRectificationChatTurn`, `runRectificationStructuredChoice` and
* `runRectificationCandidateAccept`; tests that used to slice those bodies out
* of the component source now call the functions with these fakes and assert
* on what they do (requests, abort wiring, transcript, error line).
*/
import type { SetStateAction } from "react";
import type { RenderMessage } from "../src/components/rectification-message-entry.tsx";
import {
runRectificationChatTurn,
type RectificationChatTurnDeps,
type RectificationRunOutcome,
type RectificationTurnAction,
type RectificationTurnContinuation,
} from "../src/lib/rectification-chat-turn-run.ts";
import {
runRectificationStructuredChoice,
type RectificationStructuredChoiceDeps,
} from "../src/lib/rectification-chat-choice-run.ts";
import {
runRectificationCandidateAccept,
type RectificationCandidateAcceptDeps,
} from "../src/lib/rectification-chat-accept-run.ts";
import type { CandidateResult, CurrentQuestionModel } from "../src/lib/rectification-chat-snapshot.ts";
import { parseRectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
import type { RectificationChatViewInput } from "../src/lib/rectification-chat-view.ts";
import type { RectificationChoiceCard } from "../src/lib/rectification-agentic/v9/choice-card.ts";
export const RUN_CASE_ID = "44444444-4444-4444-8444-444444444444";
export const RUN_SESSION_ID = "33333333-3333-4333-8333-333333333333";
export const RUN_FOCUS_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1";
export type RecordedState<T> = Readonly<{
get: () => T;
set: (next: SetStateAction<T>) => void;
}>;
export function recordedState<T>(initial: T): RecordedState<T> {
let value = initial;
return {
get: () => value,
set: (next) => {
value = typeof next === "function" ? (next as (current: T) => T)(value) : next;
},
};
}
export type FetchCall = Readonly<{ url: string; init: RequestInit | undefined; body: Record<string, unknown> | null }>;
/**
* Swap `fetch` (and the `window` frame scheduler the stream buffer needs) for
* the duration of `run`. `respond` sees each call in order.
*/
export async function withFakeFetch<T>(
respond: (call: FetchCall, index: number) => Response | Promise<Response>,
run: (calls: FetchCall[]) => Promise<T>,
): Promise<T> {
const calls: FetchCall[] = [];
const originalFetch = globalThis.fetch;
const globals = globalThis as { window?: unknown };
const originalWindow = globals.window;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const body = typeof init?.body === "string" ? JSON.parse(init.body) as Record<string, unknown> : null;
const call = { url: String(input), init, body };
calls.push(call);
return respond(call, calls.length - 1);
}) as typeof fetch;
globals.window = {
requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0),
cancelAnimationFrame: (handle: ReturnType<typeof setTimeout>) => clearTimeout(handle),
setTimeout,
clearTimeout,
};
try {
return await run(calls);
} finally {
globalThis.fetch = originalFetch;
globals.window = originalWindow;
}
}
export function ndjsonResponse(events: readonly Record<string, unknown>[], init: ResponseInit = {}): Response {
return new Response(`${events.map((event) => JSON.stringify(event)).join("\n")}\n`, {
status: 200,
headers: { "content-type": "application/x-ndjson; charset=utf-8" },
...init,
});
}
export function jsonResponse(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json" },
});
}
/** Everything the three run functions write, recorded for assertions. */
export function chatRunRecorder(initialMessages: RenderMessage[] = []) {
const transcript = recordedState<RenderMessage[]>(initialMessages);
/** Every transcript the run committed, in order (each setMessages call). */
const messageHistory: RenderMessage[][] = [];
const messages: RecordedState<RenderMessage[]> = {
get: transcript.get,
set: (next) => {
transcript.set(next);
messageHistory.push(transcript.get());
},
};
const error = recordedState("");
const choiceNonce = recordedState(0);
const nextUserActionId = recordedState<string | null>(null);
const acceptingCandidateId = recordedState<string | null>(null);
const candidateResult = recordedState<CandidateResult>(null);
const savedTime = recordedState<string | null>(null);
const savedStatus = recordedState<"accepted" | "confirmed" | null>(null);
const pending: boolean[] = [];
const liveLabels: string[] = [];
const snapshotLoads: Array<((turns: readonly unknown[]) => void) | undefined> = [];
const pins: number[] = [];
const sends: Array<Readonly<{ action: RectificationTurnAction; messageText: string; continuation?: RectificationTurnContinuation }>> = [];
const completed: number[] = [];
const profileIncomplete: number[] = [];
const billing: Array<{ source?: string } | undefined> = [];
const messageChanges: unknown[] = [];
const saved: Array<[string, string]> = [];
const refs = {
keyCounter: { current: 0 },
currentQuestionRef: { current: null as CurrentQuestionModel | null },
runAbort: { current: null as AbortController | null },
lastRunOutcome: { current: null as RectificationRunOutcome },
};
const shared = {
caseId: RUN_CASE_ID,
sessionId: RUN_SESSION_ID,
busy: false,
readonly: false,
...refs,
conversationAnchor: { pinLatestTurn: () => { pins.push(Date.now()); } },
setError: error.set,
setMessages: messages.set,
setPending: (value: boolean) => { pending.push(value); },
beginLiveRun: (label: string) => { liveLabels.push(label); },
loadCaseSnapshot: async (merge?: (turns: readonly unknown[]) => void) => {
snapshotLoads.push(merge);
merge?.([]);
return undefined;
},
send: async (action: RectificationTurnAction, messageText: string, continuation?: RectificationTurnContinuation) => {
sends.push({ action, messageText, continuation });
},
onCompleted: () => { completed.push(1); },
onProfileIncomplete: () => { profileIncomplete.push(1); },
onMessagesChange: (next: unknown) => { messageChanges.push(next); },
};
function turnDeps(overrides: Partial<RectificationChatTurnDeps> = {}): RectificationChatTurnDeps {
return {
...shared,
selectedModelId: "model-a",
conversationReadonly: false,
rememberLiveActivity: (label: string) => { liveLabels.push(label); return label; },
onOpenBilling: (options?: { source?: string }) => { billing.push(options); },
...overrides,
};
}
function choiceDeps(
choiceCard: RectificationChoiceCard | null,
overrides: Partial<RectificationStructuredChoiceDeps> = {},
): RectificationStructuredChoiceDeps {
return {
...shared,
choiceCard,
currentQuestion: null,
actionIdForChoice: () => globalThis.crypto.randomUUID(),
setChoiceNonce: choiceNonce.set,
setNextUserActionId: nextUserActionId.set,
...overrides,
};
}
function acceptDeps(overrides: Partial<RectificationCandidateAcceptDeps> = {}): RectificationCandidateAcceptDeps {
return {
...shared,
candidateResult: candidateResult.get(),
acceptingCandidateId: acceptingCandidateId.get(),
setAcceptingCandidateId: acceptingCandidateId.set,
setCandidateResult: candidateResult.set,
setSavedTime: savedTime.set,
setSavedStatus: savedStatus.set,
onSaved: (time: string, status: string) => { saved.push([time, status]); },
...overrides,
};
}
return {
messages,
messageHistory,
error,
choiceNonce,
nextUserActionId,
acceptingCandidateId,
candidateResult,
savedTime,
savedStatus,
pending,
liveLabels,
snapshotLoads,
pins,
sends,
completed,
profileIncomplete,
billing,
messageChanges,
saved,
refs,
turnDeps,
choiceDeps,
acceptDeps,
};
}
export function fixtureChoiceCard(overrides: Partial<RectificationChoiceCard> = {}): RectificationChoiceCard {
return {
question_id: "distinguish:1",
method_id: "candidate_discriminator",
prompt: "2023 年前后,有没有换过工作或职责明显变化?",
why: "",
varga: null,
choice_mode: "A/B/C/D",
options: [
{ key: "A", label: "明确发生且时间吻合" },
{ key: "B", label: "发生过但程度较弱" },
{ key: "C", label: "没有这回事" },
{ key: "D", label: "这段记不清楚" },
],
stop_label: "先这样,先看当前范围",
stop_message: "",
scoring: true,
probe_id: "probe:1",
case_revision: 3,
focus_id: RUN_FOCUS_ID,
...overrides,
} as RectificationChoiceCard;
}
export function fixtureCandidateResult(): NonNullable<CandidateResult> {
return {
resultId: "result-1",
candidates: [
{ candidateId: "cand-1", time: "05:07" },
{ candidateId: "cand-2", time: "05:15" },
],
selectedTime: null,
} as unknown as NonNullable<CandidateResult>;
}
/**
* Where the stop button's AbortController goes during a tapped choice or an
* adoption: is the controller in `runAbort` the one whose signal the request
* carries, and is it cleared once the run ends?
*/
export async function abortWiringOf(kind: "choice" | "accept") {
const recorder = chatRunRecorder();
let signalMatchesRunAbort = false;
let url = "";
await withFakeFetch((call) => {
url = call.url;
signalMatchesRunAbort = Boolean(
recorder.refs.runAbort.current
&& call.init?.signal === recorder.refs.runAbort.current.signal,
);
return jsonResponse({ ok: false }, 500);
}, async () => {
if (kind === "choice") {
await runRectificationStructuredChoice(recorder.choiceDeps(fixtureChoiceCard()), "answer_choice", "A");
} else {
recorder.candidateResult.set(fixtureCandidateResult());
await runRectificationCandidateAccept(recorder.acceptDeps(), "cand-1");
}
});
return {
url,
signalMatchesRunAbort,
runAbortCleared: recorder.refs.runAbort.current === null,
};
}
/**
* A result the delivery card may offer: `completed_with_range` with selection
* allowed, parsed by the real parser (same snapshot shape as
* rectification-delivery-vs-collect-20260914.test.ts).
*/
export function offerableCandidateResult(overrides: Record<string, unknown> = {}): NonNullable<CandidateResult> {
const result = parseRectificationCandidateResult({
resultId: "11111111-1111-4111-8111-111111111111",
candidates: [
{ candidateId: "88888888-8888-4888-8888-888888888881", rank: 1, time: "04:53", relativeSupport: 16, tiedMinuteCount: 2 },
{ candidateId: "88888888-8888-4888-8888-888888888882", rank: 2, time: "05:00", relativeSupport: 16, tiedMinuteCount: 2 },
],
overallConfidence: "medium",
selectionAllowed: true,
canAdopt: false,
confirmationAllowed: false,
representativeTime: "04:53",
selectedTime: null,
selectionKind: null,
credibleRange: ["04:48", "05:07"],
session_outcome: "completed_with_range",
decisionReceipt: {
display_allowed: true,
selection_allowed: true,
acceptance_allowed: true,
propose_allowed: true,
confirmation_allowed: false,
accept_allowed: true,
confirm_allowed: false,
},
...overrides,
});
if (!result) throw new Error("offerableCandidateResult: parser rejected the fixture");
return result;
}
export function settledAssistant(renderKey: string, extra: Partial<RenderMessage> = {}): RenderMessage {
return { role: "assistant", text: `回复 ${renderKey}`, renderKey, state: "settled", ...extra };
}
/** Input for deriveRectificationChatView with a quiet, loaded, idle surface. */
export function chatViewInput(overrides: Partial<RectificationChatViewInput> = {}): RectificationChatViewInput {
return {
messages: [],
busy: false,
readonly: false,
shouldStartOpening: false,
openingRequested: false,
acceptingCandidateId: null,
selectionOfferLock: null,
candidateResult: null,
currentQuestion: null,
questionSource: null,
choiceCard: null,
caseStatus: null,
caseSnapshotLoaded: true,
searchWindow: null,
caseStage: null,
nextUserActionId: null,
interviewStopReason: null,
interviewSessionOutcome: null,
questionRetryAttempts: 0,
savedTime: null,
...overrides,
};
}
export { runRectificationChatTurn, runRectificationStructuredChoice, runRectificationCandidateAccept };