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
361 lines
13 KiB
TypeScript
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 };
|