fix(consult): treat pinched answers as failed and restore reply actions
Incomplete Flash generations were billed as completed consultations. Fail those runs, keep the partial text, and reuse the rectification like/copy/rerun bar on ordinary chat replies. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -37,6 +37,7 @@ import type { AgentExecutionReceipt, WorkflowReceipt } from "@/lib/consultation-
|
||||
import {
|
||||
AGENT_MAX_STEPS,
|
||||
AGENT_TIMEOUT_MS,
|
||||
consultationGenerationSettings,
|
||||
createConsultationAgentContext,
|
||||
consultationModelStepTelemetry,
|
||||
consultationStepBudgetReceipt,
|
||||
@@ -653,6 +654,7 @@ export async function POST(request: Request) {
|
||||
maxSteps: AGENT_MAX_STEPS,
|
||||
abortSignal: agentAbortSignal,
|
||||
hooks,
|
||||
...consultationGenerationSettings(selectedModel.model),
|
||||
};
|
||||
const workflowReceipt: WorkflowReceipt = consultationMode === "general_no_birth_time"
|
||||
? {
|
||||
|
||||
@@ -679,14 +679,14 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.message-bubble { overflow: hidden; border: 0; padding: var(--space-3) var(--space-4); border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
|
||||
.message-assistant .message-bubble { border-radius: 0; background: transparent; padding: var(--space-3) 0; }
|
||||
.rectification-message-entry { min-width: 0; }
|
||||
.rectification-message-actions {
|
||||
.message-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
margin: -4px 0 8px 42px;
|
||||
color: var(--color-ink-tertiary);
|
||||
}
|
||||
.rectification-message-actions button {
|
||||
.message-actions button {
|
||||
display: inline-flex;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
@@ -702,17 +702,17 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
opacity: .7;
|
||||
transition: background-color 120ms ease-out, color 120ms ease-out, opacity 120ms ease-out, transform 120ms ease-out;
|
||||
}
|
||||
.rectification-message-actions button:hover:not(:disabled) {
|
||||
.message-actions button:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--color-canvas-muted) 72%, transparent);
|
||||
color: var(--color-ink-secondary);
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.rectification-message-actions button:active:not(:disabled) { transform: translateY(0); }
|
||||
.rectification-message-actions button.is-active { color: var(--color-action); opacity: 1; }
|
||||
.rectification-message-actions button:focus-visible { outline: 2px solid color-mix(in srgb, var(--color-focus) 52%, transparent); outline-offset: 1px; }
|
||||
.rectification-message-actions button:disabled { cursor: default; opacity: 0.32; }
|
||||
.rectification-message-actions svg { width: 13px; height: 13px; stroke-width: 1.65; }
|
||||
.message-actions button:active:not(:disabled) { transform: translateY(0); }
|
||||
.message-actions button.is-active { color: var(--color-action); opacity: 1; }
|
||||
.message-actions button:focus-visible { outline: 2px solid color-mix(in srgb, var(--color-focus) 52%, transparent); outline-offset: 1px; }
|
||||
.message-actions button:disabled { cursor: default; opacity: 0.32; }
|
||||
.message-actions svg { width: 13px; height: 13px; stroke-width: 1.65; }
|
||||
.rectification-analysis {
|
||||
width: min(620px, calc(100% - 42px));
|
||||
margin: -2px 0 var(--space-1) 42px;
|
||||
|
||||
+139
-11
@@ -26,6 +26,11 @@ import {
|
||||
import { ConversationalBirthTimeRectification, type PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification";
|
||||
import { ChatMessageContent } from "@/components/chat-message-content";
|
||||
import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row";
|
||||
import {
|
||||
ChatMessageActions,
|
||||
toggleChatMessageFeedback,
|
||||
type ChatMessageFeedback,
|
||||
} from "@/components/chat-message-actions";
|
||||
import { ModelSelector } from "@/components/model-selector";
|
||||
import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall";
|
||||
import { BirthPlacePicker } from "@/components/birth-place-picker";
|
||||
@@ -1059,6 +1064,8 @@ export default function Home() {
|
||||
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
|
||||
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
|
||||
const [streamingReply, setStreamingReply] = useState<StreamingReply | null>(null);
|
||||
const [messageFeedback, setMessageFeedback] = useState<Record<string, ChatMessageFeedback>>({});
|
||||
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
|
||||
const [replyOutcome, setReplyOutcome] = useState<ReplyOutcome | null>(null);
|
||||
const [requestError, setRequestError] = useState<RequestError | null>(null);
|
||||
const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState<BirthTimeConsultationConsentState>(
|
||||
@@ -2907,16 +2914,22 @@ export default function Home() {
|
||||
entrypoint: ConsultationEntrypoint | null = null,
|
||||
consentGrantedForRequest: ConsultationBirthTimeMode | null = null,
|
||||
targetSessionId: string | null = null,
|
||||
options: { resumeRequestId?: string } = {},
|
||||
options: {
|
||||
resumeRequestId?: string;
|
||||
sessionOverride?: ChatSession;
|
||||
restoreOnFailure?: ChatSession;
|
||||
} = {},
|
||||
): Promise<boolean> {
|
||||
const originalQuestion = text;
|
||||
const question = text.trim();
|
||||
const resumeRequestId = options.resumeRequestId;
|
||||
const resuming = Boolean(resumeRequestId);
|
||||
const currentSession = targetSessionId
|
||||
const liveSession = targetSessionId
|
||||
? sessions.find((session) => session.id === targetSessionId)
|
||||
: activeSession;
|
||||
const currentSession = options.sessionOverride ?? liveSession;
|
||||
if (!question || !currentSession || !modelCatalog || !account) return false;
|
||||
const rollbackSession = options.restoreOnFailure ?? liveSession ?? currentSession;
|
||||
if (!resuming && (pendingSessionId || cancellationInFlight.current || pendingConsultation.current)) return false;
|
||||
if (resuming) {
|
||||
const pending = pendingConsultation.current;
|
||||
@@ -3003,7 +3016,7 @@ export default function Home() {
|
||||
question: originalQuestion,
|
||||
entrypoint,
|
||||
theme,
|
||||
previousSession: currentSession,
|
||||
previousSession: rollbackSession,
|
||||
optimisticSession: userSession,
|
||||
previousOnboardingState,
|
||||
controller,
|
||||
@@ -3077,9 +3090,9 @@ export default function Home() {
|
||||
await persistSession(userSession);
|
||||
} catch (caught) {
|
||||
if (controller.signal.aborted) return false;
|
||||
updateSession(sessionId, () => currentSession);
|
||||
updateSession(sessionId, () => rollbackSession);
|
||||
setOnboardingJustCompleted(previousOnboardingState);
|
||||
if (activeSessionIdRef.current === sessionId) {
|
||||
if (!options.restoreOnFailure && activeSessionIdRef.current === sessionId) {
|
||||
setDraft(originalQuestion);
|
||||
setDraftTheme(theme);
|
||||
setDraftEntrypoint(entrypoint);
|
||||
@@ -3088,7 +3101,9 @@ export default function Home() {
|
||||
sessionId,
|
||||
message: `${caught instanceof Error ? caught.message : "问题保存失败,请稍后重试。"} 问题已放回输入框。`,
|
||||
});
|
||||
setComposerNotice("问题保存失败,未开始生成;问题已放回输入框。");
|
||||
setComposerNotice(options.restoreOnFailure
|
||||
? "问题保存失败,未开始生成;已恢复原来的回答。"
|
||||
: "问题保存失败,未开始生成;问题已放回输入框。");
|
||||
completeConsultationInterface(requestId);
|
||||
window.requestAnimationFrame(() => composerInput.current?.focus());
|
||||
return false;
|
||||
@@ -3164,6 +3179,7 @@ export default function Home() {
|
||||
};
|
||||
let agentExecutionReceipt: AgentExecutionReceipt | undefined;
|
||||
let runCompleted = false;
|
||||
let truncatedFailure: Extract<ConsultationAgentPublicEvent, { type: "run.failed" }> | undefined;
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let answer = "";
|
||||
@@ -3200,7 +3216,18 @@ export default function Home() {
|
||||
workflowReceipt = event.receipt.workflow;
|
||||
techniqueTruth = event.receipt.techniqueTruth ?? "unknown";
|
||||
}
|
||||
if (event.type === "run.failed") throw new ConsultationResponseError(502, event.message);
|
||||
if (event.type === "run.failed") {
|
||||
if (event.code === "answer_truncated") {
|
||||
truncatedFailure = event;
|
||||
if (event.receipt) {
|
||||
agentExecutionReceipt = event.receipt;
|
||||
workflowReceipt = event.receipt.workflow;
|
||||
techniqueTruth = event.receipt.techniqueTruth ?? techniqueTruth;
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new ConsultationResponseError(502, event.message);
|
||||
}
|
||||
updateActivity(event);
|
||||
});
|
||||
while (true) {
|
||||
@@ -3209,7 +3236,38 @@ export default function Home() {
|
||||
parser.push(decoder.decode(value, { stream: true }));
|
||||
}
|
||||
parser.finish(decoder.decode());
|
||||
if (!runCompleted) throw new ConsultationResponseError(502, "Agent 回答未完成,本次不会保存为成功咨询。");
|
||||
if (truncatedFailure) {
|
||||
const reply = parseAgentReply(answer);
|
||||
if (!reply.text) throw new ConsultationResponseError(502, truncatedFailure.message);
|
||||
const truncatedSession: ChatSession = {
|
||||
...userSession,
|
||||
title: userSession.title,
|
||||
messages: [...userSession.messages, {
|
||||
role: "assistant",
|
||||
text: reply.text,
|
||||
techniqueTruth,
|
||||
workflowReceipt,
|
||||
agentExecutionReceipt,
|
||||
}],
|
||||
updatedAt: timestamp(),
|
||||
};
|
||||
updateSession(sessionId, () => truncatedSession);
|
||||
setStreamingReply(null);
|
||||
setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 });
|
||||
setComposerNotice(truncatedFailure.message);
|
||||
try {
|
||||
await persistSession(truncatedSession);
|
||||
} catch (error) {
|
||||
setRequestError({
|
||||
sessionId,
|
||||
message: error instanceof Error ? error.message : "未完成的回答暂时无法同步。",
|
||||
});
|
||||
}
|
||||
completeConsultationInterface(requestId);
|
||||
void refreshAccount();
|
||||
return true;
|
||||
}
|
||||
if (!runCompleted && !truncatedFailure) throw new ConsultationResponseError(502, "Agent 回答未完成,本次不会保存为成功咨询。");
|
||||
} else {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -3264,6 +3322,11 @@ export default function Home() {
|
||||
setRequestError({ sessionId, message: caught.message });
|
||||
setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 });
|
||||
setComposerNotice(caught.message);
|
||||
const restore = options.restoreOnFailure;
|
||||
if (restore) {
|
||||
updateSession(sessionId, () => restore);
|
||||
void persistSession(restore).catch(() => {});
|
||||
}
|
||||
completeConsultationInterface(requestId);
|
||||
return false;
|
||||
}
|
||||
@@ -3299,6 +3362,42 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAssistantMessage(renderKey: string, text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedMessageKey(renderKey);
|
||||
window.setTimeout(() => setCopiedMessageKey((current) => (
|
||||
current === renderKey ? null : current
|
||||
)), 1_500);
|
||||
} catch {
|
||||
// Clipboard permission failures must not interrupt the conversation.
|
||||
}
|
||||
}
|
||||
|
||||
function regenerateLatestAnswer(renderKey: string) {
|
||||
const session = activeSession;
|
||||
if (!session || isLoading || cancellationPending || pendingConsultation.current) return;
|
||||
const last = session.messages.at(-1);
|
||||
if (last?.role !== "assistant" || last.text.trim() === "") return;
|
||||
const previous = session.messages.at(-2);
|
||||
if (previous?.role !== "user" || previous.text.trim() === "") return;
|
||||
if (`message-${session.messages.length - 1}` !== renderKey) return;
|
||||
const sessionOverride: ChatSession = {
|
||||
...session,
|
||||
messages: session.messages.slice(0, -1),
|
||||
updatedAt: timestamp(),
|
||||
};
|
||||
setMessageFeedback((current) => {
|
||||
const next = { ...current };
|
||||
delete next[`${session.id}:${renderKey}`];
|
||||
return next;
|
||||
});
|
||||
void send(previous.text, session.theme, null, null, session.id, {
|
||||
sessionOverride,
|
||||
restoreOnFailure: session,
|
||||
});
|
||||
}
|
||||
|
||||
consultationReplay.current = () => {
|
||||
const pending = pendingConsultation.current;
|
||||
if (!pending || pending.cancelled || pending.phase !== "recovering" || !pending.question.trim()) return;
|
||||
@@ -3636,9 +3735,38 @@ export default function Home() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="message-list" aria-busy={isLoading}>
|
||||
{chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity).map((message) => (
|
||||
<ChatMessageRow key={message.renderKey} message={message} />
|
||||
))}
|
||||
{chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity).map((message, _, views) => {
|
||||
const showActions = message.role === "assistant"
|
||||
&& message.state === "settled"
|
||||
&& Boolean(message.text);
|
||||
const feedbackKey = `${activeSession.id}:${message.renderKey}`;
|
||||
const latestRegeneratableKey = !isLoading && !cancellationPending
|
||||
? [...views].reverse().find((item) => (
|
||||
item.role === "assistant" && item.state === "settled" && Boolean(item.text)
|
||||
))?.renderKey
|
||||
: undefined;
|
||||
return (
|
||||
<div className="message-entry" key={message.renderKey}>
|
||||
<ChatMessageRow message={message} />
|
||||
{showActions && (
|
||||
<ChatMessageActions
|
||||
feedback={messageFeedback[feedbackKey]}
|
||||
copied={copiedMessageKey === feedbackKey}
|
||||
canRegenerate={message.renderKey === latestRegeneratableKey}
|
||||
onFeedback={(requested) => setMessageFeedback((current) => {
|
||||
const next = { ...current };
|
||||
const value = toggleChatMessageFeedback(current[feedbackKey], requested);
|
||||
if (value) next[feedbackKey] = value;
|
||||
else delete next[feedbackKey];
|
||||
return next;
|
||||
})}
|
||||
onCopy={() => void copyAssistantMessage(feedbackKey, message.text)}
|
||||
onRegenerate={() => regenerateLatestAnswer(message.renderKey)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{activeError && <p className="error-message" role="alert">{activeError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user