Ctrl K
ComunidadGuías…

test/codex-thread-revert.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 createCodexAdapter = require("../lib/yoke/adapters/codex").createCodexAdapter;
var codexContractTestKit = require("../lib/yoke/adapters/codex").contractTestKit;
var createSDKBridge = require("../lib/sdk-bridge").createSDKBridge;
var attachSessions = require("../lib/project-sessions").attachSessions;

// Codex 0.160 removed the count-based `thread/rollback` method entirely (the
// live app-server rejects it with "unknown variant") in favor of
// `thread/revert`, which cuts at an explicit `beforeTurnId` rather than a
// turn count. Clay must capture Codex's real per-turn id and send it, and
// must never trim its own local history ahead of a provider-side rollback
// that didn't happen.

function createFakeServer() {
  return {
    started: false,
    start: function () { this.started = true; return Promise.resolve(); },
    calls: [],
    send: function (method, params) {
      this.calls.push({ method: method, params: params });
      if (method === "skills/list") return Promise.resolve({ data: [] });
      // Real ThreadRevertResponse always carries `thread`, with that exact
      // thread's own required `id` -- never an empty/placeholder object.
      if (method === "thread/revert") return Promise.resolve({ thread: { id: params.threadId } });
      return Promise.resolve({});
    },
    notify: function () {},
    stop: function () { this.started = false; },
  };
}

test("rollbackThread sends thread/revert with beforeTurnId, not the removed thread/rollback", async function () {
  var server = createFakeServer();
  var adapter = createCodexAdapter({ cwd: process.cwd(), createAppServer: function () { return server; } });
  await adapter.init({});
  await adapter.rollbackThread("thread-1", "turn-abc", {});
  var revertCall = server.calls.find(function (c) { return c.method === "thread/revert"; });
  assert.ok(revertCall, "thread/revert must be sent");
  assert.deepEqual(revertCall.params, { threadId: "thread-1", beforeTurnId: "turn-abc" });
  assert.equal(server.calls.some(function (c) { return c.method === "thread/rollback"; }), false);
});

test("rollbackThread throws rather than silently succeeding when the app-server never starts", async function () {
  // start() resolves (no throw) but never flips `started`, simulating a
  // real-world silent init failure -- the caller must not read this as a
  // successful revert.
  var server = { started: false, start: function () { return Promise.resolve(); }, notify: function () {}, send: function () { return Promise.resolve({}); } };
  var adapter = createCodexAdapter({ cwd: process.cwd(), createAppServer: function () { return server; } });
  await assert.rejects(adapter.rollbackThread("thread-1", "turn-abc", {}), /not running/);
});

test("rollbackThread rejects a malformed or mistargeted revert response instead of trusting it", async function () {
  var wrongThread = { started: false, start: function () { this.started = true; return Promise.resolve(); }, notify: function () {}, send: function (method, params) {
    if (method === "thread/revert") return Promise.resolve({ thread: { id: "some-other-thread" } });
    return Promise.resolve({});
  } };
  var adapterA = createCodexAdapter({ cwd: process.cwd(), createAppServer: function () { return wrongThread; } });
  await adapterA.init({});
  await assert.rejects(adapterA.rollbackThread("thread-1", "turn-abc", {}), /did not confirm/,
    "a response naming a different thread id must not be trusted as this thread's revert");

  var emptyResponse = { started: false, start: function () { this.started = true; return Promise.resolve(); }, notify: function () {}, send: function () { return Promise.resolve({}); } };
  var adapterB = createCodexAdapter({ cwd: process.cwd(), createAppServer: function () { return emptyResponse; } });
  await adapterB.init({});
  await assert.rejects(adapterB.rollbackThread("thread-1", "turn-abc", {}), /did not confirm/,
    "a response missing the required `thread` field must not be trusted as success");
});

test("rollbackThread resolves on a correctly targeted revert response", async function () {
  var server = createFakeServer();
  var adapter = createCodexAdapter({ cwd: process.cwd(), createAppServer: function () { return server; } });
  await adapter.init({});
  var result = await adapter.rollbackThread("thread-1", "turn-abc", {});
  assert.equal(result.thread.id, "thread-1");
});

test("flattenEvent captures Codex's real turnId on the user turn_start event", function () {
  var state = codexContractTestKit.createEventState("gpt-test");
  var events = codexContractTestKit.normalizeEvent({ method: "turn/started", params: { turnId: "turn-xyz" } }, state);
  var turnStart = events.find(function (e) { return e.yokeType === "turn_start"; });
  assert.ok(turnStart, "turn_start must be emitted");
  assert.equal(turnStart.turnId, "turn-xyz");
});

test("sdk-bridge rollbackConversation throws rather than guessing when no turnId was recorded", async function () {
  var calls = [];
  var adapter = { rollbackThread: function (threadId, turnId) { calls.push([threadId, turnId]); return Promise.resolve(); } };
  var bridge = createSDKBridge({ cwd: process.cwd(), sessionManager: {}, adapter: adapter, adapters: { codex: adapter }, send: function () {} });
  var session = { localId: 1, vendor: "codex", cliSessionId: "thread-1" };
  await assert.rejects(bridge.rollbackConversation(session, null), /No turn id recorded/);
  assert.equal(calls.length, 0, "the adapter must never be called with a guessed id");
});

test("sdk-bridge rollbackConversation forwards the real turnId to the adapter", async function () {
  var calls = [];
  var adapter = { rollbackThread: function (threadId, turnId) { calls.push([threadId, turnId]); return Promise.resolve(); } };
  var bridge = createSDKBridge({ cwd: process.cwd(), sessionManager: {}, adapter: adapter, adapters: { codex: adapter }, send: function () {} });
  var session = { localId: 1, vendor: "codex", cliSessionId: "thread-1" };
  await bridge.rollbackConversation(session, "turn-abc");
  assert.deepEqual(calls, [["thread-1", "turn-abc"]]);
});

function rewindFixture(session, overrides) {
  var sent = [];
  var recorded = [];
  var sm = Object.assign({
    sessions: new Map([[session.localId, session]]),
    saveSessionFile: function () {},
    switchSession: function () {},
    broadcastSessionList: function () {},
    sendAndRecord: function (target, message) { recorded.push(message); },
  }, (overrides && overrides.sm) || {});
  var sdk = Object.assign({
    rewindExecuteFiles: function () { return Promise.resolve(); },
    rollbackConversation: function () { return Promise.resolve(); },
    forkSession: function () { return Promise.resolve({}); },
  }, (overrides && overrides.sdk) || {});
  var attached = attachSessions({
    sm: sm, sdk: sdk, clients: new Set(), opts: {},
    usersModule: { isMultiUser: function () { return false; } },
    getSessionForWs: function () { return session; },
    sendTo: function (ws, message) { sent.push(message); },
    onHumanPairStop: function () {},
    onProcessingChanged: function () {},
    hydrateImageRefs: function (x) { return x; },
  });
  return { attached: attached, sent: sent, recorded: recorded, sdk: sdk };
}

test("rewind_execute trims local history only after the provider rollback succeeds", async function () {
  var session = {
    localId: 11, cliSessionId: "thread-1",
    history: [
      { type: "user_message" }, { type: "assistant" },
      { type: "user_message" }, { type: "assistant" },
    ],
    messageUUIDs: [
      { uuid: "u1", type: "user", historyIndex: 0, turnId: "turn-1" },
      { uuid: "a1", type: "assistant", historyIndex: 1 },
      { uuid: "u2", type: "user", historyIndex: 2, turnId: "turn-2" },
      { uuid: "a2", type: "assistant", historyIndex: 3 },
    ],
    pendingAskUser: {}, isProcessing: false,
  };
  var rollbackCalls = [];
  var f = rewindFixture(session, { sdk: { rollbackConversation: function (s, turnId) { rollbackCalls.push(turnId); return Promise.resolve(); } } });
  f.attached.handleSessionsMessage({}, { type: "rewind_execute", uuid: "u1", mode: "chat" });
  await new Promise(function (resolve) { setImmediate(resolve); });
  await new Promise(function (resolve) { setImmediate(resolve); });
  assert.deepEqual(rollbackCalls, ["turn-1"], "the real recorded turnId for the target turn must be sent, not a count");
  assert.equal(session.history.length, 0, "local history is trimmed only once the provider rollback is confirmed");
  assert.ok(f.recorded.some(function (m) { return m.type === "rewind_complete"; }));
});

test("rewind_execute aborts and leaves local history untouched when the provider rollback fails", async function () {
  var session = {
    localId: 12, cliSessionId: "thread-1",
    history: [{ type: "user_message" }, { type: "assistant" }],
    messageUUIDs: [{ uuid: "u1", type: "user", historyIndex: 0, turnId: null }],
    pendingAskUser: {}, isProcessing: false,
  };
  var originalHistory = session.history.slice();
  var originalUUIDs = session.messageUUIDs.slice();
  var f = rewindFixture(session, {
    sdk: { rollbackConversation: function () { return Promise.reject(new Error("No turn id recorded; cannot roll back the Codex thread.")); } },
  });
  f.attached.handleSessionsMessage({}, { type: "rewind_execute", uuid: "u1", mode: "chat" });
  await new Promise(function (resolve) { setImmediate(resolve); });
  await new Promise(function (resolve) { setImmediate(resolve); });
  assert.deepEqual(session.history, originalHistory, "local history must not be trimmed when the provider rollback failed");
  assert.deepEqual(session.messageUUIDs, originalUUIDs);
  assert.ok(f.sent.some(function (m) { return m.type === "rewind_error"; }), "a user-visible rewind_error must be sent");
  assert.equal(f.recorded.some(function (m) { return m.type === "rewind_complete"; }), false);
});