From 41531cdbe20d61ea0bdaf25ec40deda7e6a2fd2b Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 24 May 2026 14:48:34 +0000 Subject: [PATCH] Persist fastMode, drop refused user message on retry Two follow-ups on #5715: 1) sanitizeInferenceParams stripped fastMode. fastMode is in PERSISTED_INFERENCE_PARAM_KEYS but the storage sanitizer only kept numeric fields plus systemPrompt and trustRemoteCode, so the new toggle was silently dropped on reload and on the /api/chat/settings round-trip. Save it the same way trustRemoteCode is saved. 2) Refusal recovery now also drops the triggering user turn. Returning null from toOpenAIMessage on the assistant side left the user prompt that caused the refusal in the outbound history, so the very next request would re-trigger the same classifier. Anthropic's refusal-handling guidance is explicit on this: remove the refused turn AND the user message that triggered it before the next call. Implemented via a pre-pass that pops the trailing user message when an assistant carries the refusal sentinel. Typecheck clean. --- .../src/features/chat/api/chat-adapter.ts | 24 ++++++++++++++++++- .../chat/utils/chat-settings-storage.ts | 8 +++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 6dc143662b..73411450d8 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -933,7 +933,29 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { ), ); - const outboundMessages = messages + // Two-pass build so a refused assistant turn drops the user + // message that triggered it as well. Anthropic's refusal-handling + // guidance is explicit that leaving the offending user prompt in + // context causes the next request to re-trigger the classifier; + // returning null on just the assistant side was not enough. + const survivingMessages: RunMessage[] = []; + for (const message of messages) { + if ( + message.role === "assistant" && + collectTextParts(message) + .join("\n") + .includes(ANTHROPIC_REFUSAL_SENTINEL) + ) { + const last = survivingMessages.at(-1); + if (last && last.role === "user") { + survivingMessages.pop(); + } + continue; + } + survivingMessages.push(message); + } + + const outboundMessages = survivingMessages .map(toOpenAIMessage) .filter((message): message is NonNullable => Boolean(message), diff --git a/studio/frontend/src/features/chat/utils/chat-settings-storage.ts b/studio/frontend/src/features/chat/utils/chat-settings-storage.ts index e07e1ddb1d..eea3e49d5e 100644 --- a/studio/frontend/src/features/chat/utils/chat-settings-storage.ts +++ b/studio/frontend/src/features/chat/utils/chat-settings-storage.ts @@ -140,6 +140,14 @@ function sanitizeInferenceParams( if (typeof value.trustRemoteCode === "boolean") { params.trustRemoteCode = value.trustRemoteCode; } + // fastMode is in PERSISTED_INFERENCE_PARAM_KEYS but used to be + // stripped here because the sanitizer only kept numeric fields plus + // the two explicit string/bool fields above. Save the toggle the + // same way trustRemoteCode is saved so the value survives reload + // and the /api/chat/settings round-trip. + if (typeof value.fastMode === "boolean") { + params.fastMode = value.fastMode; + } return hasKeys(params) ? params : undefined; }