mirror of
https://github.com/severian-dev/sucker.severian.dev.git
synced 2026-10-18 01:20:29 +00:00
7569fb8e82
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
150 lines
5.7 KiB
TypeScript
150 lines
5.7 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("the token a chat was started with outranks one pasted later, so a chat cannot flip between cards", () => {
|
|
const candidates = tokenCandidates([
|
|
...withProbe,
|
|
{ role: "assistant", content: `created (v1). ${TOKEN}` },
|
|
{ role: "user", content: "{{user}}: [sucker:conv=zzz999]" },
|
|
]);
|
|
const first = candidates.find((c) => c.includes("sucker:conv="));
|
|
assert.match(first!, /abc123/);
|
|
});
|
|
|
|
test("a token in the card or the greeting is never a candidate", () => {
|
|
const stale = "[sucker:conv=stale00]";
|
|
const candidates = tokenCandidates([
|
|
{ role: "system", content: `${CARD} made with sucker: ${stale}` },
|
|
{ role: "user", content: "." },
|
|
{ role: "assistant", content: `${GREETING} ${stale}` },
|
|
{ role: "user", content: NAME },
|
|
]);
|
|
assert.ok(!candidates.some((c) => c.includes(stale)), "an expired token baked into a card would make it unextractable forever");
|
|
assert.deepEqual(candidates, [NAME]);
|
|
});
|
|
|
|
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 request with roles but no system turn has no card, rather than a misdiagnosed one", () => {
|
|
const slots = selectMessageSlots([
|
|
{ role: "assistant", content: GREETING },
|
|
{ role: "user", content: NAME },
|
|
]);
|
|
// Taking the greeting as the card would produce a "custom prompt not set"
|
|
// message for a request the user could never have fixed.
|
|
assert.equal(slots.system, undefined);
|
|
assert.equal(slots.greeting, GREETING);
|
|
});
|
|
|
|
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), []);
|
|
});
|