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 = {}) { 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 = {}) { 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(); });