mirror of
https://github.com/severian-dev/sucker.severian.dev.git
synced 2026-10-18 01:20:29 +00:00
b479460804
Stress-testing the beta with request shapes we had not tried turned up two more ways to hit the same class of bug. Roles were compared exactly, so a client sending "Assistant" instead of "assistant" fell straight into the dingus error. And content was assumed to be a string, so OpenAI's multipart array form did the same. Roles are now compared case-insensitively and content is flattened to text first; messages with no usable text are skipped rather than mistaken for a slot. The slots are plain strings now, so no caller can reach past them to a raw field again. Separately: the Changelog button carries a dot when the current release has not been opened yet, on the desktop button, the mobile menu item, and the hamburger that hides it. The marker is the version in localStorage, so every release brings it back on its own and reading it once clears it. The version is a single constant now rather than being written in two places. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NX2SsmUqQftWTZAY4BDuFa
126 lines
4.6 KiB
TypeScript
126 lines
4.6 KiB
TypeScript
/**
|
|
* Every request shape we have actually seen, plus the malformed ones a client
|
|
* could plausibly send. Run with `npm test` — no framework, node's own runner.
|
|
*/
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { selectMessageSlots, tokenCandidates, textOf } 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 = [
|
|
{ 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 },
|
|
];
|
|
|
|
/** 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],
|
|
["without probe", withoutProbe],
|
|
] as const) {
|
|
const slots = selectMessageSlots(messages);
|
|
assert.equal(slots.system, CARD, `${label}: card`);
|
|
assert.equal(slots.greeting, GREETING, `${label}: greeting`);
|
|
assert.equal(slots.nameSlot, NAME, `${label}: name`);
|
|
}
|
|
});
|
|
|
|
test("the greeting and the name stay put as a conversation grows", () => {
|
|
const slots = selectMessageSlots(continued);
|
|
assert.equal(slots.greeting, GREETING);
|
|
// Not "keep going" or "and again": the name is what started the chat.
|
|
assert.equal(slots.nameSlot, NAME);
|
|
assert.equal(slots.lastUser, "{{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("roles are matched whatever their casing", () => {
|
|
const slots = selectMessageSlots([
|
|
{ role: "System", content: CARD },
|
|
{ role: " Assistant ", content: GREETING },
|
|
{ role: "USER", content: NAME },
|
|
]);
|
|
assert.equal(slots.system, CARD);
|
|
assert.equal(slots.greeting, GREETING);
|
|
assert.equal(slots.nameSlot, NAME);
|
|
});
|
|
|
|
test("content sent as OpenAI's multipart array is read as text", () => {
|
|
assert.equal(textOf([{ type: "text", text: "one " }, { type: "text", text: "two" }]), "one two");
|
|
const slots = selectMessageSlots([
|
|
{ role: "system", content: [{ type: "text", text: CARD }] },
|
|
{ role: "assistant", content: [{ type: "text", text: GREETING }] },
|
|
{ role: "user", content: NAME },
|
|
]);
|
|
assert.equal(slots.system, CARD);
|
|
assert.equal(slots.greeting, GREETING);
|
|
});
|
|
|
|
test("messages with no usable content are skipped, not treated as slots", () => {
|
|
const slots = selectMessageSlots([
|
|
{ role: "system", content: CARD },
|
|
{ role: "assistant", content: null },
|
|
{ role: "assistant" },
|
|
{ role: "assistant", content: GREETING },
|
|
{ role: "user", content: NAME },
|
|
]);
|
|
// The empty assistant turns must not be mistaken for the greeting.
|
|
assert.equal(slots.greeting, GREETING);
|
|
assert.equal(slots.nameSlot, NAME);
|
|
});
|
|
|
|
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, CARD);
|
|
});
|
|
|
|
test("nothing throws on empty or malformed input", () => {
|
|
for (const input of [[], undefined, [{}, { role: "user" }], [null], [{ content: 42 }]]) {
|
|
assert.doesNotThrow(() => selectMessageSlots(input as never));
|
|
assert.doesNotThrow(() => tokenCandidates(input as never));
|
|
}
|
|
assert.equal(selectMessageSlots([]).greeting, undefined);
|
|
assert.deepEqual(tokenCandidates(undefined), []);
|
|
});
|