test/loop-interview.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 path = require("node:path");
var attachLoopInterview = require("../lib/project-loop-interview").attachLoopInterview;
var loopGuidance = require("../lib/loop-guidance");
var root = path.join(__dirname, "..");
function source(file) { return fs.readFileSync(path.join(root, file), "utf8"); }
function interviewFixture(options) {
var session = Object.assign({ localId: 7, mode: "gui", history: [], isProcessing: false, _queryStarting: false, autonomousRun: null, loopInterviewBrief: null }, options || {});
var sent = [];
var queries = 0;
var sm = { saveSessionFile: function () {}, appendToSessionFile: function () {}, sendToSession: function (target, message) { sent.push(message); } };
var service = attachLoopInterview({ sm: sm, sendTo: function (ws, message) { sent.push(message); }, sendToSession: function (id, message) { sent.push(message); },
sdk: { startQuery: function () { queries += 1; return options && options.queryGate || Promise.resolve(); } }, autonomousRun: { startApprovedBrief: function () { return Promise.resolve(true); } },
isMate: false, getSessionForWs: function () { return session; }, canAccess: function () { return options && options.denied ? false : true; },
isDriverOperatedSession: function () { return !!(options && options.worker); }, onProcessingChanged: function () {}, ensureProjectAccessForSession: function () { return null; } });
return { session: session, service: service, sent: sent, queries: function () { return queries; } };
}
test("pending interview startup preserves a proposal published before query completion", async function () {
var resolveQuery;
var queryGate = new Promise(function (resolve) { resolveQuery = resolve; });
var f = interviewFixture({ queryGate: queryGate });
f.service.handleMessage({}, { type: "loop_interview_start", sessionId: 7, requestId: "ordered" });
assert.equal(f.sent.some(function (message) { return message.type === "loop_interview_start_result" && message.ok === true; }), true);
var tool = f.service.getToolDefs(f.session)[0];
await tool.handler({ objective: "Keep this brief", successCriteria: ["Brief remains visible"] });
assert.equal(f.session.loopInterviewBrief.objective, "Keep this brief");
resolveQuery();
await Promise.resolve(); await Promise.resolve();
assert.equal(f.session.loopInterviewBrief.objective, "Keep this brief");
assert.equal(f.sent.some(function (message) { return message.type === "loop_interview_brief" && message.brief && message.brief.objective === "Keep this brief"; }), true);
});
test("Loop interview lifecycle closes accepted interviews and rejects stale tools", async function () {
var f = interviewFixture();
f.service.handleMessage({}, { type: "loop_interview_start", sessionId: 7, requestId: "first" });
await Promise.resolve();
f.session.isProcessing = false;
var oldTool = f.service.getToolDefs(f.session)[0];
await oldTool.handler({ objective: "First", successCriteria: ["Evidence"] });
var first = f.session.loopInterviewBrief;
f.service.handleMessage({}, { type: "loop_interview_start_run", sessionId: 7, requestId: "run-1", proposalId: first.id, version: first.version });
await Promise.resolve(); await Promise.resolve();
assert.equal(f.service.getToolDefs(f.session).length, 0);
f.session.autonomousRun = { id: "waiting", state: "waiting-user" };
f.session.isProcessing = false;
f.service.handleMessage({}, { type: "loop_interview_start", sessionId: 7, requestId: "second" });
await Promise.resolve();
assert.equal(f.queries(), 1);
f.session.autonomousRun = null;
f.session.isProcessing = false;
f.service.handleMessage({}, { type: "loop_interview_start", sessionId: 7, requestId: "third" });
await Promise.resolve();
f.session.isProcessing = false;
var stale = JSON.parse((await oldTool.handler({ objective: "Stale", successCriteria: ["Wrong"] })).content[0].text);
assert.equal(stale.status, "rejected");
assert.equal(f.session.loopInterviewBrief, null);
});
test("Loop entry authorization, cancel revision, and replay deduplication are enforced", async function () {
var denied = interviewFixture({ denied: true });
denied.service.handleMessage({}, { type: "loop_interview_start", sessionId: 7, requestId: "denied" });
assert.equal(denied.queries(), 0);
var tui = interviewFixture({ mode: "tui" });
tui.service.handleMessage({}, { type: "loop_interview_start", sessionId: 7, requestId: "tui" });
assert.equal(tui.queries(), 0);
var worker = interviewFixture({ worker: true });
worker.service.handleMessage({}, { type: "loop_interview_start", sessionId: 7, requestId: "worker" });
assert.equal(worker.queries(), 0);
var f = interviewFixture();
f.service.handleMessage({}, { type: "loop_interview_start", sessionId: 7, requestId: "once" });
await Promise.resolve();
f.session.isProcessing = false;
f.service.handleMessage({}, { type: "loop_interview_start", sessionId: 7, requestId: "once" });
assert.equal(f.queries(), 1);
var tool = f.service.getToolDefs(f.session)[0];
await tool.handler({ objective: "Task", successCriteria: ["Check"] });
var brief = f.session.loopInterviewBrief;
f.service.handleMessage({}, { type: "loop_interview_edit", sessionId: 7, proposalId: brief.id, version: brief.version, brief: { objective: "Edited", successCriteria: ["Check"] } });
assert.equal(f.session.loopInterviewBrief.version, 2);
f.service.handleMessage({}, { type: "loop_interview_cancel", sessionId: 7, proposalId: brief.id, version: brief.version });
assert.notEqual(f.session.loopInterviewBrief, null);
f.service.handleMessage({}, { type: "loop_interview_cancel", sessionId: 7, proposalId: brief.id, version: 2 });
assert.equal(f.session.loopInterviewBrief, null);
});
test("composer exposes the bounded Loop interview icon and keeps run details", function () {
var html = source("lib/public/index.html");
assert.match(html, /id="loop-interview-btn"[^>]*aria-label="Start Loop interview"/);
assert.match(html, /id="autonomous-run-status"/);
assert.doesNotMatch(html, /id="execution-mode-wrap"/);
assert.doesNotMatch(html, /id="execution-until-option"/);
});
test("Loop interview start is a separate normal-query action with no autonomous controls", function () {
var server = source("lib/project-loop-interview.js");
var guidance = source("lib/loop-guidance.js");
var client = source("lib/public/modules/loop-interview.js");
assert.match(server, /msg\.type === "loop_interview_start"/);
assert.match(server, /hasInterview\(session, requestId\)/);
assert.match(server, /driverEligibility\.isEligibleDriverSession/);
assert.match(server, /session\.isProcessing \|\| session\._queryStarting/);
assert.match(server, /ctx\.sdk\.startQuery\(session, prompt/);
assert.match(guidance, /Do not execute, arm permissions/);
assert.match(client, /loopInterviewStarting/);
assert.doesNotMatch(client, /getElementById\("input"\)/); // The click path never reads or mutates composer text.
});
test("Loop interview guidance requires review before the existing Until complete handoff", function () {
var server = source("lib/project-loop-interview.js");
var guidance = source("lib/loop-guidance.js");
assert.match(guidance, /concrete evidence-based completion checks/);
assert.match(guidance, /Review the brief with the user/);
});
test("Loop guidance asks concise structured questions and derives interview lifecycle from history", function () {
var prompt = loopGuidance.interviewPrompt();
assert.match(prompt, /AskUserQuestion/);
assert.match(prompt, /request_user_input/);
assert.match(prompt, /Clay ask_user_questions/);
assert.match(prompt, /1-3 concise questions/);
assert.match(prompt, /2-6 choices/);
assert.match(prompt, /freeform question/);
var session = { history: [{ source: "loop_interview", interviewId: "i-1" }] };
assert.equal(require("../lib/project-loop-interview").activeInterviewId(session), "i-1");
session.history.push({ source: "loop_interview_cancel", interviewId: "i-1" });
assert.equal(require("../lib/project-loop-interview").activeInterviewId(session), null);
session.history.push({ source: "loop_interview", interviewId: "i-2" });
session.loopInterviewBrief = { id: "brief" };
assert.equal(require("../lib/project-loop-interview").activeInterviewId(session), "i-2");
});
test("propose_loop is interview-scoped and Start delegates to the existing arm handler", function () {
var moduleSource = source("lib/project-loop-interview.js");
var projectSource = source("lib/project.js");
assert.match(moduleSource, /name: "propose_loop"/);
assert.match(moduleSource, /session\.loopInterviewBrief = next/);
assert.match(moduleSource, /version: 1/);
assert.match(moduleSource, /autonomousRun\.startApprovedBrief\(ws, session, proposal/);
assert.match(moduleSource, /msg\.proposalId, 200\) !== proposal\.id/);
assert.match(moduleSource, /Number\(msg\.version\) !== Number\(proposal\.version\)/);
assert.match(moduleSource, /proposal\.interviewId !== currentInterview\(session\)/);
assert.match(moduleSource, /sm\.sendToSession\(session/);
assert.doesNotMatch(moduleSource, /fullAccess\.setEnabled|autonomousRun\.consume/);
assert.match(projectSource, /_loopInterview\.getToolDefs\(session\)/);
assert.match(projectSource, /_loopInterview\.handleMessage\(ws, msg\)/);
assert.match(projectSource, /var interviewTools = _loopInterview \? _loopInterview\.getToolDefs\(boundSession\)/);
});
test("Loop brief and handoff state are persisted and replayed with session switches", function () {
var sessions = source("lib/sessions.js");
assert.match(sessions, /metaObj\.loopInterviewBrief = session\.loopInterviewBrief/);
assert.match(sessions, /session\.loopInterviewBrief = m\.loopInterviewBrief/);
assert.match(sessions, /loopInterviewBrief: session\.loopInterviewBrief \|\| null/);
assert.match(sessions, /metaObj\.loopInterviewHandoff = session\.loopInterviewHandoff/);
});