Ctrl K
ComunidadGuías…

test/turn-scope.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildOpsSchema } from "../src/ai/ops.js";
import { deckSchema } from "../src/ai/catalog.js";
import { runTurn } from "../src/ai/turn.js";

/**
 * `buildOpsSchema` assigns every selected type's properties into one flat
 * object, so a property name that two types share belongs to whichever was
 * walked last. That is harmless while a turn really may write any type, and
 * wrong the moment a caller knows which type it is editing.
 *
 * Thirteen slide types declare `items`. Asked to repair a clipped card body on
 * a `feature-grid`, the fix turn was handed `contact`'s `{label, value}` items
 * and its output failed validation with `missing required field "title"`. The
 * model could not have got it right: constrained decoding made the correct
 * answer unrepresentable and the incorrect one mandatory.
 */

const itemsOf = (schema) => schema.properties.ops.items.properties.patch.properties.items;

test("an unscoped turn carries one type's `items` shape for all thirteen", async () => {
  const merged = itemsOf(buildOpsSchema(await deckSchema(), { slideCount: 12 }));
  // Not asserting WHICH type wins — that is schema order, and pinning it would
  // fail for the wrong reason. The point is that one shape stands for all.
  assert.equal(merged.items.type, "object");
  assert.ok(merged.items.required?.length, "and it brings its own required keys");
});

test("scoping to feature-grid gives feature-grid's items, not another type's", async () => {
  const scoped = itemsOf(buildOpsSchema(await deckSchema(), {
    slideCount: 12,
    onlyTypes: ["feature-grid"],
  }));
  assert.deepEqual(scoped.items.required, ["title"]);
  assert.deepEqual(
    Object.keys(scoped.items.properties).sort(),
    ["body", "icon", "title"],
    "the card shape the type actually declares",
  );
  assert.equal(scoped.items.properties.body.maxLength, 53);
});

test("runTurn passes its type scope through to the grammar", async () => {
  const deck = {
    title: "t",
    slides: [{ type: "feature-grid", headline: "h", items: [{ title: "a" }, { title: "b" }] }],
  };
  let seen = null;
  const chat = async ({ schema }) => {
    seen = schema;
    // A patch in the RIGHT shape — the one the unscoped grammar forbade.
    return {
      data: {
        ops: [{
          op: "update_slide",
          index: 0,
          patch: { items: [{ title: "a", body: "A whole sentence." }, { title: "b", body: "Another." }] },
        }],
      },
    };
  };

  const turn = await runTurn({ deck, instruction: "shorten", onlyTypes: ["feature-grid"], chat });
  assert.ok(turn.ok, `the scoped shape validates: ${JSON.stringify(turn.errors)}`);
  assert.deepEqual(itemsOf(seen).items.required, ["title"], "the turn built a feature-grid grammar");
  assert.equal(turn.deck.slides[0].items[0].body, "A whole sentence.");
});

test("no scope still lets a turn write any type — chat must stay unrestricted", async () => {
  const deck = { title: "t", slides: [{ type: "bullets", headline: "h", bullets: ["a", "b", "c", "d"] }] };
  let seen = null;
  const chat = async ({ schema }) => {
    seen = schema;
    return { data: { ops: [] } };
  };
  await runTurn({ deck, instruction: "nothing", chat });
  const type = seen.properties.ops.items.properties.slide.properties.type;
  assert.ok(!type?.enum || type.enum.length > 20, "every type stays available to an unscoped turn");
});

/**
 * The critic names its slides and so gets `onlyTypes`. Chat has the same
 * knowledge and was not using it: the panel's selection reaches `runTurn` as
 * `onlySlides`, which was only ever applied AFTER the model answered, to
 * filter the ops it produced. The grammar it answered against was still the
 * flat merge of every type.
 */

test("a selection narrows the patch grammar to the selected slides' own types", async () => {
  const deck = {
    title: "t",
    slides: [
      { type: "bullets", headline: "h", bullets: ["a", "b", "c"] },
      { type: "feature-grid", headline: "h", items: [{ title: "a" }, { title: "b" }] },
    ],
  };
  let seen = null;
  const chat = async ({ schema }) => { seen = schema; return { data: { ops: [] } }; };

  await runTurn({ deck, instruction: "shorten this", onlySlides: [1], chat });
  assert.deepEqual(
    itemsOf(seen).items.required, ["title"],
    "the selected slide is a feature-grid, so `items` should be its shape",
  );
});

test("a selection does not stop the turn adding a slide of another type", async () => {
  // scopeOpsToSelection lets append and insert through on purpose, so
  // narrowing the full-slide grammar with the patch grammar would trade one
  // silent failure for another.
  const deck = {
    title: "t",
    slides: [{ type: "feature-grid", headline: "h", items: [{ title: "a" }, { title: "b" }] }],
  };
  let seen = null;
  const chat = async ({ schema }) => { seen = schema; return { data: { ops: [] } }; };

  await runTurn({ deck, instruction: "add a chart after this", onlySlides: [0], chat });
  const type = seen.properties.ops.items.properties.slide.properties.type;
  assert.ok(!type?.enum || type.enum.length > 20, "every type stays appendable under a selection");
});

test("an empty or out-of-range selection falls back to the unscoped grammar", async () => {
  const deck = { title: "t", slides: [{ type: "bullets", headline: "h", bullets: ["a", "b", "c"] }] };
  for (const onlySlides of [null, [], [99]]) {
    let seen = null;
    const chat = async ({ schema }) => { seen = schema; return { data: { ops: [] } }; };
    await runTurn({ deck, instruction: "x", onlySlides, chat });
    assert.ok(
      itemsOf(seen),
      `a selection of ${JSON.stringify(onlySlides)} should leave the full patch grammar in place`,
    );
  }
});

test("an explicit onlyTypes still wins over the selection", async () => {
  // The critic passes both: it names slides AND the types its findings concern.
  const deck = {
    title: "t",
    slides: [{ type: "bullets", headline: "h", bullets: ["a", "b", "c"] }],
  };
  let seen = null;
  const chat = async ({ schema }) => { seen = schema; return { data: { ops: [] } }; };

  await runTurn({ deck, instruction: "x", onlySlides: [0], onlyTypes: ["feature-grid"], chat });
  const type = seen.properties.ops.items.properties.slide.properties.type;
  assert.deepEqual(type, { enum: ["feature-grid"] });
  assert.deepEqual(itemsOf(seen).items.required, ["title"]);
});