91327dc632
The unfinished-proposal board is now visible to every conversation, with entries marked scope=own/scope=other (chat type only, no IDs), so anyone can see what the single worker is busy with. All actions remain owner-only, and pending reminders still target only the owner.
998 lines
51 KiB
TypeScript
998 lines
51 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 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);
|
||
|
||
// The board is globally visible: both users see the same proposal, tagged by scope.
|
||
assert.equal(harness.manager.listProposals("webhook", "chat-1", "user-b").length, 1);
|
||
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 owner", "user-a"));
|
||
const aPrompt = readLog(harness.logFile).find((entry) => entry.method === "session/prompt" && entry.text?.startsWith("[User message]\nhello owner"));
|
||
assert.ok(aPrompt?.text);
|
||
assert.match(aPrompt.text!, /scope=own/);
|
||
|
||
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/);
|
||
// The shared board tags the entry scope=other with a chat-type label; no raw user ID leaks.
|
||
assert.match(bPrompt.text!, /scope=other chat=direct/);
|
||
assert.doesNotMatch(bPrompt.text!, /user-a/);
|
||
|
||
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"; }
|
||
}
|