Files
Jyotisha/frontend/src/lib/rectification-timeline-adapter.ts
T
Jesse_ChenandClaude Opus 5.5 ab0f01a9cf 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
2026-09-26 11:45:47 +08:00

122 lines
4.5 KiB
TypeScript

import type { AgentActivityTraceItem } from "./agent-activity-trace.ts";
import type { AgentActivityView } from "./chat-message-view.ts";
import type { ConsultationTimelineKind, ConsultationTimelineRow } from "./consultation-run-timeline.ts";
import { RECTIFICATION_ANALYZING_LIVE_LABEL, RECTIFICATION_TURN_PROGRESS_LABELS } from "./rectification-activity-labels.ts";
import type { CompletedActivityReceiptView } from "./rectification-activity-receipt.ts";
import { PUBLIC_RECTIFICATION_METHOD_LABELS } from "./rectification-varga-sentence.ts";
export const RECTIFICATION_TIMELINE_SOURCE_LIMIT = 8;
export const RECTIFICATION_TIMELINE_LIVE_ID = "activity-live";
const FAILED_SUFFIX = "未完成";
export type RectificationTimelineInput = Readonly<{
trace: readonly AgentActivityTraceItem[] | undefined;
receipt: CompletedActivityReceiptView | undefined;
activity: AgentActivityView | undefined;
settled: boolean;
}>;
function methodChips(receipt: CompletedActivityReceiptView | undefined): string[] {
if (!receipt) return [];
const seen = new Set<string>();
const chips: string[] = [];
for (const method of receipt.methods) {
const label = PUBLIC_RECTIFICATION_METHOD_LABELS[method];
if (!label || seen.has(label)) continue;
seen.add(label);
chips.push(label);
if (chips.length >= RECTIFICATION_TIMELINE_SOURCE_LIMIT) break;
}
return chips;
}
const TURN_PROGRESS_LINES = new Set<string>(Object.values(RECTIFICATION_TURN_PROGRESS_LABELS));
/**
* Progress labels are the only ones that name an action still under way: the
* 「正在…」 labels and the typed-answer stage lines (「收到,正在对照你的档案…」,
* BUG-1047).
*/
function isProgressLabel(label: string): boolean {
const trimmed = label.trim();
return /^正在/.test(trimmed) || TURN_PROGRESS_LINES.has(trimmed);
}
/**
* The stage line of a typed answer names what the turn is doing, so it is also
* what the tool step currently under way shows (BUG-1047 D3). Finished steps
* keep their own labels.
*/
export function withLiveStepLabel(
trace: readonly AgentActivityTraceItem[],
label: string,
): readonly AgentActivityTraceItem[] {
if (!trace.some((item) => item.kind === "activity" && item.status === "live" && item.label !== label)) return trace;
return trace.map((item) => (
item.kind === "activity" && item.status === "live" ? { ...item, label } : item
));
}
function liveRowKind(phase: AgentActivityView["phase"] | undefined, label: string): ConsultationTimelineKind {
if (phase === "answer-composition") return "write";
if (label === RECTIFICATION_ANALYZING_LIVE_LABEL) return "think";
return "calculate";
}
/**
* Project the rectification agent's tool trace and receipt onto the same
* timeline rows the consultation surface renders, so both sessions share one
* step list, one live marker and one settled summary. Thinking text never
* reaches this surface: the public stream drops it on the server.
*/
export function rectificationTimelineRows(input: RectificationTimelineInput): ConsultationTimelineRow[] {
const failedTool = input.receipt?.failedTool;
const rows: ConsultationTimelineRow[] = (input.trace ?? []).map((item) => {
if (item.kind === "think") {
return {
id: item.id,
kind: "think",
status: item.status,
label: item.label,
...(item.text?.trim() ? { thinkingText: item.text } : {}),
};
}
const failed = item.status === "done" && failedTool !== undefined && item.tool === failedTool;
return {
id: item.id,
kind: "calculate",
status: item.status,
label: failed ? `${item.label}${FAILED_SUFFIX}` : item.label,
};
});
const chips = methodChips(input.receipt);
if (chips.length > 0) {
for (let index = rows.length - 1; index >= 0; index -= 1) {
const row = rows[index];
if (!row || row.kind !== "calculate" || row.status !== "done" || row.label.endsWith(FAILED_SUFFIX)) continue;
rows[index] = { ...row, sources: chips };
break;
}
}
const hasLiveRow = rows.some((row) => row.status === "live");
const label = input.activity?.label ?? "";
if (!input.settled && !hasLiveRow && isProgressLabel(label)) {
rows.push({
id: RECTIFICATION_TIMELINE_LIVE_ID,
kind: liveRowKind(input.activity?.phase, label),
status: "live",
label,
});
} else if (!input.settled && !hasLiveRow && rows.length > 0) {
rows.push({
id: RECTIFICATION_TIMELINE_LIVE_ID,
kind: "think",
status: "live",
label: RECTIFICATION_ANALYZING_LIVE_LABEL,
});
}
return rows;
}