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.
This commit is contained in:
Daniel Han 2026-05-24 14:48:34 +00:00
commit 41531cdbe2
2 changed files with 31 additions and 1 deletions

View file

@ -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<typeof message> =>
Boolean(message),

View file

@ -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;
}