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
+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.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { selectMessageSlots } from "../src/lib/messages.ts";
import { selectMessageSlots, tokenCandidates } from "../src/lib/messages.ts";
const CARD =
"<.>\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 NAME = "{{user}}: Gaius";
const TOKEN = "[sucker:conv=abc123]";
/** What most clients send: a throwaway "." user turn before the greeting. */
const withProbe = [
const withProbe: { role: string; content: string }[] = [
{ role: "system", content: CARD },
{ role: "user", content: "." },
{ role: "assistant", content: GREETING },
@@ -20,12 +21,21 @@ const withProbe = [
];
/** 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: "assistant", content: GREETING },
{ 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", () => {
for (const [label, messages] of [
["with probe", withProbe],
@@ -34,21 +44,31 @@ test("both shapes resolve to the same slots", () => {
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`);
assert.equal(slots.nameSlot?.content, NAME, `${label}: name`);
}
});
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]" },
];
test("the greeting and the name stay put as a conversation grows", () => {
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]");
// Not "keep going" or "and again": the name is what started the chat.
assert.equal(slots.nameSlot?.content, NAME);
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", () => {
@@ -63,6 +83,8 @@ test("a card with no role on the system turn still resolves", () => {
test("nothing throws on empty or malformed input", () => {
for (const input of [[], undefined, [{}, { role: "user" }]]) {
assert.doesNotThrow(() => selectMessageSlots(input as never));
assert.doesNotThrow(() => tokenCandidates(input as never));
}
assert.equal(selectMessageSlots([]).greeting, undefined);
assert.deepEqual(tokenCandidates(undefined), []);
});