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
+47 -6
View File
@@ -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);
});
});