fix(rectification): stream-first typed turns, 10 s classifier cap, stage progress, run timings (BUG-1047)

- D2: each intent-classifier attempt is capped at 10 s (same session model,
  thinking untouched); a hang takes the existing retry -> classifier_unavailable
  path. Success is timed too (RectificationClassifierDiagnostic).
- D3: a typed message builds the NDJSON stream first; the first line is
  turn.progress "received", then the classifier and deterministic replies run
  inside the stream. Preflight rejections become turn.rejected (old status,
  code, message) and the client handles them like the old HTTP rejection.
  Stage lines 收到,正在对照你的档案… / 正在记下这件事… / 正在重新对照盘面… /
  正在准备下一个问题… are driven by existing tool events and engine calls, are
  transient (live row only) and never persisted. VOICE / DESIGN updated.
- D4: RectificationRunDiagnostic records per-step start/end, provider token
  usage incl. reasoning tokens, classifier timing and per-engine-call
  durations (AsyncLocalStorage scope per turn); RectificationTurnDiagnostic
  for deterministic turns. No user text, birth data or model text.
- D5: /v5/versions memo (30 s, complete identities only, per transport); the
  exit gate skips its second persistNextInterviewIfIdle when the run's own
  call found the next focus already active (provably identical).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-26 11:45:47 +08:00
co-authored by Claude Opus 5.5
parent fe76d54795
commit ab0f01a9cf
20 changed files with 1913 additions and 75 deletions
@@ -20,11 +20,15 @@ import {
RECTIFICATION_ANALYZING_LIVE_LABEL,
RECTIFICATION_TOOL_DONE_LABELS,
RECTIFICATION_TOOL_PROGRESS_LABELS,
RECTIFICATION_TURN_PROGRESS_LABELS,
activityTraceFromReceipt,
rectificationCompletedTrail,
rectificationLiveProgressLabel,
rectificationToolActivityPhase,
rectificationTurnProgressPhase,
} from "@/lib/rectification-activity-labels";
import { isRectificationTurnProgressStage } from "@/lib/rectification-agentic/v9/turn-progress";
import { withLiveStepLabel } from "@/lib/rectification-timeline-adapter";
import {
createRectificationActivityReceiptState,
receiptFromRectificationActivityState,
@@ -815,9 +819,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
clientActionId: requestId,
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
const message = payload?.message || payload?.error || `请求失败(${response.status})`;
// A turn the server turned down: the old non-2xx response, or the same
// facts as a `turn.rejected` event once the stream is already open
// (BUG-1047). Either way the live row goes and the typed mark returns.
const rejectTurn = (
response: { status: number },
payload: { code?: unknown; message?: unknown; error?: unknown } | null,
) => {
const message = (typeof payload?.message === "string" && payload.message)
|| (typeof payload?.error === "string" && payload.error)
|| `请求失败(${response.status})`;
setMessages((current) => withdrawTypedMark(
current.filter((message) => message.renderKey !== assistantRenderKey),
));
@@ -833,6 +844,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
if (response.status === 401) setError("请先登录。");
else setError(message);
};
if (!response.ok) {
const payload = await response.json().catch(() => null);
rejectTurn(response, payload);
return;
}
if (!response.body) {
@@ -849,7 +864,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
let completed = false;
let streamFailed = false;
let runFailedMessage = "";
while (true) {
let rejected: { status: number; payload: { code?: unknown; message?: unknown } } | null = null;
// Stage line of a typed answer (BUG-1047 D3); null until the first
// `turn.progress`. It replaces the generic tool / "正在分析…" labels.
let stageLabel: string | null = null;
while (!rejected) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
@@ -868,6 +887,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
turnId?: unknown;
code?: unknown;
activity?: unknown;
stage?: unknown;
httpStatus?: unknown;
};
try {
event = JSON.parse(line) as typeof event;
@@ -875,7 +896,19 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
continue;
}
if (typeof event.type !== "string") continue;
if (event.type === "answer.delta" && typeof event.text === "string") {
if (event.type === "turn.rejected" && typeof event.httpStatus === "number") {
rejected = { status: event.httpStatus, payload: { code: event.code, message: event.message } };
break;
}
if (event.type === "turn.progress" && isRectificationTurnProgressStage(event.stage)) {
stageLabel = RECTIFICATION_TURN_PROGRESS_LABELS[event.stage];
activityTrace = withLiveStepLabel(activityTrace, stageLabel);
currentActivity = nextActivityView(currentActivity, {
phase: rectificationTurnProgressPhase(event.stage),
label: rememberLiveActivity(stageLabel, null),
});
frames.touch();
} else if (event.type === "answer.delta" && typeof event.text === "string") {
raw = event.replace === true ? event.text : raw + event.text;
activityTrace = freezeLiveThink(activityTrace);
currentActivity = nextActivityView(currentActivity, {
@@ -887,7 +920,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const activity = event.activity;
currentActivity = nextActivityView(currentActivity, {
phase: "evidence-validation",
label: rememberLiveActivity(RECTIFICATION_ACTIVITY_PROGRESS_LABELS[activity]),
label: rememberLiveActivity(stageLabel ?? RECTIFICATION_ACTIVITY_PROGRESS_LABELS[activity]),
});
frames.touch();
} else if (event.type === "attempt.reset") {
@@ -896,9 +929,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
activityReceiptState = createRectificationActivityReceiptState();
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
completedTurnId = undefined;
if (stageLabel) stageLabel = RECTIFICATION_TURN_PROGRESS_LABELS.received;
currentActivity = nextActivityView(undefined, {
phase: "evidence-validation",
label: rememberLiveActivity("正在处理…", null),
label: rememberLiveActivity(stageLabel ?? "正在处理…", null),
});
frames.reset();
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
@@ -932,11 +966,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
activityTrace = startActivityTraceStep(
activityTrace,
tool,
RECTIFICATION_TOOL_PROGRESS_LABELS[tool],
stageLabel ?? RECTIFICATION_TOOL_PROGRESS_LABELS[tool],
);
currentActivity = nextActivityView(currentActivity, {
phase: rectificationToolActivityPhase(tool),
label: rememberLiveActivity(RECTIFICATION_TOOL_PROGRESS_LABELS[tool], tool),
label: rememberLiveActivity(stageLabel ?? RECTIFICATION_TOOL_PROGRESS_LABELS[tool], tool),
completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps),
});
frames.touch();
@@ -958,7 +992,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
currentActivity = nextActivityView(currentActivity, {
phase: "evidence-validation",
label: rememberLiveActivity(RECTIFICATION_ANALYZING_LIVE_LABEL, null),
label: stageLabel
? rememberLiveActivity(stageLabel, null)
: rememberLiveActivity(RECTIFICATION_ANALYZING_LIVE_LABEL, null),
completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps),
});
frames.touch();
@@ -966,6 +1002,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
}
frames.settle();
if (rejected) {
await reader.cancel().catch(() => undefined);
rejectTurn({ status: rejected.status }, rejected.payload);
return;
}
const parsed = completed && !streamFailed ? parseAgentReply(raw) : { text: "", title: undefined };
const succeeded = completed && !streamFailed && Boolean(parsed.text);