From 11c244fdd2a618c67c8aa0958847ff502ccdcc52 Mon Sep 17 00:00:00 2001 From: Severian Date: Fri, 11 Sep 2026 04:14:04 +0800 Subject: [PATCH] Read the request by role, not by position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some users' JanitorAI sends a "." user turn between the card and the greeting; others' does not. Sucker read fixed indices, so on the shorter shape every slot was off by one: first_mes was set to the name line, and the name slot did not exist at all. That TypeError was caught by the handler and reported as the "you dingus, read the directions" message, which blamed the user for a shape they never chose. src/lib/messages.ts now resolves the slots by role instead — the card is the system turn, the greeting is the first assistant turn, the user's latest input is the last user turn, and sucker's own previous reply is the last assistant turn. All four hold in both shapes and in a continued conversation, so the conversation token is found in either as well. Extraction now throws a description of what is missing rather than a TypeError, and tests/messages.test.ts pins both shapes. The tests need no framework: `npm test` runs node's built-in runner. Existing cards are unaffected: the update path already discards the extracted name and keeps the original. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NX2SsmUqQftWTZAY4BDuFa --- package.json | 3 +- src/app/api/proxy/route.ts | 30 +++++++++++------ src/lib/messages.ts | 52 +++++++++++++++++++++++++++++ tests/messages.test.ts | 68 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 src/lib/messages.ts create mode 100644 tests/messages.test.ts diff --git a/package.json b/package.json index 470e35d..13d6fe6 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test": "node --experimental-strip-types --test \"tests/**/*.test.ts\"" }, "dependencies": { "@radix-ui/react-accordion": "^1.2.2", diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts index e3b1999..8fec27f 100644 --- a/src/app/api/proxy/route.ts +++ b/src/app/api/proxy/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +import { selectMessageSlots, type Message } from "@/lib/messages"; interface CardVersion { version: number; @@ -64,10 +65,6 @@ function cleanupExpiredCards() { ); } -interface Message { - content: string; -} - // Extracted shape used during POST handling interface ExtractedCard { trackingName: string; @@ -118,9 +115,18 @@ function removePersonaTags(content: string): string { } function extractCardData(messages: Message[]): ExtractedCard { - const first_mes = messages[2].content.replace(/{user}/g, "{{user}}"); + const { system, greeting, lastUser } = selectMessageSlots(messages); - const nameContent = messages[3].content; + if (!system?.content) { + throw new Error("No system message: this request carries no character card."); + } + if (!greeting?.content) { + throw new Error("No assistant message: this chat has no greeting to read."); + } + + const first_mes = greeting.content.replace(/{user}/g, "{{user}}"); + + const nameContent = lastUser?.content ?? ""; // If the name slot is actually a token, ignore it for naming purposes const tokenInNameSlot = parseConversationToken(nameContent); const lastColonIndex = nameContent.lastIndexOf(": "); @@ -130,7 +136,7 @@ function extractCardData(messages: Message[]): ExtractedCard { ? nameContent.substring(lastColonIndex + 2).trim() : ""; - let content = messages[0].content.replace(/{user}/g, "{{user}}"); + let content = system.content.replace(/{user}/g, "{{user}}"); const inferredName = extractPersonaName(content); content = removePersonaTags(content); @@ -340,9 +346,13 @@ export async function POST(request: NextRequest) { ); } - // Parse potential token from messages[3] (user) or messages[4] (assistant prior reply) - const tokenCandidateUser: string | undefined = body.messages?.[3]?.content; - const tokenCandidateAssistant: string | undefined = body.messages?.[4]?.content; + // The token is either in what the user just typed, or in sucker's own + // previous reply. Both are found by role, so a missing "." turn or a + // longer conversation cannot shift them out from under us. + const tokenSlots = selectMessageSlots(body.messages); + const tokenCandidateUser: string | undefined = tokenSlots.lastUser?.content; + const tokenCandidateAssistant: string | undefined = + tokenSlots.lastAssistant?.content; const providedConversationId = parseConversationToken(tokenCandidateUser || undefined) || parseConversationToken(tokenCandidateAssistant || undefined); diff --git a/src/lib/messages.ts b/src/lib/messages.ts new file mode 100644 index 0000000..1d812ce --- /dev/null +++ b/src/lib/messages.ts @@ -0,0 +1,52 @@ +/** + * Locating the parts of a JanitorAI request. + * + * JanitorAI does not send a fixed number of messages. Most clients put a + * throwaway "." user turn between the card and the greeting; some do not. + * Sucker used to read fixed indices, so on the shorter shape it read the name + * slot as the greeting and the slot after that did not exist at all — a + * TypeError that reached the user as the "you dingus" message, blaming them + * for something they had no control over. + * + * So nothing here depends on a position. What is actually stable: + * + * - the card is the system turn; + * - the greeting is the FIRST assistant turn; + * - whatever the user just typed is the LAST user turn; + * - sucker's own previous reply, which carries the conversation token, is + * the LAST assistant turn. + * + * If JanitorAI changes the shape again, this is the only file that should + * need to know. + */ + +export interface Message { + role?: string; + content?: string; +} + +export interface MessageSlots { + /** The character card. */ + system: Message | undefined; + /** The character's opening message. */ + greeting: Message | undefined; + /** What the user typed most recently — the name, or a conversation token. */ + lastUser: Message | undefined; + /** Sucker's own most recent reply, when there is one. */ + lastAssistant: Message | undefined; +} + +export function selectMessageSlots(messages: Message[] | undefined): MessageSlots { + const list = Array.isArray(messages) ? messages : []; + const assistants = list.filter((m) => m?.role === "assistant"); + const users = list.filter((m) => m?.role === "user"); + + return { + // Falling back to the first message keeps working if a client ever omits + // the role on the card, which is where it has always been. + system: list.find((m) => m?.role === "system") ?? list[0], + greeting: assistants[0], + lastUser: users[users.length - 1], + lastAssistant: assistants[assistants.length - 1], + }; +} diff --git a/tests/messages.test.ts b/tests/messages.test.ts new file mode 100644 index 0000000..721a804 --- /dev/null +++ b/tests/messages.test.ts @@ -0,0 +1,68 @@ +/** + * Both request shapes JanitorAI sends, and a continued conversation. + * Run with `npm test` — no test framework, just node's built-in runner. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { selectMessageSlots } from "../src/lib/messages.ts"; + +const CARD = + "<.>\na tired innkeeper."; +const GREETING = "The clink of bottles is the only greeting you get."; +const NAME = "{{user}}: Gaius"; + +/** What most clients send: a throwaway "." user turn before the greeting. */ +const withProbe = [ + { role: "system", content: CARD }, + { role: "user", content: "." }, + { role: "assistant", content: GREETING }, + { role: "user", content: NAME }, +]; + +/** What some clients send: no "." turn, so every index shifts by one. */ +const withoutProbe = [ + { role: "system", content: CARD }, + { role: "assistant", content: GREETING }, + { role: "user", content: NAME }, +]; + +test("both shapes resolve to the same slots", () => { + for (const [label, messages] of [ + ["with probe", withProbe], + ["without probe", withoutProbe], + ] as const) { + const slots = selectMessageSlots(messages); + assert.equal(slots.system?.content, CARD, `${label}: card`); + assert.equal(slots.greeting?.content, GREETING, `${label}: greeting`); + assert.equal(slots.lastUser?.content, NAME, `${label}: name slot`); + } +}); + +test("the greeting stays the first assistant turn as a chat continues", () => { + const continued = [ + ...withProbe, + { role: "assistant", content: "Character updated (v2)." }, + { role: "user", content: "{{user}}: [sucker:conv=abc123]" }, + ]; + const slots = selectMessageSlots(continued); + assert.equal(slots.greeting?.content, GREETING); + // Sucker's own reply, where a conversation token would be found. + assert.equal(slots.lastAssistant?.content, "Character updated (v2)."); + assert.equal(slots.lastUser?.content, "{{user}}: [sucker:conv=abc123]"); +}); + +test("a card with no role on the system turn still resolves", () => { + const slots = selectMessageSlots([ + { content: CARD }, + { role: "assistant", content: GREETING }, + { role: "user", content: NAME }, + ]); + assert.equal(slots.system?.content, CARD); +}); + +test("nothing throws on empty or malformed input", () => { + for (const input of [[], undefined, [{}, { role: "user" }]]) { + assert.doesNotThrow(() => selectMessageSlots(input as never)); + } + assert.equal(selectMessageSlots([]).greeting, undefined); +});