test/generation-stop.test.js
Rama main · réplica de lectura del repositorio original.
var test = require('node:test');
var assert = require('node:assert/strict');
var fs = require('node:fs');
var vm = require('node:vm');
var createTurnStop = require('../lib/yoke/codex-turn-stop').createTurnStop;
var codex = require('../lib/yoke/adapters/codex');
function fixture() {
var calls = [];
var errors = [];
var server = { started: true, send: function (method, params) {
calls.push({ method: method, params: params }); return Promise.resolve({});
} };
return { server: server, calls: calls, errors: errors, stop: createTurnStop(server, function (err) { errors.push(err); }) };
}
test('Codex Stop sends exact current turn identity once', async function () {
var f = fixture();
f.stop.begin('thread'); f.stop.started('old'); f.stop.completed('old');
f.stop.begin('thread'); f.stop.started('current');
var pending = f.stop.abort();
assert.equal(f.stop.abort(), pending);
f.stop.completed('current');
await pending;
assert.deepEqual(f.calls, [{ method: 'turn/interrupt', params: { threadId: 'thread', turnId: 'current' } }]);
});
test('Stop during turn/start waits for its identity without interrupting the previous turn', async function () {
var f = fixture();
f.stop.begin('thread'); f.stop.started('old'); f.stop.completed('old');
f.stop.begin('thread');
var pending = f.stop.abort();
await Promise.resolve(); assert.equal(f.calls.length, 0);
f.stop.startSettled('new');
await pending;
assert.equal(f.calls[0].params.turnId, 'new');
});
test('turn notification can deliver cancellation identity before the start response', async function () {
var f = fixture(); f.stop.begin('thread');
var pending = f.stop.abort();
f.stop.started('new'); f.stop.startSettled('new');
await pending; assert.equal(f.calls.length, 1);
});
test('a late start response cannot resurrect a completed turn', async function () {
var f = fixture(); f.stop.begin('thread'); f.stop.started('done');
f.stop.completed('done'); f.stop.startSettled('done');
await f.stop.abort(); assert.equal(f.calls.length, 0);
});
test('idle and failed-start Stop do not send an invalid interrupt', async function () {
var f = fixture(); await f.stop.abort(); assert.equal(f.calls.length, 0);
f = fixture(); f.stop.begin('thread');
var pending = f.stop.abort(); f.stop.startSettled(null);
await pending; assert.equal(f.calls.length, 0);
});
test('interrupt rejection and disconnection are reported', async function () {
var f = fixture();
f.server.send = function () { return Promise.reject(new Error('rejected')); };
f.stop.begin('thread'); f.stop.started('turn'); await f.stop.abort();
assert.equal(f.errors[0].message, 'rejected');
f = fixture(); f.server.started = false;
f.stop.begin('thread'); f.stop.started('turn'); await f.stop.abort();
assert.equal(f.errors.length, 1); assert.equal(f.calls.length, 0);
});
test('adapter Stop during start interrupts the returned turn and finishes the iterator', async function () {
var calls = [];
var resolveStart;
var controller = new AbortController();
var server = { started: true, addHandler: function () { return {}; }, removeHandler: function () {}, send: function (method, params) {
if (method === 'thread/start') return Promise.resolve({ thread: { id: 'thread' } });
if (method === 'turn/start') return new Promise(function (resolve) { resolveStart = resolve; });
if (method === 'turn/interrupt') { calls.push(params); return Promise.resolve({}); }
return Promise.resolve({});
} };
var handle = codex.contractTestKit.createQueryHandle(server, { cwd: process.cwd(), skipSkills: true, abortController: controller });
handle.pushMessage('test');
while (!resolveStart) await new Promise(function (resolve) { setImmediate(resolve); });
controller.abort();
resolveStart({ turn: { id: 'turn' } });
for await (var event of handle) { /* Drain until Stop ends the iterator. */ }
assert.deepEqual(calls, [{ threadId: 'thread', turnId: 'turn' }]);
});
// Minimal fake DOM node supporting the handful of APIs generation-stop.js
// actually calls: addEventListener, classList.toggle/contains, and
// reparenting via appendChild / insertBefore (both update parentNode, as the
// real DOM does, so a node is never left registered in two containers).
function makeNode(id) {
var classes = {};
var listeners = {};
var node = {
id: id,
parentNode: null,
children: [],
classList: { toggle: function (name, on) { if (on) classes[name] = true; else delete classes[name]; }, contains: function (name) { return !!classes[name]; } },
addEventListener: function (name, fn) { listeners[name] = fn; },
dispatch: function (name) { if (listeners[name]) listeners[name](); },
appendChild: function (child) {
if (child.parentNode) child.parentNode.children.splice(child.parentNode.children.indexOf(child), 1);
node.children.push(child);
child.parentNode = node;
return child;
},
insertBefore: function (child, before) {
if (child.parentNode) child.parentNode.children.splice(child.parentNode.children.indexOf(child), 1);
var at = before ? node.children.indexOf(before) : -1;
if (at === -1) node.children.push(child); else node.children.splice(at, 0, child);
child.parentNode = node;
return child;
},
};
return node;
}
function loadGenerationStop(overrides) {
var state = Object.assign({ connected: true, processing: true }, overrides && overrides.state);
var listener;
var sent = [];
var locked = !!(overrides && overrides.locked);
var els = {
'generation-stop': makeNode('generation-stop'),
'input-area': makeNode('input-area'),
'input-bottom-right': makeNode('input-bottom-right'),
'send-btn': makeNode('send-btn'),
};
// The unlocked default: Stop already sits beside Send, as index.html wires it.
els['input-bottom-right'].appendChild(els['generation-stop']);
els['input-bottom-right'].appendChild(els['send-btn']);
var ws = { readyState: 1, send: function (message) { sent.push(JSON.parse(message)); } };
var source = fs.readFileSync('lib/public/modules/generation-stop.js', 'utf8').replace(/^import .*;\n/gm, '').replace(/export function/g, 'function');
var context = {
store: {
get: function (key) { return state[key]; },
subscribe: function (fn) { listener = fn; },
},
getWs: function () { return ws; },
isDriverOperatedView: function () { return locked; },
document: { getElementById: function (id) { return els[id] || null; } },
};
vm.runInNewContext(source + '\ninitGenerationStop();', context);
return {
els: els, sent: sent,
button: els['generation-stop'],
click: function () { els['generation-stop'].dispatch('click'); },
setLocked: function (value) { locked = value; },
setState: function (next) { var prev = state; state = Object.assign({}, state, next); listener(state, prev); },
};
}
test('emergency Stop only sends on a connected processing session', function () {
var f = loadGenerationStop();
f.click(); assert.deepEqual(f.sent, [{ type: 'stop' }]);
assert.equal(f.button.classList.contains('hidden'), false);
assert.equal(f.button.disabled, false);
f.setState({ connected: false, processing: true });
f.click(); assert.equal(f.sent.length, 1); assert.equal(f.button.disabled, true);
f.setState({ connected: true, processing: false });
f.click(); assert.equal(f.sent.length, 1); assert.equal(f.button.classList.contains('hidden'), true);
});
test('Stop lives beside Send in the ordinary composer action row', function () {
var f = loadGenerationStop({ locked: false });
assert.equal(f.button.parentNode, f.els['input-bottom-right']);
assert.ok(f.els['input-bottom-right'].children.indexOf(f.button) < f.els['input-bottom-right'].children.indexOf(f.els['send-btn']),
'Stop sits before Send, never after it');
});
test('a locked configured Worker pane keeps Stop reachable outside the hidden composer', function () {
var f = loadGenerationStop({ locked: true });
assert.equal(f.button.parentNode, f.els['input-area'],
'Stop is relocated to a direct child of #input-area, never left inside #input-bottom-right');
assert.equal(f.els['input-bottom-right'].children.indexOf(f.button), -1,
'no duplicate Stop is left behind in the composer action row');
});
test('Stop follows the pane across a lock/unlock transition, never duplicated', function () {
var f = loadGenerationStop({ locked: false });
assert.equal(f.button.parentNode, f.els['input-bottom-right']);
f.setLocked(true);
f.setState({ splitGroups: [{ id: 'sg1' }] });
assert.equal(f.button.parentNode, f.els['input-area']);
assert.equal(f.els['input-bottom-right'].children.indexOf(f.button), -1);
f.setLocked(false);
f.setState({ activeSessionId: 9 });
assert.equal(f.button.parentNode, f.els['input-bottom-right']);
assert.equal(f.els['input-area'].children.indexOf(f.button), -1);
// At no point does either container hold the node twice.
assert.equal(f.els['input-bottom-right'].children.filter(function (c) { return c === f.button; }).length, 1);
});
test('adapter surfaces interrupt rejection before ending generation', async function () {
var handler;
var began;
var server = { started: true, addHandler: function (fn) { handler = fn; return {}; }, removeHandler: function () {}, send: function (method) {
if (method === 'thread/start') return Promise.resolve({ thread: { id: 'thread' } });
if (method === 'turn/start') {
handler({ method: 'turn/started', params: { threadId: 'thread', turn: { id: 'turn' } } });
began = true; return Promise.resolve({ turn: { id: 'turn' } });
}
if (method === 'turn/interrupt') return Promise.reject(new Error('test rejection'));
return Promise.resolve({});
} };
var handle = codex.contractTestKit.createQueryHandle(server, { cwd: process.cwd(), skipSkills: true });
handle.pushMessage('test');
while (!began) await new Promise(function (resolve) { setImmediate(resolve); });
await handle.abort();
var errors = [];
for await (var event of handle) if (event.yokeType === 'error') errors.push(event.text);
assert.equal(errors.length, 1);
assert.match(errors[0], /could not be confirmed/);
});
test('Stop between turns releases the query handler and discards queued input', async function () {
var handler;
var removed = false;
var server = { started: true, addHandler: function (fn) { handler = fn; return {}; }, removeHandler: function () { removed = true; }, send: function (method) {
if (method === 'thread/start') return Promise.resolve({ thread: { id: 'thread' } });
if (method === 'turn/start') {
setImmediate(function () { handler({ method: 'turn/completed', params: { threadId: 'thread' } }); });
return Promise.resolve({ turn: { id: 'turn' } });
}
assert.notEqual(method, 'turn/interrupt');
return Promise.resolve({});
} };
var handle = codex.contractTestKit.createQueryHandle(server, { cwd: process.cwd(), skipSkills: true });
handle.pushMessage('test');
for await (var event of handle) if (event.yokeType === 'result') break;
await new Promise(function (resolve) { setImmediate(resolve); });
await handle.abort();
await new Promise(function (resolve) { setImmediate(resolve); });
assert.equal(removed, true);
assert.equal(handle.pushMessage('should not run'), false);
});
test('Codex pre-aborted controller rejects input before starting a thread', async function () {
var controller = new AbortController(); controller.abort();
var calls = [];
var server = { started: true, send: function (method) { calls.push(method); return Promise.resolve({}); } };
var handle = codex.contractTestKit.createQueryHandle(server, { cwd: process.cwd(), skipSkills: true, abortController: controller });
assert.equal(handle.pushMessage('late'), false);
for await (var event of handle) { /* Drain cancellation. */ }
assert.deepEqual(calls, []);
});
test('resumed Codex rejects old output and completion before and after the new turn starts', async function () {
var handler, resume, start;
var server = { started: true, addHandler: function (fn) { handler = fn; return {}; }, removeHandler: function () {}, send: function (method) {
if (method === 'thread/resume') return new Promise(function (resolve) { resume = resolve; });
if (method === 'turn/start') return new Promise(function (resolve) { start = resolve; });
return Promise.resolve({});
} };
var handle = codex.contractTestKit.createQueryHandle(server, { cwd: process.cwd(), skipSkills: true, resumeSessionId: 'thread' });
function delta(turn, text) { handler({ method: 'item/agentMessage/delta', params: { threadId: 'thread', turnId: turn, itemId: turn, delta: text } }); }
function complete(turn) { handler({ method: 'turn/completed', params: { threadId: 'thread', turn: { id: turn, status: 'completed' } } }); }
handle.pushMessage('new prompt');
delta('old', 'stale replay during resume');
resume({ thread: { id: 'thread' } });
while (!start) await new Promise(function (resolve) { setImmediate(resolve); });
delta('old', 'stale before start response'); complete('old');
handler({ method: 'turn/started', params: { threadId: 'thread', turn: { id: 'new' } } });
delta('new', 'fresh before start response');
start({ turn: { id: 'new' } });
await new Promise(function (resolve) { setImmediate(resolve); });
delta('old', 'stale after start response'); complete('old');
delta('new', 'fresh after stale completion');
handle.endInput(); complete('new');
var texts = [];
for await (var event of handle) if (event.yokeType === 'text_delta') texts.push(event.text);
assert.deepEqual(texts, ['fresh before start response', 'fresh after stale completion']);
});
test('a locked Worker keeps Stop available even with a stale idle presentation', function () {
var f = loadGenerationStop({ locked: true, state: { processing: false } });
assert.equal(f.button.classList.contains('hidden'), false);
f.click(); assert.deepEqual(f.sent, [{ type: 'stop' }]);
});