Keep multi-turn conversations linked to their card

The first pass read the name from the last user turn and the token from
the last assistant turn. Both are wrong once a conversation continues.

Only sucker's *first* reply carries a conversation token — later ones say
"Character updated" or "unchanged" with no token in them — so reading the
last assistant turn lost the link three turns in and would have created a
duplicate card on every message. tokenCandidates() now searches the whole
conversation, still preferring whatever the user just typed, since pasting
a token is deliberate.

The name is the first user turn after the greeting, not the last one. That
is what the old code read at messages[3], and unlike the last turn it stays
put as messages are appended.

Tests cover a three-turn conversation for both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NX2SsmUqQftWTZAY4BDuFa
This commit is contained in:
Severian
2026-09-11 04:18:26 +08:00
parent 11c244fdd2
commit e7c9adbcc5
3 changed files with 86 additions and 37 deletions
+14 -12
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { selectMessageSlots, type Message } from "@/lib/messages"; import {
selectMessageSlots,
tokenCandidates,
type Message,
} from "@/lib/messages";
interface CardVersion { interface CardVersion {
version: number; version: number;
@@ -115,7 +119,7 @@ function removePersonaTags(content: string): string {
} }
function extractCardData(messages: Message[]): ExtractedCard { function extractCardData(messages: Message[]): ExtractedCard {
const { system, greeting, lastUser } = selectMessageSlots(messages); const { system, greeting, nameSlot } = selectMessageSlots(messages);
if (!system?.content) { if (!system?.content) {
throw new Error("No system message: this request carries no character card."); throw new Error("No system message: this request carries no character card.");
@@ -126,7 +130,7 @@ function extractCardData(messages: Message[]): ExtractedCard {
const first_mes = greeting.content.replace(/{user}/g, "{{user}}"); const first_mes = greeting.content.replace(/{user}/g, "{{user}}");
const nameContent = lastUser?.content ?? ""; const nameContent = nameSlot?.content ?? "";
// If the name slot is actually a token, ignore it for naming purposes // If the name slot is actually a token, ignore it for naming purposes
const tokenInNameSlot = parseConversationToken(nameContent); const tokenInNameSlot = parseConversationToken(nameContent);
const lastColonIndex = nameContent.lastIndexOf(": "); const lastColonIndex = nameContent.lastIndexOf(": ");
@@ -346,16 +350,14 @@ export async function POST(request: NextRequest) {
); );
} }
// The token is either in what the user just typed, or in sucker's own // The token may be in what the user just typed, or in sucker's own first
// previous reply. Both are found by role, so a missing "." turn or a // reply from earlier in the conversation. Search every message rather than
// longer conversation cannot shift them out from under us. // a fixed pair of slots: only the first reply carries a token, so a long
const tokenSlots = selectMessageSlots(body.messages); // chat would otherwise lose the link and duplicate the card.
const tokenCandidateUser: string | undefined = tokenSlots.lastUser?.content;
const tokenCandidateAssistant: string | undefined =
tokenSlots.lastAssistant?.content;
const providedConversationId = const providedConversationId =
parseConversationToken(tokenCandidateUser || undefined) || tokenCandidates(body.messages)
parseConversationToken(tokenCandidateAssistant || undefined); .map((c) => parseConversationToken(c))
.find((id): id is string => Boolean(id)) ?? null;
let existingCard: StoredCard | null = null; let existingCard: StoredCard | null = null;
let linkedByToken = false; let linkedByToken = false;
+36 -11
View File
@@ -8,13 +8,14 @@
* TypeError that reached the user as the "you dingus" message, blaming them * TypeError that reached the user as the "you dingus" message, blaming them
* for something they had no control over. * for something they had no control over.
* *
* So nothing here depends on a position. What is actually stable: * So nothing here depends on a position. What is actually stable, in both
* shapes and however long the conversation runs:
* *
* - the card is the system turn; * - the card is the system turn;
* - the greeting is the FIRST assistant turn; * - the greeting is the FIRST assistant turn;
* - whatever the user just typed is the LAST user turn; * - the name is the first user turn AFTER the greeting — the one the user
* - sucker's own previous reply, which carries the conversation token, is * typed to start, which stays put as more turns are appended;
* the LAST assistant turn. * - what the user typed most recently is the LAST user turn.
* *
* If JanitorAI changes the shape again, this is the only file that should * If JanitorAI changes the shape again, this is the only file that should
* need to know. * need to know.
@@ -30,23 +31,47 @@ export interface MessageSlots {
system: Message | undefined; system: Message | undefined;
/** The character's opening message. */ /** The character's opening message. */
greeting: Message | undefined; greeting: Message | undefined;
/** What the user typed most recently — the name, or a conversation token. */ /** The name — or conversation token — the user typed to start the chat. */
nameSlot: Message | undefined;
/** Whatever the user typed most recently. */
lastUser: Message | undefined; lastUser: Message | undefined;
/** Sucker's own most recent reply, when there is one. */
lastAssistant: Message | undefined;
} }
export function selectMessageSlots(messages: Message[] | undefined): MessageSlots { export function selectMessageSlots(
messages: Message[] | undefined
): MessageSlots {
const list = Array.isArray(messages) ? messages : []; const list = Array.isArray(messages) ? messages : [];
const assistants = list.filter((m) => m?.role === "assistant"); const greetingIndex = list.findIndex((m) => m?.role === "assistant");
const users = list.filter((m) => m?.role === "user"); const users = list.filter((m) => m?.role === "user");
return { return {
// Falling back to the first message keeps working if a client ever omits // 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. // the role on the card, which is where it has always been.
system: list.find((m) => m?.role === "system") ?? list[0], system: list.find((m) => m?.role === "system") ?? list[0],
greeting: assistants[0], greeting: greetingIndex === -1 ? undefined : list[greetingIndex],
nameSlot:
greetingIndex === -1
? undefined
: list.slice(greetingIndex + 1).find((m) => m?.role === "user"),
lastUser: users[users.length - 1], lastUser: users[users.length - 1],
lastAssistant: assistants[assistants.length - 1],
}; };
} }
/**
* Message contents to search for a conversation token, most promising first.
*
* Only sucker's *first* reply carries a token — every later reply says
* "Character updated" or "unchanged" with no token in it. So a long
* conversation has to be searched all the way back rather than at its tail,
* or the link to the existing card is lost and a duplicate gets created.
* What the user just typed still wins, since pasting a token is deliberate.
*/
export function tokenCandidates(messages: Message[] | undefined): string[] {
const list = Array.isArray(messages) ? messages : [];
const users = list.filter((m) => m?.role === "user");
const mostRecentUser = users[users.length - 1];
return [mostRecentUser, ...list.filter((m) => m !== mostRecentUser)]
.map((m) => m?.content)
.filter((c): c is string => typeof c === "string" && c.length > 0);
}
+36 -14
View File
@@ -1,18 +1,19 @@
/** /**
* Both request shapes JanitorAI sends, and a continued conversation. * Both request shapes JanitorAI sends, and conversations that keep going.
* Run with `npm test` — no test framework, just node's built-in runner. * Run with `npm test` — no test framework, just node's built-in runner.
*/ */
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { selectMessageSlots } from "../src/lib/messages.ts"; import { selectMessageSlots, tokenCandidates } from "../src/lib/messages.ts";
const CARD = const CARD =
"<.>\n<Gaius's Persona>a tired innkeeper</Gaius's Persona><UserPersona>.</UserPersona>"; "<.>\n<Gaius's Persona>a tired innkeeper</Gaius's Persona><UserPersona>.</UserPersona>";
const GREETING = "The clink of bottles is the only greeting you get."; const GREETING = "The clink of bottles is the only greeting you get.";
const NAME = "{{user}}: Gaius"; const NAME = "{{user}}: Gaius";
const TOKEN = "[sucker:conv=abc123]";
/** What most clients send: a throwaway "." user turn before the greeting. */ /** What most clients send: a throwaway "." user turn before the greeting. */
const withProbe = [ const withProbe: { role: string; content: string }[] = [
{ role: "system", content: CARD }, { role: "system", content: CARD },
{ role: "user", content: "." }, { role: "user", content: "." },
{ role: "assistant", content: GREETING }, { role: "assistant", content: GREETING },
@@ -20,12 +21,21 @@ const withProbe = [
]; ];
/** What some clients send: no "." turn, so every index shifts by one. */ /** What some clients send: no "." turn, so every index shifts by one. */
const withoutProbe = [ const withoutProbe: { role: string; content: string }[] = [
{ role: "system", content: CARD }, { role: "system", content: CARD },
{ role: "assistant", content: GREETING }, { role: "assistant", content: GREETING },
{ role: "user", content: NAME }, { role: "user", content: NAME },
]; ];
/** Three turns in. Only sucker's FIRST reply carries the token. */
const continued = [
...withProbe,
{ role: "assistant", content: `New character "Gaius" created (v1). ${TOKEN}` },
{ role: "user", content: "{{user}}: keep going" },
{ role: "assistant", content: "Character data unchanged (v1, message 2)." },
{ role: "user", content: "{{user}}: and again" },
];
test("both shapes resolve to the same slots", () => { test("both shapes resolve to the same slots", () => {
for (const [label, messages] of [ for (const [label, messages] of [
["with probe", withProbe], ["with probe", withProbe],
@@ -34,21 +44,31 @@ test("both shapes resolve to the same slots", () => {
const slots = selectMessageSlots(messages); const slots = selectMessageSlots(messages);
assert.equal(slots.system?.content, CARD, `${label}: card`); assert.equal(slots.system?.content, CARD, `${label}: card`);
assert.equal(slots.greeting?.content, GREETING, `${label}: greeting`); assert.equal(slots.greeting?.content, GREETING, `${label}: greeting`);
assert.equal(slots.lastUser?.content, NAME, `${label}: name slot`); assert.equal(slots.nameSlot?.content, NAME, `${label}: name`);
} }
}); });
test("the greeting stays the first assistant turn as a chat continues", () => { test("the greeting and the name stay put as a conversation grows", () => {
const continued = [
...withProbe,
{ role: "assistant", content: "Character updated (v2)." },
{ role: "user", content: "{{user}}: [sucker:conv=abc123]" },
];
const slots = selectMessageSlots(continued); const slots = selectMessageSlots(continued);
assert.equal(slots.greeting?.content, GREETING); assert.equal(slots.greeting?.content, GREETING);
// Sucker's own reply, where a conversation token would be found. // Not "keep going" or "and again": the name is what started the chat.
assert.equal(slots.lastAssistant?.content, "Character updated (v2)."); assert.equal(slots.nameSlot?.content, NAME);
assert.equal(slots.lastUser?.content, "{{user}}: [sucker:conv=abc123]"); assert.equal(slots.lastUser?.content, "{{user}}: and again");
});
test("the token is still found once later replies stop carrying it", () => {
const found = tokenCandidates(continued).find((c) => c.includes("sucker:conv="));
assert.ok(found, "a token must still be discoverable three turns in");
assert.match(found!, /\[sucker:conv=abc123\]/);
});
test("a token the user just pasted outranks an older one", () => {
const candidates = tokenCandidates([
...withProbe,
{ role: "assistant", content: `created (v1). ${TOKEN}` },
{ role: "user", content: "{{user}}: [sucker:conv=zzz999]" },
]);
assert.match(candidates[0], /zzz999/);
}); });
test("a card with no role on the system turn still resolves", () => { test("a card with no role on the system turn still resolves", () => {
@@ -63,6 +83,8 @@ test("a card with no role on the system turn still resolves", () => {
test("nothing throws on empty or malformed input", () => { test("nothing throws on empty or malformed input", () => {
for (const input of [[], undefined, [{}, { role: "user" }]]) { for (const input of [[], undefined, [{}, { role: "user" }]]) {
assert.doesNotThrow(() => selectMessageSlots(input as never)); assert.doesNotThrow(() => selectMessageSlots(input as never));
assert.doesNotThrow(() => tokenCandidates(input as never));
} }
assert.equal(selectMessageSlots([]).greeting, undefined); assert.equal(selectMessageSlots([]).greeting, undefined);
assert.deepEqual(tokenCandidates(undefined), []);
}); });