Skip to content

Commit bbeb378

Browse files
committed
fix(actions): skip non-string entries in formatActionSimiles/Tags
Production observation (2026-04-28): the bot dropped a user message in cozy ("@remilio nubilio would you like that? look what me and shaw were just talking about ...") and stayed silent. Bot log showed: [PLUGIN:DISCORD] Error handling message (error=undefined is not an object (evaluating 'tag.trim')) Trace: plugin-discord's messageCreate handler called the runtime's formatActions to build the planner prompt. One of the registered actions had `tags` containing a non-string entry (undefined or null — the array was malformed somewhere upstream). The chain `(action.tags ?? []).map((tag) => tag.trim())` then called .trim() on undefined and threw. plugin-discord caught the throw as the generic "Error handling message" and aborted the entire message pipeline before reaching the planner. No reply, no specific log line about which action / which tag — just silence. Same shape exists in formatActionSimiles for action.similes. Fix: filter out non-string entries before .trim() in both helpers. String entries still trim/dedupe/empty-filter the same way. ```diff function formatActionTags(action: Action): string | null { const tags = [ - ...new Set((action.tags ?? []).map((tag) => tag.trim())), + ...new Set( + (action.tags ?? []) + .filter((tag): tag is string => typeof tag === "string") + .map((tag) => tag.trim()), + ), ].filter((tag) => tag.length > 0 && tag !== "always-include"); ``` Why filter rather than rely on the Action type contract: action.tags and action.similes are `string[]` per the Action type, but external plugin authors are not always strict — a malformed entry from an inadequately-typed source poisoning the array crashes the entire planner-prompt builder, which silently kills message handling for every message the bot receives until the offending plugin is fixed. The cost of a `typeof === "string"` filter is negligible; the failure mode it prevents (silent bot, no actionable diagnostic) is severe. Tests: two regression cases in actions.test.ts covering action.tags and action.similes with mixed [undefined, null, string, number] arrays. Both should produce a normal formatted output with only the real string entries surviving.
1 parent 2f998a4 commit bbeb378

2 files changed

Lines changed: 74 additions & 2 deletions

File tree

packages/typescript/src/__tests__/actions.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,70 @@ describe("Actions", () => {
298298
const formatted = formatActions([]);
299299
expect(formatted).toBe("");
300300
});
301+
302+
// Production observation (2026-04-28): a plugin's action shipped with
303+
// `tags: [undefined, "foo"]` (a malformed entry from elsewhere in the
304+
// runtime). When the planner-prompt builder formatted that action, the
305+
// `(action.tags ?? []).map((tag) => tag.trim())` chain crashed with
306+
// "undefined is not an object (evaluating 'tag.trim')". plugin-discord
307+
// caught that exception as "Error handling message" and the bot
308+
// silently dropped the user's message — no reply, no log line about
309+
// the cause beyond the bare error message.
310+
//
311+
// formatActions must be defensive against non-string entries in
312+
// action.tags / action.similes since Action consumers don't always
313+
// validate their own arrays.
314+
it("skips non-string entries in action.tags without throwing", () => {
315+
const formatted = formatActions([
316+
{
317+
name: "WIDGET",
318+
description: "Do widget things.",
319+
examples: [],
320+
similes: [],
321+
tags: [
322+
undefined as unknown as string,
323+
null as unknown as string,
324+
" alpha ",
325+
42 as unknown as string,
326+
"beta",
327+
],
328+
handler: async () => {
329+
throw new Error("Not implemented");
330+
},
331+
validate: async () => {
332+
throw new Error("Not implemented");
333+
},
334+
},
335+
]);
336+
337+
expect(formatted).toContain("- WIDGET:");
338+
expect(formatted).toContain("tags[2]: alpha, beta");
339+
});
340+
341+
it("skips non-string entries in action.similes without throwing", () => {
342+
const formatted = formatActions([
343+
{
344+
name: "GREET",
345+
description: "Say hi.",
346+
examples: [],
347+
similes: [
348+
undefined as unknown as string,
349+
null as unknown as string,
350+
" hi ",
351+
"hello",
352+
],
353+
handler: async () => {
354+
throw new Error("Not implemented");
355+
},
356+
validate: async () => {
357+
throw new Error("Not implemented");
358+
},
359+
},
360+
]);
361+
362+
expect(formatted).toContain("- GREET:");
363+
expect(formatted).toContain("aliases[2]: hi, hello");
364+
});
301365
});
302366

303367
describe("Action benchmark cases", () => {

packages/typescript/src/actions.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,11 @@ function shuffleActions<T>(items: T[], seed = "actions"): T[] {
212212

213213
function formatActionSimiles(action: Action): string | null {
214214
const similes = [
215-
...new Set((action.similes ?? []).map((simile) => simile.trim())),
215+
...new Set(
216+
(action.similes ?? [])
217+
.filter((simile): simile is string => typeof simile === "string")
218+
.map((simile) => simile.trim()),
219+
),
216220
].filter((simile) => simile.length > 0);
217221

218222
if (similes.length === 0) {
@@ -224,7 +228,11 @@ function formatActionSimiles(action: Action): string | null {
224228

225229
function formatActionTags(action: Action): string | null {
226230
const tags = [
227-
...new Set((action.tags ?? []).map((tag) => tag.trim())),
231+
...new Set(
232+
(action.tags ?? [])
233+
.filter((tag): tag is string => typeof tag === "string")
234+
.map((tag) => tag.trim()),
235+
),
228236
].filter((tag) => tag.length > 0 && tag !== "always-include");
229237

230238
if (tags.length === 0) {

0 commit comments

Comments
 (0)