test/home-debate-ask-user-canonical.test.js
Rama main · réplica de lectura del repositorio original.
var test = require("node:test");
var assert = require("node:assert/strict");
var attachSessions = require("../lib/project-sessions").attachSessions;
var attachAskUser = require("../lib/project-ask-user").attachAskUser;
var attachDebateProposal = require("../lib/project-debate-proposal").attachDebateProposal;
var historyToHomeChat = require("../lib/server-home-chat-events").historyToHomeChat;
var transformEvent = require("../lib/server-home-chat-events").transformEvent;
var createSDKBridge = require("../lib/sdk-bridge").createSDKBridge;
var yokeUserInput = require("../lib/yoke/user-input");
function fixture(users) {
var recorded = [];
var manager = {
currentPermissionMode: "default",
permissionRequestIndex: {},
sendAndRecord: function (session, event) { session.history.push(event); recorded.push(event); },
appendToSessionFile: function () {},
};
var sessions = attachSessions({
cwd: "/tmp/project", slug: "mate-builtin:clay", isMate: true, osUsers: null,
currentVersion: "test", sm: manager, sdk: { startQuery: function () {}, pushMessage: function () {} }, tm: {}, clients: new Set(),
send: function () {}, sendTo: function () {}, sendToAdmins: function () {}, sendToSession: function () {}, sendToSessionOthers: function () {},
opts: {}, usersModule: users || {}, userPresence: {}, matesModule: {}, getSessionForWs: function () { return null; },
getLinuxUserForSession: function () {}, ensureProjectAccessForSession: function () {}, getOsUserInfoForWs: function () {},
hydrateImageRefs: function () {}, onProcessingChanged: function () {}, broadcastPresence: function () {}, adapter: {},
});
return { sessions: sessions, recorded: recorded };
}
test("canonical AskUserQuestion response accepts the exact pending tool once", function () {
var f = fixture();
var result = null;
var session = { localId: 1, history: [], pendingAskUser: { "ask-1": { input: { questions: [{ question: "Outcome?" }] }, resolve: function (value) { result = value; } } } };
assert.equal(f.sessions.respondToAskUser(session, { toolId: "ask-1", answers: { 0: "Decision" } }), true);
assert.equal(f.sessions.respondToAskUser(session, { toolId: "ask-1", answers: { 0: "Again" } }), false);
assert.deepEqual(f.recorded, [{ type: "ask_user_answered", toolId: "ask-1", answers: { 0: "Decision" } }]);
assert.equal(result.behavior, "deny");
assert.match(result.message, /Decision/);
});
test("project persistence resumes one canonical Yoke request and rejects stale answers", async function () {
var f = fixture();
var askUser = attachAskUser({ record: function (session, event) { session.history.push(event); } });
var session = { localId: 11, history: [], pendingAskUser: {} };
var pending = yokeUserInput.dispatchUserInput(askUser.createHandler(session), {
questions: [{ id: "topic", header: "Topic", question: "What topic?", options: [] }],
}, { requestId: "yoke-ask" });
assert.equal(f.sessions.respondToAskUser(session, { toolId: "yoke-ask", answers: { topic: "Housing" } }), true);
assert.equal(f.sessions.respondToAskUser(session, { toolId: "yoke-ask", answers: { topic: "Again" } }), false);
assert.deepEqual(await pending, { status: "submitted", answers: { topic: ["Housing"] } });
assert.equal(session.pendingAskUser["yoke-ask"], undefined);
assert.equal(session.history[0].name, "AskUserQuestion");
assert.equal(f.recorded[0].type, "ask_user_answered");
});
test("MCP AskUserQuestion answer continues canonically without a duplicate Home user bubble", function () {
var f = fixture();
var input = { questions: [{ question: "Outcome?", options: [{ label: "Decision" }, { label: "Trade-offs" }] }] };
var session = { localId: 2, history: [{ type: "tool_executing", id: "ask-mcp", name: "AskUserQuestion", input: input }], pendingAskUser: { "ask-mcp": { mode: "mcp", input: input } }, isProcessing: true };
assert.equal(f.sessions.respondToAskUser(session, { toolId: "ask-mcp", answers: { 0: "Decision" } }), true);
var injected = session.history.filter(function (event) { return event.type === "user_message"; });
assert.equal(injected.length, 1);
assert.equal(injected[0].askUserAnswer, true);
var home = historyToHomeChat(session.history, true);
assert.equal(home.length, 1);
assert.equal(home[0].role, "question");
assert.equal(home[0].status, "answered");
});
test("Mate AskUserQuestion MCP records against its query-bound session, never a global active session", async function () {
var records = [];
var askUser = attachAskUser({ record: function (session, event) { records.push({ session: session, event: event }); } });
var adapter = { createToolServer: function (definition) { return definition; } };
var exact = { localId: 7, history: [], pendingAskUser: {} };
var unrelated = { localId: 8, history: [], pendingAskUser: {} };
var server = askUser.createMcpServer(adapter, exact);
var resultPromise = server.tools[0].handler({ questions: [{ header: "Outcome", question: "What result?", options: [{ label: "Decision", description: "Choose" }, { label: "Trade-offs", description: "Compare" }] }] });
await Promise.resolve();
assert.equal(records.length, 1);
assert.equal(records[0].session, exact);
assert.equal(records[0].event.name, "AskUserQuestion");
assert.equal(Object.keys(exact.pendingAskUser).length, 1);
assert.equal(Object.keys(unrelated.pendingAskUser).length, 0);
exact.pendingAskUser[Object.keys(exact.pendingAskUser)[0]].respond({ 0: "Decision" });
var result = await resultPromise;
assert.equal(result.isError, undefined);
var unbound = askUser.createMcpServer(adapter, null);
var rejected = await unbound.tools[0].handler({ questions: [{ question: "No session", options: [{ label: "A" }, { label: "B" }] }] });
assert.equal(rejected.isError, true);
});
test("planning SDK query binds AskUser and proposal tools to the same exact Home session", async function () {
var captured = [];
var projected = [];
var debateStarts = [];
var manager = {
defaultVendor: "codex",
currentPermissionMode: "default",
capabilitiesByVendor: { codex: {} },
modelsByVendor: { codex: [] },
permissionRequestIndex: {},
saveSessionFile: function () {},
broadcastSessionList: function () {},
sendToSession: function () {},
sendAndRecord: function (session, event) {
session.history.push(event);
var message = transformEvent(event, "builtin:clay", session, "plan-sdk", "local:31");
if (message) projected.push(message);
},
};
var askUser = attachAskUser({ record: manager.sendAndRecord });
var proposal = attachDebateProposal({
cwd: "/mates/builtin:clay",
isMate: true,
isHostAgent: true,
sendTo: function () {},
buildMateCtx: function (userId) { return { userId: userId }; },
getMate: function (ctx, mateId) { return mateId === "builtin:clay" || mateId === "panel-1" ? { id: mateId } : null; },
getProjectOwnerId: function () { return "owner-a"; },
recordSessionEvent: manager.sendAndRecord,
startDebate: function (session, brief, moderatorId) {
debateStarts.push({ session: session, brief: brief, moderatorId: moderatorId });
session.homeDebatePhase = "live";
return { ok: true };
},
});
var handle = {
pushMessage: function () { return true; },
close: function () {},
[Symbol.asyncIterator]: function () { return { next: function () { return new Promise(function () {}); } }; },
};
var adapter = {
vendor: "codex",
userInputCapability: { mode: "native", native: true },
createQuery: function (options) { captured.push(options); return Promise.resolve(handle); },
};
var bridge = createSDKBridge({
cwd: process.cwd(),
sessionManager: manager,
adapter: adapter,
adapters: { codex: adapter },
send: function () {},
getSessionToolDefs: function (session) { return proposal.getToolDefs(session); },
onUserInputRequest: function (boundSession, request, respond) { return askUser.createHandler(boundSession)(request, respond); },
});
var session = { localId: 31, ownerId: "owner-a", vendor: "codex", model: "gpt-test", debateSetupMode: true, homeDebatePlanning: true, history: [], pendingAskUser: {}, isProcessing: false };
await bridge.startQuery(session, "hidden debate setup");
assert.equal(captured.length, 1);
assert.equal(captured[0].userInputMode, "fallback");
assert.deepEqual(captured[0].dynamicTools.map(function (tool) { return tool.name; }), ["propose_debate", "ask_user_questions"]);
var input = { questions: [{ header: "Topic", question: "What would you like to debate?", options: [{ label: "Product direction", description: "Choose a roadmap" }, { label: "Architecture", description: "Discuss systems" }, { label: "Growth", description: "Explore acquisition" }] }] };
var firstCall = captured[0].callDynamicTool("ask_user_questions", input);
await Promise.resolve();
assert.equal(projected.length, 1);
assert.equal(projected[0].type, "home_debate_question");
assert.equal(projected[0].requestId, "plan-sdk");
assert.equal(projected[0].sessionId, "local:31");
assert.equal(projected[0].questions[0].question, "What would you like to debate?");
assert.deepEqual(projected[0].questions[0].options, []);
assert.equal(session.history[0].input.questions[0].options.length, 0);
assert.doesNotMatch(JSON.stringify(session.history[0].input), /Product direction|Architecture|Growth/);
var firstQuestionId = session.history[0].id;
session.pendingAskUser[firstQuestionId].respond({ 0: "Housing affordability" });
await firstCall;
manager.sendAndRecord(session, { type: "ask_user_answered", toolId: firstQuestionId, answers: { 0: "Housing affordability" } });
var secondCall = captured[0].callDynamicTool("ask_user_questions", {
questions: [{ header: "Format", question: "How should the panel approach it?", options: [{ label: "Trade-offs", description: "Compare policies" }, { label: "Recommendation", description: "Choose a path" }] }],
});
await Promise.resolve();
var secondQuestion = session.history.filter(function (event) { return event.type === "tool_executing"; })[1];
session.pendingAskUser[secondQuestion.id].respond({ 0: "Trade-offs" });
await secondCall;
manager.sendAndRecord(session, { type: "ask_user_answered", toolId: secondQuestion.id, answers: { 0: "Trade-offs" } });
session.cliSessionId = "01a05622-durable";
var proposalPermission = await captured[0].canUseTool("propose_debate", {}, { toolUseID: "proposal-call" });
assert.equal(proposalPermission.behavior, "allow");
var proposalResult = captured[0].callDynamicTool("propose_debate", {
topic: "Housing affordability",
panelists: JSON.stringify([{ mateId: "panel-1", role: "Skeptic", brief: "Challenge assumptions" }]),
});
var proposalEvent = session.history.filter(function (event) { return event.type === "debate_proposal"; }).pop();
assert.ok(proposalEvent);
assert.equal(projected.filter(function (message) { return message.type === "home_debate_proposal"; }).length, 1);
var ws = { _clayUser: { id: "owner-a" } };
assert.equal(proposal.handleHomeMessage(ws, {
type: "debate_proposal_response",
proposalId: proposalEvent.proposal.proposalId,
action: "start",
}, session), true);
var result = await proposalResult;
assert.equal(result.isError, undefined);
assert.equal(debateStarts.length, 1);
assert.equal(debateStarts[0].session, session);
assert.equal(debateStarts[0].moderatorId, "builtin:clay");
assert.equal(session.homeDebatePhase, "live");
});
test("native structured input callback remains available for an ordinary Claude query", async function () {
var captured = [];
var manager = {
defaultVendor: "claude", currentPermissionMode: "default", permissionRequestIndex: {},
capabilitiesByVendor: { claude: {} }, modelsByVendor: { claude: [] },
saveSessionFile: function () {}, broadcastSessionList: function () {}, sendToSession: function () {}, sendAndRecord: function () {},
};
var handle = { pushMessage: function () { return true; }, close: function () {}, [Symbol.asyncIterator]: function () { return { next: function () { return new Promise(function () {}); } }; } };
var askUser = attachAskUser({ record: function (boundSession, event) { boundSession.history.push(event); } });
var adapter = { vendor: "claude", userInputCapability: { mode: "native", native: true }, createQuery: function (options) { captured.push(options); return Promise.resolve(handle); } };
var defaultAdapter = { vendor: "legacy", createQuery: function () { throw new Error("wrong adapter"); } };
var bridge = createSDKBridge({
cwd: process.cwd(), sessionManager: manager, adapter: defaultAdapter, adapters: { claude: adapter }, send: function () {},
onUserInputRequest: function (boundSession, request, respond) { return askUser.createHandler(boundSession)(request, respond); },
});
var session = { localId: 32, vendor: "claude", model: "sonnet", history: [], pendingAskUser: {}, isProcessing: false };
await bridge.startQuery(session, "ordinary question");
assert.equal(captured[0].userInputMode, "native");
var answer = null;
var respond = function (value) { answer = value; };
respond.cancel = function () {};
captured[0].onUserInputRequest({ id: "native-ask", questions: [{ id: "topic", header: "Topic", question: "What topic?", options: [] }] }, respond);
assert.ok(session.pendingAskUser["native-ask"]);
session.pendingAskUser["native-ask"].respond({ topic: "Housing" });
assert.deepEqual(answer, { topic: "Housing" });
var typedContent = null;
var elicitationRespond = function () {};
elicitationRespond.cancel = function () {};
elicitationRespond.onSettle = function () {};
elicitationRespond.submitContent = function (content) { typedContent = content; };
var elicitation = captured[0].onUserInputRequest({
id: "elicit-form", presentation: "elicitation", questions: [{ id: "count", question: "Count?", options: [] }],
diagnostics: { elicitation: { serverName: "Example", message: "Configure", mode: "form", requestedSchema: { type: "object", properties: { count: { type: "number" } } } } },
}, elicitationRespond);
var elicitationId = Object.keys(session.pendingElicitations)[0];
session.pendingElicitations[elicitationId].resolve({ action: "accept", content: { count: 2 } });
await elicitation;
assert.deepEqual(typedContent, { count: 2 });
});
test("primary work confirmation accepts only the session owner", async function () {
var f = fixture({ isMultiUser: function () { return true; } });
var askUser = attachAskUser({ record: function (session, event) { session.history.push(event); } });
var session = { localId: 11, ownerId: 'owner', history: [], pendingAskUser: {} };
var task = yokeUserInput.dispatchUserInput(askUser.createHandler(session), {
questions: [{ id: 'primary', question: 'Set primary?', options: [{ label: 'Set primary' }, { label: 'Keep current' }] }],
}, { source: 'work-primary', requestId: 'primary-ask' });
var msg = { toolId: 'primary-ask', answers: { primary: 'Set primary' } };
assert.equal(f.sessions.respondToAskUser(session, msg), false);
assert.equal(f.sessions.respondToAskUser(session, msg, { _clayUser: { id: 'viewer' } }), false);
assert.ok(session.pendingAskUser['primary-ask']);
assert.equal(f.sessions.respondToAskUser(session, msg, { _clayUser: { id: 'owner' } }), true);
assert.equal((await task).status, 'submitted');
});
test("fallback questions and resolutions retain their exact Home planning surface", function () {
[
{ mode: {}, prefix: "home_mate_question" },
{ mode: { debateSetupMode: true }, prefix: "home_debate_question" },
{ mode: { mateCreationMode: true }, prefix: "home_mate_creation_question" },
].forEach(function (item) {
var event = { type: "tool_executing", name: "AskUserQuestion", id: "surface-question", source: "mcp_fallback", input: { questions: [{ question: "Choose the next step", options: [] }] } };
var session = Object.assign({ localId: 41, history: [event] }, item.mode);
var projected = transformEvent(event, "builtin:clay", session, "fixture-request");
assert.equal(projected.type, item.prefix);
assert.equal(projected.toolId, event.id);
var resolved = transformEvent({ type: "ask_user_answered", toolId: event.id, answers: { 0: "Continue" } }, "builtin:clay", session, "fixture-request");
assert.equal(resolved.type, item.prefix + "_resolved");
assert.equal(resolved.toolId, event.id);
assert.deepEqual(resolved.answers, { 0: "Continue" });
});
});