feat: add assistant proposal worker runtime
This commit is contained in:
@@ -1,122 +0,0 @@
|
||||
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();
|
||||
});
|
||||
+47
-2
@@ -1,4 +1,6 @@
|
||||
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 { AcpWorker } from "../src/acp/worker.js";
|
||||
@@ -7,10 +9,10 @@ import { BotProfileResolver } from "../src/roles/role-registry.js";
|
||||
|
||||
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
|
||||
|
||||
function makeWorker(promptTimeoutMs = 80) {
|
||||
function makeWorker(promptTimeoutMs = 80, env: Record<string, string> = {}) {
|
||||
const config = parseConfig({
|
||||
configVersion: 3,
|
||||
bot: { id: "test-bot", workspace: path.resolve("."), persona: "", agent: { id: "fake", command: process.execPath, args: [fixture] }, permissions: { mode: "deny" } },
|
||||
bot: { id: "test-bot", workspace: path.resolve("."), persona: "", agent: { id: "fake", command: process.execPath, args: [fixture], env }, permissions: { mode: "deny" } },
|
||||
gateway: { platform: { type: "webhook", secret: "secret" } },
|
||||
runtime: { acp: { promptTimeoutMs, cancelGraceMs: 30 } }
|
||||
});
|
||||
@@ -33,3 +35,46 @@ test("worker reports an unexpected ACP subprocess exit", async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(current.crashes(), 1);
|
||||
});
|
||||
|
||||
test("immediate termination kills ACP descendants in the worker process group", async (t) => {
|
||||
await assertDescendantTermination(t, (worker) => { worker.terminateImmediately(); });
|
||||
});
|
||||
|
||||
test("normal termination SIGKILLs descendants that ignore SIGTERM", async (t) => {
|
||||
await assertDescendantTermination(t, (worker) => worker.terminate());
|
||||
});
|
||||
|
||||
test("persisted worker token safely identifies and kills an orphaned process group", async () => {
|
||||
const { worker } = makeWorker(2_000);
|
||||
await worker.start();
|
||||
assert.ok(worker.processGroup);
|
||||
await AcpWorker.terminatePersistedGroup(worker.processGroup, 100);
|
||||
assert.equal(processGroupExists(worker.processGroup.pgid), false);
|
||||
});
|
||||
|
||||
async function assertDescendantTermination(t: test.TestContext, terminate: (worker: AcpWorker) => void | Promise<void>): Promise<void> {
|
||||
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-acp-group-"));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const pidFile = path.join(directory, "descendant.pid");
|
||||
const { worker } = makeWorker(2_000, { FAKE_ACP_DESCENDANT_PID_FILE: pidFile });
|
||||
await worker.start();
|
||||
const prompt = worker.prompt("spawn descendant");
|
||||
const promptRejected = assert.rejects(prompt);
|
||||
for (let count = 0; count < 100 && !fs.existsSync(pidFile); count++) await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const pid = Number(fs.readFileSync(pidFile, "utf8").trim());
|
||||
assert.equal(processExists(pid), true);
|
||||
await terminate(worker);
|
||||
await promptRejected;
|
||||
for (let count = 0; count < 100 && processExists(pid); count++) await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(processExists(pid), false);
|
||||
}
|
||||
|
||||
function processExists(pid: number): boolean {
|
||||
try { process.kill(pid, 0); return true; }
|
||||
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
|
||||
}
|
||||
|
||||
function processGroupExists(pgid: number): boolean {
|
||||
try { process.kill(-pgid, 0); return true; }
|
||||
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { AssistantManager, parseAssistantActions, parseWorkerResult } from "../src/acp/assistant-manager.js";
|
||||
import { parseConfig } from "../src/config.js";
|
||||
import { DurableSessionStore } from "../src/core/durable-session-store.js";
|
||||
import { ProposalStore } from "../src/core/proposal-store.js";
|
||||
import { BotProfileResolver } from "../src/roles/role-registry.js";
|
||||
|
||||
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
|
||||
const CHAT_KEY = "webhook:chat-1";
|
||||
|
||||
interface Harness {
|
||||
home: string;
|
||||
workspace: string;
|
||||
store: DurableSessionStore;
|
||||
proposals: ProposalStore;
|
||||
manager: AssistantManager;
|
||||
events: { chatKey: string; text: string }[];
|
||||
logFile: string;
|
||||
}
|
||||
|
||||
async function createHarness(options: { initialize?: boolean; agentEnv?: Record<string, string> } = {}): Promise<Harness> {
|
||||
const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-assistant-home-"));
|
||||
const workspace = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-assistant-ws-"));
|
||||
const logFile = path.join(home, "fake-acp.log");
|
||||
const config = parseConfig({
|
||||
configVersion: 3,
|
||||
bot: { id: "test-bot", workspace, persona: "", agent: { id: "fake", command: process.execPath, args: [fixture], env: { FAKE_ACP_LOG: logFile, ...options.agentEnv } }, permissions: { mode: "deny" } },
|
||||
gateway: { platform: { type: "webhook", secret: "secret" } },
|
||||
runtime: { acp: { promptTimeoutMs: 10_000, cancelGraceMs: 200 } }
|
||||
});
|
||||
const identity = { botId: "test-bot", platform: "webhook" };
|
||||
const store = new DurableSessionStore(path.join(home, "state", "acp-sessions.json"), identity);
|
||||
const proposals = new ProposalStore(path.join(home, "state", "proposals.json"), identity);
|
||||
await store.open();
|
||||
await proposals.open();
|
||||
const events: { chatKey: string; text: string }[] = [];
|
||||
const manager = new AssistantManager(config.runtime.acp, new BotProfileResolver(config).bot, store, proposals, {
|
||||
assistantWorkspaceHome: home,
|
||||
allowUnverifiedAssistantAgent: true
|
||||
});
|
||||
manager.setEventSink(async (chatKey, text) => { events.push({ chatKey, text }); });
|
||||
if (options.initialize !== false) await manager.initialize();
|
||||
return { home, workspace, store, proposals, manager, events, logFile };
|
||||
}
|
||||
|
||||
async function reopenManager(harness: Harness): Promise<void> {
|
||||
await harness.manager.shutdown().catch(() => undefined);
|
||||
await harness.proposals.close().catch(() => undefined);
|
||||
await harness.store.close().catch(() => undefined);
|
||||
const identity = { botId: "test-bot", platform: "webhook" };
|
||||
harness.store = new DurableSessionStore(path.join(harness.home, "state", "acp-sessions.json"), identity);
|
||||
harness.proposals = new ProposalStore(path.join(harness.home, "state", "proposals.json"), identity);
|
||||
await harness.store.open();
|
||||
await harness.proposals.open();
|
||||
const config = parseConfig({
|
||||
configVersion: 3,
|
||||
bot: { id: "test-bot", workspace: harness.workspace, persona: "", agent: { id: "fake", command: process.execPath, args: [fixture], env: { FAKE_ACP_LOG: harness.logFile } }, permissions: { mode: "deny" } },
|
||||
gateway: { platform: { type: "webhook", secret: "secret" } },
|
||||
runtime: { acp: { promptTimeoutMs: 10_000, cancelGraceMs: 200 } }
|
||||
});
|
||||
harness.manager = new AssistantManager(config.runtime.acp, new BotProfileResolver(config).bot, harness.store, harness.proposals, {
|
||||
assistantWorkspaceHome: harness.home,
|
||||
allowUnverifiedAssistantAgent: true
|
||||
});
|
||||
harness.manager.setEventSink(async (chatKey, text) => { harness.events.push({ chatKey, text }); });
|
||||
await harness.manager.initialize();
|
||||
}
|
||||
|
||||
async function closeHarness(harness: Harness): Promise<void> {
|
||||
await harness.manager.shutdown().catch(() => undefined);
|
||||
await harness.proposals.close().catch(() => undefined);
|
||||
await harness.store.close().catch(() => undefined);
|
||||
await fs.promises.rm(harness.home, { recursive: true, force: true });
|
||||
await fs.promises.rm(harness.workspace, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const request = (text: string) => ({ platform: "webhook", chatId: "chat-1", userId: "user-1", text });
|
||||
|
||||
async function waitFor(condition: () => boolean, timeoutMs = 8_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!condition()) {
|
||||
if (Date.now() > deadline) throw new Error("condition not met before timeout");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
function readLog(logFile: string): { method: string; sessionId?: string; cwd?: string; text?: string }[] {
|
||||
if (!fs.existsSync(logFile)) return [];
|
||||
return fs.readFileSync(logFile, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line) as { method: string });
|
||||
}
|
||||
|
||||
test("create proposal stays proposed until confirm; confirm starts the worker without waiting for it", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const created = await harness.manager.prompt(request("create proposal: hang"));
|
||||
assert.equal(created.mode, "assistant");
|
||||
const [proposal] = harness.proposals.list();
|
||||
assert.ok(proposal);
|
||||
assert.equal(proposal.status, "proposed");
|
||||
assert.equal(proposal.ownerChatKey, CHAT_KEY);
|
||||
assert.equal(readLog(harness.logFile).filter((entry) => entry.method === "session/new" && entry.cwd === harness.workspace).length, 0);
|
||||
assert.ok(harness.store.getBinding(CHAT_KEY));
|
||||
|
||||
await harness.manager.prompt(request("confirm"));
|
||||
const working = harness.proposals.get(proposal.id)!;
|
||||
assert.equal(working.status, "working");
|
||||
assert.ok(working.confirmedAt);
|
||||
assert.ok(working.startedAt);
|
||||
await waitFor(() => Boolean(harness.proposals.get(proposal.id)!.workerProcessGroup));
|
||||
assert.equal(harness.proposals.get(proposal.id)!.status, "working");
|
||||
|
||||
await harness.manager.prompt(request("stop"));
|
||||
const stopped = harness.proposals.get(proposal.id)!;
|
||||
assert.equal(stopped.status, "cancelled");
|
||||
assert.equal(stopped.workerProcessGroup, undefined);
|
||||
assert.ok(stopped.finishedAt);
|
||||
} finally {
|
||||
await closeHarness(harness);
|
||||
}
|
||||
});
|
||||
|
||||
test("stop does not deadlock while a worker result is waiting to settle", async () => {
|
||||
const gateFile = path.join(os.tmpdir(), `gori-worker-gate-${crypto.randomUUID()}`);
|
||||
const harness = await createHarness({ agentEnv: { FAKE_ACP_WORKER_GATE_FILE: gateFile } });
|
||||
try {
|
||||
await harness.manager.prompt(request("create proposal: succeed"));
|
||||
const proposal = harness.proposals.list()[0]!;
|
||||
await harness.manager.prompt(request("confirm"));
|
||||
await waitFor(() => readLog(harness.logFile).some((entry) => entry.method === "session/prompt" && entry.text?.startsWith("Execute this confirmed proposal")));
|
||||
|
||||
type Scheduler = <T>(operation: () => Promise<T> | T) => Promise<T>;
|
||||
const internals = harness.manager as unknown as { scheduler: Scheduler };
|
||||
const original = internals.scheduler.bind(harness.manager);
|
||||
let intercepted = false;
|
||||
let notifySettle!: () => void;
|
||||
let startSettle: (() => Promise<unknown>) | undefined;
|
||||
const settleReached = new Promise<void>((resolve) => { notifySettle = resolve; });
|
||||
internals.scheduler = <T>(operation: () => Promise<T> | T): Promise<T> => {
|
||||
if (!intercepted) {
|
||||
intercepted = true;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
notifySettle();
|
||||
startSettle = () => original(operation).then(resolve, reject);
|
||||
});
|
||||
}
|
||||
return original(operation);
|
||||
};
|
||||
|
||||
fs.writeFileSync(gateFile, "go\n", { mode: 0o600 });
|
||||
await settleReached;
|
||||
const stopped = harness.manager.stop("webhook", "chat-1");
|
||||
assert.ok(startSettle);
|
||||
void startSettle();
|
||||
assert.equal(await stopped, true);
|
||||
await waitFor(() => harness.proposals.get(proposal.id)!.status === "cancelled");
|
||||
assert.equal(harness.proposals.get(proposal.id)!.status, "cancelled");
|
||||
} finally {
|
||||
await fs.promises.rm(gateFile, { force: true });
|
||||
await closeHarness(harness);
|
||||
}
|
||||
});
|
||||
|
||||
test("SUCCESS waits for user confirmation and never auto-completes or auto-starts the next proposal", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
await harness.manager.prompt(request("create proposal: succeed"));
|
||||
const first = harness.proposals.list()[0]!;
|
||||
await harness.manager.prompt(request("confirm"));
|
||||
await waitFor(() => harness.proposals.get(first.id)!.status === "awaiting_user_confirmation");
|
||||
assert.equal(harness.proposals.get(first.id)!.pending?.kind, "success");
|
||||
assert.equal(harness.proposals.get(first.id)!.finishedAt, undefined);
|
||||
|
||||
await harness.manager.prompt(request("create proposal: succeed"));
|
||||
const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!;
|
||||
await harness.manager.prompt(request("confirm"));
|
||||
assert.equal(harness.proposals.get(second.id)!.status, "queued");
|
||||
assert.equal(harness.proposals.get(first.id)!.status, "awaiting_user_confirmation");
|
||||
|
||||
await harness.manager.prompt(request("confirm"));
|
||||
assert.equal(harness.proposals.get(first.id)!.status, "completed");
|
||||
assert.ok(harness.proposals.get(first.id)!.finishedAt);
|
||||
assert.equal(harness.proposals.get(second.id)!.status, "queued");
|
||||
|
||||
await harness.manager.prompt(request("start next"));
|
||||
assert.equal(harness.proposals.get(second.id)!.status, "working");
|
||||
await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation");
|
||||
await harness.manager.prompt(request("confirm"));
|
||||
assert.equal(harness.proposals.get(second.id)!.status, "completed");
|
||||
} finally {
|
||||
await closeHarness(harness);
|
||||
}
|
||||
});
|
||||
|
||||
test("FAILED requires confirmation; confirm ends it and confirm retry restarts a working worker", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
await harness.manager.prompt(request("create proposal: fail"));
|
||||
const first = harness.proposals.list()[0]!;
|
||||
await harness.manager.prompt(request("confirm"));
|
||||
await waitFor(() => harness.proposals.get(first.id)!.status === "awaiting_user_confirmation");
|
||||
assert.equal(harness.proposals.get(first.id)!.pending?.kind, "failure");
|
||||
await harness.manager.prompt(request("confirm"));
|
||||
assert.equal(harness.proposals.get(first.id)!.status, "failed");
|
||||
assert.ok(harness.proposals.get(first.id)!.finishedAt);
|
||||
|
||||
await harness.manager.prompt(request("create proposal: fail"));
|
||||
const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!;
|
||||
await harness.manager.prompt(request("confirm"));
|
||||
await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation");
|
||||
await harness.manager.prompt(request("confirm retry"));
|
||||
assert.equal(harness.proposals.get(second.id)!.status, "working");
|
||||
await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation");
|
||||
assert.equal(harness.proposals.get(second.id)!.pending?.kind, "failure");
|
||||
await harness.manager.prompt(request("confirm"));
|
||||
assert.equal(harness.proposals.get(second.id)!.status, "failed");
|
||||
} finally {
|
||||
await closeHarness(harness);
|
||||
}
|
||||
});
|
||||
|
||||
test("NEEDS_CONFIRMATION answer continues the same worker session", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
await harness.manager.prompt(request("create proposal: ask"));
|
||||
const proposal = harness.proposals.list()[0]!;
|
||||
await harness.manager.prompt(request("confirm"));
|
||||
await waitFor(() => harness.proposals.get(proposal.id)!.status === "awaiting_user_confirmation");
|
||||
const pendingStep = harness.proposals.get(proposal.id)!.pending!;
|
||||
assert.equal(pendingStep.kind, "step");
|
||||
assert.equal(pendingStep.question, "May I overwrite it?");
|
||||
const workerSessionId = harness.proposals.get(proposal.id)!.workerNativeSessionId!;
|
||||
assert.ok(workerSessionId);
|
||||
|
||||
await harness.manager.prompt(request("answer: yes, overwrite it"));
|
||||
await waitFor(() => harness.proposals.get(proposal.id)!.pending?.kind === "success");
|
||||
const continued = harness.proposals.get(proposal.id)!;
|
||||
assert.equal(continued.workerNativeSessionId, workerSessionId);
|
||||
assert.match(continued.lastWorkerSummary || "", new RegExp(`continued ${workerSessionId}`));
|
||||
|
||||
const workerPrompts = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt"
|
||||
&& entry.text && (entry.text.startsWith("Execute this confirmed proposal") || entry.text.startsWith("The user answered your question")));
|
||||
assert.equal(workerPrompts.length, 2);
|
||||
assert.ok(workerPrompts.every((entry) => entry.sessionId === workerSessionId));
|
||||
|
||||
await harness.manager.prompt(request("confirm"));
|
||||
assert.equal(harness.proposals.get(proposal.id)!.status, "completed");
|
||||
} finally {
|
||||
await closeHarness(harness);
|
||||
}
|
||||
});
|
||||
|
||||
test("assistant tool activity fails closed and the next turn starts a clean session", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
await assert.rejects(harness.manager.prompt(request("force tool")), /forbidden tool activity/);
|
||||
assert.equal(harness.store.getBinding(CHAT_KEY), undefined);
|
||||
const ok = await harness.manager.prompt(request("hello"));
|
||||
assert.equal(ok.text, "ok");
|
||||
assert.ok(harness.store.getBinding(CHAT_KEY));
|
||||
} finally {
|
||||
await closeHarness(harness);
|
||||
}
|
||||
});
|
||||
|
||||
test("worker completion notifies the owner chat through the event sink", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
await harness.manager.prompt(request("create proposal: succeed"));
|
||||
const proposal = harness.proposals.list()[0]!;
|
||||
await harness.manager.prompt(request("confirm"));
|
||||
await waitFor(() => harness.proposals.get(proposal.id)!.status === "awaiting_user_confirmation");
|
||||
await waitFor(() => harness.events.length > 0);
|
||||
assert.equal(harness.events[0]!.chatKey, CHAT_KEY);
|
||||
assert.match(harness.events[0]!.text, /event received: worker update/);
|
||||
} finally {
|
||||
await closeHarness(harness);
|
||||
}
|
||||
});
|
||||
|
||||
test("restart recovery kills the persisted worker group and marks the working proposal failed as worker_lost", async () => {
|
||||
const harness = await createHarness();
|
||||
const token = crypto.randomUUID();
|
||||
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: { ...process.env, GORI_AGENT_WORKER_TOKEN: token }
|
||||
});
|
||||
child.unref();
|
||||
try {
|
||||
const proposal = await harness.proposals.create({ title: "lost", goal: "lost", steps: ["step"], ownerChatKey: CHAT_KEY, requesterUserId: "user-1" });
|
||||
await harness.proposals.update(proposal.id, { status: "working", startedAt: Date.now(), workerProcessGroup: { pgid: child.pid!, token } });
|
||||
assert.equal(processGroupExists(child.pid!), true);
|
||||
|
||||
await reopenManager(harness);
|
||||
|
||||
const recovered = harness.proposals.get(proposal.id)!;
|
||||
assert.equal(recovered.status, "failed");
|
||||
assert.match(recovered.pending?.summary || "", /worker_lost/);
|
||||
assert.match(recovered.lastWorkerSummary || "", /worker_lost/);
|
||||
assert.ok(recovered.finishedAt);
|
||||
await waitFor(() => !processGroupExists(child.pid!));
|
||||
} finally {
|
||||
try { process.kill(-child.pid!, "SIGKILL"); } catch { /* already dead */ }
|
||||
await closeHarness(harness);
|
||||
}
|
||||
});
|
||||
|
||||
test("envelope parsers accept valid tails and reject invalid ones", () => {
|
||||
assert.deepEqual(
|
||||
parseAssistantActions(`text\n<GORI_ASSISTANT_ACTION_V1>{"reply":"hi","actions":[{"type":"stop"}]}</GORI_ASSISTANT_ACTION_V1>`),
|
||||
{ reply: "hi", actions: [{ type: "stop" }] }
|
||||
);
|
||||
assert.equal(parseAssistantActions("no envelope"), undefined);
|
||||
assert.equal(parseAssistantActions(`x\n<GORI_ASSISTANT_ACTION_V1>{"reply":"hi","actions":[{"type":"nuke"}]}</GORI_ASSISTANT_ACTION_V1>`), undefined);
|
||||
assert.deepEqual(
|
||||
parseWorkerResult(`text\n<GORI_WORKER_RESULT_V1>{"status":"NEEDS_CONFIRMATION","summary":"s","question":"q","dirty":true}</GORI_WORKER_RESULT_V1>`),
|
||||
{ status: "NEEDS_CONFIRMATION", summary: "s", question: "q", nextStep: undefined, dirty: true }
|
||||
);
|
||||
assert.equal(parseWorkerResult(`x\n<GORI_WORKER_RESULT_V1>{"status":"DONE","summary":"s"}</GORI_WORKER_RESULT_V1>`), undefined);
|
||||
});
|
||||
|
||||
function processGroupExists(pgid: number): boolean {
|
||||
try { process.kill(-pgid, 0); return true; }
|
||||
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
|
||||
}
|
||||
@@ -28,8 +28,10 @@ test("config writes are atomic and mode 0600", async () => {
|
||||
assert.equal((await fs.promises.readdir(dir)).some((name) => name.endsWith(".tmp")), false);
|
||||
});
|
||||
|
||||
test("operational validation rejects placeholders for every credential family", () => {
|
||||
test("operational validation rejects placeholders, unsafe ACP args, and every credential family", () => {
|
||||
assert.doesNotThrow(() => assertOperationalConfig(config));
|
||||
const unsafeArgs = parseConfig({ ...config, bot: { ...config.bot, agent: { ...config.bot.agent, args: ["--yolo", "acp"] } } });
|
||||
assert.throws(() => assertOperationalConfig(unsafeArgs), /exactly \['acp'\]/);
|
||||
const platforms = [
|
||||
{ type: "qq", appId: "QQ_APP_ID", clientSecret: "QQ_CLIENT_SECRET", botNames: ["QQ_BOT_NAME"] },
|
||||
{ type: "feishu", appId: "FEISHU_APP_ID", appSecret: "FEISHU_APP_SECRET" },
|
||||
|
||||
+3
-2
@@ -24,7 +24,8 @@ test("parses Config v3 defaults for one Bot, agent, and platform", () => {
|
||||
assert.equal(config.bot.id, "test-bot");
|
||||
assert.equal(config.bot.agent.id, "kimi");
|
||||
assert.equal(config.gateway.platform.type, "webhook");
|
||||
assert.equal(config.runtime.acp.promptTimeoutMs, 7_200_000);
|
||||
assert.equal(config.runtime.acp.promptTimeoutMs, 14_400_000);
|
||||
assert.equal(config.runtime.acp.maxAssistantSessions, 4);
|
||||
assert.equal(config.bot.permissions.mode, "deny");
|
||||
});
|
||||
|
||||
@@ -60,7 +61,7 @@ test("config.example.json is a parseable, secret-free documentation template", (
|
||||
assert.equal(config.configVersion, 3);
|
||||
assert.equal(config.bot.id, "BOT_ID");
|
||||
assert.equal(config.bot.permissions.mode, "deny");
|
||||
assert.equal(config.runtime.acp.promptTimeoutMs, 7_200_000);
|
||||
assert.equal(config.runtime.acp.promptTimeoutMs, 14_400_000);
|
||||
assert.equal(config.gateway.platform.type, "qq");
|
||||
assert.match(text, /QQ_CLIENT_SECRET/);
|
||||
assert.doesNotMatch(text, /configVersion"\s*:\s*[12]/);
|
||||
|
||||
@@ -3,43 +3,65 @@ 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";
|
||||
import { DurableSessionStore, chatKeyFor } from "../src/core/durable-session-store.js";
|
||||
|
||||
const identity = { botId: "test-bot", platform: "qq" };
|
||||
|
||||
test("persists state v2 binding across reopen with 0600 atomic file", async () => {
|
||||
const binding = { chatKey: "qq:chat", agentId: "kimi", nativeSessionId: "native-1", assistantWorkspace: "/tmp/assistant", botFingerprint: "fp", createdAt: 1, updatedAt: 1 };
|
||||
|
||||
test("persists state v3 assistant binding across reopen with 0600 atomic file", 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, identity);
|
||||
await first.open();
|
||||
await first.setBinding({ chatKey: "qq:chat", agentId: "kimi", nativeSessionId: "native-1", workspace: "/tmp", botFingerprint: "fp", createdAt: 1, updatedAt: 1 });
|
||||
await first.setBinding(binding);
|
||||
await first.close();
|
||||
assert.equal((await fs.promises.readdir(dir)).some((name) => name.endsWith(".tmp")), false);
|
||||
assert.equal((await fs.promises.stat(file)).mode & 0o777, 0o600);
|
||||
const persisted = JSON.parse(await fs.promises.readFile(file, "utf8"));
|
||||
assert.equal(persisted.version, 2); assert.equal(persisted.botId, "test-bot"); assert.equal(persisted.platform, "qq");
|
||||
const text = await fs.promises.readFile(file, "utf8");
|
||||
const persisted = JSON.parse(text);
|
||||
assert.equal(persisted.version, 3); assert.equal(persisted.botId, "test-bot"); assert.equal(persisted.platform, "qq");
|
||||
assert.deepEqual(persisted.bindings["qq:chat"], binding);
|
||||
assert.equal("mainTask" in persisted, false);
|
||||
assert.doesNotMatch(text, /message|prompt|summary|content|lease/i);
|
||||
|
||||
const second = new DurableSessionStore(file, identity);
|
||||
await second.open();
|
||||
assert.equal(second.getBinding("qq:chat")?.nativeSessionId, "native-1");
|
||||
assert.deepEqual(second.getBinding("qq:chat"), binding);
|
||||
assert.equal(second.stats().bindings, 1);
|
||||
await second.touchBinding("qq:chat");
|
||||
const touched = second.getBinding("qq:chat");
|
||||
assert.ok((touched?.updatedAt ?? 0) >= 1);
|
||||
assert.deepEqual(await second.deleteBinding("qq:chat"), touched);
|
||||
assert.equal(second.getBinding("qq:chat"), undefined);
|
||||
await second.close();
|
||||
});
|
||||
|
||||
test("rejects old state and bot or platform identity mismatch without rewriting", async () => {
|
||||
test("rejects state v1/v2 and identity mismatch without rewriting the original file", async () => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-id-"));
|
||||
const file = path.join(dir, "state.json");
|
||||
const old = '{"version":1,"bindings":{}}\n';
|
||||
await fs.promises.writeFile(file, old);
|
||||
await assert.rejects(new DurableSessionStore(file, identity).open(), /requires state v2/);
|
||||
assert.equal(await fs.promises.readFile(file, "utf8"), old);
|
||||
const v1 = '{"version":1,"bindings":{}}\n';
|
||||
await fs.promises.writeFile(file, v1);
|
||||
await assert.rejects(new DurableSessionStore(file, identity).open(), /requires state v3/);
|
||||
assert.equal(await fs.promises.readFile(file, "utf8"), v1);
|
||||
|
||||
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "other", platform: "qq", bindings: {} }));
|
||||
const v2 = JSON.stringify({ version: 2, botId: "test-bot", platform: "qq", bindings: {}, mainTask: { ownerChatKey: "qq:chat", state: "running", workspace: "/tmp", botFingerprint: "fp", startedAt: 1, updatedAt: 1 } });
|
||||
await fs.promises.writeFile(file, v2);
|
||||
await assert.rejects(new DurableSessionStore(file, identity).open(), /requires state v3/);
|
||||
assert.equal(await fs.promises.readFile(file, "utf8"), v2);
|
||||
|
||||
await fs.promises.writeFile(file, JSON.stringify({ version: 3, botId: "other", platform: "qq", bindings: {} }));
|
||||
await assert.rejects(new DurableSessionStore(file, identity).open(), /identity mismatch/);
|
||||
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "test-bot", platform: "feishu", bindings: {} }));
|
||||
await fs.promises.writeFile(file, JSON.stringify({ version: 3, botId: "test-bot", platform: "feishu", bindings: {} }));
|
||||
await assert.rejects(new DurableSessionStore(file, identity).open(), /identity mismatch/);
|
||||
|
||||
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "test-bot", platform: "qq", bindings: { "qq:chat": { chatKey: "qq:other" } } }));
|
||||
await assert.rejects(new DurableSessionStore(file, identity).open(), /invalid state v2 binding/);
|
||||
await fs.promises.writeFile(file, JSON.stringify({ version: 3, botId: "test-bot", platform: "qq", bindings: { "qq:chat": { chatKey: "qq:other" } } }));
|
||||
await assert.rejects(new DurableSessionStore(file, identity).open(), /invalid state v3 binding/);
|
||||
|
||||
const legacyField = { ...binding, workspace: "/tmp" } as Record<string, unknown>;
|
||||
delete legacyField.assistantWorkspace;
|
||||
await fs.promises.writeFile(file, JSON.stringify({ version: 3, botId: "test-bot", platform: "qq", bindings: { "qq:chat": legacyField } }));
|
||||
await assert.rejects(new DurableSessionStore(file, identity).open(), /invalid state v3 binding/);
|
||||
});
|
||||
|
||||
test("preserves corrupt state and refuses a second writer lock", async () => {
|
||||
@@ -60,3 +82,8 @@ test("preserves corrupt state and refuses a second writer lock", async () => {
|
||||
await recovered.close();
|
||||
assert.equal(fs.existsSync(`${file}.lock`), false);
|
||||
});
|
||||
|
||||
test("chatKeyFor builds platform-qualified keys", () => {
|
||||
assert.equal(chatKeyFor("qq", "group:abc"), "qq:group:abc");
|
||||
assert.equal(chatKeyFor("feishu", "oc_1"), "feishu:oc_1");
|
||||
});
|
||||
|
||||
Vendored
+93
-18
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import * as acp from "@agentclientprotocol/sdk";
|
||||
@@ -6,6 +7,8 @@ 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 assistantEnvelope = (reply, actions) => `${reply}\n<GORI_ASSISTANT_ACTION_V1>${JSON.stringify({ reply, actions })}</GORI_ASSISTANT_ACTION_V1>`;
|
||||
const workerEnvelope = (result) => `worker reply\n<GORI_WORKER_RESULT_V1>${JSON.stringify(result)}</GORI_WORKER_RESULT_V1>`;
|
||||
|
||||
const app = acp.agent({ name: "fake-acp-agent" })
|
||||
.onRequest(acp.methods.agent.initialize, ({ params }) => {
|
||||
@@ -16,9 +19,10 @@ const app = acp.agent({ name: "fake-acp-agent" })
|
||||
agentInfo: { name: "fake-acp-agent", version: "1" }
|
||||
};
|
||||
})
|
||||
.onRequest(acp.methods.agent.session.new, ({ params }) => {
|
||||
.onRequest(acp.methods.agent.session.new, async ({ params }) => {
|
||||
const sessionId = `fake-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
log({ method: "session/new", sessionId, cwd: params.cwd });
|
||||
if (process.env.FAKE_ACP_NEW_HANG === "1") await new Promise(() => undefined);
|
||||
return { sessionId };
|
||||
})
|
||||
.onRequest(acp.methods.agent.session.load, ({ params }) => {
|
||||
@@ -36,20 +40,94 @@ const app = acp.agent({ name: "fake-acp-agent" })
|
||||
.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");
|
||||
if (text === "crash" || text.startsWith("crash\n\nWhen this turn")) process.exit(9);
|
||||
if (text.includes("Initialize this ACP session")) {
|
||||
if (process.env.FAKE_ACP_BOOTSTRAP_DELAY_MS) await new Promise((resolve) => setTimeout(resolve, Number(process.env.FAKE_ACP_BOOTSTRAP_DELAY_MS)));
|
||||
await update(client, params.sessionId, "READY");
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
if (text === "hang") {
|
||||
|
||||
// Assistant protocol
|
||||
if (text.startsWith("Your previous response did not end with a valid GORI_ASSISTANT_ACTION_V1")) {
|
||||
await update(client, params.sessionId, assistantEnvelope("repaired", []));
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
if (text.startsWith("Your previous response did not end with a valid GORI_WORKER_RESULT_V1")) {
|
||||
await update(client, params.sessionId, process.env.FAKE_ACP_INVALID_REPAIR === "1"
|
||||
? "still invalid"
|
||||
: workerEnvelope({ status: "SUCCESS", summary: "repaired" }));
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
if (text.includes("[Internal event")) {
|
||||
await update(client, params.sessionId, assistantEnvelope("event received: worker update", []));
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
if (text.includes("[User message]")) {
|
||||
const userText = (text.split("[User message]\n")[1] || "").split("\n\n")[0].trim();
|
||||
if (userText === "force tool") {
|
||||
await client.notify(acp.methods.client.session.update, {
|
||||
sessionId: params.sessionId,
|
||||
update: { sessionUpdate: "tool_call", toolCallId: "read-1", title: "Read a file", kind: "read", status: "in_progress" }
|
||||
});
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
let response;
|
||||
if (userText.startsWith("create proposal:")) {
|
||||
const goal = userText.slice("create proposal:".length).trim();
|
||||
response = assistantEnvelope("proposal drafted", [{ type: "create_proposal", title: "Test proposal", goal, steps: ["step 1"] }]);
|
||||
} else if (userText === "confirm") response = assistantEnvelope("confirmed", [{ type: "confirm" }]);
|
||||
else if (userText === "confirm retry") response = assistantEnvelope("retrying", [{ type: "confirm", answer: "retry" }]);
|
||||
else if (userText.startsWith("answer:")) response = assistantEnvelope("answering", [{ type: "confirm", answer: userText.slice("answer:".length).trim() }]);
|
||||
else if (userText === "start next") response = assistantEnvelope("starting next", [{ type: "start_next" }]);
|
||||
else if (userText === "stop") response = assistantEnvelope("stopping", [{ type: "stop" }]);
|
||||
else if (userText === "cancel proposal") response = assistantEnvelope("cancelling", [{ type: "cancel" }]);
|
||||
else if (userText === "invalid assistant") response = "invalid without envelope";
|
||||
else response = assistantEnvelope("ok", []);
|
||||
await update(client, params.sessionId, response);
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
|
||||
// Worker protocol
|
||||
if (text.startsWith("The user answered your question")) {
|
||||
await update(client, params.sessionId, workerEnvelope({ status: "SUCCESS", summary: `continued ${params.sessionId}` }));
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
if (text.startsWith("Execute this confirmed proposal")) {
|
||||
const goal = ((text.split("Goal: ")[1] || "").split("\n")[0] || "").trim();
|
||||
if (goal.includes("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" };
|
||||
}
|
||||
if (goal.includes("invalid")) {
|
||||
await update(client, params.sessionId, "invalid without envelope");
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
if (process.env.FAKE_ACP_WORKER_GATE_FILE) {
|
||||
while (!fs.existsSync(process.env.FAKE_ACP_WORKER_GATE_FILE)) await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
const result = goal.includes("ask")
|
||||
? { status: "NEEDS_CONFIRMATION", summary: "hit a dirty target", question: "May I overwrite it?", nextStep: "overwrite it", dirty: true }
|
||||
: goal.includes("fail")
|
||||
? { status: "FAILED", summary: "something broke" }
|
||||
: { status: "SUCCESS", summary: "goal done" };
|
||||
await update(client, params.sessionId, workerEnvelope(result));
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
|
||||
// Legacy behaviors used by acp-worker/acp-client tests
|
||||
const request = text.split("\n\nWhen this turn is finished")[0];
|
||||
if (request === "spawn descendant") {
|
||||
const descendant = spawn(process.execPath, ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"], { stdio: "ignore" });
|
||||
if (process.env.FAKE_ACP_DESCENDANT_PID_FILE) fs.writeFileSync(process.env.FAKE_ACP_DESCENDANT_PID_FILE, `${descendant.pid}\n`);
|
||||
await new Promise((resolve) => signal.addEventListener("abort", resolve, { once: true }));
|
||||
return { stopReason: "cancelled" };
|
||||
}
|
||||
if (request === "hang") {
|
||||
await new Promise((resolve) => {
|
||||
const done = () => resolve(undefined);
|
||||
pending.set(params.sessionId, done);
|
||||
@@ -58,17 +136,14 @@ const app = acp.agent({ name: "fake-acp-agent" })
|
||||
pending.delete(params.sessionId);
|
||||
return { stopReason: "cancelled" };
|
||||
}
|
||||
if (text === "activity") {
|
||||
if (request === "activity") {
|
||||
await update(client, params.sessionId, "first");
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
await update(client, params.sessionId, "second");
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
if (text.includes("Initialize this ACP session") && process.env.FAKE_ACP_BOOTSTRAP_DELAY_MS) {
|
||||
await new Promise((resolve) => setTimeout(resolve, Number(process.env.FAKE_ACP_BOOTSTRAP_DELAY_MS)));
|
||||
}
|
||||
await update(client, params.sessionId, text.includes("Initialize this ACP session") ? "READY" : `reply:${params.sessionId}:${text}`);
|
||||
await update(client, params.sessionId, `reply:${params.sessionId}:${request}`);
|
||||
return { stopReason: "end_turn" };
|
||||
})
|
||||
.onNotification(acp.methods.agent.session.cancel, ({ params }) => {
|
||||
|
||||
+166
-285
@@ -2,7 +2,8 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { ConversationRequest, ConversationRuntime } from "../src/acp/types.js";
|
||||
import type { PlatformAdapter } from "../src/core/adapter.js";
|
||||
import { Gateway, type GatewayTimer } from "../src/core/gateway.js";
|
||||
import { Gateway } from "../src/core/gateway.js";
|
||||
import type { Proposal } from "../src/core/proposal-store.js";
|
||||
import type { IncomingMessage, OutgoingMessage } from "../src/core/types.js";
|
||||
|
||||
interface PendingTurn {
|
||||
@@ -11,11 +12,31 @@ interface PendingTurn {
|
||||
reject(error: Error): void;
|
||||
}
|
||||
|
||||
function fakeProposal(overrides: Partial<Proposal> = {}): Proposal {
|
||||
return {
|
||||
id: "proposal-1",
|
||||
title: "修复登录页",
|
||||
goal: "goal",
|
||||
steps: ["step"],
|
||||
ownerChatKey: "qq:chat",
|
||||
requesterUserId: "user",
|
||||
status: "proposed",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
class FakeRuntime implements ConversationRuntime {
|
||||
readonly turns: PendingTurn[] = [];
|
||||
cancelled = 0;
|
||||
resets = 0;
|
||||
idleSeconds = 0;
|
||||
confirmed = 0;
|
||||
stopped = 0;
|
||||
listed = 0;
|
||||
cancelResult = true;
|
||||
confirmResult = true;
|
||||
stopResult = true;
|
||||
proposals: Proposal[] = [];
|
||||
|
||||
async prompt(request: ConversationRequest) {
|
||||
return new Promise<{ text: string; botId: string; agentId: string }>((resolve, reject) => {
|
||||
@@ -26,75 +47,21 @@ class FakeRuntime implements ConversationRuntime {
|
||||
});
|
||||
});
|
||||
}
|
||||
async cancel() { this.cancelled++; this.turns.at(-1)?.resolve("cancelled"); return true; }
|
||||
async reset() { this.resets++; }
|
||||
async cancel() { this.cancelled++; return this.cancelResult; }
|
||||
async confirm() { this.confirmed++; return this.confirmResult; }
|
||||
async stop() { this.stopped++; return this.stopResult; }
|
||||
listProposals() { this.listed++; return this.proposals; }
|
||||
async reset() {}
|
||||
status() {
|
||||
return {
|
||||
bot: "test-bot", agent: "kimi", workspace: "/tmp",
|
||||
running: this.turns.length > 0, phase: "processing", idleSeconds: this.idleSeconds
|
||||
running: this.turns.length > 0, phase: "processing"
|
||||
};
|
||||
}
|
||||
stats() { return { activeWorkers: 0, inFlight: 0, crashes: 0, persistedBindings: 0 }; }
|
||||
async shutdown() {}
|
||||
}
|
||||
|
||||
interface ScheduledTimer extends GatewayTimer {
|
||||
deadline: number;
|
||||
callback: () => void;
|
||||
cancelled: boolean;
|
||||
order: number;
|
||||
}
|
||||
|
||||
class FakeClock {
|
||||
nowMs = 0;
|
||||
private order = 0;
|
||||
readonly timers: ScheduledTimer[] = [];
|
||||
|
||||
readonly now = () => this.nowMs;
|
||||
readonly schedule = (callback: () => void, delayMs: number): GatewayTimer => {
|
||||
const timer: ScheduledTimer = {
|
||||
deadline: this.nowMs + delayMs,
|
||||
callback,
|
||||
cancelled: false,
|
||||
order: this.order++,
|
||||
cancel() { timer.cancelled = true; }
|
||||
};
|
||||
this.timers.push(timer);
|
||||
return timer;
|
||||
};
|
||||
|
||||
set(timeMs: number) { this.nowMs = timeMs; }
|
||||
|
||||
async advanceTo(timeMs: number) {
|
||||
while (true) {
|
||||
const next = this.timers
|
||||
.filter((timer) => !timer.cancelled && timer.deadline <= timeMs)
|
||||
.sort((left, right) => left.deadline - right.deadline || left.order - right.order)[0];
|
||||
if (!next) break;
|
||||
next.cancelled = true;
|
||||
this.nowMs = next.deadline;
|
||||
next.callback();
|
||||
await flush();
|
||||
}
|
||||
this.nowMs = timeMs;
|
||||
}
|
||||
|
||||
async jumpTo(timeMs: number) {
|
||||
this.nowMs = timeMs;
|
||||
const due = this.timers
|
||||
.filter((timer) => !timer.cancelled && timer.deadline <= timeMs)
|
||||
.sort((left, right) => left.deadline - right.deadline || left.order - right.order);
|
||||
for (const timer of due) {
|
||||
if (timer.cancelled) continue;
|
||||
timer.cancelled = true;
|
||||
timer.callback();
|
||||
await flush();
|
||||
}
|
||||
}
|
||||
|
||||
activeTimers() { return this.timers.filter((timer) => !timer.cancelled).length; }
|
||||
}
|
||||
|
||||
const policy = { allowedUsers: [] as string[], allowedChats: [] as string[], requireMentionInGroup: false };
|
||||
const message = (text: string, messageId = text, chatId = "chat"): IncomingMessage => ({
|
||||
platform: "qq", chatId, userId: "user", text, messageId
|
||||
@@ -109,8 +76,8 @@ function recordingAdapter(sent: OutgoingMessage[] = []): PlatformAdapter {
|
||||
return { name: "test", async handleWebhook() { return {}; }, async sendMessage(outgoing) { sent.push(outgoing); } };
|
||||
}
|
||||
|
||||
function createGateway(runtime = new FakeRuntime(), clock = new FakeClock()) {
|
||||
return { runtime, clock, gateway: new Gateway(policy, runtime, { now: clock.now, schedule: clock.schedule }) };
|
||||
function createGateway(runtime = new FakeRuntime()) {
|
||||
return { runtime, gateway: new Gateway(policy, runtime) };
|
||||
}
|
||||
|
||||
function messagesFor(sent: OutgoingMessage[], replyTo: string) {
|
||||
@@ -127,172 +94,43 @@ test("short asynchronous work sends only the real result with sequence one", asy
|
||||
assert.deepEqual(sent.map((outgoing) => [outgoing.text, outgoing.replySequence]), [["done", 1]]);
|
||||
});
|
||||
|
||||
test("completion and timer callbacks are safe in both orders at 15 seconds", async () => {
|
||||
{
|
||||
const { runtime, clock, gateway } = createGateway();
|
||||
const sent: OutgoingMessage[] = [];
|
||||
const turn = gateway.receive(message("work", "completion-first"), recordingAdapter(sent));
|
||||
await waitFor(() => runtime.turns.length === 1);
|
||||
clock.set(15_000);
|
||||
runtime.turns[0].resolve("done");
|
||||
await turn;
|
||||
await clock.advanceTo(15_000);
|
||||
assert.deepEqual(sent.map((outgoing) => outgoing.text), ["done"]);
|
||||
}
|
||||
{
|
||||
const { runtime, clock, gateway } = createGateway();
|
||||
const sent: OutgoingMessage[] = [];
|
||||
const turn = gateway.receive(message("work", "timer-first"), recordingAdapter(sent));
|
||||
await waitFor(() => runtime.turns.length === 1);
|
||||
await clock.advanceTo(15_000);
|
||||
runtime.turns[0].resolve("done");
|
||||
await turn;
|
||||
assert.deepEqual(sent.map((outgoing) => [outgoing.text, outgoing.replySequence]), [
|
||||
["收到,我还在处理,稍等我一下。", 1],
|
||||
["done", 2]
|
||||
]);
|
||||
}
|
||||
test("ordinary asynchronous messages send nothing before the runtime resolves", async () => {
|
||||
const { runtime, gateway } = createGateway();
|
||||
const sent: OutgoingMessage[] = [];
|
||||
const turn = gateway.receive(message("work", "no-timer"), recordingAdapter(sent));
|
||||
await waitFor(() => runtime.turns.length === 1);
|
||||
await flush();
|
||||
assert.deepEqual(sent, []);
|
||||
runtime.turns[0].resolve("done");
|
||||
await turn;
|
||||
assert.deepEqual(sent.map((outgoing) => outgoing.text), ["done"]);
|
||||
});
|
||||
|
||||
test("running and queued notices are independent and queued work announces start", async () => {
|
||||
const { runtime, clock, gateway } = createGateway();
|
||||
test("ordinary messages reach the runtime immediately without a Gateway queue", async () => {
|
||||
const { runtime, gateway } = createGateway();
|
||||
const sent: OutgoingMessage[] = [];
|
||||
const adapter = recordingAdapter(sent);
|
||||
const first = gateway.receive(message("one", "first"), adapter);
|
||||
const second = gateway.receive(message("two", "second"), adapter);
|
||||
await waitFor(() => runtime.turns.length === 1);
|
||||
await clock.advanceTo(15_000);
|
||||
assert.deepEqual(messagesFor(sent, "first").map((outgoing) => outgoing.text), ["收到,我还在处理,稍等我一下。"]);
|
||||
assert.deepEqual(messagesFor(sent, "second").map((outgoing) => outgoing.text), ["前面还有一件事没处理完,这条我记着,轮到后马上处理。"]);
|
||||
await waitFor(() => runtime.turns.length === 2);
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
runtime.turns[0].resolve("first done");
|
||||
await waitFor(() => runtime.turns.length === 2);
|
||||
assert.deepEqual(messagesFor(sent, "second").map((outgoing) => [outgoing.text, outgoing.replySequence]), [
|
||||
["前面还有一件事没处理完,这条我记着,轮到后马上处理。", 1],
|
||||
["轮到这件事了,我现在开始处理。", 2]
|
||||
]);
|
||||
runtime.turns[1].resolve("second done");
|
||||
await Promise.all([first, second]);
|
||||
assert.deepEqual(messagesFor(sent, "first").map((outgoing) => outgoing.replySequence), [1, 2]);
|
||||
assert.deepEqual(messagesFor(sent, "second").map((outgoing) => outgoing.replySequence), [1, 2, 3]);
|
||||
assert.deepEqual(messagesFor(sent, "first").map((outgoing) => [outgoing.text, outgoing.replySequence]), [["first done", 1]]);
|
||||
assert.deepEqual(messagesFor(sent, "second").map((outgoing) => [outgoing.text, outgoing.replySequence]), [["second done", 1]]);
|
||||
});
|
||||
|
||||
test("running reminders use staged wording and wait from 8 to 18 minutes", async () => {
|
||||
const { runtime, clock, gateway } = createGateway();
|
||||
const sent: OutgoingMessage[] = [];
|
||||
const turn = gateway.receive(message("work", "reminders"), recordingAdapter(sent));
|
||||
await waitFor(() => runtime.turns.length === 1);
|
||||
await clock.advanceTo(15_000);
|
||||
await clock.advanceTo(60_000);
|
||||
await clock.advanceTo(180_000);
|
||||
await clock.advanceTo(480_000);
|
||||
assert.deepEqual(sent.map((outgoing) => outgoing.text), [
|
||||
"收到,我还在处理,稍等我一下。",
|
||||
"还在弄,暂时还没出结果,弄好我马上回你。",
|
||||
"这件事比预想中多花了一点时间,我还在继续处理。你不用一直盯着,弄好我会直接回你。",
|
||||
"我还在处理这件事,确实花了些时间。想看看现在的状态可以发 /status,不想继续等也可以发 /cancel。"
|
||||
]);
|
||||
await clock.advanceTo(780_000);
|
||||
assert.equal(sent.length, 4);
|
||||
await clock.advanceTo(1_080_000);
|
||||
assert.equal(sent.at(-1)?.text,
|
||||
"我还在处理这件事,确实花了些时间。想看看现在的状态可以发 /status,不想继续等也可以发 /cancel。");
|
||||
assert.equal(sent.length, 5);
|
||||
runtime.turns[0].resolve("done");
|
||||
await turn;
|
||||
});
|
||||
|
||||
test("queued reminders use staged queue wording without ACP activity details", async () => {
|
||||
const { runtime, clock, gateway } = createGateway();
|
||||
const sent: OutgoingMessage[] = [];
|
||||
const adapter = recordingAdapter(sent);
|
||||
const first = gateway.receive(message("one", "active"), adapter);
|
||||
const second = gateway.receive(message("two", "queued"), adapter);
|
||||
await waitFor(() => runtime.turns.length === 1);
|
||||
await clock.advanceTo(15_000);
|
||||
await clock.advanceTo(60_000);
|
||||
assert.deepEqual(messagesFor(sent, "queued").map((outgoing) => outgoing.text), [
|
||||
"前面还有一件事没处理完,这条我记着,轮到后马上处理。",
|
||||
"前面的事情还没处理完,这条还在等。我没有漏掉,轮到后会接着做。"
|
||||
]);
|
||||
runtime.turns[0].resolve("one done");
|
||||
await waitFor(() => runtime.turns.length === 2);
|
||||
runtime.turns[1].resolve("two done");
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
test("a delayed timer jump emits one reminder instead of backfilling every deadline", async () => {
|
||||
const { runtime, clock, gateway } = createGateway();
|
||||
const sent: OutgoingMessage[] = [];
|
||||
const turn = gateway.receive(message("work", "jump"), recordingAdapter(sent));
|
||||
await waitFor(() => runtime.turns.length === 1);
|
||||
await clock.advanceTo(15_000);
|
||||
await clock.jumpTo(1_000_000);
|
||||
assert.equal(sent.length, 2);
|
||||
assert.equal(clock.activeTimers(), 1);
|
||||
runtime.turns[0].resolve("done");
|
||||
await turn;
|
||||
});
|
||||
|
||||
test("completion and shutdown cancel all inbound timers", async () => {
|
||||
const { runtime, clock, gateway } = createGateway();
|
||||
const sent: OutgoingMessage[] = [];
|
||||
const first = gateway.receive(message("one", "complete"), recordingAdapter(sent));
|
||||
await waitFor(() => runtime.turns.length === 1);
|
||||
assert.equal(clock.activeTimers(), 1);
|
||||
runtime.turns[0].resolve("done");
|
||||
await first;
|
||||
assert.equal(clock.activeTimers(), 0);
|
||||
|
||||
const second = gateway.receive(message("two", "shutdown"), recordingAdapter(sent));
|
||||
await waitFor(() => runtime.turns.length === 2);
|
||||
assert.equal(clock.activeTimers(), 1);
|
||||
gateway.shutdown();
|
||||
assert.equal(clock.activeTimers(), 0);
|
||||
await clock.advanceTo(600_000);
|
||||
assert.deepEqual(messagesFor(sent, "shutdown"), []);
|
||||
runtime.turns[1].resolve("done after shutdown");
|
||||
await second;
|
||||
});
|
||||
|
||||
test("prompt does not wait for a notice send and final delivery delay releases the chat lock", async () => {
|
||||
const { runtime, clock, gateway } = createGateway();
|
||||
const sent: OutgoingMessage[] = [];
|
||||
let releaseFirstSend!: () => void;
|
||||
const firstSend = new Promise<void>((resolve) => { releaseFirstSend = resolve; });
|
||||
const adapter: PlatformAdapter = {
|
||||
name: "test", async handleWebhook() { return {}; },
|
||||
async sendMessage(outgoing) {
|
||||
sent.push(outgoing);
|
||||
if (outgoing.replyTo === "first" && outgoing.replySequence === 1) await firstSend;
|
||||
}
|
||||
};
|
||||
const first = gateway.receive(message("one", "first"), adapter);
|
||||
await waitFor(() => runtime.turns.length === 1);
|
||||
await clock.advanceTo(15_000);
|
||||
assert.equal(runtime.turns.length, 1);
|
||||
runtime.turns[0].resolve("first done");
|
||||
const second = gateway.receive(message("two", "second"), adapter);
|
||||
await waitFor(() => runtime.turns.length === 2);
|
||||
assert.equal(messagesFor(sent, "first").length, 1);
|
||||
releaseFirstSend();
|
||||
runtime.turns[1].resolve("second done");
|
||||
await Promise.all([first, second]);
|
||||
assert.deepEqual(messagesFor(sent, "first").map((outgoing) => outgoing.text), [
|
||||
"收到,我还在处理,稍等我一下。", "first done"
|
||||
]);
|
||||
});
|
||||
|
||||
test("delivery failures are logged safely and do not block later sends", async () => {
|
||||
const { runtime, clock, gateway } = createGateway();
|
||||
test("delivery failures are logged safely and do not block the result", async () => {
|
||||
const { runtime, gateway } = createGateway();
|
||||
const sent: OutgoingMessage[] = [];
|
||||
const errors: string[] = [];
|
||||
let sends = 0;
|
||||
const adapter: PlatformAdapter = {
|
||||
name: "test", async handleWebhook() { return {}; },
|
||||
async sendMessage(outgoing) {
|
||||
sent.push(outgoing);
|
||||
if (++sends === 1) throw new Error("sensitive provider response");
|
||||
throw new Error("sensitive provider response");
|
||||
}
|
||||
};
|
||||
const originalError = console.error;
|
||||
@@ -300,35 +138,12 @@ test("delivery failures are logged safely and do not block later sends", async (
|
||||
try {
|
||||
const turn = gateway.receive(message("work", "failure"), adapter);
|
||||
await waitFor(() => runtime.turns.length === 1);
|
||||
await clock.advanceTo(15_000);
|
||||
runtime.turns[0].resolve("done");
|
||||
assert.deepEqual(await turn, { ok: true, reply: "done" });
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
assert.deepEqual(sent.map((outgoing) => outgoing.text), ["收到,我还在处理,稍等我一下。", "done"]);
|
||||
assert.deepEqual(errors, ["Gateway delivery send failed (platform=qq)"]);
|
||||
});
|
||||
|
||||
test("a failed final send does not turn a successful runtime result into Agent error", async () => {
|
||||
const { runtime, gateway } = createGateway();
|
||||
const sent: OutgoingMessage[] = [];
|
||||
const errors: string[] = [];
|
||||
const adapter: PlatformAdapter = {
|
||||
name: "test", async handleWebhook() { return {}; },
|
||||
async sendMessage(outgoing) { sent.push(outgoing); throw new Error("provider detail"); }
|
||||
};
|
||||
const originalError = console.error;
|
||||
console.error = (...args: unknown[]) => { errors.push(args.map(String).join(" ")); };
|
||||
try {
|
||||
const turn = gateway.receive(message("work", "final-failure"), adapter);
|
||||
await waitFor(() => runtime.turns.length === 1);
|
||||
runtime.turns[0].resolve("real result");
|
||||
assert.deepEqual(await turn, { ok: true, reply: "real result" });
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
assert.deepEqual(sent.map((outgoing) => outgoing.text), ["real result"]);
|
||||
assert.deepEqual(sent.map((outgoing) => outgoing.text), ["done"]);
|
||||
assert.deepEqual(errors, ["Gateway delivery send failed (platform=qq)"]);
|
||||
});
|
||||
|
||||
@@ -342,45 +157,130 @@ test("runtime errors remain runtime errors and are delivered through the same st
|
||||
assert.deepEqual(sent.map((outgoing) => [outgoing.text, outgoing.replySequence]), [["Agent error: failed", 1]]);
|
||||
});
|
||||
|
||||
test("status, cancel, and help bypass the chat lock while new remains serial", async () => {
|
||||
const { runtime, clock, gateway } = createGateway();
|
||||
test("sendEvent pushes a fresh message to the latest chat target without replyTo", async () => {
|
||||
const { runtime, gateway } = createGateway();
|
||||
const sent: OutgoingMessage[] = [];
|
||||
const turn = gateway.receive(message("work", "event-source"), recordingAdapter(sent));
|
||||
await waitFor(() => runtime.turns.length === 1);
|
||||
runtime.turns[0].resolve("done");
|
||||
await turn;
|
||||
|
||||
await gateway.sendEvent("qq:chat", "任务完成了");
|
||||
const event = sent.at(-1)!;
|
||||
assert.equal(event.text, "任务完成了");
|
||||
assert.equal(event.replyTo, undefined);
|
||||
assert.equal(event.replySequence, undefined);
|
||||
assert.deepEqual(event.target, { platform: "qq", chatId: "chat", userId: "user", raw: undefined });
|
||||
});
|
||||
|
||||
test("sendEvent uses the most recent target and adapter for the chat", async () => {
|
||||
const { runtime, gateway } = createGateway();
|
||||
const firstSent: OutgoingMessage[] = [];
|
||||
const secondSent: OutgoingMessage[] = [];
|
||||
const first = gateway.receive(message("one", "one", "chat"), recordingAdapter(firstSent));
|
||||
await waitFor(() => runtime.turns.length === 1);
|
||||
const second = gateway.receive(message("two", "two", "chat"), recordingAdapter(secondSent));
|
||||
await waitFor(() => runtime.turns.length === 2);
|
||||
runtime.turns[0].resolve("one done");
|
||||
runtime.turns[1].resolve("two done");
|
||||
await Promise.all([first, second]);
|
||||
|
||||
await gateway.sendEvent("qq:chat", "event");
|
||||
assert.equal(firstSent.filter((outgoing) => outgoing.text === "event").length, 0);
|
||||
assert.equal(secondSent.at(-1)?.text, "event");
|
||||
assert.equal(secondSent.at(-1)?.replyTo, undefined);
|
||||
});
|
||||
|
||||
test("sendEvent drops safely when the chat has no known target", async () => {
|
||||
const { gateway } = createGateway();
|
||||
await gateway.sendEvent("qq:unknown", "event");
|
||||
});
|
||||
|
||||
test("sendEvent delivery failures are logged safely without message content", async () => {
|
||||
const { runtime, gateway } = createGateway();
|
||||
const errors: string[] = [];
|
||||
const adapter: PlatformAdapter = {
|
||||
name: "test", async handleWebhook() { return {}; },
|
||||
async sendMessage(outgoing) {
|
||||
if (!outgoing.replyTo) throw new Error("sensitive provider response");
|
||||
}
|
||||
};
|
||||
const turn = gateway.receive(message("work", "event-failure"), adapter);
|
||||
await waitFor(() => runtime.turns.length === 1);
|
||||
runtime.turns[0].resolve("done");
|
||||
await turn;
|
||||
|
||||
const originalError = console.error;
|
||||
console.error = (...args: unknown[]) => { errors.push(args.map(String).join(" ")); };
|
||||
try {
|
||||
await gateway.sendEvent("qq:chat", "event text must not leak");
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
assert.deepEqual(errors, ["Gateway event send failed (platform=qq)"]);
|
||||
});
|
||||
|
||||
test("status and help reach the runtime or reply directly", async () => {
|
||||
const { runtime, gateway } = createGateway();
|
||||
const adapter = recordingAdapter();
|
||||
const turn = gateway.receive(message("work"), adapter, { synchronous: true });
|
||||
await waitFor(() => runtime.turns.length === 1);
|
||||
clock.set(5_000);
|
||||
|
||||
const status = await gateway.receive(message("/status"), adapter, { synchronous: true });
|
||||
assert.match(status.reply || "", /gatewayRunning=true/);
|
||||
assert.match(status.reply || "", /queued=0/);
|
||||
assert.match(status.reply || "", /gatewayRunningSeconds=5/);
|
||||
assert.match((await gateway.receive(message("/help"), adapter, { synchronous: true })).reply || "", /\/status/);
|
||||
assert.equal((await gateway.receive(message("/cancel"), adapter, { synchronous: true })).reply, "Cancellation requested.");
|
||||
assert.match(status.reply || "", /running=true/);
|
||||
const help = await gateway.receive(message("/help"), adapter, { synchronous: true });
|
||||
assert.match(help.reply || "", /自然语言/);
|
||||
assert.match(help.reply || "", /\/confirm/);
|
||||
assert.match(help.reply || "", /\/stop/);
|
||||
assert.match(help.reply || "", /\/cancel/);
|
||||
runtime.turns[0].resolve("done");
|
||||
await turn;
|
||||
|
||||
const second = gateway.receive(message("work"), adapter, { synchronous: true });
|
||||
await waitFor(() => runtime.turns.length === 2);
|
||||
const reset = gateway.receive(message("/new"), adapter, { synchronous: true });
|
||||
await flush();
|
||||
assert.equal(runtime.resets, 0);
|
||||
runtime.turns[1].resolve("done");
|
||||
await second;
|
||||
await reset;
|
||||
assert.equal(runtime.resets, 1);
|
||||
});
|
||||
|
||||
test("synchronous work sends no notices or delivery messages", async () => {
|
||||
const { runtime, clock, gateway } = createGateway();
|
||||
test("list, confirm, stop, and cancel forward to the runtime with the chat identity", async () => {
|
||||
const { runtime, gateway } = createGateway();
|
||||
const adapter = recordingAdapter();
|
||||
runtime.proposals = [fakeProposal()];
|
||||
|
||||
const list = await gateway.receive(message("/list"), adapter, { synchronous: true });
|
||||
assert.equal(runtime.listed, 1);
|
||||
assert.match(list.reply || "", /proposal-1 \[proposed\] 修复登录页/);
|
||||
|
||||
runtime.proposals = [];
|
||||
const emptyList = await gateway.receive(message("/list"), adapter, { synchronous: true });
|
||||
assert.equal(emptyList.reply, "当前没有提案。");
|
||||
|
||||
const confirm = await gateway.receive(message("/confirm"), adapter, { synchronous: true });
|
||||
assert.equal(runtime.confirmed, 1);
|
||||
assert.equal(confirm.reply, "已确认,继续执行。");
|
||||
|
||||
const stop = await gateway.receive(message("/stop"), adapter, { synchronous: true });
|
||||
assert.equal(runtime.stopped, 1);
|
||||
assert.equal(stop.reply, "已停止当前任务。");
|
||||
|
||||
const cancel = await gateway.receive(message("/cancel"), adapter, { synchronous: true });
|
||||
assert.equal(runtime.cancelled, 1);
|
||||
assert.equal(cancel.reply, "已取消最近的提案。");
|
||||
|
||||
runtime.confirmResult = false;
|
||||
runtime.stopResult = false;
|
||||
runtime.cancelResult = false;
|
||||
assert.equal((await gateway.receive(message("/confirm"), adapter, { synchronous: true })).reply, "当前没有待确认的提案。");
|
||||
assert.equal((await gateway.receive(message("/stop"), adapter, { synchronous: true })).reply, "当前没有正在执行的任务。");
|
||||
assert.equal((await gateway.receive(message("/cancel"), adapter, { synchronous: true })).reply, "没有可取消的提案。");
|
||||
});
|
||||
|
||||
test("synchronous work sends no delivery messages", async () => {
|
||||
const { runtime, gateway } = createGateway();
|
||||
const sent: OutgoingMessage[] = [];
|
||||
const turn = gateway.receive(message("work"), recordingAdapter(sent), { synchronous: true });
|
||||
await waitFor(() => runtime.turns.length === 1);
|
||||
await clock.advanceTo(600_000);
|
||||
assert.deepEqual(sent, []);
|
||||
runtime.turns[0].resolve("done");
|
||||
assert.deepEqual(await turn, { ok: true, reply: "done" });
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test("asynchronous help replies with sequence one", async () => {
|
||||
test("asynchronous help replies with sequence one without reaching the runtime", async () => {
|
||||
const { runtime, gateway } = createGateway();
|
||||
const sent: OutgoingMessage[] = [];
|
||||
await gateway.receive(message("/help", "help"), recordingAdapter(sent));
|
||||
@@ -388,32 +288,13 @@ test("asynchronous help replies with sequence one", async () => {
|
||||
assert.equal(runtime.turns.length, 0);
|
||||
});
|
||||
|
||||
test("asynchronous new waits for prior work and then replies with sequence one", async () => {
|
||||
test("asynchronous commands reply with sequence one without reaching prompt", async () => {
|
||||
const { runtime, gateway } = createGateway();
|
||||
const sent: OutgoingMessage[] = [];
|
||||
const adapter = recordingAdapter(sent);
|
||||
const work = gateway.receive(message("work", "work"), adapter);
|
||||
await waitFor(() => runtime.turns.length === 1);
|
||||
|
||||
const reset = gateway.receive(message("/new", "new"), adapter);
|
||||
await flush();
|
||||
assert.equal(runtime.resets, 0);
|
||||
assert.deepEqual(messagesFor(sent, "new"), []);
|
||||
|
||||
runtime.turns[0].resolve("done");
|
||||
await work;
|
||||
assert.deepEqual(await reset, { ok: true, reply: "Started a new native ACP session for this chat." });
|
||||
assert.equal(runtime.resets, 1);
|
||||
assert.deepEqual(messagesFor(sent, "new").map((outgoing) => [outgoing.text, outgoing.replySequence]), [
|
||||
["Started a new native ACP session for this chat.", 1]
|
||||
]);
|
||||
});
|
||||
|
||||
test("asynchronous retired-role command replies with sequence one without reaching ACP", async () => {
|
||||
const { runtime, gateway } = createGateway();
|
||||
const sent: OutgoingMessage[] = [];
|
||||
const result = await gateway.receive(message("/roles", "retired"), recordingAdapter(sent));
|
||||
assert.match(result.reply || "", /removed in Config v3/);
|
||||
runtime.proposals = [fakeProposal()];
|
||||
const result = await gateway.receive(message("/list", "list"), recordingAdapter(sent));
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(runtime.turns.length, 0);
|
||||
assert.deepEqual(messagesFor(sent, "retired").map((outgoing) => outgoing.replySequence), [1]);
|
||||
assert.deepEqual(messagesFor(sent, "list").map((outgoing) => outgoing.replySequence), [1]);
|
||||
assert.match(messagesFor(sent, "list")[0].text, /proposal-1/);
|
||||
});
|
||||
|
||||
+25
-2
@@ -4,9 +4,9 @@ import { EventEmitter } from "node:events";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { writeConfigFile } from "../src/cli/config-file.js";
|
||||
import { configDigest, writeConfigFile } from "../src/cli/config-file.js";
|
||||
import { assertSetupPidSafe, instanceDirectory, instanceRunnerPath, matchesInstanceRunner, runInstanceCommand } from "../src/cli/instance.js";
|
||||
import { runInstanceRunner, waitForShutdown } from "../src/cli/instance-runner.js";
|
||||
import { assertRunnerConfigSnapshot, assertRunnerWorkspaceSafety, runInstanceRunner, waitForShutdown } from "../src/cli/instance-runner.js";
|
||||
import { parseConfig } from "../src/config.js";
|
||||
|
||||
test("instance paths reject traversal and config identity mismatch", async () => {
|
||||
@@ -69,6 +69,29 @@ test("instance runner rejects unsafe argument and config paths before starting a
|
||||
await assert.rejects(runInstanceRunner([path.join(os.tmpdir(), `missing-runner-${Date.now()}.json`)]), /Runtime config does not exist/);
|
||||
});
|
||||
|
||||
test("instance runner binds startup to the validated config snapshot and repeats workspace checks", async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-runner-safety-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const parent = path.join(root, "workspace");
|
||||
const child = path.join(parent, "child");
|
||||
fs.mkdirSync(child, { recursive: true });
|
||||
const config = parseConfig({
|
||||
configVersion: 3,
|
||||
bot: { id: "runner-bot", workspace: child, persona: "test", agent: { id: "kimi", command: "kimi", args: ["acp"] }, permissions: { mode: "deny" } },
|
||||
gateway: { platform: { type: "webhook", secret: "secret" } },
|
||||
runtime: {}
|
||||
});
|
||||
const configFile = path.join(root, "instances", "runner-bot", "config.json");
|
||||
writeConfigFile(configFile, config);
|
||||
assert.doesNotThrow(() => assertRunnerConfigSnapshot(config, configDigest(config)));
|
||||
assert.throws(() => assertRunnerConfigSnapshot(config, "changed"), /config changed/);
|
||||
assert.throws(() => assertRunnerWorkspaceSafety(config, path.join(root, "wrong.json"), root), /directory contract/);
|
||||
|
||||
const peer = parseConfig({ ...config, bot: { ...config.bot, id: "peer-bot", workspace: parent } });
|
||||
writeConfigFile(path.join(root, "instances", "peer-bot", "config.json"), peer);
|
||||
assert.throws(() => assertRunnerWorkspaceSafety(config, configFile, root), /identical, parent, or child workspace/);
|
||||
});
|
||||
|
||||
test("instance runner gracefully shuts down once on SIGTERM", async () => {
|
||||
const signals = new EventEmitter();
|
||||
let shutdowns = 0;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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 { ProposalStore } from "../src/core/proposal-store.js";
|
||||
|
||||
const identity = { botId: "test-bot", platform: "qq" };
|
||||
|
||||
const input = { title: "Refactor store", goal: "Move to assistant proposals", steps: ["design", "implement", "test"], ownerChatKey: "qq:chat", requesterUserId: "user-1" };
|
||||
|
||||
test("creates, updates and reloads proposals with 0600 atomic file", async () => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-"));
|
||||
const file = path.join(dir, "proposals.json");
|
||||
const first = new ProposalStore(file, identity);
|
||||
await first.open();
|
||||
const created = await first.create(input);
|
||||
assert.equal(created.status, "proposed");
|
||||
assert.ok(created.id.length > 0);
|
||||
assert.equal(created.createdAt, created.updatedAt);
|
||||
|
||||
const confirmedAt = created.createdAt + 1000;
|
||||
const updated = await first.update(created.id, {
|
||||
status: "working",
|
||||
confirmedAt,
|
||||
startedAt: confirmedAt + 1,
|
||||
workerNativeSessionId: "native-1",
|
||||
workerProcessGroup: { pgid: 4321, token: "worker-token" },
|
||||
pending: { kind: "step", summary: "needs dirty confirm", question: "overwrite?", nextStep: "edit file" },
|
||||
lastWorkerSummary: "half done"
|
||||
});
|
||||
assert.equal(updated.status, "working");
|
||||
assert.ok(updated.updatedAt >= created.updatedAt);
|
||||
await first.close();
|
||||
|
||||
assert.equal((await fs.promises.readdir(dir)).some((name) => name.endsWith(".tmp")), false);
|
||||
assert.equal((await fs.promises.stat(file)).mode & 0o777, 0o600);
|
||||
const persisted = JSON.parse(await fs.promises.readFile(file, "utf8"));
|
||||
assert.equal(persisted.version, 1); assert.equal(persisted.botId, "test-bot"); assert.equal(persisted.platform, "qq");
|
||||
assert.equal(persisted.proposals[created.id].workerProcessGroup.pgid, 4321);
|
||||
|
||||
const second = new ProposalStore(file, identity);
|
||||
await second.open();
|
||||
const loaded = second.get(created.id);
|
||||
assert.deepEqual(loaded, updated);
|
||||
assert.deepEqual(second.list().map((proposal) => proposal.id), [created.id]);
|
||||
assert.deepEqual(second.list({ status: "working" }).map((proposal) => proposal.id), [created.id]);
|
||||
assert.equal(second.list({ status: "queued" }).length, 0);
|
||||
assert.equal(second.stats().proposals, 1);
|
||||
await second.close();
|
||||
});
|
||||
|
||||
test("orders list by confirmation time for queued proposals", async () => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-order-"));
|
||||
const file = path.join(dir, "proposals.json");
|
||||
const store = new ProposalStore(file, identity);
|
||||
await store.open();
|
||||
const firstCreated = await store.create({ ...input, title: "first created" });
|
||||
const secondCreated = await store.create({ ...input, title: "second created" });
|
||||
await store.update(secondCreated.id, { status: "queued", confirmedAt: 100 });
|
||||
await store.update(firstCreated.id, { status: "queued", confirmedAt: 200 });
|
||||
assert.deepEqual(store.list({ status: "queued" }).map((proposal) => proposal.title), ["second created", "first created"]);
|
||||
await store.close();
|
||||
});
|
||||
|
||||
test("validates proposal fields on create and update", async () => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-invalid-"));
|
||||
const file = path.join(dir, "proposals.json");
|
||||
const store = new ProposalStore(file, identity);
|
||||
await store.open();
|
||||
await assert.rejects(store.create({ ...input, title: "" }), /title/);
|
||||
await assert.rejects(store.create({ ...input, steps: ["ok", ""] }), /steps/);
|
||||
const created = await store.create(input);
|
||||
await assert.rejects(store.update(created.id, { status: "bogus" as never }), /status/);
|
||||
await assert.rejects(store.update(created.id, { workerProcessGroup: { pgid: 1, token: "t" } }), /workerProcessGroup/);
|
||||
await assert.rejects(store.update(created.id, { pending: { kind: "bogus", summary: "s" } as never }), /pending/);
|
||||
await assert.rejects(store.update("missing", { status: "queued" }), /unknown proposal/);
|
||||
assert.equal(store.get(created.id)?.status, "proposed");
|
||||
await store.close();
|
||||
});
|
||||
|
||||
test("rejects identity or version mismatch and preserves corrupt state", async () => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-id-"));
|
||||
const file = path.join(dir, "proposals.json");
|
||||
const wrongVersion = '{"version":2,"botId":"test-bot","platform":"qq","proposals":{}}\n';
|
||||
await fs.promises.writeFile(file, wrongVersion);
|
||||
await assert.rejects(new ProposalStore(file, identity).open(), /expected version 1/);
|
||||
assert.equal(await fs.promises.readFile(file, "utf8"), wrongVersion);
|
||||
|
||||
await fs.promises.writeFile(file, JSON.stringify({ version: 1, botId: "other", platform: "qq", proposals: {} }));
|
||||
await assert.rejects(new ProposalStore(file, identity).open(), /identity mismatch/);
|
||||
await fs.promises.writeFile(file, JSON.stringify({ version: 1, botId: "test-bot", platform: "feishu", proposals: {} }));
|
||||
await assert.rejects(new ProposalStore(file, identity).open(), /identity mismatch/);
|
||||
|
||||
await fs.promises.writeFile(file, "not-json");
|
||||
await assert.rejects(new ProposalStore(file, identity).open(), /original file was preserved/);
|
||||
assert.equal(await fs.promises.readFile(file, "utf8"), "not-json");
|
||||
|
||||
await fs.promises.writeFile(file, JSON.stringify({ version: 1, botId: "test-bot", platform: "qq", proposals: { abc: { id: "other" } } }));
|
||||
await assert.rejects(new ProposalStore(file, identity).open(), /invalid proposal/);
|
||||
});
|
||||
|
||||
test("refuses a second writer and recovers a stale lock", async () => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-lock-"));
|
||||
const file = path.join(dir, "proposals.json");
|
||||
const first = new ProposalStore(file, identity); await first.open();
|
||||
await assert.rejects(new ProposalStore(file, identity).open(), /locked by another/);
|
||||
await first.close();
|
||||
|
||||
await fs.promises.writeFile(`${file}.lock`, "2147483647\n", { mode: 0o600 });
|
||||
const recovered = new ProposalStore(file, identity);
|
||||
await recovered.open();
|
||||
await recovered.close();
|
||||
assert.equal(fs.existsSync(`${file}.lock`), false);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
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 { canonicalWorkspace, findOverlappingWorkspace } from "../src/core/workspace-scope.js";
|
||||
|
||||
function writePeer(instances: string, botId: string, workspace: string): void {
|
||||
const directory = path.join(instances, botId);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
fs.writeFileSync(path.join(directory, "config.json"), JSON.stringify({
|
||||
configVersion: 3,
|
||||
bot: {
|
||||
id: botId,
|
||||
workspace,
|
||||
persona: "test",
|
||||
agent: { id: "fake", command: process.execPath, args: ["agent.mjs"], env: {} },
|
||||
skills: [],
|
||||
permissions: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }
|
||||
},
|
||||
gateway: { platform: { type: "webhook", secret: "test-secret" } },
|
||||
runtime: { acp: {} }
|
||||
}));
|
||||
}
|
||||
|
||||
test("peer scan rejects identical, parent, and child workspaces and allows disjoint scopes", async (t) => {
|
||||
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-workspace-scope-"));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const instances = path.join(directory, "instances");
|
||||
const workspace = path.join(directory, "workspace");
|
||||
const child = path.join(workspace, "child");
|
||||
const sibling = path.join(directory, "sibling");
|
||||
fs.mkdirSync(child, { recursive: true });
|
||||
fs.mkdirSync(sibling, { recursive: true });
|
||||
|
||||
// Disjoint workspaces are allowed.
|
||||
writePeer(instances, "sibling-bot", sibling);
|
||||
assert.equal(findOverlappingWorkspace(workspace, "current", instances), undefined);
|
||||
|
||||
// An identical workspace is rejected.
|
||||
writePeer(instances, "same-bot", workspace);
|
||||
assert.equal(findOverlappingWorkspace(workspace, "current", instances), "same-bot");
|
||||
|
||||
// A child workspace is rejected from the parent's perspective.
|
||||
fs.rmSync(path.join(instances, "same-bot"), { recursive: true, force: true });
|
||||
writePeer(instances, "child-bot", child);
|
||||
assert.equal(findOverlappingWorkspace(workspace, "current", instances), "child-bot");
|
||||
|
||||
// From the child's perspective an identical or parent peer workspace is rejected.
|
||||
assert.equal(findOverlappingWorkspace(child, "current", instances), "child-bot");
|
||||
writePeer(instances, "same-bot", workspace);
|
||||
assert.ok(["same-bot", "child-bot"].includes(findOverlappingWorkspace(child, "current", instances)!));
|
||||
|
||||
// The instance's own entry is skipped even when its workspace matches.
|
||||
fs.rmSync(path.join(instances, "child-bot"), { recursive: true, force: true });
|
||||
assert.equal(findOverlappingWorkspace(workspace, "same-bot", instances), undefined);
|
||||
|
||||
// A missing instances directory means no peers to conflict with.
|
||||
assert.equal(findOverlappingWorkspace(workspace, "current", path.join(directory, "absent")), undefined);
|
||||
});
|
||||
|
||||
test("peer scan fails closed for unsafe or invalid peer configs", async (t) => {
|
||||
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-workspace-scope-closed-"));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const instances = path.join(directory, "instances");
|
||||
const workspace = path.join(directory, "workspace");
|
||||
fs.mkdirSync(workspace, { recursive: true });
|
||||
|
||||
// Peer config declares a workspace that cannot be canonicalized.
|
||||
writePeer(instances, "missing-bot", path.join(directory, "missing"));
|
||||
assert.throws(() => findOverlappingWorkspace(workspace, "current", instances));
|
||||
fs.rmSync(path.join(instances, "missing-bot"), { recursive: true, force: true });
|
||||
|
||||
// Corrupt peer config fails closed.
|
||||
const corrupt = path.join(instances, "corrupt-bot");
|
||||
fs.mkdirSync(corrupt);
|
||||
fs.writeFileSync(path.join(corrupt, "config.json"), "not-json");
|
||||
assert.throws(() => findOverlappingWorkspace(workspace, "current", instances), /Invalid peer Config v3/);
|
||||
fs.rmSync(path.join(corrupt, "config.json"));
|
||||
|
||||
// Missing peer config fails closed.
|
||||
assert.throws(() => findOverlappingWorkspace(workspace, "current", instances), /missing config/);
|
||||
fs.rmSync(corrupt, { recursive: true, force: true });
|
||||
|
||||
// Symlinked peer entries are refused.
|
||||
writePeer(instances, "real-bot", path.join(directory, "sibling-real"));
|
||||
fs.mkdirSync(path.join(directory, "sibling-real"), { recursive: true });
|
||||
fs.symlinkSync(path.join(instances, "real-bot"), path.join(instances, "linked-bot"));
|
||||
assert.throws(() => findOverlappingWorkspace(workspace, "current", instances), /unsafe peer instance entry/);
|
||||
});
|
||||
|
||||
test("canonicalWorkspace resolves symlinks and relative segments", async (t) => {
|
||||
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-workspace-canonical-"));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const real = path.join(directory, "real");
|
||||
fs.mkdirSync(real, { recursive: true });
|
||||
const link = path.join(directory, "link");
|
||||
fs.symlinkSync(real, link);
|
||||
assert.equal(canonicalWorkspace(link), fs.realpathSync.native(real));
|
||||
assert.equal(canonicalWorkspace(path.join(real, "..", "real")), fs.realpathSync.native(real));
|
||||
assert.throws(() => canonicalWorkspace(path.join(directory, "missing")));
|
||||
});
|
||||
Reference in New Issue
Block a user