mirror of
https://github.com/severian-dev/sucker.severian.dev.git
synced 2026-10-18 01:20:29 +00:00
Harden the parsing, and mark unread changelog entries
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
This commit is contained in:
@@ -17,7 +17,7 @@ You can also build and run Sucker as a Docker container with `docker compose bui
|
|||||||
|
|
||||||
### Changelog
|
### Changelog
|
||||||
|
|
||||||
- 2.5: Fixed extraction for users whose Janitor sends a request without the blank turn before the first message — sucker read the parts by position rather than by role, so for them it read the wrong ones and errored. Now resolved by role, with tests for both shapes.
|
- 2.5: Unread dot on the Changelog button. Fixed extraction for users whose Janitor sends a request without the blank turn before the first message — sucker read the parts by position rather than by role, so for them it read the wrong ones and errored. Now resolved by role, with tests for both shapes.
|
||||||
- 2.4: CSS editor for Janitor profiles. New banner links to the live-preview editor at tools.from-avalon.com/janitor-profile-maker.
|
- 2.4: CSS editor for Janitor profiles. New banner links to the live-preview editor at tools.from-avalon.com/janitor-profile-maker.
|
||||||
- 2.3: ECHO. Put ECHO: anywhere in a message and sucker repeats whatever comes after it in the reply — for triggering char-side script and lorebook keywords.
|
- 2.3: ECHO. Put ECHO: anywhere in a message and sucker repeats whatever comes after it in the reply — for triggering char-side script and lorebook keywords.
|
||||||
- 2.2: Added support for directly inputting webp file names or character image links when creating PNG character cards
|
- 2.2: Added support for directly inputting webp file names or character image links when creating PNG character cards
|
||||||
|
|||||||
@@ -121,16 +121,16 @@ function removePersonaTags(content: string): string {
|
|||||||
function extractCardData(messages: Message[]): ExtractedCard {
|
function extractCardData(messages: Message[]): ExtractedCard {
|
||||||
const { system, greeting, nameSlot } = selectMessageSlots(messages);
|
const { system, greeting, nameSlot } = selectMessageSlots(messages);
|
||||||
|
|
||||||
if (!system?.content) {
|
if (!system) {
|
||||||
throw new Error("No system message: this request carries no character card.");
|
throw new Error("No system message: this request carries no character card.");
|
||||||
}
|
}
|
||||||
if (!greeting?.content) {
|
if (!greeting) {
|
||||||
throw new Error("No assistant message: this chat has no greeting to read.");
|
throw new Error("No assistant message: this chat has no greeting to read.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const first_mes = greeting.content.replace(/{user}/g, "{{user}}");
|
const first_mes = greeting.replace(/{user}/g, "{{user}}");
|
||||||
|
|
||||||
const nameContent = nameSlot?.content ?? "";
|
const nameContent = nameSlot ?? "";
|
||||||
// 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(": ");
|
||||||
@@ -140,7 +140,7 @@ function extractCardData(messages: Message[]): ExtractedCard {
|
|||||||
? nameContent.substring(lastColonIndex + 2).trim()
|
? nameContent.substring(lastColonIndex + 2).trim()
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
let content = system.content.replace(/{user}/g, "{{user}}");
|
let content = system.replace(/{user}/g, "{{user}}");
|
||||||
const inferredName = extractPersonaName(content);
|
const inferredName = extractPersonaName(content);
|
||||||
content = removePersonaTags(content);
|
content = removePersonaTags(content);
|
||||||
|
|
||||||
|
|||||||
+48
-5
@@ -67,9 +67,36 @@ interface Card {
|
|||||||
initialVersion?: CardDataV2;
|
initialVersion?: CardDataV2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bumped once per release; the changelog dot keys off it. */
|
||||||
|
const APP_VERSION = "2.5";
|
||||||
|
const CHANGELOG_SEEN_KEY = "sucker:changelog-seen";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [cards, setCards] = useState<Card[]>([]);
|
const [cards, setCards] = useState<Card[]>([]);
|
||||||
|
// The changelog dot. Starts hidden so the server and the first client render
|
||||||
|
// agree; the effect below turns it on for anyone who has not opened the
|
||||||
|
// dialog since this version shipped.
|
||||||
|
const [changelogSeen, setChangelogSeen] = useState(true);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
setChangelogSeen(localStorage.getItem(CHANGELOG_SEEN_KEY) === APP_VERSION);
|
||||||
|
} catch {
|
||||||
|
// Private mode or blocked storage: just leave the dot off.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Open the changelog and remember that this version has been read. */
|
||||||
|
const openChangelog = () => {
|
||||||
|
setChangelogDialogOpen(true);
|
||||||
|
setChangelogSeen(true);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CHANGELOG_SEEN_KEY, APP_VERSION);
|
||||||
|
} catch {
|
||||||
|
// Nothing to do; the dot simply comes back next time.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const [selectedCardIndex, setSelectedCardIndex] = useState<number | null>(
|
const [selectedCardIndex, setSelectedCardIndex] = useState<number | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -373,15 +400,21 @@ export default function Home() {
|
|||||||
{/* Row 1: Title on left, Hamburger on right */}
|
{/* Row 1: Title on left, Hamburger on right */}
|
||||||
<div className="flex justify-between items-start mb-3">
|
<div className="flex justify-between items-start mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Sucker v2.5</h1>
|
<h1 className="text-2xl font-bold">Sucker v{APP_VERSION}</h1>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
New: a live CSS editor for your profile.
|
New: a live CSS editor for your profile.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="icon">
|
<Button variant="outline" size="icon" className="relative">
|
||||||
<Menu className="h-4 w-4" />
|
<Menu className="h-4 w-4" />
|
||||||
|
{!changelogSeen && (
|
||||||
|
<span
|
||||||
|
aria-label="New changelog entry"
|
||||||
|
className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-sky-500 ring-2 ring-background"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
@@ -391,8 +424,11 @@ export default function Home() {
|
|||||||
<DropdownMenuItem onClick={() => setMirrorsDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setMirrorsDialogOpen(true)}>
|
||||||
Mirrors
|
Mirrors
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setChangelogDialogOpen(true)}>
|
<DropdownMenuItem onClick={openChangelog}>
|
||||||
Changelog
|
Changelog
|
||||||
|
{!changelogSeen && (
|
||||||
|
<span className="ml-2 h-2 w-2 rounded-full bg-sky-500" />
|
||||||
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -411,7 +447,7 @@ export default function Home() {
|
|||||||
{/* Desktop Layout */}
|
{/* Desktop Layout */}
|
||||||
<div className="hidden sm:flex justify-between items-center mb-4">
|
<div className="hidden sm:flex justify-between items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Sucker v2.5</h1>
|
<h1 className="text-3xl font-bold">Sucker v{APP_VERSION}</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
New: a live CSS editor for your profile.
|
New: a live CSS editor for your profile.
|
||||||
</p>
|
</p>
|
||||||
@@ -430,10 +466,17 @@ export default function Home() {
|
|||||||
Mirrors
|
Mirrors
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setChangelogDialogOpen(true)}
|
onClick={openChangelog}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="relative"
|
||||||
>
|
>
|
||||||
Changelog
|
Changelog
|
||||||
|
{!changelogSeen && (
|
||||||
|
<span
|
||||||
|
aria-label="New changelog entry"
|
||||||
|
className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-sky-500 ring-2 ring-background"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={fetchCards}
|
onClick={fetchCards}
|
||||||
|
|||||||
+53
-19
@@ -17,43 +17,77 @@
|
|||||||
* typed to start, which stays put as more turns are appended;
|
* typed to start, which stays put as more turns are appended;
|
||||||
* - what the user typed most recently is the LAST user turn.
|
* - what the user typed most recently is the LAST user turn.
|
||||||
*
|
*
|
||||||
|
* Roles are compared case-insensitively and content is flattened to text
|
||||||
|
* first, because neither is guaranteed: a client may send "Assistant", or
|
||||||
|
* content as OpenAI's multipart array rather than a plain string. Getting
|
||||||
|
* either wrong looks exactly like the bug above.
|
||||||
|
*
|
||||||
* 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
role?: string;
|
role?: string;
|
||||||
content?: string;
|
content?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageSlots {
|
export interface MessageSlots {
|
||||||
/** The character card. */
|
/** The character card. */
|
||||||
system: Message | undefined;
|
system: string | undefined;
|
||||||
/** The character's opening message. */
|
/** The character's opening message. */
|
||||||
greeting: Message | undefined;
|
greeting: string | undefined;
|
||||||
/** The name — or conversation token — the user typed to start the chat. */
|
/** The name — or conversation token — the user typed to start the chat. */
|
||||||
nameSlot: Message | undefined;
|
nameSlot: string | undefined;
|
||||||
/** Whatever the user typed most recently. */
|
/** Whatever the user typed most recently. */
|
||||||
lastUser: Message | undefined;
|
lastUser: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectMessageSlots(
|
/** "Assistant", " user " and "USER" all have to count. */
|
||||||
messages: Message[] | undefined
|
function roleOf(message: Message | undefined): string {
|
||||||
): MessageSlots {
|
return typeof message?.role === "string" ? message.role.trim().toLowerCase() : "";
|
||||||
const list = Array.isArray(messages) ? messages : [];
|
}
|
||||||
const greetingIndex = list.findIndex((m) => m?.role === "assistant");
|
|
||||||
const users = list.filter((m) => m?.role === "user");
|
/**
|
||||||
|
* Content is usually a string, but the OpenAI shape allows an array of parts
|
||||||
|
* ({ type: "text", text: "…" }). Anything else — null, a number, a missing
|
||||||
|
* key — flattens to "" so it is simply skipped rather than throwing.
|
||||||
|
*/
|
||||||
|
export function textOf(content: unknown): string {
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content
|
||||||
|
.map((part) => {
|
||||||
|
if (typeof part === "string") return part;
|
||||||
|
const text = (part as { text?: unknown } | null)?.text;
|
||||||
|
return typeof text === "string" ? text : "";
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Messages with usable text, paired with their normalised role. */
|
||||||
|
function usable(messages: Message[] | undefined): { role: string; text: string }[] {
|
||||||
|
return (Array.isArray(messages) ? messages : [])
|
||||||
|
.map((m) => ({ role: roleOf(m), text: textOf(m?.content) }))
|
||||||
|
.filter((m) => m.text.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectMessageSlots(messages: Message[] | undefined): MessageSlots {
|
||||||
|
const list = usable(messages);
|
||||||
|
const greetingIndex = list.findIndex((m) => m.role === "assistant");
|
||||||
|
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])?.text,
|
||||||
greeting: greetingIndex === -1 ? undefined : list[greetingIndex],
|
greeting: greetingIndex === -1 ? undefined : list[greetingIndex].text,
|
||||||
nameSlot:
|
nameSlot:
|
||||||
greetingIndex === -1
|
greetingIndex === -1
|
||||||
? undefined
|
? undefined
|
||||||
: list.slice(greetingIndex + 1).find((m) => m?.role === "user"),
|
: list.slice(greetingIndex + 1).find((m) => m.role === "user")?.text,
|
||||||
lastUser: users[users.length - 1],
|
lastUser: users[users.length - 1]?.text,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,11 +101,11 @@ export function selectMessageSlots(
|
|||||||
* What the user just typed still wins, since pasting a token is deliberate.
|
* What the user just typed still wins, since pasting a token is deliberate.
|
||||||
*/
|
*/
|
||||||
export function tokenCandidates(messages: Message[] | undefined): string[] {
|
export function tokenCandidates(messages: Message[] | undefined): string[] {
|
||||||
const list = Array.isArray(messages) ? messages : [];
|
const list = usable(messages);
|
||||||
const users = list.filter((m) => m?.role === "user");
|
const users = list.filter((m) => m.role === "user");
|
||||||
const mostRecentUser = users[users.length - 1];
|
const mostRecentUser = users[users.length - 1];
|
||||||
|
|
||||||
return [mostRecentUser, ...list.filter((m) => m !== mostRecentUser)]
|
return [mostRecentUser, ...list.filter((m) => m !== mostRecentUser)]
|
||||||
.map((m) => m?.content)
|
.filter((m): m is { role: string; text: string } => Boolean(m))
|
||||||
.filter((c): c is string => typeof c === "string" && c.length > 0);
|
.map((m) => m.text);
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-13
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Both request shapes JanitorAI sends, and conversations that keep going.
|
* Every request shape we have actually seen, plus the malformed ones a client
|
||||||
* Run with `npm test` — no test framework, just node's built-in runner.
|
* could plausibly send. Run with `npm test` — no framework, node's own 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, tokenCandidates } from "../src/lib/messages.ts";
|
import { selectMessageSlots, tokenCandidates, textOf } 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>";
|
||||||
@@ -13,7 +13,7 @@ const NAME = "{{user}}: Gaius";
|
|||||||
const TOKEN = "[sucker:conv=abc123]";
|
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: { role: string; content: string }[] = [
|
const withProbe = [
|
||||||
{ role: "system", content: CARD },
|
{ role: "system", content: CARD },
|
||||||
{ role: "user", content: "." },
|
{ role: "user", content: "." },
|
||||||
{ role: "assistant", content: GREETING },
|
{ role: "assistant", content: GREETING },
|
||||||
@@ -21,7 +21,7 @@ const withProbe: { role: string; content: string }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/** 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: { role: string; content: string }[] = [
|
const withoutProbe = [
|
||||||
{ role: "system", content: CARD },
|
{ role: "system", content: CARD },
|
||||||
{ role: "assistant", content: GREETING },
|
{ role: "assistant", content: GREETING },
|
||||||
{ role: "user", content: NAME },
|
{ role: "user", content: NAME },
|
||||||
@@ -42,18 +42,18 @@ test("both shapes resolve to the same slots", () => {
|
|||||||
["without probe", withoutProbe],
|
["without probe", withoutProbe],
|
||||||
] as const) {
|
] as const) {
|
||||||
const slots = selectMessageSlots(messages);
|
const slots = selectMessageSlots(messages);
|
||||||
assert.equal(slots.system?.content, CARD, `${label}: card`);
|
assert.equal(slots.system, CARD, `${label}: card`);
|
||||||
assert.equal(slots.greeting?.content, GREETING, `${label}: greeting`);
|
assert.equal(slots.greeting, GREETING, `${label}: greeting`);
|
||||||
assert.equal(slots.nameSlot?.content, NAME, `${label}: name`);
|
assert.equal(slots.nameSlot, NAME, `${label}: name`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the greeting and the name stay put as a conversation grows", () => {
|
test("the greeting and the name stay put as a conversation grows", () => {
|
||||||
const slots = selectMessageSlots(continued);
|
const slots = selectMessageSlots(continued);
|
||||||
assert.equal(slots.greeting?.content, GREETING);
|
assert.equal(slots.greeting, GREETING);
|
||||||
// Not "keep going" or "and again": the name is what started the chat.
|
// Not "keep going" or "and again": the name is what started the chat.
|
||||||
assert.equal(slots.nameSlot?.content, NAME);
|
assert.equal(slots.nameSlot, NAME);
|
||||||
assert.equal(slots.lastUser?.content, "{{user}}: and again");
|
assert.equal(slots.lastUser, "{{user}}: and again");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the token is still found once later replies stop carrying it", () => {
|
test("the token is still found once later replies stop carrying it", () => {
|
||||||
@@ -71,17 +71,52 @@ test("a token the user just pasted outranks an older one", () => {
|
|||||||
assert.match(candidates[0], /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", () => {
|
test("a card with no role on the system turn still resolves", () => {
|
||||||
const slots = selectMessageSlots([
|
const slots = selectMessageSlots([
|
||||||
{ content: CARD },
|
{ content: CARD },
|
||||||
{ role: "assistant", content: GREETING },
|
{ role: "assistant", content: GREETING },
|
||||||
{ role: "user", content: NAME },
|
{ role: "user", content: NAME },
|
||||||
]);
|
]);
|
||||||
assert.equal(slots.system?.content, CARD);
|
assert.equal(slots.system, CARD);
|
||||||
});
|
});
|
||||||
|
|
||||||
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" }], [null], [{ content: 42 }]]) {
|
||||||
assert.doesNotThrow(() => selectMessageSlots(input as never));
|
assert.doesNotThrow(() => selectMessageSlots(input as never));
|
||||||
assert.doesNotThrow(() => tokenCandidates(input as never));
|
assert.doesNotThrow(() => tokenCandidates(input as never));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user