feat: share finish stop and cancel across the board

Proposal visibility is already global; this change makes finish, stop,
and cancel shared queue-management actions so any user can unblock the
single worker and shared queue. Confirm, adjust, follow_up, and
start_next remain owner-only. Assistant/bootstrap copy, /help, /list,
and runtime checks now align with that split.
This commit is contained in:
zenord
2026-08-22 00:02:26 +08:00
parent 91327dc632
commit 5229059fe3
9 changed files with 111 additions and 45 deletions
+37 -24
View File
@@ -392,18 +392,19 @@ test("proposals are owned per chat+user: a second user in the same chat cannot c
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(await harness.manager.cancel("webhook", "chat-1", "user-b"), true);
assert.equal(harness.proposals.get(proposal.id)!.status, "finished");
assert.equal(harness.proposals.get(proposal.id)!.finishKind, "cancelled");
await harness.manager.prompt(request("create proposal: hang", "user-a"));
const running = harness.proposals.list().find((item) => item.status === "proposed")!;
// 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(harness.manager.listProposals("webhook", "chat-1", "user-b").some((item) => item.id === running.id), true);
assert.equal(harness.manager.listProposals("webhook", "chat-1", "user-a").some((item) => item.id === running.id), true);
assert.equal(await harness.manager.confirm("webhook", "chat-1", "user-a"), true);
assert.equal(harness.proposals.get(proposal.id)!.status, "working");
assert.equal(harness.proposals.get(running.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"));
@@ -420,11 +421,11 @@ test("proposals are owned per chat+user: a second user in the same chat cannot c
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, "被用户中止");
assert.equal(await harness.manager.stop("webhook", "chat-1", "user-b"), true);
assert.equal(harness.proposals.get(running.id)!.status, "pending");
assert.equal(harness.proposals.get(running.id)!.pending?.summary, "被用户中止");
assert.equal(await harness.manager.finish("webhook", "chat-1", "user-b"), true);
assert.equal(harness.proposals.get(running.id)!.status, "finished");
} finally {
await closeHarness(harness);
}
@@ -662,32 +663,44 @@ test("a working proposal blocks start_next for everyone with a sanitized reason"
}
});
test("confirm, finish, stop, cancel, and follow_up actions with no matching proposal append an ownership correction", async () => {
test("confirm and follow_up stay owner-only while finish, stop, and cancel are shared", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: hang", "user-a"));
const proposed = harness.proposals.list()[0]!;
const confirmed = await harness.manager.prompt(request("confirm", "user-b"));
assert.match(confirmed.text, /没有可确认的提案/);
assert.match(confirmed.text, /自己发起/);
const cancelled = await harness.manager.prompt(request("cancel proposal", "user-b"));
assert.equal(cancelled.text, "cancelling");
assert.equal(harness.proposals.get(proposed.id)!.status, "finished");
assert.equal(harness.proposals.get(proposed.id)!.finishKind, "cancelled");
await harness.manager.prompt(request("create proposal: ask", "user-a"));
const pending = harness.proposals.list().find((proposal) => proposal.status === "proposed")!;
await harness.manager.prompt(request("confirm", "user-a"));
await waitFor(() => harness.proposals.get(pending.id)!.status === "pending");
const finished = await harness.manager.prompt(request("finish", "user-b"));
assert.match(finished.text, /没有可结束的任务/);
assert.match(finished.text, /自己发起/);
assert.equal(finished.text, "finishing");
assert.equal(harness.proposals.get(pending.id)!.status, "finished");
assert.equal(harness.proposals.get(pending.id)!.finishKind, "done");
await harness.manager.prompt(request("create proposal: hang", "user-a"));
const running = harness.proposals.list().find((proposal) => proposal.status === "proposed")!;
await harness.manager.prompt(request("confirm", "user-a"));
assert.equal(harness.proposals.get(running.id)!.status, "working");
const stopped = await harness.manager.prompt(request("stop", "user-b"));
assert.match(stopped.text, /没有可停止的任务/);
assert.match(stopped.text, /自己发起/);
const cancelled = await harness.manager.prompt(request("cancel proposal", "user-b"));
assert.match(cancelled.text, /没有可取消的提案/);
assert.match(cancelled.text, /自己发起/);
assert.equal(stopped.text, "stopping");
assert.equal(harness.proposals.get(running.id)!.status, "pending");
assert.equal(harness.proposals.get(running.id)!.pending?.summary, "被用户中止");
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);
}
+23 -2
View File
@@ -50,6 +50,7 @@ class FakeRuntime implements ConversationRuntime {
confirmResult = true;
finishResult = true;
stopResult = true;
resetResult = false;
proposals: Proposal[] = [];
commandUsers: Record<string, string | undefined> = {};
@@ -66,8 +67,8 @@ class FakeRuntime implements ConversationRuntime {
async confirm(_platform: string, _chatId: string, userId: string) { this.confirmed++; this.commandUsers.confirm = userId; return this.confirmResult; }
async finish(_platform: string, _chatId: string, userId: string) { this.finished++; this.commandUsers.finish = userId; return this.finishResult; }
async stop(_platform: string, _chatId: string, userId: string) { this.stopped++; this.commandUsers.stop = userId; return this.stopResult; }
async reset(_platform: string, _chatId: string, userId: string) { this.commandUsers.reset = userId; return this.resetResult; }
listProposals(_platform: string, _chatId: string, userId: string) { this.listed++; this.commandUsers.list = userId; return this.proposals; }
async reset() {}
status(_platform?: string, _chatId?: string, userId?: string) {
this.commandUsers.status = userId;
return {
@@ -590,6 +591,7 @@ test("status and help reach the runtime or reply directly", async () => {
assert.match(help.reply || "", /\/finish/);
assert.match(help.reply || "", /\/stop/);
assert.match(help.reply || "", /\/cancel/);
assert.match(help.reply || "", /\/reset/);
runtime.turns[0].resolve("done");
await turn;
});
@@ -654,7 +656,7 @@ test("the list panel orders pending first, then working, queued, proposed, and r
const list = await gateway.receive(message("/list"), adapter, { synchronous: true });
const reply = list.reply || "";
const order = ["待确认(等你处理)", "进行中", "排队中", "未确认(proposed)", "最近结束"]
const order = ["待确认", "进行中", "排队中", "未确认(proposed)", "最近结束"]
.map((section) => reply.indexOf(section));
assert.ok(order.every((index) => index >= 0), reply);
assert.deepEqual([...order].sort((a, b) => a - b), order);
@@ -665,6 +667,25 @@ test("the list panel orders pending first, then working, queued, proposed, and r
assert.match(reply, /p-finished「旧任务」(你的)已完成/);
});
test("/reset resets the current conversation session and reminds about unfinished proposals", async () => {
const { runtime, gateway } = createGateway();
const adapter = recordingAdapter();
const noSession = await gateway.receive(message("/reset"), adapter, { synchronous: true });
assert.equal(noSession.reply, "当前没有需要重置的会话。");
runtime.resetResult = true;
const reset = await gateway.receive(message("/reset"), adapter, { synchronous: true });
assert.match(reset.reply || "", /已重置当前对话/);
assert.doesNotMatch(reset.reply || "", /未完成任务/);
assert.equal(runtime.commandUsers.reset, "user");
runtime.proposals = [fakeProposal({ id: "p-1", status: "pending", pending: { summary: "s", receivedAt: 1 } })];
const reminded = await gateway.receive(message("/reset"), adapter, { synchronous: true });
assert.match(reminded.reply || "", /已重置当前对话/);
assert.match(reminded.reply || "", /你还有 1 个未完成任务,proposal 板不受影响/);
});
test("the list panel shows other members' proposals without exposing their identity", async () => {
const { runtime, gateway } = createGateway();
const adapter = recordingAdapter();