From d01ea0e1cbd2b5cd70491d43414536298fe5298d Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Thu, 9 Jul 2026 18:59:31 +0800 Subject: [PATCH] feat: interrupt running agent via card button with confirm dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hub/src/agent/runner.ts | 15 ++- hub/src/feishu/card/builder.ts | 36 ++++++- hub/src/feishu/card/streaming-card.ts | 12 ++- hub/src/feishu/trigger.ts | 119 ++++++++++++++++++--- hub/src/permissions/authorizer.ts | 14 ++- hub/test/integration/authorization.test.ts | 56 +++++++++- hub/test/integration/trigger.test.ts | 112 ++++++++++++++++++- hub/test/unit/runner.test.ts | 53 +++++++-- 8 files changed, 383 insertions(+), 34 deletions(-) diff --git a/hub/src/agent/runner.ts b/hub/src/agent/runner.ts index e08cb58..aa1a90c 100644 --- a/hub/src/agent/runner.ts +++ b/hub/src/agent/runner.ts @@ -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 { 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 { ...(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) }), }; } } diff --git a/hub/src/feishu/card/builder.ts b/hub/src/feishu/card/builder.ts index d3e44de..9319b1e 100644 --- a/hub/src/feishu/card/builder.ts +++ b/hub/src/feishu/card/builder.ts @@ -59,9 +59,10 @@ export function buildAgentCard(params: { toolUseSteps: ToolUseTraceStep[]; toolUseElapsedMs: number | undefined; isError: boolean | undefined; + interrupted: boolean | undefined; runId: string | undefined; }): Record { - 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 // --------------------------------------------------------------------------- diff --git a/hub/src/feishu/card/streaming-card.ts b/hub/src/feishu/card/streaming-card.ts index 8b07d58..945960e 100644 --- a/hub/src/feishu/card/streaming-card.ts +++ b/hub/src/feishu/card/streaming-card.ts @@ -56,6 +56,7 @@ export class StreamingAgentCard { private flushChain: Promise = 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 { + async finish(fallbackText: string, options: { readonly interrupted?: boolean } = {}): Promise { 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); } diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index 558becb..44a5971 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -88,6 +88,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { const approvalManager = new ApprovalManager(); const triggerQueue = deps.triggerQueue ?? defaultTriggerQueue; const batchContexts = new Map(); + // 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(); 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 => { - 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 { + 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 => { 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)["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(); diff --git a/hub/src/permissions/authorizer.ts b/hub/src/permissions/authorizer.ts index 9397910..2aeb2f5 100644 --- a/hub/src/permissions/authorizer.ts +++ b/hub/src/permissions/authorizer.ts @@ -121,7 +121,11 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer { private async requiredRole(req: AuthorizationRequest): Promise { let required = defaultRequiredRole(req.action); - if ((req.action === "agent.trigger" || req.action === "role.trigger") && req.resource.type === "PROJECT") { + // ADR-0019 + PermissionGrant spec: agentTrigger and agentCancel are + // distinct policy knobs ("谁能触发" ≠ "谁能取消"). Each can tighten the + // default role to MANAGE_ONLY or DISABLE the action entirely. The default + // role (trigger=EDIT, cancel=MANAGE) is the floor when no policy is set. + if (req.resource.type === "PROJECT" && (req.action === "agent.trigger" || req.action === "role.trigger")) { const settings = await this.prisma.permissionSettings.findFirst({ where: { resourceType: req.resource.type, resourceId: req.resource.id }, select: { agentTrigger: true }, @@ -129,6 +133,14 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer { const policy = normalizePolicy(settings?.agentTrigger); if (policy === "DISABLED") return "DISABLED"; if (policy === "MANAGE_ONLY") required = "MANAGE"; + } else if (req.resource.type === "PROJECT" && req.action === "agent.cancel") { + const settings = await this.prisma.permissionSettings.findFirst({ + where: { resourceType: req.resource.type, resourceId: req.resource.id }, + select: { agentCancel: true }, + }); + const policy = normalizePolicy(settings?.agentCancel); + if (policy === "DISABLED") return "DISABLED"; + if (policy === "MANAGE_ONLY") required = "MANAGE"; } return required; } diff --git a/hub/test/integration/authorization.test.ts b/hub/test/integration/authorization.test.ts index f70fbc6..74a5e84 100644 --- a/hub/test/integration/authorization.test.ts +++ b/hub/test/integration/authorization.test.ts @@ -287,6 +287,55 @@ describe("ADR-0019 authorization", () => { expect(decision.allowed).toBe(false); expect(decision.reason).toContain("no active role review grant"); }); + it("agent.cancel defaults to MANAGE and is allowed for a manage grant", async () => { + await prisma.project.create({ data: { id: "p-cancel", name: "Cancel", workspaceDir: "/tmp/cancel" } }); + await prisma.user.create({ data: { id: "u-cancel", feishuOpenId: "ou_cancel", displayName: "Manager" } }); + await prisma.permissionGrant.create({ + data: { resourceType: "PROJECT", resourceId: "p-cancel", principalType: "USER", principalId: "ou_cancel", role: "MANAGE" }, + }); + + const decision = await createPermissionAuthorizer(prisma).can({ + actor: { feishuOpenId: "ou_cancel" }, + action: "agent.cancel", + resource: { type: "PROJECT", id: "p-cancel" }, + }); + expect(decision.allowed).toBe(true); + expect(decision.requiredRole).toBe("MANAGE"); + }); + + it("agent.cancel is denied to an edit grant (below the MANAGE default)", async () => { + await prisma.project.create({ data: { id: "p-cancel-edit", name: "Cancel Edit", workspaceDir: "/tmp/cancel-edit" } }); + await prisma.user.create({ data: { id: "u-cancel-edit", feishuOpenId: "ou_cancel_edit", displayName: "Editor" } }); + await prisma.permissionGrant.create({ + data: { resourceType: "PROJECT", resourceId: "p-cancel-edit", principalType: "USER", principalId: "ou_cancel_edit", role: "EDIT" }, + }); + + const decision = await createPermissionAuthorizer(prisma).can({ + actor: { feishuOpenId: "ou_cancel_edit" }, + action: "agent.cancel", + resource: { type: "PROJECT", id: "p-cancel-edit" }, + }); + expect(decision.allowed).toBe(false); + }); + + it("agentCancel DISABLED policy denies cancel even for a manage grant", async () => { + await prisma.project.create({ data: { id: "p-cancel-off", name: "Cancel Off", workspaceDir: "/tmp/cancel-off" } }); + await prisma.user.create({ data: { id: "u-cancel-off", feishuOpenId: "ou_cancel_off", displayName: "Manager Off" } }); + await prisma.permissionGrant.create({ + data: { resourceType: "PROJECT", resourceId: "p-cancel-off", principalType: "USER", principalId: "ou_cancel_off", role: "MANAGE" }, + }); + await prisma.permissionSettings.create({ + data: defaultProjectSettings("p-cancel-off", { agentCancel: "DISABLED" }), + }); + + const decision = await createPermissionAuthorizer(prisma).can({ + actor: { feishuOpenId: "ou_cancel_off" }, + action: "agent.cancel", + resource: { type: "PROJECT", id: "p-cancel-off" }, + }); + expect(decision.allowed).toBe(false); + expect(decision.reason).toContain("disables this action"); + }); }); async function seedUserTeamProject(suffix: string) { @@ -309,7 +358,10 @@ async function seedUserTeamProject(suffix: string) { return { user, team, project }; } -function defaultProjectSettings(projectId: string, overrides: { agentTrigger?: string } = {}) { +function defaultProjectSettings( + projectId: string, + overrides: { agentTrigger?: string; agentCancel?: string } = {}, +) { return { resourceType: "PROJECT" as const, resourceId: projectId, @@ -318,6 +370,6 @@ function defaultProjectSettings(projectId: string, overrides: { agentTrigger?: s copyDownload: "ROLE", collaboratorMgmt: "ROLE", agentTrigger: overrides.agentTrigger ?? "ROLE", - agentCancel: "ROLE", + agentCancel: overrides.agentCancel ?? "ROLE", }; } diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index 028dbfc..07bf88e 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -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 { @@ -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((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(); diff --git a/hub/test/unit/runner.test.ts b/hub/test/unit/runner.test.ts index bbc0242..902fcb7 100644 --- a/hub/test/unit/runner.test.ts +++ b/hub/test/unit/runner.test.ts @@ -34,6 +34,18 @@ function messages(...items: unknown[]) { for (const item of items) yield item; })(); } +function abortableMessages(ac: AbortController, ...items: unknown[]) { + return (async function* () { + for (const item of items) yield item; + // Simulate the SDK: after yielding, the query blocks until the abort + // signal fires, then throws (the SDK raises AbortError). + if (ac.signal.aborted) throw new Error("aborted"); + await new Promise((resolve) => { + ac.signal.addEventListener("abort", () => resolve(), { once: true }); + }); + throw new Error("aborted"); + })(); +} describe("runAgent", () => { beforeEach(() => { @@ -111,12 +123,16 @@ describe("runAgent", () => { prisma: stubPrisma, }); - const call = queryMock.mock.calls[0]?.[0] as { options?: { env?: Record } } | undefined; - expect(call?.options?.env).toMatchObject({ - ANTHROPIC_BASE_URL: "https://openrouter.ai/api", - ANTHROPIC_AUTH_TOKEN: "test-token", - ANTHROPIC_API_KEY: "", - CPH_TEST_PROVIDER_ENV_MUTATION: "per-run", + const call = queryMock.mock.calls[0]?.[0]; + expect(call).toMatchObject({ + options: { + env: { + ANTHROPIC_BASE_URL: "https://openrouter.ai/api", + ANTHROPIC_AUTH_TOKEN: "test-token", + ANTHROPIC_API_KEY: "", + CPH_TEST_PROVIDER_ENV_MUTATION: "per-run", + }, + }, }); expect(process.env["CPH_TEST_PROVIDER_ENV_MUTATION"]).toBeUndefined(); }); @@ -157,4 +173,29 @@ describe("runAgent", () => { }), }); }); + it("returns interrupted status when the abort controller fires", async () => { + const ac = new AbortController(); + queryMock.mockReturnValue(abortableMessages(ac, assistantMessage("partial"), resultMessage("sdk-session-abort"))); + + const runPromise = runAgent({ + prompt: "长任务", + model: undefined, + project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, + systemPrompt: undefined, + runId: "run-abort", + sessionId: "hub-session-abort", + prisma: stubPrisma, + abortController: ac, + }); + // Let the query yield its messages and block on the abort wait. + await Promise.resolve(); + ac.abort(); + + const result = await runPromise; + expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ options: { abortController: ac } }); + expect(result.error).toBeUndefined(); + expect(result.sdkSessionId).toBe("sdk-session-abort"); + const call = queryMock.mock.calls[0]?.[0] as { options?: { abortController?: AbortController } } | undefined; + expect(call?.options?.abortController).toBe(ac); + }); });