fix(rectification): stream answers live and keep mobile chat visible

Use the quiet overlay scrollbar on chat and the house board, replace the blocking mobile dialog with a bottom sheet, and emit agent deltas before billing so users can read progress and stop a run.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-20 19:46:27 +08:00
co-authored by Cursor
parent 421ec0f9bf
commit 994f0583af
10 changed files with 322 additions and 121 deletions
+72 -44
View File
@@ -394,20 +394,48 @@ button:disabled { cursor: default; opacity: .45; }
.chat-app { width: 100%; height: 100%; min-height: 0; overflow: hidden; display: grid; background: var(--color-canvas); grid-template-columns: minmax(0, 1fr); }
.sidebar { position: relative; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column; border-right: 1px solid var(--sidebar-border); color: var(--sidebar-foreground); padding: var(--space-5) var(--space-3) 0; background: var(--sidebar-background); backdrop-filter: saturate(130%) blur(20px); }
[data-sidebar="header"] { flex: 0 0 auto; }
[data-sidebar="content"] { min-height: 0; flex: 1 1 auto; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: transparent transparent; }
[data-sidebar="content"]:hover, [data-sidebar="content"]:focus-within { scrollbar-color: color-mix(in srgb, var(--color-ink) 26%, transparent) transparent; }
[data-sidebar="content"]::-webkit-scrollbar { width: 10px; background: transparent; }
[data-sidebar="content"]::-webkit-scrollbar-button { display: none; width: 0; height: 0; }
[data-sidebar="content"]::-webkit-scrollbar-track, [data-sidebar="content"]::-webkit-scrollbar-corner { background: transparent; }
[data-sidebar="content"]::-webkit-scrollbar-thumb { background-color: transparent; border: 3px solid transparent; border-radius: 999px; background-clip: content-box; }
[data-sidebar="content"]:hover::-webkit-scrollbar-thumb, [data-sidebar="content"]:focus-within::-webkit-scrollbar-thumb { background-color: color-mix(in srgb, var(--color-ink) 26%, transparent); }
[data-sidebar="content"]::-webkit-scrollbar-thumb:hover { background-color: color-mix(in srgb, var(--color-ink) 40%, transparent); }
[data-sidebar="content"] { min-height: 0; flex: 1 1 auto; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; }
[data-sidebar="content"],
.conversation,
.rectification-board__body {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
[data-sidebar="content"]:hover, [data-sidebar="content"]:focus-within,
.conversation:hover, .conversation:focus-within,
.rectification-board__body:hover, .rectification-board__body:focus-within {
scrollbar-color: color-mix(in srgb, var(--color-ink) 26%, transparent) transparent;
}
[data-sidebar="content"]::-webkit-scrollbar,
.conversation::-webkit-scrollbar,
.rectification-board__body::-webkit-scrollbar { width: 10px; background: transparent; }
[data-sidebar="content"]::-webkit-scrollbar-button,
.conversation::-webkit-scrollbar-button,
.rectification-board__body::-webkit-scrollbar-button { display: none; width: 0; height: 0; }
[data-sidebar="content"]::-webkit-scrollbar-track, [data-sidebar="content"]::-webkit-scrollbar-corner,
.conversation::-webkit-scrollbar-track, .conversation::-webkit-scrollbar-corner,
.rectification-board__body::-webkit-scrollbar-track, .rectification-board__body::-webkit-scrollbar-corner { background: transparent; }
[data-sidebar="content"]::-webkit-scrollbar-thumb,
.conversation::-webkit-scrollbar-thumb,
.rectification-board__body::-webkit-scrollbar-thumb { background-color: transparent; border: 3px solid transparent; border-radius: 999px; background-clip: content-box; }
[data-sidebar="content"]:hover::-webkit-scrollbar-thumb, [data-sidebar="content"]:focus-within::-webkit-scrollbar-thumb,
.conversation:hover::-webkit-scrollbar-thumb, .conversation:focus-within::-webkit-scrollbar-thumb,
.rectification-board__body:hover::-webkit-scrollbar-thumb, .rectification-board__body:focus-within::-webkit-scrollbar-thumb { background-color: color-mix(in srgb, var(--color-ink) 26%, transparent); }
[data-sidebar="content"]::-webkit-scrollbar-thumb:hover,
.conversation::-webkit-scrollbar-thumb:hover,
.rectification-board__body::-webkit-scrollbar-thumb:hover { background-color: color-mix(in srgb, var(--color-ink) 40%, transparent); }
@media (prefers-contrast: more) {
[data-sidebar="content"] { scrollbar-color: var(--color-ink-secondary) transparent; }
[data-sidebar="content"]::-webkit-scrollbar-thumb, [data-sidebar="content"]:hover::-webkit-scrollbar-thumb, [data-sidebar="content"]:focus-within::-webkit-scrollbar-thumb { background-color: var(--color-ink-secondary); }
[data-sidebar="content"],
.conversation,
.rectification-board__body { scrollbar-color: var(--color-ink-secondary) transparent; }
[data-sidebar="content"]::-webkit-scrollbar-thumb, [data-sidebar="content"]:hover::-webkit-scrollbar-thumb, [data-sidebar="content"]:focus-within::-webkit-scrollbar-thumb,
.conversation::-webkit-scrollbar-thumb, .conversation:hover::-webkit-scrollbar-thumb, .conversation:focus-within::-webkit-scrollbar-thumb,
.rectification-board__body::-webkit-scrollbar-thumb, .rectification-board__body:hover::-webkit-scrollbar-thumb, .rectification-board__body:focus-within::-webkit-scrollbar-thumb { background-color: var(--color-ink-secondary); }
}
@media (forced-colors: active) {
[data-sidebar="content"] { scrollbar-width: auto; scrollbar-color: auto; }
[data-sidebar="content"],
.conversation,
.rectification-board__body { scrollbar-width: auto; scrollbar-color: auto; }
}
[data-sidebar="footer"] { flex: 0 0 auto; }
[data-sidebar="sidebar"]:focus { outline: none; }
@@ -482,7 +510,7 @@ button:disabled { cursor: default; opacity: .45; }
.chat-header-actions > * { flex: 0 0 auto; }
.credit-button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 11px; cursor: pointer; font-size: 13px; font-variant-numeric: tabular-nums; transition: background-color 120ms ease-out, transform 120ms ease-out; min-width: 64px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); color: var(--color-ink-secondary); font-weight: 500; }
.conversation { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding-bottom: var(--composer-reserve); background: var(--color-canvas); }
.conversation { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding-bottom: var(--composer-reserve); background: var(--color-canvas); }
.conversation.is-empty { display: grid; place-items: center; padding: var(--space-8); }
.welcome { width: min(820px, 100%); padding: var(--space-10) 0 var(--space-16); }
.welcome > .onboarding-message:first-child { padding-bottom: var(--space-8); }
@@ -1124,17 +1152,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.paywall-footer > a, .paywall-footer > button { width: 100%; }
.dialog-actions { flex-direction: column-reverse; }
.dialog-actions > button { width: 100%; }
dialog.rectification-board {
width: 100%;
height: min(86dvh, 100%);
margin-block-start: auto;
margin-inline: 0;
border-inline-start: 0;
border-block-start: 1px solid var(--color-border);
border-start-start-radius: var(--radius-lg);
border-start-end-radius: var(--radius-lg);
padding-bottom: env(safe-area-inset-bottom);
}
.auth-story { min-height: 0; padding: var(--space-5); }
.auth-story h2 { font-size: var(--type-title-lg); }
.auth-panel { padding: var(--space-6) var(--space-5) max(var(--space-6), env(safe-area-inset-bottom)); }
@@ -1612,10 +1629,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
padding: var(--space-10) var(--session-column-gutter) var(--space-24);
}
.conversation:not(.is-empty):not(.is-rectification) {
scrollbar-gutter: stable both-edges;
}
.conversation:not(.is-empty):not(.is-rectification) .message {
padding: 0;
animation: none;
@@ -1871,6 +1884,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.rectification-workspace.is-compact {
grid-template-columns: minmax(0, 1fr);
}
.rectification-workspace.is-compact.is-board-open {
grid-template-rows: minmax(8rem, 1fr) minmax(13rem, 46%);
}
.rectification-workspace__chat {
min-width: 0;
min-height: 0;
@@ -1897,21 +1913,20 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
background: var(--color-canvas-soft);
color: var(--color-ink);
}
dialog.rectification-board {
width: min(22.5rem, 100%);
height: 100dvh;
max-width: none;
max-height: none;
margin: 0;
margin-inline-start: auto;
padding: 0;
border: 0;
border-inline-start: 1px solid var(--color-border);
background: var(--color-canvas-soft);
color: var(--color-ink);
.rectification-board.is-sheet {
border-inline-start: 0;
border-block-start: 1px solid var(--color-border);
border-start-start-radius: var(--radius-lg);
border-start-end-radius: var(--radius-lg);
box-shadow: 0 -8px 28px color-mix(in srgb, var(--color-ink) 10%, transparent);
padding-bottom: env(safe-area-inset-bottom);
}
dialog.rectification-board::backdrop {
background: var(--color-scrim);
.rectification-board__handle {
width: 36px;
height: 4px;
margin: var(--space-2) auto 0;
border-radius: 999px;
background: color-mix(in srgb, var(--color-ink) 18%, transparent);
}
.rectification-board__header {
position: sticky;
@@ -1919,17 +1934,21 @@ dialog.rectification-board::backdrop {
z-index: 1;
display: grid;
gap: var(--space-2);
padding: var(--space-4) var(--space-4) var(--space-3);
padding: var(--space-3) var(--space-4) var(--space-3);
background: var(--color-canvas-soft);
}
.rectification-board__title-row {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
gap: var(--space-2) var(--space-3);
}
.rectification-board__header h2 {
min-width: 0;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-display);
font-size: var(--type-title-sm);
font-weight: 600;
@@ -1942,15 +1961,24 @@ dialog.rectification-board::backdrop {
font-weight: 600;
}
.rectification-board__close {
width: 44px;
height: 44px;
min-width: 44px;
min-height: 44px;
margin-inline-start: auto;
padding: 0 var(--space-3);
padding: 0;
display: grid;
place-items: center;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas);
color: var(--color-ink);
cursor: pointer;
transition: background-color 120ms ease-out, transform 120ms ease-out;
}
.rectification-board__close svg { width: 18px; height: 18px; }
.rectification-board__close:hover { background: var(--color-canvas-muted); }
.rectification-board__close:active { transform: scale(.96); }
.rectification-board__close:focus-visible,
.rectification-board-peek:focus-visible {
outline: 2px solid var(--color-focus);
@@ -1,6 +1,6 @@
"use client";
import { ArrowUp } from "lucide-react";
import { ArrowUp, Square } from "lucide-react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { parseAgentReply } from "@/lib/agent-reply";
import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
@@ -258,6 +258,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const keyCounter = useRef(0);
const openingStarted = useRef(false);
const previousBoardResult = useRef<CandidateResult>(null);
const runAbort = useRef<AbortController | null>(null);
const [compactBoard, setCompactBoard] = useState(false);
const [boardOpen, setBoardOpen] = useState(false);
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
@@ -284,6 +285,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}, [candidateResult]);
const closeBoard = useCallback(() => setBoardOpen(false), []);
const toggleBoard = useCallback(() => setBoardOpen((current) => !current), []);
const stopRun = useCallback(() => {
runAbort.current?.abort();
}, []);
const setPending = useCallback((value: boolean) => {
setBusy(value);
@@ -349,10 +355,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
let activityReceiptState = createRectificationActivityReceiptState();
let completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
let completedTurnId: string | undefined;
const abortController = new AbortController();
runAbort.current = abortController;
try {
const response = await fetch("/api/rectification/agent", {
method: "POST",
headers: { "content-type": "application/json" },
signal: abortController.signal,
body: JSON.stringify({
caseId,
sessionId,
@@ -418,6 +427,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? { ...message, text: parsed.text, state: "streaming" }
: message));
} else if (event.type === "attempt.reset") {
raw = "";
activityReceiptState = createRectificationActivityReceiptState();
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
completedTurnId = undefined;
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? { ...message, text: "", state: "thinking", completedReceipt: undefined, failed: false, turnId: undefined }
: message));
} else if (event.type === "run.failed") {
streamFailed = true;
} else if (event.type === "error") {
@@ -483,7 +500,28 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (result) setCandidateResult(result);
});
}
} catch {
} catch (caught) {
const aborted = caught instanceof DOMException
? caught.name === "AbortError"
: caught instanceof Error && caught.name === "AbortError";
if (aborted) {
const parsed = parseAgentReply(raw);
setMessages((current) => current.flatMap((message): RenderMessage[] => {
if (message.renderKey !== assistantRenderKey) return [message];
if (parsed.text) {
return [{
...message,
text: parsed.text,
state: "settled",
completedReceipt,
failed: false,
turnId: completedTurnId,
}];
}
return [];
}));
return;
}
setError("生时校正暂时不可用,请稍后再试。");
setMessages((current) => current.flatMap((message): RenderMessage[] => {
if (message.renderKey !== assistantRenderKey) return [message];
@@ -498,6 +536,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: [];
}));
} finally {
if (runAbort.current === abortController) runAbort.current = null;
setPending(false);
}
}, [busy, caseId, loadCandidate, onCompleted, onMessagesChange, onProfileIncomplete, readonly, selectedModelId, sessionId, setPending]);
@@ -650,7 +689,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
ref={workspace}
className={`rectification-workspace${compactBoard ? " is-compact" : ""}${boardOpen ? " is-board-open" : ""}`}
>
<div className="rectification-workspace__chat" inert={compactBoard && boardOpen ? true : undefined}>
<div className="rectification-workspace__chat">
<section ref={conversation} className="conversation is-rectification" aria-label="生时校正对话" aria-busy={busy || regeneratingMessageKey !== null}>
<div className="message-list">
{pendingConsultationQuestion?.trim() && (
@@ -745,7 +784,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
result={candidateResult}
expanded={boardOpen}
boardId={boardId}
onOpen={() => setBoardOpen(true)}
onOpen={toggleBoard}
/>
)}
<form className="composer" onSubmit={submit}>
@@ -765,9 +804,22 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
}}
/>
<Button aria-label="发送" disabled={!draft.trim() || !canSend} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
{busy ? (
<Button
className="composer-stop"
aria-label="停止回答"
title="停止当前推理,已生成内容会保留"
size="icon"
type="button"
onClick={stopRun}
>
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label="发送" disabled={!draft.trim() || !canSend} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}
</form>
<div className="composer-footer">
+26 -40
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import { X } from "lucide-react";
import { useEffect } from "react";
import {
groupWindowTransitions,
rectificationBoardPeekCopy,
@@ -63,6 +64,7 @@ function RectificationBoardBody({
return (
<>
{compact ? <div className="rectification-board__handle" aria-hidden="true" /> : null}
<header className="rectification-board__header">
<div className="rectification-board__title-row">
<h2 id={titleId}></h2>
@@ -71,9 +73,10 @@ function RectificationBoardBody({
<button
className="rectification-board__close"
type="button"
aria-label="关闭盘面"
onClick={onClose}
>
<X aria-hidden="true" strokeWidth={2} />
</button>
)}
</div>
@@ -207,48 +210,31 @@ export function RectificationBoard({
titleId: string;
onClose: () => void;
}>) {
const dialogRef = useRef<HTMLDialogElement>(null);
const body = (
<RectificationBoardBody
result={result}
savedStatus={savedStatus}
diff={diff}
compact={compact}
titleId={titleId}
onClose={onClose}
/>
);
useEffect(() => {
const node = dialogRef.current;
if (!compact || !node) return;
if (open) {
if (!node.open) node.showModal();
} else if (node.open) {
node.close();
}
}, [compact, open]);
if (!compact || !open) return;
const handleKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [compact, open, onClose]);
useEffect(() => {
if (!compact) return;
const node = dialogRef.current;
if (!node) return;
const handleClose = () => onClose();
node.addEventListener("close", handleClose);
return () => node.removeEventListener("close", handleClose);
}, [compact, onClose]);
if (compact) {
return (
<dialog ref={dialogRef} id={boardId} className="rectification-board" aria-labelledby={titleId}>
{body}
</dialog>
);
}
if (compact && !open) return null;
return (
<aside id={boardId} className="rectification-board" aria-labelledby={titleId}>
{body}
<aside
id={boardId}
className={`rectification-board${compact ? " is-sheet" : ""}`}
aria-labelledby={titleId}
>
<RectificationBoardBody
result={result}
savedStatus={savedStatus}
diff={diff}
compact={compact}
titleId={titleId}
onClose={onClose}
/>
</aside>
);
}
@@ -1,9 +1,10 @@
/**
* V10 rectification turn runner over the durable V9 Case/Evidence domains.
*
* Every model attempt is isolated. Text, tool sets, usage and public receipt
* events are committed only from the successful attempt. The browser never
* sees partial text from an attempt that is retried or abandoned.
* Tool activity and answer tokens are published as they happen so the browser
* can render progress. A retried attempt emits `attempt.reset` first so the
* client discards the abandoned attempt's visible text. Durable receipts,
* billing, and settled history still come only from the successful attempt.
*/
import type { Agent } from "@mastra/core/agent";
import { RectificationAgentAction, resolveRectificationStepBudget } from "@/mastra/agentic-rectification";
@@ -316,6 +317,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|| outcome.status === "failed"
|| !shouldAutoRetry(outcome.errorCode ?? "run_failed", signal)
|| attemptNumber === MAX_ATTEMPTS) break;
await emit({ type: "attempt.reset" });
}
const outcome = finalOutcome ?? {
@@ -336,7 +338,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (!outcome.ok) {
await billing.release();
await finalizeTurn(outcome.status, null, outcome.attemptId, null);
for (const event of outcome.events) await emit(event);
await emit({ type: "run.failed" });
return {
ok: false,
@@ -404,9 +405,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
);
await finalizeTurn("completed", outcome.answerText, outcome.attemptId, outcome.attemptId, true);
for (const event of outcome.events) await emit(event);
await emit({ type: "billing.settled" });
for (const text of outcome.answerDeltas) await emit({ type: "answer.delta", text });
await emit({ type: "run.completed", turnId });
return {
@@ -463,13 +462,18 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
let phaseSequence = 0;
const recordPhase = async (phase: string, tool: string | null = null) => {
if (phase === "answer.delta" || emittedKeys.has(`${phase}:${tool ?? ""}`)) return;
if (phase === "answer.delta" || phase === "attempt.reset" || emittedKeys.has(`${phase}:${tool ?? ""}`)) return;
emittedKeys.add(`${phase}:${tool ?? ""}`);
phases.push(phase);
phaseSequence += 1;
await persistCommittedPhase(phase, tool, attemptId, phaseSequence);
};
const publish = async (event: PublicStreamEvent) => {
events.push(event);
await emit(event);
};
try {
await recordPhase("run.started");
await insertV9SkillRunReceipt(
@@ -482,7 +486,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
skillPackage,
);
await recordPhase("skill.bound");
events.push({ type: "skill.bound" });
await publish({ type: "skill.bound" });
emittedKeys.add("event:skill.bound::");
const result = await (agent as unknown as {
@@ -537,7 +541,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
const activityEvent = mapStreamChunkToActivity(chunk as never);
if (activityEvent) {
events.push(activityEvent);
await publish(activityEvent);
if (activityEvent.status === "completed" || activityEvent.status === "failed") {
toolTerminalStatus.set(activityEvent.tool, activityEvent.status);
}
@@ -562,22 +566,26 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (phaseEvent.type === "answer.delta") {
const text = phaseEvent.text ?? "";
if (text) {
const hadVisible = Boolean(answerText.trim());
answerText += text;
answerDeltas.push(text);
if (hadVisible || text.trim()) {
await emit(phaseEvent);
}
}
} else {
await recordPhase(phaseEvent.type, phaseEvent.tool ?? null);
const key = `${phaseEvent.type}:${phaseEvent.tool ?? ""}:${(phaseEvent.methods ?? []).join(",")}`;
if (!emittedKeys.has(`event:${key}`)) {
emittedKeys.add(`event:${key}`);
events.push(phaseEvent);
await publish(phaseEvent);
}
if (phaseEvent.type === "case.loaded") {
caseLoaded = true;
if (!intentClassified) {
intentClassified = true;
await recordPhase("intent.classified");
events.push({ type: "intent.classified" });
await publish({ type: "intent.classified" });
}
}
}
@@ -611,7 +619,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
const usage = await (result.totalUsage ?? Promise.resolve({ inputTokens: 0, outputTokens: 0 }));
await recordPhase("answer.composed");
events.push({ type: "answer.composed" });
await publish({ type: "answer.composed" });
return {
ok: true,
status: "completed",
@@ -23,6 +23,7 @@ export const PUBLIC_RECTIFICATION_PHASES = [
"answer.composed",
"billing.settled",
"answer.delta",
"attempt.reset",
"run.completed",
"run.failed",
] as const;