123 lines
7.2 KiB
TypeScript
123 lines
7.2 KiB
TypeScript
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 { AcpSessionManager } from "../src/acp/session-manager.js";
|
|
import { parseConfig } from "../src/config.js";
|
|
import { DurableSessionStore } from "../src/core/durable-session-store.js";
|
|
import { BotProfileResolver } from "../src/roles/role-registry.js";
|
|
|
|
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
|
|
|
|
function config(stateFile: string, logFile: string, persona = "test", agentEnv: Record<string, string> = {}) {
|
|
return parseConfig({
|
|
configVersion: 3,
|
|
bot: {
|
|
id: "test-bot", workspace: path.resolve("."), persona,
|
|
agent: { id: "fake", command: process.execPath, args: [fixture], env: { FAKE_ACP_LOG: logFile, ...agentEnv } },
|
|
skills: [], permissions: { mode: "deny" }
|
|
},
|
|
gateway: { platform: { type: "qq", appId: "id", clientSecret: "secret", botNames: ["bot"] } },
|
|
runtime: { acp: { stateFile, promptTimeoutMs: 2_000, cancelGraceMs: 100, idleTimeoutMs: 100, sweepIntervalMs: 20, maxProcesses: 2 } }
|
|
});
|
|
}
|
|
|
|
async function runtime(stateFile: string, logFile: string, persona = "test", agentEnv: Record<string, string> = {}) {
|
|
const cfg = config(stateFile, logFile, persona, agentEnv);
|
|
const store = new DurableSessionStore(stateFile, { botId: cfg.bot.id, platform: cfg.gateway.platform.type }); await store.open();
|
|
const bot = new BotProfileResolver(cfg).bot;
|
|
return { cfg, store, bot, manager: new AcpSessionManager(cfg.runtime.acp, bot, 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.equal(response.botId, "test-bot"); 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("resume falls back to load and failed restore creates a new session", async () => {
|
|
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-acp-restore-"));
|
|
const state = path.join(dir, "state.json"); const log = path.join(dir, "fake.log");
|
|
const resumeFail = path.join(dir, "resume-fail"); const loadFail = path.join(dir, "load-fail");
|
|
const agentEnv = { FAKE_ACP_RESUME_FAIL_FILE: resumeFail, FAKE_ACP_LOAD_FAIL_FILE: loadFail };
|
|
const first = await runtime(state, log, "test", agentEnv);
|
|
const initial = await first.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "one" });
|
|
const initialSession = initial.text.split(":")[1];
|
|
await first.manager.shutdown(); await first.store.close();
|
|
|
|
await fs.promises.writeFile(resumeFail, "1");
|
|
const fallback = await runtime(state, log, "test", agentEnv);
|
|
const loaded = await fallback.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "two" });
|
|
assert.equal(loaded.text, `reply:${initialSession}:two`);
|
|
await fallback.manager.shutdown(); await fallback.store.close();
|
|
|
|
await fs.promises.writeFile(loadFail, "1");
|
|
const replacement = await runtime(state, log, "test", agentEnv);
|
|
const fresh = await replacement.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "three" });
|
|
assert.notEqual(fresh.text.split(":")[1], initialSession);
|
|
await replacement.manager.shutdown(); await replacement.store.close();
|
|
|
|
const entries = (await fs.promises.readFile(log, "utf8")).trim().split("\n").map(JSON.parse);
|
|
assert.ok(entries.some((entry) => entry.method === "session/load" && entry.sessionId === initialSession));
|
|
});
|
|
|
|
test("cancel reaches hanging prompt, reset unbinds, and fingerprint changes session", 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();
|
|
|
|
const original = await runtime(state, log, "one");
|
|
const first = await original.manager.prompt({ platform: "qq", chatId: "other", userId: "user", text: "first" });
|
|
const firstSession = first.text.split(":")[1];
|
|
await original.manager.shutdown(); await original.store.close();
|
|
const changed = await runtime(state, log, "two");
|
|
const second = await changed.manager.prompt({ platform: "qq", chatId: "other", userId: "user", text: "second" });
|
|
assert.notEqual(second.text.split(":")[1], firstSession);
|
|
await changed.manager.shutdown(); await changed.store.close();
|
|
});
|
|
|
|
test("status reports initializing, processing, running time, and update activity", async () => {
|
|
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-acp-status-")); const state = path.join(dir, "state.json"); const log = path.join(dir, "fake.log");
|
|
const current = await runtime(state, log, "test", { FAKE_ACP_BOOTSTRAP_DELAY_MS: "200" });
|
|
const first = current.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "ready" });
|
|
for (let count = 0; count < 50 && current.manager.status("qq", "chat").phase !== "initializing"; count++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
assert.deepEqual(current.manager.status("qq", "chat"), {
|
|
bot: "test-bot", agent: "fake", workspace: path.resolve("."), persisted: false, running: true,
|
|
phase: "initializing", runningSeconds: 0, idleSeconds: 0
|
|
});
|
|
await first;
|
|
|
|
const activity = current.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "activity" });
|
|
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
|
const processing = current.manager.status("qq", "chat");
|
|
assert.equal(processing.phase, "processing");
|
|
assert.equal(processing.running, true);
|
|
assert.equal(processing.runningSeconds, 1);
|
|
assert.equal(processing.idleSeconds, 0);
|
|
await activity;
|
|
assert.equal(current.manager.status("qq", "chat").phase, "idle");
|
|
await current.manager.shutdown(); await current.store.close();
|
|
});
|