feat: interrupt running agent via card button with confirm dialog

Add a  中断 button to live streaming cards; Feishu's native confirm
dialog is the confirmation step, so no extra card round-trip is needed.

- builder.ts: render interrupt action (danger button + confirm) while the
  run is live; new `interrupted` flag renders a 已中断 footer instead of
  完成/失败 on the complete card.
- streaming-card.ts: finish(text, { interrupted }) threads the flag into
  the final patch; overflow cards suppress the button.
- runner.ts: RunRequest gains abortController, passed to the SDK query;
  abort surfaces as RunStatus "interrupted" (no error) in the catch.
- trigger.ts: activeRuns registry maps runId → AbortController; onCardAction
  resolves the interrupt button, authorizes agent.cancel, aborts the run.
  Interrupted runs persist as CANCELED (spec RunState.canceled, terminal →
  lock released per ADR-0002).
- authorizer.ts: agent.cancel now consults PermissionSettings.agentCancel
  (MANAGE_ONLY/DISABLED) — previously only agentTrigger was honored, but
  spec/PermissionGrant deliberately splits these knobs ("谁能触发" ≠
  "谁能取消"). Default role remains MANAGE (Capability.normalCancel).

Tests: runner abort unit test, trigger interrupt + denied integration
tests, three agent.cancel authorization tests (manage allowed, edit denied,
DISABLED policy denies even manage). 26 files / 175 tests pass.
This commit is contained in:
2026-07-09 18:59:31 +08:00
parent d6724e5a83
commit d01ea0e1cb
8 changed files with 383 additions and 34 deletions
+111 -1
View File
@@ -3,7 +3,7 @@ import { prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from ".
import { InMemoryModelRegistry } from "../../src/agent/models.js";
import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
import { TriggerQueue } from "../../src/feishu/triggerQueue.js";
import type { MessageReceiveEvent } from "../../src/feishu/client.js";
import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.js";
import type { RunRequest, RunResult } from "../../src/agent/runner.js";
import type { RuntimeSettings } from "../../src/settings/runtime.js";
@@ -582,6 +582,71 @@ describe("trigger full lifecycle (integration)", () => {
expect(msgs.every((m) => m.runId === run?.id)).toBe(true);
expect(msgs.some((m) => m.role === "assistant")).toBe(true);
});
it("interrupts a running agent via the card interrupt button", async () => {
await seedProject("proj-int", "chat-int", { role: "MANAGE" });
runAgent = createHangingRunAgent();
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-int", "@_user_1 写教案"), rt);
// The run started: a card was sent (with an interrupt button) and the run is ACTIVE.
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
expect(runs[0]?.status).toBe("ACTIVE");
});
expect(rt.sentCards.length).toBeGreaterThanOrEqual(1);
const run = await prisma.agentRun.findFirst();
expect(run).not.toBeNull();
await trigger.onCardAction(makeInterruptEvent("chat-int", run!.id, "ou_test_user"), rt);
// The run transitions to CANCELED (spec RunState.canceled is terminal).
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs[0]?.status).toBe("CANCELED");
});
// The lock is released (terminal ⇒ release, ADR-0002).
await vi.waitFor(async () => {
const locks = await prisma.projectAgentLock.findMany();
expect(locks).toHaveLength(0);
});
// Audit records the interrupt.
const audit = await prisma.auditEntry.findMany();
expect(audit.some((a) => a.action === "run.interrupted")).toBe(true);
// The card was patched to a complete state with an "已中断" footer.
expect(rt.sentPatches.some((card) => cardHasInterruptedFooter(card))).toBe(true);
});
it("denies interrupt when the operator lacks agent.cancel permission", async () => {
// EDIT role can trigger but cannot cancel (agent.cancel requires MANAGE).
await seedProject("proj-int-deny", "chat-int-deny", { role: "EDIT" });
runAgent = createHangingRunAgent();
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-int-deny", "@_user_1 写教案"), rt);
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs[0]?.status).toBe("ACTIVE");
});
const run = await prisma.agentRun.findFirst();
expect(run).not.toBeNull();
await trigger.onCardAction(makeInterruptEvent("chat-int-deny", run!.id, "ou_test_user"), rt);
expect(rt.sentTexts).toContain("无权限中断该运行。");
const audit = await prisma.auditEntry.findMany();
expect(audit.some((a) => a.action === "run.interrupt_denied")).toBe(true);
// The run is still active — it was not aborted.
const runs = await prisma.agentRun.findMany();
expect(runs[0]?.status).toBe("ACTIVE");
// Clean up: release the hanging run so afterEach isolation isn't disturbed.
const active = await prisma.agentRun.findFirst({ where: { status: "ACTIVE" } });
if (active !== null) {
await prisma.agentRun.update({ where: { id: active.id }, data: { status: "FAILED", finishedAt: new Date() } });
await prisma.projectAgentLock.deleteMany({ where: { runId: active.id } });
}
});
});
interface Deferred<T> {
@@ -609,6 +674,51 @@ function completedRunResult(text: string, sdkSessionId: string): RunResult {
sdkSessionId,
};
}
function createHangingRunAgent(): TestRunner {
// Simulates a long-running agent: never resolves on its own, but resolves
// with status "interrupted" when its abortController fires (mirrors the
// real runner's abort path).
return async (req) => {
req.onStream?.({ type: "text-delta", text: "处理中..." });
await req.prisma.agentMessage.create({
data: { sessionId: req.sessionId, runId: req.runId, role: "assistant", content: "处理中...", attachments: [] },
});
const ac = req.abortController;
return new Promise<RunResult>((resolve) => {
if (ac === undefined) return;
if (ac.signal.aborted) {
resolve({ status: "interrupted", text: "处理中...", usage: { inputTokens: 1, outputTokens: 1 }, numTurns: 1, sdkSessionId: "sdk-int" });
return;
}
ac.signal.addEventListener("abort", () => {
resolve({ status: "interrupted", text: "处理中...", usage: { inputTokens: 1, outputTokens: 1 }, numTurns: 1, sdkSessionId: "sdk-int" });
}, { once: true });
});
};
}
function makeInterruptEvent(chatId: string, runId: string, openId: string): CardActionEvent {
return {
operator: { open_id: openId },
action: { value: { interrupt_run: runId }, tag: "button" },
context: { open_chat_id: chatId },
};
}
function cardHasInterruptedFooter(card: unknown): boolean {
if (typeof card !== "object" || card === null) return false;
if (!("elements" in card)) return false;
const elements = card.elements;
if (!Array.isArray(elements)) return false;
return elements.some((el) => {
if (typeof el !== "object" || el === null) return false;
if (!("text" in el)) return false;
const text = el.text;
if (typeof text !== "object" || text === null || !("content" in text)) return false;
const content = text.content;
return typeof content === "string" && content.includes("已中断");
});
}
afterAll(async () => {
await prisma.$disconnect();