Files
gori-agent/test/assistant-manager.test.ts
T
zenord f17fee7383 feat: show live worker status card to all users during debugging
Non-owners now see the same real-time status card as the owner, marked
as another user's proposal, instead of a desensitized busy line. This
is a debugging-phase relaxation; restore owner-only visibility for
production.
2026-08-19 16:43:53 +08:00

988 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 type { IncomingAttachment } from "../src/core/types.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; images?: { path: string; mimeType?: string; filename?: string }[] }[];
logFile: string;
}
async function createHarness(options: { initialize?: boolean; agentEnv?: Record<string, string>; acp?: Record<string, unknown> } = {}): 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, ...options.acp } }
});
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; images?: { path: string; mimeType?: string; filename?: string }[] }[] = [];
const manager = new AssistantManager(config.runtime.acp, new BotProfileResolver(config).bot, store, proposals, {
assistantWorkspaceHome: home,
allowUnverifiedAssistantAgent: true
});
manager.setEventSink(async (chatKey, text, images) => { events.push({ chatKey, text, images }); });
if (options.initialize !== false) await manager.initialize();
return { home, workspace, store, proposals, manager, events, logFile };
}
async function reopenManager(harness: Harness, options: { acp?: Record<string, unknown> } = {}): 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, ...options.acp } }
});
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, images) => { harness.events.push({ chatKey, text, images }); });
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", attachments?: IncomingAttachment[]) => ({ platform: "webhook", chatId: "chat-1", userId, text, attachments });
const pngAttachment = (): IncomingAttachment[] => [{ mimeType: "image/png", data: Buffer.from("fake-png").toString("base64"), filename: "fake.png" }];
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; images?: number }[] {
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, "pending");
assert.equal(stopped.pending?.summary, "被用户中止");
assert.equal(stopped.pending?.workspaceDirty, true);
assert.equal(stopped.workerProcessGroup, undefined);
} 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 === "pending");
assert.equal(harness.proposals.get(proposal.id)!.pending?.summary, "被用户中止");
} finally {
await fs.promises.rm(gateFile, { force: true });
await closeHarness(harness);
}
});
test("pending never auto-finishes or auto-starts the next proposal; only finish settles it", 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 === "pending");
assert.equal(harness.proposals.get(first.id)!.pending?.summary, "goal done");
assert.equal(harness.proposals.get(first.id)!.finishedAt, undefined);
assert.equal(harness.proposals.get(first.id)!.finishKind, undefined);
// A new proposal can be queued while the owner has a pending one, but it must not start.
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, "pending");
await harness.manager.prompt(request("finish"));
const finishedFirst = harness.proposals.get(first.id)!;
assert.equal(finishedFirst.status, "finished");
assert.equal(finishedFirst.finishKind, "done");
assert.ok(finishedFirst.finishedAt);
assert.equal(finishedFirst.pending, undefined);
assert.equal(finishedFirst.lastWorkerSummary, "goal done");
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 === "pending");
assert.equal(await harness.manager.finish("webhook", "chat-1", "user-1"), true);
assert.equal(harness.proposals.get(second.id)!.status, "finished");
} finally {
await closeHarness(harness);
}
});
test("follow_up on a pending proposal resumes the same worker session and carries image attachments", 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 === "pending");
const pendingCard = harness.proposals.get(proposal.id)!.pending!;
assert.equal(pendingCard.question, "May I overwrite it?");
assert.equal(pendingCard.workspaceDirty, true);
const workerSessionId = harness.proposals.get(proposal.id)!.workerNativeSessionId!;
assert.ok(workerSessionId);
await harness.manager.prompt(request("follow up: yes, overwrite it", "user-1", pngAttachment()));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const continued = harness.proposals.get(proposal.id)!;
assert.equal(continued.workerNativeSessionId, workerSessionId);
assert.match(continued.lastWorkerSummary || "", new RegExp(`continued ${workerSessionId}`));
assert.equal(continued.pending?.question, undefined);
assert.equal(continued.pending?.workspaceDirty, undefined);
const resumeEntries = readLog(harness.logFile).filter((entry) => entry.method === "session/resume" && entry.sessionId === workerSessionId);
assert.ok(resumeEntries.length > 0);
const followUpTurns = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt"
&& entry.text?.startsWith("The user sent a follow-up instruction"));
assert.equal(followUpTurns.length, 1);
assert.equal(followUpTurns[0]!.sessionId, workerSessionId);
assert.equal(followUpTurns[0]!.images, 1);
await harness.manager.prompt(request("finish"));
assert.equal(harness.proposals.get(proposal.id)!.status, "finished");
assert.equal(harness.proposals.get(proposal.id)!.finishKind, "done");
} finally {
await closeHarness(harness);
}
});
test("follow_up falls back to a fresh worker session with proposal context when resume fails", async () => {
const harness = await createHarness({ agentEnv: { FAKE_ACP_RESUME_FAIL: "1", FAKE_ACP_LOAD_FAIL: "1" } });
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 === "pending");
const workerSessionId = harness.proposals.get(proposal.id)!.workerNativeSessionId!;
await harness.manager.prompt(request("follow up: yes, overwrite it"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const continued = harness.proposals.get(proposal.id)!;
assert.ok(continued.workerNativeSessionId);
assert.notEqual(continued.workerNativeSessionId, workerSessionId);
assert.match(continued.lastWorkerSummary || "", /continued/);
const followUpTurns = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt"
&& entry.text?.startsWith("The user sent a follow-up instruction"));
assert.equal(followUpTurns.length, 1);
assert.match(followUpTurns[0]!.text || "", /fresh session continuing the same proposal/);
} finally {
await closeHarness(harness);
}
});
test("a fresh non-finish message reminds about the pending proposal when the assistant forgets it", 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 === "pending");
const forgotten = await harness.manager.prompt(request("hello"));
assert.match(forgotten.text, /待确认的任务「Test proposal」/);
assert.match(forgotten.text, /finish 结束/);
const acknowledged = await harness.manager.prompt(request("ack pending"));
assert.match(acknowledged.text, /任务「Test proposal」还在等你确认/);
assert.doesNotMatch(acknowledged.text, /提醒:你还有/);
const prompts = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nhello"));
assert.equal(prompts.length, 1);
assert.match(prompts[0]!.text!, /\[Pending proposals\]/);
assert.match(prompts[0]!.text!, /summary="goal done"/);
} finally {
await closeHarness(harness);
}
});
test("adjust_proposal edits proposed or queued proposals but not pending ones", 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("adjust proposal: Better title"));
assert.equal(harness.proposals.get(first.id)!.title, "Better title");
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(first.id)!.status === "pending");
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");
await harness.manager.prompt(request("adjust proposal: Queued title"));
assert.equal(harness.proposals.get(second.id)!.title, "Queued title");
// With nothing proposed or queued left, the pending proposal cannot be adjusted.
assert.equal(await harness.manager.cancel("webhook", "chat-1", "user-1"), true);
assert.equal(harness.proposals.get(second.id)!.status, "finished");
const rejected = await harness.manager.prompt(request("adjust proposal: Nope"));
assert.match(rejected.text, /没有可调整的提案/);
assert.equal(harness.proposals.get(first.id)!.title, "Better title");
} 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 === "pending");
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 pending 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, "pending");
assert.match(recovered.pending?.summary || "", /worker_lost/);
assert.equal(recovered.pending?.workspaceDirty, true);
assert.match(recovered.lastWorkerSummary || "", /worker_lost/);
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, finish, 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.finish("webhook", "chat-1", "user-b"), false);
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);
// Debugging phase: non-owners also see the live card, marked as another user's proposal.
assert.match(bPrompt.text!, /Test proposal/);
assert.match(bPrompt.text!, /another user's 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, "pending");
assert.equal(harness.proposals.get(proposal.id)!.pending?.summary, "被用户中止");
} 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 === "pending");
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("finish", "user-a"));
assert.equal(harness.proposals.get(first.id)!.status, "finished");
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 === "pending");
} finally {
await closeHarness(harness);
}
});
test("cancel settles proposed, queued, or pending proposals as cancelled and keeps finished history", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: succeed", "user-a"));
const first = harness.proposals.list()[0]!;
assert.equal(await harness.manager.cancel("webhook", "chat-1", "user-a"), true);
assert.equal(harness.proposals.get(first.id)!.status, "finished");
assert.equal(harness.proposals.get(first.id)!.finishKind, "cancelled");
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"));
await waitFor(() => harness.proposals.get(second.id)!.status === "pending");
assert.equal(await harness.manager.cancel("webhook", "chat-1", "user-a"), true);
const cancelled = harness.proposals.get(second.id)!;
assert.equal(cancelled.status, "finished");
assert.equal(cancelled.finishKind, "cancelled");
assert.ok(cancelled.finishedAt);
const finished = harness.manager.listProposals("webhook", "chat-1", "user-a");
assert.equal(finished.length, 2);
assert.ok(finished.every((proposal) => proposal.status === "finished"));
} finally {
await closeHarness(harness);
}
});
test("envelope parsers accept valid tails and reject invalid ones", () => {
assert.deepEqual(
parseAssistantActions(`text\n<GORI_ASSISTANT_ACTION_V2>{"reply":"hi","actions":[{"type":"stop"}]}</GORI_ASSISTANT_ACTION_V2>`),
{ reply: "hi", actions: [{ type: "stop" }] }
);
assert.deepEqual(
parseAssistantActions(`text\n<GORI_ASSISTANT_ACTION_V2>{"reply":"hi","actions":[{"type":"follow_up","instruction":"keep going"},{"type":"finish","note":"done"}]}</GORI_ASSISTANT_ACTION_V2>`),
{ reply: "hi", actions: [{ type: "follow_up", id: undefined, instruction: "keep going" }, { type: "finish", id: undefined, note: "done" }] }
);
assert.equal(parseAssistantActions("no envelope"), undefined);
assert.equal(parseAssistantActions(`x\n<GORI_ASSISTANT_ACTION_V2>{"reply":"hi","actions":[{"type":"nuke"}]}</GORI_ASSISTANT_ACTION_V2>`), undefined);
assert.equal(parseAssistantActions(`x\n<GORI_ASSISTANT_ACTION_V1>{"reply":"hi","actions":[]}</GORI_ASSISTANT_ACTION_V1>`), undefined);
assert.deepEqual(
parseWorkerResult(`text\n<GORI_WORKER_RESULT_V2>{"status":"PENDING","summary":"s","question":"q","workspaceDirty":true}</GORI_WORKER_RESULT_V2>`),
{ status: "PENDING", summary: "s", question: "q", workspaceDirty: true, attachments: undefined }
);
assert.deepEqual(
parseWorkerResult(`text\n<GORI_WORKER_RESULT_V2>{"status":"PENDING","summary":"s","attachments":[{"path":".gori-outbox/a.png","mimeType":"image/png"}]}</GORI_WORKER_RESULT_V2>`),
{ status: "PENDING", summary: "s", question: undefined, workspaceDirty: undefined, attachments: [{ path: ".gori-outbox/a.png", mimeType: "image/png" }] }
);
assert.equal(parseWorkerResult(`x\n<GORI_WORKER_RESULT_V2>{"status":"PENDING","summary":"s","attachments":[{"path":"a.png"},{"path":"b.png"},{"path":"c.png"},{"path":"d.png"}]}</GORI_WORKER_RESULT_V2>`), undefined);
assert.equal(parseWorkerResult(`x\n<GORI_WORKER_RESULT_V2>{"status":"PENDING","summary":"s","attachments":[{"mimeType":"image/png"}]}</GORI_WORKER_RESULT_V2>`), undefined);
assert.equal(parseWorkerResult(`x\n<GORI_WORKER_RESULT_V2>{"status":"SUCCESS","summary":"s"}</GORI_WORKER_RESULT_V2>`), undefined);
assert.equal(parseWorkerResult(`x\n<GORI_WORKER_RESULT_V1>{"status":"PENDING","summary":"s"}</GORI_WORKER_RESULT_V1>`), undefined);
assert.deepEqual(
parseAssistantActions(`text\n<GORI_ASSISTANT_ACTION_V2>{"reply":"hi","actions":[{"type":"send_image","path":".gori-outbox/a.png"}]}</GORI_ASSISTANT_ACTION_V2>`),
{ reply: "hi", actions: [{ type: "send_image", path: ".gori-outbox/a.png" }] }
);
assert.equal(parseAssistantActions(`x\n<GORI_ASSISTANT_ACTION_V2>{"reply":"hi","actions":[{"type":"send_image"}]}</GORI_ASSISTANT_ACTION_V2>`), undefined);
});
test("envelope parsers salvage envelopes missing only the closing tag", () => {
// Complete JSON truncated at the closing tag is accepted, attachments included.
assert.deepEqual(
parseWorkerResult(`worker reply\n<GORI_WORKER_RESULT_V2>{"status":"PENDING","summary":"s","attachments":[{"path":".gori-outbox/a.png"}]}`),
{ status: "PENDING", summary: "s", question: undefined, workspaceDirty: undefined, attachments: [{ path: ".gori-outbox/a.png", mimeType: undefined }] }
);
// Braces and escapes inside strings do not confuse the balancing.
assert.deepEqual(
parseWorkerResult(`r\n<GORI_WORKER_RESULT_V2>{"status":"PENDING","summary":"use {a} and \\"}\\" end"}`),
{ status: "PENDING", summary: "use {a} and \"}\" end", question: undefined, workspaceDirty: undefined, attachments: undefined }
);
// Trailing whitespace after the balanced JSON is fine, trailing content is not.
assert.deepEqual(
parseWorkerResult(`r\n<GORI_WORKER_RESULT_V2>{"status":"PENDING","summary":"s"}\n`),
{ status: "PENDING", summary: "s", question: undefined, workspaceDirty: undefined, attachments: undefined }
);
// JSON cut off mid-object stays invalid.
assert.equal(parseWorkerResult(`r\n<GORI_WORKER_RESULT_V2>{"status":"PENDING","summary":"cut off`), undefined);
// Balanced JSON with trailing content (e.g. a closing tag plus more text) stays invalid.
assert.equal(parseWorkerResult(`r\n<GORI_WORKER_RESULT_V2>{"status":"PENDING","summary":"s"}</GORI_WORKER_RESULT_V2> extra`), undefined);
assert.equal(parseWorkerResult(`r\n<GORI_WORKER_RESULT_V2>{"status":"PENDING","summary":"s"} trailing`), undefined);
// Salvage never accepts an invalid payload even when the JSON balances.
assert.equal(parseWorkerResult(`r\n<GORI_WORKER_RESULT_V2>{"status":"SUCCESS","summary":"s"}`), undefined);
assert.deepEqual(
parseAssistantActions(`ok\n<GORI_ASSISTANT_ACTION_V2>{"reply":"ok","actions":[]}`),
{ reply: "ok", actions: [] }
);
assert.equal(parseAssistantActions(`ok\n<GORI_ASSISTANT_ACTION_V2>{"reply":"ok","actions":[{"type":"stop"}`), undefined);
assert.equal(parseAssistantActions(`ok\n<GORI_ASSISTANT_ACTION_V2>{"reply":"ok","actions":[]}</GORI_ASSISTANT_ACTION_V2> more`), undefined);
assert.equal(parseAssistantActions(`ok\n<GORI_ASSISTANT_ACTION_V2>{"reply":"ok","actions":[]} trailing`), undefined);
});
test("an owner's own pending proposal blocks their start_next with an actionable correction", 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 === "pending");
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");
const blocked = await harness.manager.prompt(request("start next", "user-a"));
assert.match(blocked.text, /还不能开始/);
assert.match(blocked.text, /先 finish 或直接继续说要求/);
assert.match(blocked.text, /保留在队列里/);
assert.equal(harness.proposals.get(second.id)!.status, "queued");
const status = harness.manager.status("webhook", "chat-1", "user-a");
assert.equal(status.schedulerState, "pending");
assert.equal(status.blockedReason, "your proposal is waiting for your decision");
assert.equal(status.myPendingProposals, 1);
assert.equal(status.myQueuedProposals, 1);
assert.equal(status.nextAction, "finish your pending proposal or follow up with new instructions");
} finally {
await closeHarness(harness);
}
});
test("a clean pending proposal does not block other users, but a dirty one blocks everyone", async () => {
const harness = await createHarness();
try {
// user-a's clean pending: user-b's confirmed proposal starts immediately.
await harness.manager.prompt(request("create proposal: succeed", "user-a"));
const clean = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm", "user-a"));
await waitFor(() => harness.proposals.get(clean.id)!.status === "pending");
assert.equal(harness.proposals.get(clean.id)!.pending?.workspaceDirty, undefined);
await harness.manager.prompt(request("create proposal: succeed", "user-b"));
const other = harness.proposals.list().find((proposal) => proposal.id !== clean.id)!;
await harness.manager.prompt(request("confirm", "user-b"));
assert.equal(harness.proposals.get(other.id)!.status, "working");
await waitFor(() => harness.proposals.get(other.id)!.status === "pending");
assert.equal(await harness.manager.finish("webhook", "chat-1", "user-b"), true);
await harness.manager.prompt(request("finish", "user-a"));
assert.equal(harness.proposals.get(clean.id)!.status, "finished");
// user-a's dirty pending: user-b's start_next is globally blocked.
await harness.manager.prompt(request("create proposal: ask", "user-a"));
const dirty = harness.proposals.list().find((proposal) => proposal.status === "proposed")!;
await harness.manager.prompt(request("confirm", "user-a"));
await waitFor(() => harness.proposals.get(dirty.id)!.status === "pending");
assert.equal(harness.proposals.get(dirty.id)!.pending?.workspaceDirty, true);
await harness.manager.prompt(request("create proposal: succeed", "user-b"));
const blockedProposal = harness.proposals.list().find((proposal) => proposal.status === "proposed")!;
await harness.manager.prompt(request("confirm", "user-b"));
assert.equal(harness.proposals.get(blockedProposal.id)!.status, "queued");
const blocked = await harness.manager.prompt(request("start next", "user-b"));
assert.match(blocked.text, /还不能开始/);
assert.match(blocked.text, /dirty 工作区/);
assert.doesNotMatch(blocked.text, new RegExp(dirty.title));
assert.equal(harness.proposals.get(blockedProposal.id)!.status, "queued");
const bStatus = harness.manager.status("webhook", "chat-1", "user-b");
assert.equal(bStatus.schedulerState, "pending");
assert.equal(bStatus.blockedReason, "a pending proposal left a dirty workspace");
await harness.manager.prompt(request("finish", "user-a"));
assert.equal(harness.proposals.get(dirty.id)!.status, "finished");
await harness.manager.prompt(request("start next", "user-b"));
assert.equal(harness.proposals.get(blockedProposal.id)!.status, "working");
await waitFor(() => harness.proposals.get(blockedProposal.id)!.status === "pending");
} finally {
await closeHarness(harness);
}
});
test("a working proposal blocks start_next for everyone with a sanitized reason", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: hang", "user-a"));
const first = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm", "user-a"));
await waitFor(() => harness.proposals.get(first.id)!.status === "working");
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.equal(harness.proposals.get(second.id)!.status, "queued");
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!, /busy: another proposal is working/);
const bStatus = harness.manager.status("webhook", "chat-1", "user-b");
assert.equal(bStatus.schedulerState, "working");
assert.equal(bStatus.blockedReason, "another proposal is working");
assert.equal(bStatus.nextAction, "wait for the current proposal to settle");
// follow_up is also globally blocked while a worker is running.
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true);
assert.equal(harness.proposals.get(first.id)!.status, "pending");
} finally {
await closeHarness(harness);
}
});
test("confirm, finish, stop, cancel, and follow_up 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 finished = await harness.manager.prompt(request("finish", "user-b"));
assert.match(finished.text, /没有可结束的任务/);
assert.match(finished.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, /自己发起/);
const followedUp = await harness.manager.prompt(request("follow up: more", "user-b"));
assert.match(followedUp.text, /现在没法继续/);
assert.match(followedUp.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);
}
});
test("image attachments reach the assistant as image blocks when the agent supports them", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("hello", "user-1", pngAttachment()));
const prompts = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nhello"));
assert.equal(prompts.length, 1);
assert.equal(prompts[0]!.images, 1);
} finally {
await closeHarness(harness);
}
});
test("image attachments degrade to a text note when the agent has no image capability", async () => {
const harness = await createHarness({ agentEnv: { FAKE_ACP_IMAGE_CAP: "0" } });
try {
await harness.manager.prompt(request("hello", "user-1", pngAttachment()));
const prompts = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nhello"));
assert.equal(prompts.length, 1);
assert.equal(prompts[0]!.images, 0);
assert.match(prompts[0]!.text!, /\[图片,当前 agent 不支持图片输入\]/);
} finally {
await closeHarness(harness);
}
});
test("the owner sees a live worker activity card while others get only a sanitized busy state", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: toolhang", "user-a"));
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm", "user-a"));
const internals = harness.manager as unknown as {
active?: { activity: { turnStartedAt?: number; lastActivityAt?: number; lastCategory?: string; counts: Record<string, number> } };
};
await waitFor(() => (internals.active?.activity.counts.read || 0) === 2);
const activity = internals.active!.activity;
assert.equal(activity.lastCategory, "execute");
assert.equal(activity.counts.read, 2);
assert.equal(activity.counts.execute, 1);
assert.ok(activity.turnStartedAt);
assert.ok(activity.lastActivityAt);
await harness.manager.prompt(request("进度如何", "user-a"));
await harness.manager.prompt(request("进度如何", "user-b"));
const progressPrompts = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt"
&& entry.text?.startsWith("[User message]\n进度如何"));
assert.equal(progressPrompts.length, 2);
const ownerPrompt = progressPrompts[0]!.text!;
assert.match(ownerPrompt, /title="Test proposal" running=\d{2}:\d{2}/);
assert.match(ownerPrompt, /lastActivity=execute \(\d+s ago\)/);
assert.match(ownerPrompt, /counts: read×2 execute×1/);
assert.match(ownerPrompt, /real-time snapshot of the worker/);
const otherPrompt = progressPrompts[1]!.text!;
// Debugging phase: non-owners see the same live card, marked as foreign.
assert.match(otherPrompt, /title="Test proposal" running=\d{2}:\d{2} phase=processing inFlight=true \(another user's proposal\)/);
assert.match(otherPrompt, /lastActivity=execute \(\d+s ago\)/);
// Once the worker is no longer working, the card disappears.
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-a"), true);
assert.equal(harness.proposals.get(proposal.id)!.status, "pending");
await harness.manager.prompt(request("hello", "user-a"));
const idlePrompts = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt"
&& entry.text?.startsWith("[User message]\nhello"));
assert.equal(idlePrompts.length, 1);
assert.match(idlePrompts[0]!.text!, /no active worker/);
assert.doesNotMatch(idlePrompts[0]!.text!, /lastActivity=/);
} finally {
await closeHarness(harness);
}
});
test("an assistant session idle beyond the reset threshold is dropped when the owner has no unfinished proposal", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("hello"));
const first = harness.store.getBinding(CONVERSATION_KEY)!;
assert.ok(first.lastActiveAt);
await harness.store.setBinding({ ...first, lastActiveAt: Date.now() - 3_700_000 });
await reopenManager(harness);
const logs: string[] = [];
const originalLog = console.log;
console.log = (...args: unknown[]) => { logs.push(args.map(String).join(" ")); };
try {
await harness.manager.prompt(request("hello again"));
} finally {
console.log = originalLog;
}
const reset = harness.store.getBinding(CONVERSATION_KEY)!;
assert.notEqual(reset.nativeSessionId, first.nativeSessionId);
assert.ok(logs.some((line) => /Assistant session reset for conversation [0-9a-f]{8} after \d+s idle/.test(line)), logs.join("\n"));
const restores = readLog(harness.logFile).filter((entry) => (entry.method === "session/resume" || entry.method === "session/load")
&& entry.sessionId === first.nativeSessionId);
assert.equal(restores.length, 0);
} finally {
await closeHarness(harness);
}
});
test("an idle assistant session is resumed while the owner has an unfinished proposal", async () => {
const harness = await createHarness();
try {
// Proposed (never confirmed) is enough to keep the session.
await harness.manager.prompt(request("create proposal: succeed"));
const first = harness.store.getBinding(CONVERSATION_KEY)!;
await harness.store.setBinding({ ...first, lastActiveAt: Date.now() - 3_700_000 });
await reopenManager(harness);
await harness.manager.prompt(request("hello"));
assert.equal(harness.store.getBinding(CONVERSATION_KEY)!.nativeSessionId, first.nativeSessionId);
// Pending keeps it too.
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const second = harness.store.getBinding(CONVERSATION_KEY)!;
await harness.store.setBinding({ ...second, lastActiveAt: Date.now() - 3_700_000 });
await reopenManager(harness);
await harness.manager.prompt(request("hello again"));
assert.equal(harness.store.getBinding(CONVERSATION_KEY)!.nativeSessionId, first.nativeSessionId);
const restores = readLog(harness.logFile).filter((entry) => (entry.method === "session/resume" || entry.method === "session/load")
&& entry.sessionId === first.nativeSessionId);
assert.ok(restores.length > 0);
} finally {
await closeHarness(harness);
}
});
test("a recently active assistant session resumes and lastActiveAt persists across restarts", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("hello"));
const first = harness.store.getBinding(CONVERSATION_KEY)!;
await reopenManager(harness);
await harness.manager.prompt(request("hello again"));
const touched = harness.store.getBinding(CONVERSATION_KEY)!;
assert.equal(touched.nativeSessionId, first.nativeSessionId);
assert.ok(touched.lastActiveAt! >= first.lastActiveAt!);
await reopenManager(harness);
assert.equal(harness.store.getBinding(CONVERSATION_KEY)!.lastActiveAt, touched.lastActiveAt);
} finally {
await closeHarness(harness);
}
});
test("assistantSessionResetIdleMs 0 disables the idle reset", async () => {
const harness = await createHarness({ acp: { assistantSessionResetIdleMs: 0 } });
try {
await harness.manager.prompt(request("hello"));
const first = harness.store.getBinding(CONVERSATION_KEY)!;
await harness.store.setBinding({ ...first, lastActiveAt: Date.now() - 86_400_000 });
await reopenManager(harness, { acp: { assistantSessionResetIdleMs: 0 } });
await harness.manager.prompt(request("hello again"));
assert.equal(harness.store.getBinding(CONVERSATION_KEY)!.nativeSessionId, first.nativeSessionId);
} finally {
await closeHarness(harness);
}
});
test("a worker envelope truncated at the closing tag is salvaged without a repair round", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: truncattach"));
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const pending = harness.proposals.get(proposal.id)!.pending!;
assert.equal(pending.summary, "made a pic");
assert.equal(pending.attachments?.length, 1);
assert.ok(pending.attachments![0]!.path.endsWith(path.join(".gori-outbox", "shot.png")));
const repairs = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt"
&& entry.text?.startsWith("Your previous response did not end with a valid GORI_WORKER_RESULT_V2"));
assert.equal(repairs.length, 0);
} finally {
await closeHarness(harness);
}
});
test("a worker envelope with unbalanced JSON still goes through exactly one repair round", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: truncbad"));
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
assert.equal(harness.proposals.get(proposal.id)!.pending?.summary, "repaired");
const repairs = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt"
&& entry.text?.startsWith("Your previous response did not end with a valid GORI_WORKER_RESULT_V2"));
assert.equal(repairs.length, 1);
} finally {
await closeHarness(harness);
}
});
test("an assistant envelope truncated at the closing tag is salvaged without a repair round", async () => {
const harness = await createHarness();
try {
const reply = await harness.manager.prompt(request("truncated assistant"));
assert.equal(reply.text, "ok");
const repairs = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt"
&& entry.text?.startsWith("Your previous response did not end with a valid GORI_ASSISTANT_ACTION_V2"));
assert.equal(repairs.length, 0);
} finally {
await closeHarness(harness);
}
});
test("worker attachments are validated against the workspace and ride along with the owner event", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: attach"));
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const pending = harness.proposals.get(proposal.id)!.pending!;
assert.equal(pending.attachments?.length, 1);
const stored = pending.attachments![0]!;
assert.equal(stored.mimeType, "image/png");
const workspaceRoot = fs.realpathSync(harness.workspace);
assert.ok(stored.path.startsWith(`${workspaceRoot}${path.sep}`), stored.path);
assert.ok(stored.path.endsWith(path.join(".gori-outbox", "shot.png")), stored.path);
assert.equal(pending.droppedAttachments, undefined);
await waitFor(() => harness.events.length > 0);
assert.equal(harness.events[0]!.images?.length, 1);
assert.equal(harness.events[0]!.images![0]!.path, stored.path);
assert.equal(harness.events[0]!.images![0]!.filename, "shot.png");
} finally {
await closeHarness(harness);
}
});
test("worker attachments outside the workspace or not png/jpg are dropped without failing the result", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: attachbad"));
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const pending = harness.proposals.get(proposal.id)!.pending!;
assert.equal(pending.summary, "made a pic");
assert.equal(pending.attachments, undefined);
assert.deepEqual(pending.droppedAttachments, ["/tmp/evil.png", "not-an-image.txt"]);
await waitFor(() => harness.events.length > 0);
assert.equal(harness.events[0]!.images, undefined);
} finally {
await closeHarness(harness);
}
});
test("send_image validates the path and attaches the image to the reply", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: attach"));
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const sent = await harness.manager.prompt(request("send image: .gori-outbox/shot.png"));
assert.equal(sent.images?.length, 1);
assert.equal(sent.images![0]!.filename, "shot.png");
assert.equal(sent.images![0]!.mimeType, "image/png");
assert.ok(sent.images![0]!.path.startsWith(fs.realpathSync(harness.workspace)));
assert.doesNotMatch(sent.text, /发不出去/);
const outside = await harness.manager.prompt(request("send image: /tmp/evil.png"));
assert.equal(outside.images, undefined);
assert.match(outside.text, /发不出去/);
const notImage = await harness.manager.prompt(request("send image: not-an-image.txt"));
assert.equal(notImage.images, undefined);
assert.match(notImage.text, /发不出去/);
} 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"; }
}