Add ACP-backed role sessions

This commit is contained in:
zenord
2026-08-16 10:08:03 +08:00
parent d59c4a654f
commit a80fe68d57
34 changed files with 1683 additions and 887 deletions
+23
View File
@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { RequestPermissionRequest } from "@agentclientprotocol/sdk";
import { decidePermission } from "../src/acp/client.js";
const request = (rawInput: unknown): RequestPermissionRequest => ({
sessionId: "session",
toolCall: { toolCallId: "call", title: "bash git status", kind: "execute", name: "bash", rawInput },
options: [
{ optionId: "allow", name: "Allow", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" }
]
});
test("permission deny and allowlist fail closed", () => {
assert.equal(decidePermission(request("git status"), { permissionMode: "deny", allowedTools: [], allowedCommandPatterns: [] }).outcome.outcome, "selected");
const allowed = decidePermission(request("git status"), { permissionMode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] });
assert.deepEqual(allowed.outcome, { outcome: "selected", optionId: "allow" });
const missingDetail = decidePermission(request(undefined), { permissionMode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] });
assert.deepEqual(missingDetail.outcome, { outcome: "selected", optionId: "reject" });
const destructive = decidePermission(request("rm -rf /"), { permissionMode: "allowlist", allowedTools: ["bash"], allowedCommandPatterns: ["^git status$"] });
assert.deepEqual(destructive.outcome, { outcome: "selected", optionId: "reject" });
});
+58
View File
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { AcpBackendRegistry } from "../src/acp/backend-registry.js";
import { AcpSessionManager } from "../src/acp/session-manager.js";
import { parseConfig } from "../src/config.js";
import { DurableSessionStore } from "../src/core/durable-session-store.js";
import { RoleRegistry } from "../src/roles/role-registry.js";
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
function config(stateFile: string, logFile: string) {
return parseConfig({
configVersion: 2, acp: { stateFile, promptTimeoutMs: 2_000, cancelGraceMs: 100, idleTimeoutMs: 100, sweepIntervalMs: 20, maxProcesses: 2 },
backends: [{ id: "kimi", command: process.execPath, args: [fixture], env: { FAKE_ACP_LOG: logFile } }],
skills: [], defaultRole: "assistant",
roles: [{ id: "assistant", backend: "kimi", workspace: path.resolve("."), persona: "test", skills: [], policy: { permissionMode: "deny" } }],
platforms: {}
});
}
async function runtime(stateFile: string, logFile: string) {
const cfg = config(stateFile, logFile); const store = new DurableSessionStore(stateFile); await store.open();
return { cfg, store, manager: new AcpSessionManager(cfg.acp, new AcpBackendRegistry(cfg.backends), new RoleRegistry(cfg), store) };
}
test("creates, persists, idles, and resumes the same native session", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-acp-")); const state = path.join(dir, "state.json"); const log = path.join(dir, "fake.log");
const first = await runtime(state, log);
const response = await first.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "one" });
const sessionId = response.text.split(":")[1];
assert.match(response.text, /reply:fake-/);
await new Promise((resolve) => setTimeout(resolve, 180));
await first.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "two" });
await first.manager.shutdown(); await first.store.close();
const second = await runtime(state, log);
const resumed = await second.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "three" });
assert.equal(resumed.text, `reply:${sessionId}:three`);
const entries = (await fs.promises.readFile(log, "utf8")).trim().split("\n").map(JSON.parse);
assert.ok(entries.some((entry) => entry.method === "session/resume" && entry.sessionId === sessionId));
await second.manager.shutdown(); await second.store.close();
});
test("cancel reaches a hanging ACP prompt and new unbinds", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-acp-cancel-")); const state = path.join(dir, "state.json"); const log = path.join(dir, "fake.log");
const current = await runtime(state, log);
await current.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "ready" });
const hanging = current.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "hang" });
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(await current.manager.cancel("qq", "chat"), true);
await hanging;
await current.manager.reset("qq", "chat");
assert.equal(current.store.stats().bindings, 0);
await current.manager.shutdown(); await current.store.close();
});
+37
View File
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import path from "node:path";
import test from "node:test";
import { AcpWorker } from "../src/acp/worker.js";
import { parseConfig } from "../src/config.js";
import { RoleRegistry } from "../src/roles/role-registry.js";
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
function makeWorker(promptTimeoutMs = 80) {
const config = parseConfig({
configVersion: 2,
acp: { promptTimeoutMs, cancelGraceMs: 30 },
backends: [{ id: "kimi", command: process.execPath, args: [fixture] }],
defaultRole: "assistant",
roles: [{ id: "assistant", backend: "kimi", workspace: path.resolve("."), policy: { permissionMode: "deny" } }],
platforms: {}
});
let crashes = 0;
const worker = new AcpWorker(config.backends[0], new RoleRegistry(config).get(), config.acp, () => { crashes++; });
return { worker, crashes: () => crashes };
}
test("worker times out a hung prompt and can be terminated", async () => {
const { worker } = makeWorker();
await worker.start();
await assert.rejects(worker.prompt("hang"), /timed out/);
await worker.terminate();
});
test("worker reports an unexpected ACP subprocess exit", async () => {
const current = makeWorker(500);
await current.worker.start();
await assert.rejects(current.worker.prompt("crash"));
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(current.crashes(), 1);
});
+31
View File
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseConfig } from "../src/config.js";
const platforms = {
qq: { enabled: true, appId: "id", clientSecret: "secret", connectionMode: "websocket" as const },
feishu: {}, wecom: {}, webhook: {}, weixin: {}
};
test("migrates a v1 Kimi config and preserves QQ fields", () => {
const config = parseConfig({
server: { port: 8787 }, policy: {}, defaultAgent: "kimi",
agents: [{ name: "kimi", command: "/home/ubuntu/.kimi-code/bin/kimi", args: ["-p"], cwd: "/tmp" }], platforms
});
assert.equal(config.configVersion, 2);
assert.deepEqual(config.backends[0].args, ["acp"]);
assert.equal(config.roles[0].workspace, "/tmp");
assert.equal(config.platforms.qq.clientSecret, "secret");
assert.equal(config.platforms.qq.connectionMode, "websocket");
});
test("does not silently migrate a non-Kimi CLI agent", () => {
assert.throws(() => parseConfig({ defaultAgent: "echo", agents: [{ name: "echo", command: "node" }], platforms }), /only supports a Kimi/);
});
test("validates role references and absolute workspace", () => {
assert.throws(() => parseConfig({
configVersion: 2, backends: [{ id: "kimi", command: "kimi", args: ["acp"] }], defaultRole: "a",
roles: [{ id: "a", backend: "missing", workspace: "relative" }], platforms
}), /workspace must be absolute/);
});
+41
View File
@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { DurableSessionStore } from "../src/core/durable-session-store.js";
test("persists chat role and native session across reopen", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-"));
const file = path.join(dir, "state.json");
const first = new DurableSessionStore(file);
await first.open();
await first.setSelectedRole("qq:chat", "ops");
await first.setBinding({ chatKey: "qq:chat", roleId: "ops", backendId: "kimi", nativeSessionId: "native-1", workspace: "/tmp", roleFingerprint: "fp", createdAt: 1, updatedAt: 1 });
await first.close();
assert.equal((await fs.promises.readdir(dir)).some((name) => name.endsWith(".tmp")), false);
const second = new DurableSessionStore(file);
await second.open();
assert.equal(second.getSelectedRole("qq:chat", "assistant"), "ops");
assert.equal(second.getBinding("qq:chat", "ops")?.nativeSessionId, "native-1");
await second.close();
});
test("preserves corrupt state and fails explicitly", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-bad-"));
const file = path.join(dir, "state.json");
await fs.promises.writeFile(file, "not-json");
const store = new DurableSessionStore(file);
await assert.rejects(store.open(), /original file was preserved/);
assert.equal(await fs.promises.readFile(file, "utf8"), "not-json");
});
test("refuses a second writer lock", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-lock-"));
const file = path.join(dir, "state.json");
const first = new DurableSessionStore(file); await first.open();
const second = new DurableSessionStore(file);
await assert.rejects(second.open(), /locked by another/);
await first.close();
});
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env node
import fs from "node:fs";
import { Readable, Writable } from "node:stream";
import * as acp from "@agentclientprotocol/sdk";
const pending = new Map();
const logFile = process.env.FAKE_ACP_LOG;
const log = (entry) => { if (logFile) fs.appendFileSync(logFile, `${JSON.stringify(entry)}\n`); };
const app = acp.agent({ name: "fake-acp-agent" })
.onRequest(acp.methods.agent.initialize, ({ params }) => {
log({ method: "initialize" });
return {
protocolVersion: params.protocolVersion,
agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {}, close: {}, list: {} } },
agentInfo: { name: "fake-acp-agent", version: "1" }
};
})
.onRequest(acp.methods.agent.session.new, ({ params }) => {
const sessionId = `fake-${Date.now()}-${Math.random().toString(16).slice(2)}`;
log({ method: "session/new", sessionId, cwd: params.cwd });
return { sessionId };
})
.onRequest(acp.methods.agent.session.load, ({ params }) => { log({ method: "session/load", sessionId: params.sessionId }); return {}; })
.onRequest(acp.methods.agent.session.resume, ({ params }) => { log({ method: "session/resume", sessionId: params.sessionId }); return {}; })
.onRequest(acp.methods.agent.session.close, ({ params }) => { log({ method: "session/close", sessionId: params.sessionId }); return {}; })
.onRequest(acp.methods.agent.session.list, () => ({ sessions: [] }))
.onRequest(acp.methods.agent.session.prompt, async ({ params, client, signal }) => {
const text = params.prompt.filter((item) => item.type === "text").map((item) => item.text).join("");
log({ method: "session/prompt", sessionId: params.sessionId, text });
if (text === "crash") process.exit(9);
if (text === "permission") {
const response = await client.request(acp.methods.client.session.requestPermission, {
sessionId: params.sessionId,
toolCall: { toolCallId: "bash-1", title: "bash git status", kind: "execute", name: "bash", rawInput: "git status" },
options: [
{ optionId: "allow", name: "Allow", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" }
]
});
await update(client, params.sessionId, response.outcome.outcome === "selected" ? response.outcome.optionId : "cancelled");
return { stopReason: "end_turn" };
}
if (text === "hang") {
await new Promise((resolve) => {
const done = () => resolve(undefined);
pending.set(params.sessionId, done);
signal.addEventListener("abort", done, { once: true });
});
pending.delete(params.sessionId);
return { stopReason: "cancelled" };
}
await update(client, params.sessionId, text.includes("Initialize this ACP session") ? "READY" : `reply:${params.sessionId}:${text}`);
return { stopReason: "end_turn" };
})
.onNotification(acp.methods.agent.session.cancel, ({ params }) => {
log({ method: "session/cancel", sessionId: params.sessionId });
pending.get(params.sessionId)?.();
});
function update(client, sessionId, text) {
return client.notify(acp.methods.client.session.update, {
sessionId,
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } }
});
}
const stream = acp.ndJsonStream(
Writable.toWeb(process.stdout),
Readable.toWeb(process.stdin)
);
const connection = app.connect(stream);
await connection.closed;
+45
View File
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ConversationRuntime } from "../src/acp/types.js";
import { parseConfig } from "../src/config.js";
import type { PlatformAdapter } from "../src/core/adapter.js";
import { Gateway } from "../src/core/gateway.js";
import type { IncomingMessage } from "../src/core/types.js";
import { RoleRegistry } from "../src/roles/role-registry.js";
class FakeRuntime implements ConversationRuntime {
role = "assistant"; prompts = 0; cancelled = 0; resets = 0; release?: () => void;
async prompt() { this.prompts++; await new Promise<void>((resolve) => { this.release = resolve; }); return { text: "done", roleId: this.role, backendId: "kimi" }; }
async cancel() { this.cancelled++; this.release?.(); return true; }
async reset() { this.resets++; }
async selectRole(_p: string, _c: string, role: string) { this.role = role; }
selectedRole() { return this.role; }
status() { return { role: this.role, running: Boolean(this.release) }; }
stats() { return { activeWorkers: 0, inFlight: 0, crashes: 0, persistedBindings: 0 }; }
async shutdown() {}
}
const cfg = parseConfig({ configVersion: 2, backends: [{ id: "kimi", command: "kimi", args: ["acp"] }], defaultRole: "assistant", roles: [
{ id: "assistant", backend: "kimi", workspace: "/tmp" }, { id: "ops", backend: "kimi", workspace: "/tmp" }
], platforms: {} });
const adapter: PlatformAdapter = { name: "test", async handleWebhook() { return {}; }, async sendMessage() {} };
const message = (text: string): IncomingMessage => ({ platform: "qq", chatId: "chat", userId: "user", text });
test("cancel bypasses the chat lock and new resets", async () => {
const runtime = new FakeRuntime(); const gateway = new Gateway(cfg.policy, runtime, new RoleRegistry(cfg));
const turn = gateway.receive(message("work"), adapter, { synchronous: true });
await new Promise((resolve) => setTimeout(resolve, 10));
const cancelled = await gateway.receive(message("/cancel"), adapter, { synchronous: true });
assert.equal(cancelled.reply, "Cancellation requested.");
await turn;
const reset = await gateway.receive(message("/new"), adapter, { synchronous: true });
assert.match(reset.reply || "", /new native ACP session/);
assert.equal(runtime.resets, 1);
});
test("role aliases, role selection, and status are routed", async () => {
const runtime = new FakeRuntime(); const gateway = new Gateway(cfg.policy, runtime, new RoleRegistry(cfg));
assert.match((await gateway.receive(message("/agents"), adapter, { synchronous: true })).reply || "", /Deprecated alias/);
assert.equal((await gateway.receive(message("/role ops"), adapter, { synchronous: true })).reply, "Selected role: ops");
assert.match((await gateway.receive(message("/status"), adapter, { synchronous: true })).reply || "", /role=ops/);
});
+34
View File
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseConfig } from "../src/config.js";
import { QqAdapter } from "../src/platforms/qq/adapter.js";
const config = parseConfig({ configVersion: 2, backends: [{ id: "kimi", command: "kimi", args: ["acp"] }], defaultRole: "assistant", roles: [{ id: "assistant", backend: "kimi", workspace: "/tmp" }], platforms: { qq: {
enabled: true, connectionMode: "webhook", appId: "id", clientSecret: "secret", verifySignature: false, botNames: ["Bot"]
} } });
test("normalizes GROUP and C2C author openid while ACK remains immediate", async () => {
const received: any[] = [];
const gateway = { receive: async (message: unknown) => { received.push(message); throw new Error("ACP failed"); } };
const adapter = new QqAdapter(config.platforms.qq, gateway as never);
const group = await adapter.handleWebhook({ body: { op: 0, t: "GROUP_AT_MESSAGE_CREATE", d: { id: "m1", group_openid: "g1", author: { user_openid: "u1" }, content: "@Bot hi" } }, headers: {}, query: {}, req: {} as never });
const c2c = await adapter.handleWebhook({ body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m2", author: { user_openid: "u2" }, content: "hello" } }, headers: {}, query: {}, req: {} as never });
assert.deepEqual(group.body, { op: 12 }); assert.deepEqual(c2c.body, { op: 12 });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(received[0].chatId, "group:g1"); assert.equal(received[0].userId, "u1");
assert.equal(received[1].chatId, "user:u2"); assert.equal(received[1].userId, "u2");
});
test("sendMessage uses nested author.user_openid for C2C endpoint", async () => {
const original = globalThis.fetch; const urls: string[] = [];
globalThis.fetch = (async (input: string | URL | Request) => {
urls.push(String(input));
if (String(input).includes("getAppAccessToken")) return new Response(JSON.stringify({ access_token: "token", expires_in: 7200 }), { status: 200 });
return new Response(JSON.stringify({ id: "sent" }), { status: 200 });
}) as typeof fetch;
try {
const adapter = new QqAdapter(config.platforms.qq, { receive: async () => ({ ok: true }) } as never);
await adapter.sendMessage({ target: { platform: "qq", chatId: "user:u2", raw: { author: { user_openid: "u2" } } }, text: "reply", replyTo: "m2" });
assert.ok(urls.some((url) => url.endsWith("/v2/users/u2/messages")));
} finally { globalThis.fetch = original; }
});