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
+11 -4
View File
@@ -60,9 +60,14 @@ export interface RunRequest {
readonly sessionId: string;
readonly prisma: PrismaClient;
readonly onStream?: StreamCallback;
/**
* Abort controller for the run. Aborting the signal interrupts the SDK query
* (it stops the agent loop and cleans up); the run resolves with status
* "interrupted". The caller owns the controller and triggers abort.
*/
readonly abortController?: AbortController | undefined;
}
export type RunStatus = "completed" | "length" | "failed";
export type RunStatus = "completed" | "length" | "failed" | "interrupted";
export interface RunResult {
readonly status: RunStatus;
@@ -152,6 +157,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
if (req.providerEnv !== undefined) options.env = { ...process.env, ...req.providerEnv };
if (req.resumeSessionId !== undefined) options.resume = req.resumeSessionId;
if (req.mcpServers !== undefined) options.mcpServers = req.mcpServers;
if (req.abortController !== undefined) options.abortController = req.abortController;
const conversation = query({
prompt: req.prompt,
@@ -260,13 +266,14 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
...(error !== undefined ? { error } : {}),
};
} catch (e) {
const aborted = req.abortController?.signal.aborted === true;
return {
status: "failed",
status: aborted ? "interrupted" : "failed",
text: fullText,
usage,
numTurns,
sdkSessionId,
error: e instanceof Error ? e.message : String(e),
...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }),
};
}
}