523 lines
26 KiB
TypeScript
523 lines
26 KiB
TypeScript
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, assistantKeyFor, 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";
|
|
const CONVERSATION_KEY = assistantKeyFor(CHAT_KEY, "user-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, userId = "user-1") => ({ platform: "webhook", chatId: "chat-1", userId, 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(CONVERSATION_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", "user-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(CONVERSATION_KEY), undefined);
|
|
const ok = await harness.manager.prompt(request("hello"));
|
|
assert.equal(ok.text, "ok");
|
|
assert.ok(harness.store.getBinding(CONVERSATION_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("proposals are owned per chat+user: a second user in the same chat cannot confirm, stop, cancel, or list them", async () => {
|
|
const harness = await createHarness();
|
|
try {
|
|
await harness.manager.prompt(request("create proposal: hang", "user-a"));
|
|
await harness.manager.prompt(request("hello", "user-b"));
|
|
assert.equal(harness.store.stats().bindings, 2);
|
|
const proposal = harness.proposals.list()[0]!;
|
|
assert.equal(proposal.status, "proposed");
|
|
assert.equal(proposal.requesterUserId, "user-a");
|
|
|
|
assert.equal(await harness.manager.confirm("webhook", "chat-1", "user-b"), false);
|
|
await harness.manager.prompt(request("confirm", "user-b"));
|
|
assert.equal(harness.proposals.get(proposal.id)!.status, "proposed");
|
|
assert.equal(await harness.manager.cancel("webhook", "chat-1", "user-b"), false);
|
|
await harness.manager.prompt(request("cancel proposal", "user-b"));
|
|
assert.equal(harness.proposals.get(proposal.id)!.status, "proposed");
|
|
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-b"), false);
|
|
assert.equal(harness.manager.listProposals("webhook", "chat-1", "user-b").length, 0);
|
|
assert.equal(harness.manager.listProposals("webhook", "chat-1", "user-a").length, 1);
|
|
|
|
assert.equal(await harness.manager.confirm("webhook", "chat-1", "user-a"), true);
|
|
assert.equal(harness.proposals.get(proposal.id)!.status, "working");
|
|
|
|
await harness.manager.prompt(request("hello again", "user-b"));
|
|
const bPrompt = readLog(harness.logFile).find((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nhello again"));
|
|
assert.ok(bPrompt?.text);
|
|
assert.match(bPrompt.text!, /details hidden/);
|
|
assert.doesNotMatch(bPrompt.text!, /Test proposal/);
|
|
|
|
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-b"), false);
|
|
assert.equal(harness.proposals.get(proposal.id)!.status, "working");
|
|
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true);
|
|
assert.equal(harness.proposals.get(proposal.id)!.status, "cancelled");
|
|
} finally {
|
|
await closeHarness(harness);
|
|
}
|
|
});
|
|
|
|
test("start_next only starts the requesting user's own queued proposal", async () => {
|
|
const harness = await createHarness();
|
|
try {
|
|
await harness.manager.prompt(request("create proposal: succeed", "user-a"));
|
|
const first = harness.proposals.list()[0]!;
|
|
await harness.manager.prompt(request("confirm", "user-a"));
|
|
await waitFor(() => harness.proposals.get(first.id)!.status === "awaiting_user_confirmation");
|
|
await harness.manager.prompt(request("create proposal: succeed", "user-a"));
|
|
const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!;
|
|
await harness.manager.prompt(request("confirm", "user-a"));
|
|
assert.equal(harness.proposals.get(second.id)!.status, "queued");
|
|
await harness.manager.prompt(request("confirm", "user-a"));
|
|
assert.equal(harness.proposals.get(first.id)!.status, "completed");
|
|
assert.equal(harness.proposals.get(second.id)!.status, "queued");
|
|
|
|
await harness.manager.prompt(request("start next", "user-b"));
|
|
assert.equal(harness.proposals.get(second.id)!.status, "queued");
|
|
await harness.manager.prompt(request("start next", "user-a"));
|
|
assert.equal(harness.proposals.get(second.id)!.status, "working");
|
|
await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation");
|
|
} finally {
|
|
await closeHarness(harness);
|
|
}
|
|
});
|
|
|
|
test("stop settles the latest awaiting proposal (success completes, failure fails, step cancels) without auto-starting the next", async () => {
|
|
const harness = await createHarness();
|
|
try {
|
|
await harness.manager.prompt(request("create proposal: succeed", "user-a"));
|
|
const first = harness.proposals.list()[0]!;
|
|
await harness.manager.prompt(request("confirm", "user-a"));
|
|
await waitFor(() => harness.proposals.get(first.id)!.status === "awaiting_user_confirmation");
|
|
assert.equal(harness.proposals.get(first.id)!.pending?.kind, "success");
|
|
|
|
await harness.manager.prompt(request("create proposal: succeed", "user-a"));
|
|
const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!;
|
|
await harness.manager.prompt(request("confirm", "user-a"));
|
|
assert.equal(harness.proposals.get(second.id)!.status, "queued");
|
|
|
|
await harness.manager.prompt(request("stop", "user-a"));
|
|
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");
|
|
|
|
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-b"), false);
|
|
assert.equal(harness.proposals.get(second.id)!.status, "queued");
|
|
|
|
await harness.manager.prompt(request("start next", "user-a"));
|
|
await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation");
|
|
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true);
|
|
assert.equal(harness.proposals.get(second.id)!.status, "completed");
|
|
|
|
await harness.manager.prompt(request("create proposal: fail", "user-a"));
|
|
const third = harness.proposals.list().find((proposal) => proposal.id !== first.id && proposal.id !== second.id)!;
|
|
await harness.manager.prompt(request("confirm", "user-a"));
|
|
await waitFor(() => harness.proposals.get(third.id)!.status === "awaiting_user_confirmation");
|
|
assert.equal(harness.proposals.get(third.id)!.pending?.kind, "failure");
|
|
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true);
|
|
assert.equal(harness.proposals.get(third.id)!.status, "failed");
|
|
|
|
await harness.manager.prompt(request("create proposal: ask", "user-a"));
|
|
const fourth = harness.proposals.list().find((proposal) => proposal.status === "proposed")!;
|
|
await harness.manager.prompt(request("confirm", "user-a"));
|
|
await waitFor(() => harness.proposals.get(fourth.id)!.status === "awaiting_user_confirmation");
|
|
assert.equal(harness.proposals.get(fourth.id)!.pending?.kind, "step");
|
|
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true);
|
|
assert.equal(harness.proposals.get(fourth.id)!.status, "cancelled");
|
|
assert.equal(harness.manager.status("webhook", "chat-1", "user-a").workerRunning, false);
|
|
} finally {
|
|
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);
|
|
});
|
|
|
|
test("start_next blocked by another user's pending confirmation appends a correction and exposes no details", async () => {
|
|
const harness = await createHarness();
|
|
try {
|
|
await harness.manager.prompt(request("create proposal: succeed", "user-a"));
|
|
const first = harness.proposals.list()[0]!;
|
|
await harness.manager.prompt(request("confirm", "user-a"));
|
|
await waitFor(() => harness.proposals.get(first.id)!.status === "awaiting_user_confirmation");
|
|
|
|
await harness.manager.prompt(request("create proposal: succeed", "user-b"));
|
|
const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!;
|
|
await harness.manager.prompt(request("confirm", "user-b"));
|
|
assert.equal(harness.proposals.get(second.id)!.status, "queued");
|
|
|
|
const blocked = await harness.manager.prompt(request("start next", "user-b"));
|
|
assert.match(blocked.text, /还不能开始/);
|
|
assert.match(blocked.text, /发起人确认/);
|
|
assert.match(blocked.text, /保留在队列里/);
|
|
assert.equal(harness.proposals.get(second.id)!.status, "queued");
|
|
assert.equal(harness.proposals.get(first.id)!.status, "awaiting_user_confirmation");
|
|
|
|
const bPrompt = readLog(harness.logFile).find((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nstart next"));
|
|
assert.ok(bPrompt?.text);
|
|
assert.match(bPrompt.text!, /blocked: another proposal is awaiting owner confirmation/);
|
|
assert.doesNotMatch(bPrompt.text!, /goal done/);
|
|
|
|
const bStatus = harness.manager.status("webhook", "chat-1", "user-b");
|
|
assert.equal(bStatus.schedulerState, "awaiting_confirmation");
|
|
assert.equal(bStatus.blockedReason, "another proposal is awaiting owner confirmation");
|
|
assert.equal(bStatus.myQueuedProposals, 1);
|
|
assert.equal(bStatus.myPendingConfirmations, 0);
|
|
assert.equal(bStatus.nextAction, "wait for the current proposal to settle");
|
|
|
|
const aStatus = harness.manager.status("webhook", "chat-1", "user-a");
|
|
assert.equal(aStatus.myPendingConfirmations, 1);
|
|
assert.equal(aStatus.blockedReason, "your proposal is awaiting your confirmation");
|
|
assert.equal(aStatus.nextAction, "confirm your pending proposal");
|
|
} finally {
|
|
await closeHarness(harness);
|
|
}
|
|
});
|
|
|
|
test("confirm and stop actions with no matching proposal append an ownership correction", async () => {
|
|
const harness = await createHarness();
|
|
try {
|
|
await harness.manager.prompt(request("create proposal: hang", "user-a"));
|
|
|
|
const confirmed = await harness.manager.prompt(request("confirm", "user-b"));
|
|
assert.match(confirmed.text, /没有可确认的提案/);
|
|
assert.match(confirmed.text, /自己发起/);
|
|
|
|
const stopped = await harness.manager.prompt(request("stop", "user-b"));
|
|
assert.match(stopped.text, /没有可停止的任务/);
|
|
assert.match(stopped.text, /自己发起/);
|
|
|
|
const cancelled = await harness.manager.prompt(request("cancel proposal", "user-b"));
|
|
assert.match(cancelled.text, /没有可取消的提案/);
|
|
assert.match(cancelled.text, /自己发起/);
|
|
|
|
assert.equal(harness.proposals.list()[0]!.status, "proposed");
|
|
} finally {
|
|
await closeHarness(harness);
|
|
}
|
|
});
|
|
|
|
test("start_next reports when the queue is empty instead of claiming a start", async () => {
|
|
const harness = await createHarness();
|
|
try {
|
|
const reply = await harness.manager.prompt(request("start next", "user-a"));
|
|
assert.match(reply.text, /还不能开始/);
|
|
assert.match(reply.text, /没有已确认并排队/);
|
|
const status = harness.manager.status("webhook", "chat-1", "user-a");
|
|
assert.equal(status.schedulerState, "idle");
|
|
assert.equal(status.blockedReason, "none");
|
|
assert.equal(status.nextAction, "none");
|
|
} finally {
|
|
await closeHarness(harness);
|
|
}
|
|
});
|
|
|
|
function processGroupExists(pgid: number): boolean {
|
|
try { process.kill(-pgid, 0); return true; }
|
|
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
|
|
}
|