Add ACP-backed role sessions
This commit is contained in:
@@ -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" });
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
Vendored
+73
@@ -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;
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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; }
|
||||
});
|
||||
Reference in New Issue
Block a user