Ctrl K
ComunidadGuías…

test/kiro-acp-routing.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
var test = require("node:test");
var assert = require("node:assert");

var { KiroAcpServer } = require("../lib/yoke/kiro-acp-server");

// One ACP child process is shared by every session in a project, so
// server-initiated messages have to be routed by params.sessionId. The failure
// mode these tests guard against is a dropped server -> client *request*:
// requests carry an id and kiro-cli blocks on the response until the
// session/prompt timeout (30 minutes), with no error surfaced to the user.

// Build a server instance without spawning anything, and capture what it would
// write to the child's stdin.
function makeServer() {
  var srv = new KiroAcpServer("/nonexistent/kiro-cli", {});
  srv.started = true;
  srv.proc = { stdin: { write: function () { return true; } } };
  srv.written = [];
  srv._write = function (msg) { srv.written.push(msg); };
  return srv;
}

function permissionRequest(id, sessionId) {
  return {
    jsonrpc: "2.0",
    id: id,
    method: "session/request_permission",
    params: { sessionId: sessionId, toolCall: { toolCallId: "tc1" }, options: [] },
  };
}

// ============================================================
// 1. Routing by sessionId
// ============================================================

test("routes a session event to the handler bound to that session", function () {
  var srv = makeServer();
  var seenA = [];
  var seenB = [];
  var a = srv.addHandler(function (m) { seenA.push(m); });
  var b = srv.addHandler(function (m) { seenB.push(m); });
  a.sessionId = "sess-a";
  b.sessionId = "sess-b";

  srv._handleMessage({ method: "session/update", params: { sessionId: "sess-b", update: {} } });

  assert.strictEqual(seenA.length, 0, "handler A should not see session B's event");
  assert.strictEqual(seenB.length, 1, "handler B should receive its own event");
});

test("a second query does not steal the first query's permission requests", function () {
  // This is the original bug: a single eventHandler slot meant the newest query
  // overwrote the previous one, and the older session's permission request was
  // silently dropped.
  var srv = makeServer();
  var answeredA = [];
  var a = srv.addHandler(function (m) { answeredA.push(m); });
  a.sessionId = "sess-a";

  // Second session starts while the first is still running.
  var b = srv.addHandler(function () {});
  b.sessionId = "sess-b";

  srv._handleMessage(permissionRequest(7, "sess-a"));

  assert.strictEqual(answeredA.length, 1, "session A's handler must still receive its request");
  assert.strictEqual(answeredA[0].id, 7);
});

// ============================================================
// 2. Unroutable requests must be answered, never dropped
// ============================================================

test("responds with an error when no handler matches a request", function () {
  var srv = makeServer();
  var a = srv.addHandler(function () { assert.fail("should not be called"); });
  a.sessionId = "sess-a";

  srv._handleMessage(permissionRequest(42, "sess-gone"));

  assert.strictEqual(srv.written.length, 1, "an unroutable request must still get a reply");
  assert.strictEqual(srv.written[0].id, 42);
  assert.ok(srv.written[0].error, "reply should be a JSON-RPC error");
});

test("responds with an error when there are no handlers at all", function () {
  var srv = makeServer();

  srv._handleMessage(permissionRequest(1, "sess-a"));

  assert.strictEqual(srv.written.length, 1);
  assert.ok(srv.written[0].error);
});

test("a notification with no matching handler is dropped without a reply", function () {
  var srv = makeServer();

  srv._handleMessage({ method: "session/update", params: { sessionId: "sess-gone", update: {} } });

  assert.strictEqual(srv.written.length, 0, "notifications carry no id and must not be answered");
});

test("a throwing handler still produces an error response", function () {
  var srv = makeServer();
  var a = srv.addHandler(function () { throw new Error("boom"); });
  a.sessionId = "sess-a";

  srv._handleMessage(permissionRequest(9, "sess-a"));

  assert.strictEqual(srv.written.length, 1, "a handler crash must not leave kiro-cli hanging");
  assert.strictEqual(srv.written[0].id, 9);
  assert.ok(srv.written[0].error);
});

test("a request is delivered to exactly one handler", function () {
  // Two handlers on the same session must not both answer: responding twice to
  // one JSON-RPC id is a protocol violation.
  var srv = makeServer();
  var calls = 0;
  var a = srv.addHandler(function () { calls++; });
  var b = srv.addHandler(function () { calls++; });
  a.sessionId = "sess-a";
  b.sessionId = "sess-a";

  srv._handleMessage(permissionRequest(3, "sess-a"));

  assert.strictEqual(calls, 1, "only the first matching handler should answer");
});

// ============================================================
// 3. Handler lifecycle
// ============================================================

test("removeHandler stops routing to a finished query", function () {
  var srv = makeServer();
  var seen = 0;
  var a = srv.addHandler(function () { seen++; });
  a.sessionId = "sess-a";

  srv.removeHandler(a);
  srv._handleMessage(permissionRequest(5, "sess-a"));

  assert.strictEqual(seen, 0, "a removed handler must not receive events");
  assert.strictEqual(srv.written.length, 1, "and the request must be rejected rather than dropped");
  assert.ok(srv.written[0].error);
});

test("removeHandler is idempotent and does not disturb other handlers", function () {
  var srv = makeServer();
  var a = srv.addHandler(function () {});
  var seenB = 0;
  var b = srv.addHandler(function () { seenB++; });
  a.sessionId = "sess-a";
  b.sessionId = "sess-b";

  srv.removeHandler(a);
  srv.removeHandler(a);

  assert.strictEqual(srv.handlers.length, 1);
  srv._handleMessage({ method: "session/update", params: { sessionId: "sess-b", update: {} } });
  assert.strictEqual(seenB, 1);
});

test("a handler with no sessionId yet receives no session-tagged events", function () {
  // Between addHandler() and session/new resolving, the entry has no sessionId.
  var srv = makeServer();
  var seen = 0;
  srv.addHandler(function () { seen++; });

  srv._handleMessage({ method: "session/update", params: { sessionId: "sess-a", update: {} } });

  assert.strictEqual(seen, 0);
});

// ============================================================
// 4. Process-wide events fan out to every session
// ============================================================

test("an event with no sessionId reaches every active handler", function () {
  var srv = makeServer();
  var seenA = 0;
  var seenB = 0;
  var a = srv.addHandler(function () { seenA++; });
  var b = srv.addHandler(function () { seenB++; });
  a.sessionId = "sess-a";
  b.sessionId = "sess-b";

  srv._handleMessage({ method: "_kiro/error", params: { error: { message: "unauthorized" } } });

  assert.strictEqual(seenA, 1, "auth failure is process-wide, session A should see it");
  assert.strictEqual(seenB, 1, "auth failure is process-wide, session B should see it");
});

test("a stderr auth signal is delivered to active sessions", function () {
  var srv = makeServer();
  var seen = [];
  var a = srv.addHandler(function (m) { seen.push(m); });
  a.sessionId = "sess-a";

  srv._maybeSignalAuthError("Error: not logged in, run kiro-cli login");

  assert.strictEqual(seen.length, 1);
  assert.strictEqual(seen[0].method, "_kiro/error");
  assert.strictEqual(seen[0].params.error.kiroErrorInfo, "unauthorized");
});

test("repeated auth signals are deduped", function () {
  var srv = makeServer();
  var seen = 0;
  var a = srv.addHandler(function () { seen++; });
  a.sessionId = "sess-a";

  srv._maybeSignalAuthError("not logged in");
  srv._maybeSignalAuthError("not logged in");
  srv._maybeSignalAuthError("not logged in");

  assert.strictEqual(seen, 1, "a burst of stderr lines should collapse to one event");
});

// ============================================================
// 5. Responses still resolve their pending request
// ============================================================

test("a response is matched to its pending request and not treated as an event", function () {
  var srv = makeServer();
  srv.addHandler(function () { assert.fail("a response must not reach event handlers"); });

  var resolved = null;
  srv.pendingRequests[11] = {
    resolve: function (v) { resolved = v; },
    reject: function () { assert.fail("should not reject"); },
    timer: null,
  };

  srv._handleMessage({ jsonrpc: "2.0", id: 11, result: { sessionId: "sess-a" } });

  assert.deepStrictEqual(resolved, { sessionId: "sess-a" });
  assert.strictEqual(srv.pendingRequests[11], undefined, "pending entry should be cleared");
});

test("a process request handler answers a v3 auth callback exactly once", async function () {
  var srv = makeServer();
  srv.addRequestHandler("_kiro/auth/getAccessToken", function(params) {
    assert.deepStrictEqual(params, { reason: "expired" });
    return { accessToken: "secret", expiresAt: "later" };
  });

  srv._handleMessage({
    jsonrpc: "2.0",
    id: 17,
    method: "_kiro/auth/getAccessToken",
    params: { reason: "expired" },
  });
  await new Promise(function(resolve) { setImmediate(resolve); });

  assert.strictEqual(srv.written.length, 1);
  assert.deepStrictEqual(srv.written[0], {
    jsonrpc: "2.0",
    id: 17,
    result: { accessToken: "secret", expiresAt: "later" },
  });
});