test/acp-elicitation-bridge.test.js
Rama main · réplica de lectura del repositorio original.
var test = require("node:test");
var assert = require("node:assert/strict");
var createSDKBridge = require("../lib/sdk-bridge").createSDKBridge;
var createAcpAdapter = require("../lib/yoke/adapters/acp").createAcpAdapter;
var acpUserInput = require("../lib/yoke/acp-user-input");
var elicitationResponse = require("../lib/project-elicitation-response");
var userInput = require("../lib/yoke/user-input");
var VENDORS = ["opencode", "kimi", "grok", "copilot", "qwen", "junie"];
function pendingHandle() {
return {
pushMessage: function () { return true; },
close: function () {},
[Symbol.asyncIterator]: function () { return { next: function () { return new Promise(function () {}); } }; },
};
}
function tick() { return new Promise(function (resolve) { setImmediate(resolve); }); }
function fakeAcp() {
var calls = [];
return {
calls: calls,
respond: function (id, result) { calls.push({ id: id, result: result }); },
respondError: function (id, code, message) { calls.push({ id: id, error: { code: code, message: message } }); },
};
}
async function startVendorQuery(vendor, localId) {
var recorded = [];
var adapter = createAcpAdapter(vendor, {});
var queryOptions = null;
adapter.createQuery = function (options) { queryOptions = options; return Promise.resolve(pendingHandle()); };
var session = { localId: localId, vendor: vendor, pendingAskUser: {}, pendingPermissions: {}, pendingElicitations: {} };
var adapters = {};
adapters[vendor] = adapter;
var ready = {};
ready[vendor] = {};
var models = {};
models[vendor] = [];
var bridge = createSDKBridge({
cwd: process.cwd(),
sessionManager: {
sessions: new Map([[localId, session]]), availableModels: [], capabilitiesByVendor: ready, modelsByVendor: models, saveSessionFile: function () {},
broadcastSessionList: function () {}, sendAndRecord: function (target, message) { recorded.push(message); }, sendToSession: function () {},
},
adapter: adapter,
adapters: adapters,
getSessionToolDefs: function () { return []; },
onUserInputRequest: function (boundSession, request, respond) { respond({}); },
send: function () {},
});
await bridge.startQuery(session, "Plan the change", null, null);
return { bridge: bridge, session: session, queryOptions: queryOptions, recorded: recorded, adapter: adapter };
}
function browserReply(run, requestId, action, content) {
var sent = [];
elicitationResponse.handleElicitationResponse(run.session, {}, { requestId: requestId, action: action, content: content }, {
sendTo: function (ws, message) { sent.push(message); },
sendAndRecord: function (target, message) { run.recorded.push(message); },
});
return sent;
}
function lastRequest(run) {
var requests = run.recorded.filter(function (message) { return message.type === "elicitation_request"; });
return requests[requests.length - 1];
}
test("shared ACP vendors keep the ask_user_questions fallback in ordinary sessions", async function () {
for (var i = 0; i < VENDORS.length; i++) {
var vendor = VENDORS[i];
var adapter = createAcpAdapter(vendor, {});
assert.equal(userInput.selectMode(adapter, "auto"), "fallback", vendor);
assert.equal(adapter.userInputCapability.nativeElicitation, true, vendor);
var run = await startVendorQuery(vendor, 900 + i);
assert.equal(run.queryOptions.userInputMode, "fallback", vendor);
assert.equal(typeof run.queryOptions.onUserInputRequest, "function", vendor + " still receives native elicitation");
var tools = run.bridge.getQueryToolDefs(run.session, run.session._sdkQueryGeneration).map(function (tool) { return tool.name; });
assert.ok(tools.indexOf("ask_user_questions") !== -1, vendor + " exposes ask_user_questions through the exact-query bridge");
var mcpServers = run.queryOptions.adapterOptions.ACP.mcpServers.map(function (server) { return server.name; });
assert.ok(mcpServers.indexOf("clay-tools") !== -1, vendor + " receives the clay-tools bridge");
}
});
test("native ACP form elicitation round-trips through the SDK bridge without question limits", async function () {
var run = await startVendorQuery("opencode", 950);
var acp = fakeAcp();
var properties = { a: { type: "string" }, b: { type: "boolean" }, c: { type: "integer", minimum: 1 }, d: { type: "array", items: { anyOf: [{ const: "x", title: "X" }, { const: "y", title: "Y" }] } } };
properties["field_" + "n".repeat(140)] = { type: "string", description: "Long description ".repeat(150) };
var schema = { type: "object", properties: properties, required: ["a", "c"] };
acpUserInput.handleElicitation(acp, { id: 61, method: "elicitation/create", params: { sessionId: "s", mode: "form", message: "Configure", requestedSchema: schema } },
run.queryOptions.onUserInputRequest, run.queryOptions.onElicitation, new AbortController().signal, "opencode", "OpenCode");
await tick();
var request = lastRequest(run);
assert.equal(request.mode, "form");
assert.deepEqual(request.requestedSchema, schema);
var invalid = browserReply(run, request.requestId, "accept", { a: "", c: 0, d: ["z"] });
assert.equal(invalid.length, 1);
assert.equal(invalid[0].type, "elicitation_error");
assert.ok(run.session.pendingElicitations[request.requestId], "invalid form stays pending and editable");
assert.deepEqual(acp.calls, []);
assert.deepEqual(browserReply(run, request.requestId, "accept", { a: "", b: false, c: 2, d: ["y"] }), []);
await tick();
assert.deepEqual(acp.calls, [{ id: 61, result: { action: "accept", content: { a: "", b: false, c: 2, d: ["y"] } } }]);
assert.equal(run.recorded.filter(function (m) { return m.type === "elicitation_resolved"; }).pop().action, "accept");
});
test("decline, cancel, abort and unsupported modes keep distinct ACP outcomes exactly once", async function () {
var run = await startVendorQuery("qwen", 951);
var schema = { type: "object", properties: { a: { type: "string" } } };
function ask(acp, id, signal) {
acpUserInput.handleElicitation(acp, { id: id, method: "elicitation/create", params: { sessionId: "s", mode: "form", message: "Q", requestedSchema: schema } },
run.queryOptions.onUserInputRequest, run.queryOptions.onElicitation, signal || new AbortController().signal, "qwen", "Qwen Code");
}
var declined = fakeAcp();
ask(declined, 71);
await tick();
browserReply(run, lastRequest(run).requestId, "decline");
await tick();
assert.deepEqual(declined.calls, [{ id: 71, result: { action: "decline" } }]);
var legacy = fakeAcp();
ask(legacy, 72);
await tick();
browserReply(run, lastRequest(run).requestId, "reject");
await tick();
assert.deepEqual(legacy.calls, [{ id: 72, result: { action: "decline" } }], "legacy browser reject is an explicit decline");
var cancelled = fakeAcp();
ask(cancelled, 73);
await tick();
browserReply(run, lastRequest(run).requestId, "cancel");
await tick();
assert.deepEqual(cancelled.calls, [{ id: 73, result: { action: "cancel" } }]);
var aborted = fakeAcp();
var controller = new AbortController();
ask(aborted, 74, controller.signal);
await tick();
var abortedId = lastRequest(run).requestId;
controller.abort();
controller.abort();
await tick();
assert.deepEqual(aborted.calls, [{ id: 74, result: { action: "cancel" } }]);
assert.equal(run.recorded.filter(function (m) { return m.type === "elicitation_resolved" && m.requestId === abortedId; }).map(function (m) { return m.action; }).join(), "cancel");
var late = browserReply(run, abortedId, "accept", { a: "late" });
await tick();
assert.equal(late[0].expired, true, "late browser answer is reported as expired");
assert.equal(aborted.calls.length, 1, "late answer never reaches the agent");
var unsupported = fakeAcp();
acpUserInput.handleElicitation(unsupported, { id: 75, method: "elicitation/create", params: { sessionId: "s", mode: "url", url: "https://example.com", elicitationId: "e" } },
run.queryOptions.onUserInputRequest, null, new AbortController().signal, "qwen", "Qwen Code");
assert.equal(unsupported.calls.length, 1);
assert.equal(unsupported.calls[0].error.code, -32602);
});