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
+34 -2
View File
@@ -59,9 +59,10 @@ export function buildAgentCard(params: {
toolUseSteps: ToolUseTraceStep[];
toolUseElapsedMs: number | undefined;
isError: boolean | undefined;
interrupted: boolean | undefined;
runId: string | undefined;
}): Record<string, unknown> {
const { phase, text, reasoningText, toolUseSteps, toolUseElapsedMs, isError } = params;
const { phase, text, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
const elements: unknown[] = [];
// Tool-use panel (always present if there are steps)
@@ -100,10 +101,19 @@ export function buildAgentCard(params: {
});
}
// Interrupt button while the run is live. The button carries the run id in
// its action value; the card action handler aborts the run. Feishu's native
// `confirm` dialog is the "are you sure" step — the action only fires after
// the user confirms, so no second round-trip is needed.
if (phase !== "complete" && params.runId !== undefined) {
elements.push(buildInterruptAction(params.runId));
}
// Footer for complete state
if (phase === "complete") {
const footerParts: string[] = [];
if (isError === true) footerParts.push("\u274C \u5931\u8D25");
if (interrupted === true) footerParts.push("\u{1F6D1} \u5DF2\u4E2D\u65AD");
else if (isError === true) footerParts.push("\u274C \u5931\u8D25");
else footerParts.push("\u2705 \u5B8C\u6210");
if (toolUseElapsedMs !== undefined) {
footerParts.push(formatElapsed(toolUseElapsedMs));
@@ -125,6 +135,28 @@ export function buildAgentCard(params: {
};
}
/** Action row with a single danger "interrupt" button for a live run. */
function buildInterruptAction(runId: string): unknown {
return {
tag: "action",
actions: [
{
tag: "button",
text: { tag: "plain_text", content: "\u{1F6D1} \u4E2D\u65AD" },
type: "danger",
value: { interrupt_run: runId },
confirm: {
title: { tag: "plain_text", content: "\u786E\u8BA4\u4E2D\u65AD" },
text: {
tag: "plain_text",
content: "\u786E\u5B9A\u8981\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u5417\uFF1F\u4E2D\u65AD\u540E\u65E0\u6CD5\u6062\u590D\uFF0C\u5DF2\u4EA7\u51FA\u7684\u5185\u5BB9\u4F1A\u4FDD\u7559\u3002",
},
},
},
],
};
}
// ---------------------------------------------------------------------------
// Tool-use panel
// ---------------------------------------------------------------------------
+8 -4
View File
@@ -56,6 +56,7 @@ export class StreamingAgentCard {
private flushChain: Promise<void> = Promise.resolve();
private flushScheduled = false;
private lastPatchAt = 0;
private interrupted = false;
private readonly runId: string;
private readonly rt: FeishuRuntime;
@@ -102,9 +103,9 @@ export class StreamingAgentCard {
recordToolUseEnd({ runId: this.runId, ...params });
this.scheduleFlush();
}
async finish(fallbackText: string): Promise<void> {
async finish(fallbackText: string, options: { readonly interrupted?: boolean } = {}): Promise<void> {
await this.flushChain;
this.interrupted = options.interrupted === true;
if (this.text.length > 0) {
await this.flushCard("complete", this.text);
return;
@@ -164,6 +165,7 @@ export class StreamingAgentCard {
toolUseSteps,
toolUseElapsedMs: this.toolUseElapsedMs,
isError,
interrupted: this.interrupted,
runId: this.runId,
});
@@ -178,7 +180,8 @@ export class StreamingAgentCard {
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError,
runId: this.runId,
interrupted: this.interrupted,
runId: undefined,
});
this.currentMessageId = await sendCard(this.rt, this.chatId, overflowCard);
}
@@ -193,7 +196,8 @@ export class StreamingAgentCard {
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError,
runId: this.runId,
interrupted: this.interrupted,
runId: undefined,
});
this.currentMessageId = await sendCard(this.rt, this.chatId, overflowCard);
}
+105 -14
View File
@@ -88,6 +88,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const approvalManager = new ApprovalManager();
const triggerQueue = deps.triggerQueue ?? defaultTriggerQueue;
const batchContexts = new Map<string, TriggerRunContext>();
// runId → AbortController for live runs. Registered when a run starts,
// used by the interrupt card action to abort the SDK query, cleared in the
// run's finally. Absence means the run already terminated (or never started).
const activeRuns = new Map<string, AbortController>();
const messageBatcher = new MessageBatcher(async (mergedText, key) => {
if (key === undefined) {
deps.logger.warn("feishu trigger: message batch flushed without a key");
@@ -281,6 +285,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
},
});
// Abort handle for this run; the interrupt card action calls abort() to
// stop the SDK query. Cleared in the finally below.
const abortController = new AbortController();
activeRuns.set(run.id, abortController);
runAgent({
prompt: promptForAgent,
model,
@@ -293,6 +301,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
runId: run.id,
sessionId: session.id,
prisma: deps.prisma,
abortController,
onStream: (event) => {
switch (event.type) {
case "text-delta":
@@ -330,13 +339,14 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
},
})
.then(async (result) => {
const interrupted = result.status === "interrupted";
const finalText =
result.text !== ""
? result.text
: result.status === "failed" && result.error !== undefined
? `\u5904\u7406\u5931\u8D25: ${result.error}`
: result.text;
await card.finish(finalText);
await card.finish(finalText, { interrupted });
const metadataPatch = sessionMetadataPatch(result.sdkSessionId);
if (metadataPatch !== null) {
await deps.prisma.agentSession.update({
@@ -347,7 +357,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
await deps.prisma.agentRun.update({
where: { id: run.id },
data: {
status: result.status === "completed" ? "COMPLETED" : result.status === "length" ? "TIMED_OUT" : "FAILED",
status: interrupted ? "CANCELED" : result.status === "completed" ? "COMPLETED" : result.status === "length" ? "TIMED_OUT" : "FAILED",
inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens,
error: result.error ?? null,
@@ -389,6 +399,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
} catch (e) {
deps.logger.warn({ projectId, err: e instanceof Error ? e.message : String(e) }, "trigger: could not drain trigger queue");
}
activeRuns.delete(run.id);
});
return "started";
}
@@ -466,20 +477,86 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
}
const onCardAction = async (event: CardActionEvent, rt: FeishuRuntime): Promise<void> => {
const action = approvalActionFromValue(event.action.value);
if (action === null) return;
const messageId = nonEmpty(event.context?.open_message_id);
if (messageId === undefined || !approvalManager.hasPending(messageId)) return;
// Approval buttons (file-delivery / explicit approval cards).
const approvalAction = approvalActionFromValue(event.action.value);
if (approvalAction !== null) {
const messageId = nonEmpty(event.context?.open_message_id);
if (messageId === undefined || !approvalManager.hasPending(messageId)) return;
const operatorOpenId = nonEmpty(event.operator.open_id);
const resolvedBy = operatorOpenId ?? "unknown";
const resolvedByName =
operatorOpenId === undefined ? "unknown" : await senderDisplayNameOrOpenId(rt, operatorOpenId);
const resolution = approvalResolutionFor(approvalAction);
approvalManager.resolve(messageId, approvalAction, resolvedBy);
await resolveCard(rt, messageId, resolution.icon, resolution.label, resolution.template, resolvedByName);
return;
}
const operatorOpenId = nonEmpty(event.operator.open_id);
const resolvedBy = operatorOpenId ?? "unknown";
const resolvedByName =
operatorOpenId === undefined ? "unknown" : await senderDisplayNameOrOpenId(rt, operatorOpenId);
const resolution = approvalResolutionFor(action);
approvalManager.resolve(messageId, action, resolvedBy);
await resolveCard(rt, messageId, resolution.icon, resolution.label, resolution.template, resolvedByName);
// Interrupt button on a live run card.
const runId = interruptRunFromValue(event.action.value);
if (runId !== null) {
await handleInterruptAction(event, rt, runId);
}
};
/** Abort a live run after authorizing agent.cancel on its project. */
async function handleInterruptAction(event: CardActionEvent, rt: FeishuRuntime, runId: string): Promise<void> {
const chatId = nonEmpty(event.context?.open_chat_id);
if (chatId === undefined) {
deps.logger.warn({ runId }, "feishu interrupt: card action missing chat id");
return;
}
const binding = await deps.prisma.projectGroupBinding.findUnique({
where: { chatId },
select: { projectId: true },
});
if (binding === null) {
deps.logger.debug({ chatId }, "feishu interrupt: chat not bound to any project");
return;
}
const projectId = binding.projectId;
const senderOpenId = nonEmpty(event.operator.open_id) ?? "";
if (senderOpenId === "") {
deps.logger.warn({ projectId, chatId, runId }, "feishu interrupt: operator has no open_id, denying");
await sendText(rt, chatId, "无法识别操作者,拒绝中断。");
return;
}
const actor = { feishuOpenId: senderOpenId, chatId };
const decision = await authorizer.can({
actor,
action: "agent.cancel",
resource: { type: "PROJECT", id: projectId },
});
if (!decision.allowed) {
deps.logger.info({ projectId, runId, senderOpenId, reason: decision.reason }, "feishu interrupt: permission denied");
await writeAudit(deps.prisma, {
projectId,
...(decision.actorUserId !== undefined ? { actorUserId: decision.actorUserId } : {}),
runId,
action: "run.interrupt_denied",
metadata: { decision: auditDecision(decision), sender: await senderAuditMetadata(rt, senderOpenId) },
});
await sendText(rt, chatId, "无权限中断该运行。");
return;
}
const controller = activeRuns.get(runId);
if (controller === undefined) {
// Run already terminated (or unknown): nothing to abort. The card's
// complete-state patch already removed the button, so a stray click is
// best-effort ignored rather than spammed with a message.
deps.logger.debug({ runId, projectId }, "feishu interrupt: no active run for runId");
return;
}
controller.abort();
await writeAudit(deps.prisma, {
projectId,
...(decision.actorUserId !== undefined ? { actorUserId: decision.actorUserId } : {}),
runId,
action: "run.interrupted",
metadata: { sender: await senderAuditMetadata(rt, senderOpenId) },
});
}
const onMessage = async (event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void> => {
const msg = event.message;
@@ -721,9 +798,23 @@ function approvalActionFromValue(value: unknown): string | null {
}
}
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
const action = (value as Record<string, unknown>)["approval_action"];
if (!("approval_action" in value)) return null;
const action = value.approval_action;
return typeof action === "string" && action !== "" ? action : null;
}
function interruptRunFromValue(value: unknown): string | null {
if (typeof value === "string") {
try {
return interruptRunFromValue(JSON.parse(value));
} catch {
return null;
}
}
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
if (!("interrupt_run" in value)) return null;
const runId = value.interrupt_run;
return typeof runId === "string" && runId !== "" ? runId : null;
}
function approvalResolutionFor(action: string): { readonly icon: string; readonly label: string; readonly template: "green" | "red" } {
const normalized = action.trim().toLowerCase();