forked from EduCraft/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:
@@ -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