Diagnose setup by what is certain, and deliver errors the way the client asked

Review of the branch found the persona diagnosis was mostly truncation
wearing a name tag. The <.> marker sits at the start of the system
message and the persona marker after the card, so a card Janitor cut
short arrives with the prompt present and the persona gone — and was
told its persona was wrong, with truncation as an afterthought. The
original message was only ever about context size, which is the best
evidence there is about what actually happens.

diagnoseSetup() now says only what the markers make certain. A missing
<.> is a missing prompt, since truncation cannot remove the first three
characters. A persona marker that is present but not "." is a wrong
description, quoted back so there is nothing to guess. A persona marker
that is gone, or cut off mid-tag, leads with truncation and mentions the
persona second.

The error path decided "streaming?" from the Accept header while the
success path used body.stream, so a client that sent stream:true without
that header got JSON back for every error and never displayed it. The
flag is hoisted above the try; the header is only a fallback for when
the body could not be read.

Token search is restricted to after the greeting, in order. A card built
from a sucker-proxied chat could carry an expired token in its text, and
a lookup on it would fail forever. Conversation order also means the
token a chat was started with always wins over one pasted later. The
system fallback now takes a role-less first message only; a request with
roles but no system turn is reported as such rather than misdiagnosed as
a missing prompt. The echo feature reads the parser's lastUser instead of
its own case-sensitive lookup. "Nothing was saved" is dropped from the
unknown-error message, since it is not guaranteed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NX2SsmUqQftWTZAY4BDuFa
This commit is contained in:
Severian
2026-09-11 06:46:52 +08:00
parent a8b3669c17
commit 7569fb8e82
5 changed files with 201 additions and 84 deletions
+16 -17
View File
@@ -9,7 +9,7 @@ import {
NO_CARD,
NO_GREETING,
TOO_FEW_MESSAGES,
missingSetup,
diagnoseSetup,
userFacingMessage,
} from "@/lib/errors";
@@ -165,12 +165,11 @@ function extractCardData(messages: Message[]): ExtractedCard {
`Name extraction: user="${nameFromUser}", inferred="${inferredName}", tracking="${cleanTrackingName}", display="${displayName}"`
);
// Each marker is one of the two REQUIRED steps in the instructions, so the
// error can name the one that did not arrive.
const hasPrompt = content.includes("<.>");
const hasPersona = content.includes("<UserPersona>.</UserPersona>");
if (!hasPrompt || !hasPersona) {
throw missingSetup(hasPrompt, hasPersona);
// The two markers are the two REQUIRED setup steps; the diagnosis names
// the one that is wrong, or says the card was cut short.
const setupProblem = diagnoseSetup(content);
if (setupProblem) {
throw setupProblem;
}
content = content.replace("<.>", "");
@@ -337,9 +336,12 @@ export async function POST(request: NextRequest) {
});
}
// Decided from the body, and read again in the catch below: an error must
// go back the same way a success would, or the client never shows it.
let isStreamingRequest = false;
try {
const body = await request.json();
const isStreamingRequest = body.stream === true;
isStreamingRequest = body.stream === true;
if (!body.messages || body.messages.length < 2) {
if (isStreamingRequest) {
@@ -516,12 +518,7 @@ export async function POST(request: NextRequest) {
// Echo back anything the user sent after an "ECHO:" marker in their
// latest message, appended after the normal status message
const lastUserMessage =
[...body.messages]
.reverse()
.find((m: { role?: string }) => m?.role === "user") ??
body.messages[body.messages.length - 1];
const echoText = parseEchoText(lastUserMessage?.content);
const echoText = parseEchoText(selectMessageSlots(body.messages).lastUser);
if (echoText) {
responseMessage += `\n\n${echoText}`;
}
@@ -568,11 +565,13 @@ export async function POST(request: NextRequest) {
const errorMessage = userFacingMessage(error);
// Check if this was a streaming request
// The body already said whether the client wanted a stream; the Accept
// header is only a fallback for when the body could not be read at all.
const acceptHeader = request.headers.get("accept");
const isStreamingRequest = acceptHeader?.includes("text/event-stream");
const streaming =
isStreamingRequest || (acceptHeader?.includes("text/event-stream") ?? false);
if (isStreamingRequest) {
if (streaming) {
return createSSEErrorResponse(errorMessage);
}