forked from bai/curriculum-project-hub
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:
+11
-4
@@ -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) }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -121,7 +121,11 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
|
||||
|
||||
private async requiredRole(req: AuthorizationRequest): Promise<PermissionRole | "DISABLED"> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<void>((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<string, string | undefined> } } | 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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user