Ctrl K
ComunidadGuías…

test/capsule-mate-turn.test.js

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

Archivo original ↗Preguntar a KiBot
var test = require("node:test");
var assert = require("node:assert/strict");
var fs = require("node:fs");
var os = require("node:os");
var path = require("node:path");

var testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "clay-capsule-turn-"));
process.env.CLAY_HOME = testRoot;

var capsuleMateTurn = require("../lib/capsule-mate-turn");
var projectCapsuleTurn = require("../lib/project-capsule-turn");
var registry = require("../lib/tools-registry");
var serverTools = require("../lib/server-tools");

test.after(function () { fs.rmSync(testRoot, { recursive: true, force: true }); });

var PIG_MANIFEST = { id: "pig", name: "Pig" };

function playingEvent(seq, turn, status) {
  var event = { seq: seq, actor: "user", action: "hold", previous: {}, next: { status: status || "playing", turn: turn } };
  // Mirrors what a Capsule's Logic declares: engage on a turn handed to the
  // mate seat in a live game, nothing otherwise.
  if ((status || "playing") === "playing" && turn === "mate") event.engage = { kind: "turn" };
  return event;
}

function fakeMateProject() {
  var record = { deliveries: [], lookups: [], broadcasts: [] };
  record.broadcastToUser = function (userId, payload) { record.broadcasts.push({ userId: userId, payload: payload }); };
  record.findMateProject = function (userId, mateId, ensureRegistered) {
    record.lookups.push({ userId: userId, mateId: mateId, ensureRegistered: ensureRegistered });
    return {
      mate: { id: mateId || "clay-builtin" },
      ctx: {
        deliverCapsuleTurn: function (principal, options) {
          record.deliveries.push({ principal: principal, options: options });
          return Promise.resolve({ session: { localId: 7 }, created: record.deliveries.length === 1, reference: "local:7" });
        },
      },
    };
  };
  return record;
}

test("a human act that hands the turn to the mate seat wakes the opponent exactly once", async function () {
  var mateProject = fakeMateProject();
  var bridge = capsuleMateTurn.attachCapsuleMateTurn({
    users: { isMultiUser: function () { return true; } },
    findMateProject: mateProject.findMateProject,
  });

  await bridge.maybeNudge("user-a", PIG_MANIFEST, "human", playingEvent(4, "mate"));
  assert.strictEqual(mateProject.deliveries.length, 1);
  assert.strictEqual(mateProject.deliveries[0].principal.userId, "user-a");
  assert.strictEqual(mateProject.deliveries[0].options.toolId, "pig");
  assert.match(mateProject.deliveries[0].options.text, /clay_tool_snapshot/);
  assert.match(mateProject.deliveries[0].options.text, /"pig"/);

  // The same event never nudges twice, and older events never re-fire.
  await bridge.maybeNudge("user-a", PIG_MANIFEST, "human", playingEvent(4, "mate"));
  await bridge.maybeNudge("user-a", PIG_MANIFEST, "human", playingEvent(3, "mate"));
  assert.strictEqual(mateProject.deliveries.length, 1);

  // Mate acts, turns back to the user, or a finished game: no nudge.
  await bridge.maybeNudge("user-a", PIG_MANIFEST, "mate", playingEvent(5, "mate"));
  await bridge.maybeNudge("user-a", PIG_MANIFEST, "human", playingEvent(6, "user"));
  await bridge.maybeNudge("user-a", PIG_MANIFEST, "human", playingEvent(7, "mate", "complete"));
  assert.strictEqual(mateProject.deliveries.length, 1);
});

test("starting a new game seats the Mate at the table without letting it act", async function () {
  var mateProject = fakeMateProject();
  var bridge = capsuleMateTurn.attachCapsuleMateTurn({
    users: { isMultiUser: function () { return true; } },
    findMateProject: mateProject.findMateProject,
    broadcastToUser: mateProject.broadcastToUser,
  });

  var resetEvent = { seq: 9, actor: "user", action: "reset", previous: {}, next: { status: "playing", turn: "user" }, engage: { kind: "start" } };
  await bridge.maybeNudge("user-a", PIG_MANIFEST, "human", resetEvent);
  assert.strictEqual(mateProject.deliveries.length, 1);
  assert.match(mateProject.deliveries[0].options.text, /started a new game/);
  assert.match(mateProject.deliveries[0].options.text, /do not call clay_tool_act now/);
  // The explicit start pushes the game session to the user's clients, so the
  // home board can navigate into the Mate's game conversation.
  assert.strictEqual(mateProject.broadcasts.length, 1);
  assert.strictEqual(mateProject.broadcasts[0].userId, "user-a");
  assert.strictEqual(mateProject.broadcasts[0].payload.type, "capsule_game_session");
  assert.strictEqual(mateProject.broadcasts[0].payload.kind, "start");
  assert.strictEqual(mateProject.broadcasts[0].payload.sessionId, "local:7");
  assert.strictEqual(mateProject.broadcasts[0].payload.mateId, "clay-builtin");

  // A Mate resetting a finished game does not announce to itself, and the
  // same reset event never fires twice.
  await bridge.maybeNudge("user-a", PIG_MANIFEST, "human", resetEvent);
  await bridge.maybeNudge("user-a", PIG_MANIFEST, "mate", { seq: 10, actor: "mate", action: "reset", previous: {}, next: { status: "playing", turn: "user" }, engage: { kind: "start" } });
  assert.strictEqual(mateProject.deliveries.length, 1);
});

test("the opponent seat belongs to the Mate that last acted, with the host Mate as the fallback", async function () {
  var mateProject = fakeMateProject();
  var bridge = capsuleMateTurn.attachCapsuleMateTurn({
    users: { isMultiUser: function () { return false; } },
    findMateProject: mateProject.findMateProject,
  });

  // No Mate has acted yet: the lookup falls back to the built-in host Mate.
  await bridge.maybeNudge("default", PIG_MANIFEST, "human", playingEvent(1, "mate"));
  assert.strictEqual(mateProject.lookups[0].mateId, null);
  assert.strictEqual(mateProject.lookups[0].ensureRegistered, true);
  // Single-user mode binds no owner onto the session.
  assert.strictEqual(mateProject.deliveries[0].principal.userId, null);

  bridge.rememberOpponent("default", "pig", "mate-folder-7");
  await bridge.maybeNudge("default", PIG_MANIFEST, "human", playingEvent(2, "mate"));
  assert.strictEqual(mateProject.lookups[1].mateId, "mate-folder-7");
});

test("a nudge failure is contained and never breaks the act that caused it", async function () {
  var bridge = capsuleMateTurn.attachCapsuleMateTurn({
    users: { isMultiUser: function () { return true; } },
    findMateProject: function () { throw new Error("registry exploded"); },
  });
  var result = await bridge.maybeNudge("user-a", PIG_MANIFEST, "human", playingEvent(1, "mate"));
  assert.strictEqual(result, null);
});

test("deliverCapsuleTurn hosts the whole game in one reused session", async function () {
  var sessions = new Map();
  var recorded = [];
  var queries = [];
  var nextId = 1;
  var sm = {
    sessions: sessions,
    createSessionRaw: function (options) {
      var session = { localId: "s" + nextId++, ownerId: options.ownerId, vendor: options.vendor, model: options.model, effort: options.effort || null };
      sessions.set(session.localId, session);
      return session;
    },
    sendAndRecord: function (session, message) { recorded.push({ session: session, message: message }); },
    broadcastSessionList: function () {},
  };
  var sdk = {
    pushMessage: function () { return false; },
    startQuery: function (session, text) { queries.push({ session: session, text: text }); return Promise.resolve(); },
  };
  var turns = projectCapsuleTurn.attachCapsuleTurn({
    sm: sm,
    getSdk: function () { return sdk; },
    getLinuxUserForSession: function () { return null; },
    isMultiUser: function () { return false; },
    resolveModel: function () { return Promise.resolve({ status: "ready", vendor: "claude", model: "test-model", effort: "high" }); },
  });

  var principal = { userId: null };
  var first = await turns.deliverCapsuleTurn(principal, { toolId: "pig", toolName: "Pig", kind: "turn", text: "your turn" });
  assert.strictEqual(first.created, true);
  assert.strictEqual(first.reference, "local:" + first.session.localId);
  assert.strictEqual(first.session.title, "Pig game");
  assert.deepStrictEqual(first.session.capsuleGame, { toolId: "pig" });
  assert.strictEqual(first.session.effort, "high");
  assert.strictEqual(queries.length, 1);
  // The delivery is recorded as a dedicated event the transcript renders as a
  // short system note, never as a human turn.
  assert.strictEqual(recorded[0].message.type, "capsule_turn");
  assert.strictEqual(recorded[0].message.toolId, "pig");
  assert.strictEqual(recorded[0].message.kind, "turn");

  var second = await turns.deliverCapsuleTurn(principal, { toolId: "pig", toolName: "Pig", text: "your turn again" });
  assert.strictEqual(second.session, first.session, "the game keeps one session across turns");
  assert.strictEqual(second.created, false);
  assert.strictEqual(sessions.size, 1);
  assert.strictEqual(queries.length, 2);
});

test("a live query receives the turn through pushMessage instead of a second query", async function () {
  var pushed = [];
  var sm = {
    sessions: new Map(),
    createSessionRaw: function (options) {
      var session = { localId: "s1", ownerId: options.ownerId };
      sm.sessions.set(session.localId, session);
      return session;
    },
    sendAndRecord: function () {},
    broadcastSessionList: function () {},
  };
  var sdk = {
    pushMessage: function (session, text) { pushed.push(text); return true; },
    startQuery: function () { throw new Error("startQuery must not run when pushMessage delivered"); },
  };
  var turns = projectCapsuleTurn.attachCapsuleTurn({
    sm: sm,
    getSdk: function () { return sdk; },
    getLinuxUserForSession: function () { return null; },
    isMultiUser: function () { return false; },
    resolveModel: function () { return Promise.resolve({ status: "ready", vendor: "claude", model: "test-model" }); },
  });
  await turns.deliverCapsuleTurn({ userId: null }, { toolId: "pig", toolName: "Pig", text: "turn" });
  assert.deepStrictEqual(pushed, ["turn"]);
});

test("the act pipeline wakes the opponent when the user hands the turn over", async function () {
  var mateProject = fakeMateProject();
  var tools = serverTools.attachTools({
    users: { isMultiUser: function () { return true; }, findUserById: function (id) { return { id: id }; } },
    projects: new Map(),
    findMateProject: mateProject.findMateProject,
  });
  registry.listTools({ userId: "turn-user", multiUser: true });

  // The user rolls and holds; holding hands the turn to the mate seat.
  var state = await tools.controlForUser("turn-user", "pig", "act", { actionId: "roll", args: {} });
  if (state.turn === "user") state = await tools.controlForUser("turn-user", "pig", "act", { actionId: "hold", args: {} });
  assert.strictEqual(state.turn, "mate");
  for (var i = 0; i < 100 && mateProject.deliveries.length === 0; i++) {
    await new Promise(function (resolve) { setTimeout(resolve, 5); });
  }
  assert.strictEqual(mateProject.deliveries.length, 1);
  assert.strictEqual(mateProject.deliveries[0].options.toolId, "pig");

  // The mate plays its turn back: the opponent seat is remembered, and the
  // mate's own acts never nudge anyone.
  var before = mateProject.deliveries.length;
  var mateState = await tools.controlForMate("turn-user", "mate-folder-9", "pig", "act", { actionId: "hold", args: {} });
  assert.strictEqual(mateState.turn, "user");
  await new Promise(function (resolve) { setTimeout(resolve, 25); });
  assert.strictEqual(mateProject.deliveries.length, before);

  state = await tools.controlForUser("turn-user", "pig", "act", { actionId: "roll", args: {} });
  if (state.turn === "user") state = await tools.controlForUser("turn-user", "pig", "act", { actionId: "hold", args: {} });
  for (var j = 0; j < 100 && mateProject.deliveries.length === before; j++) {
    await new Promise(function (resolve) { setTimeout(resolve, 5); });
  }
  var lastLookup = mateProject.lookups[mateProject.lookups.length - 1];
  assert.strictEqual(lastLookup.mateId, "mate-folder-9", "the Mate that played holds the seat");
});