test/codex-thread-revert.test.js
Rama main · réplica de lectura del repositorio original.
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);
});