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) }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user