Say what a skill proposal changes, so a kept runbook never reads as saved

propose_skill took no strict schema, so a runbook sent under another key
was dropped and the saved one proposed again; the confirm still said
"Saved". Now an unknown key is an error, a change that changes nothing is
refused, and both the bot and the confirm outcome are told which fields
change and whether the runbook is among them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 15:29:55 +01:00
co-authored by Claude Opus 5.5
parent b9c5311fde
commit 027c67c0a8
6 changed files with 152 additions and 9 deletions
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, test } from "bun:test";
import type { SkillRecord } from "@/src/types";
import skillProposalChanges from "./skill-proposal-changes";
const current: SkillRecord = {
id: 2,
name: "WhatsApp Web API",
slug: "whatsapp-web-api",
description: "Old description",
body: "1. Install Baileys.\n2. Pair.",
all_bots: false,
enabled: true,
};
describe("skillProposalChanges", () => {
test("a change that kept the old runbook doesn't name the runbook", () => {
const changes = skillProposalChanges({ current, data: { ...current, skill_id: 2, description: "New description" } });
expect(changes.fields).toEqual(["description"]);
expect(changes.body_changed).toBe(false);
});
test("a new runbook is named, with both lengths", () => {
const body = "1. Install Baileys, @hapi/boom and pino.\n2. Pair.\n3. Allowlist.";
const changes = skillProposalChanges({ current, data: { ...current, skill_id: 2, body } });
expect(changes.fields).toEqual(["runbook"]);
expect(changes.body_chars).toBe(body.length);
expect(changes.was_body_chars).toBe(current.body.length);
});
test("saving it as it is changes nothing", () => {
expect(skillProposalChanges({ current, data: { ...current, skill_id: 2 } }).fields).toEqual([]);
});
test("turning it off and giving it to every bot are changes", () => {
const changes = skillProposalChanges({ current, data: { ...current, enabled: false, all_bots: true } });
expect(changes.fields).toEqual(["on/off", "every bot gets it"]);
});
test("a new skill is all new", () => {
const changes = skillProposalChanges({
current: null,
data: { name: "Tone", slug: "tone", description: "Every reply", body: "Be brief.", enabled: true, all_bots: false },
});
expect(changes.fields).toEqual(["name", "slug", "description", "runbook"]);
expect(changes.body_changed).toBe(true);
});
});
+45
View File
@@ -0,0 +1,45 @@
import type { SaveSkillRequest } from "@/src/schemas/extension-schema";
import type { SkillRecord } from "@/src/types";
type Params = {
data: SaveSkillRequest;
/**
* The skill as saved now. Null for a new one, where everything is new.
*/
current: SkillRecord | null;
};
export type SkillProposalChanges = {
/**
* What a change would make different, in words ("name", "runbook", ...).
* Empty when confirming it would save the skill exactly as it is.
*/
fields: string[];
body_changed: boolean;
body_chars: number;
was_body_chars: number;
};
/**
* What saving a proposed skill would change against the saved one, so the
* bot and the confirm outcome can say it plainly. A proposal whose runbook
* came out the same (the bot left out `body`) reads "runbook" nowhere here.
*/
export default function skillProposalChanges({ data, current }: Params): SkillProposalChanges {
const body_changed = !current || current.body != data.body;
const checks = [
{ field: "name", changed: !current || current.name != data.name },
{ field: "slug", changed: !current || current.slug != data.slug },
{ field: "description", changed: !current || current.description != data.description },
{ field: "runbook", changed: body_changed },
{ field: "on/off", changed: !!current && !!current.enabled != data.enabled },
{ field: "every bot gets it", changed: !!current && data.all_bots && !current.all_bots },
];
return {
fields: checks.filter((check) => check.changed).map((check) => check.field),
body_changed,
body_chars: data.body.length,
was_body_chars: current?.body.length || 0,
};
}