forked from bai/curriculum-project-hub
fix: ack Feishu card actions before interrupt work
This commit is contained in:
@@ -618,6 +618,34 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
expect(rt.sentPatches.some((card) => cardHasInterruptedFooter(card))).toBe(true);
|
||||
});
|
||||
|
||||
it("sends a fallback interrupt notice when the final card patch fails", async () => {
|
||||
await seedProject("proj-int-patch-fail", "chat-int-patch-fail", { role: "MANAGE" });
|
||||
runAgent = createHangingRunAgent();
|
||||
const patch = vi.fn(async () => {
|
||||
throw new Error("patch failed");
|
||||
});
|
||||
(rt.client as unknown as { im: { v1: { message: { patch: typeof patch } } } }).im.v1.message.patch = patch;
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-int-patch-fail", "@_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-patch-fail", run!.id, "ou_test_user"), rt);
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs[0]?.status).toBe("CANCELED");
|
||||
});
|
||||
expect(patch).toHaveBeenCalled();
|
||||
expect(rt.sentTexts).toContain("已中断当前运行。");
|
||||
});
|
||||
|
||||
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" });
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { CardActionEvent, FeishuRuntime } from "../../src/feishu/client.js";
|
||||
import { startFeishuListenerWithClient } from "../../src/feishu/client.js";
|
||||
|
||||
const larkMock = vi.hoisted(() => {
|
||||
const starts: Array<{ readonly eventDispatcher?: unknown }> = [];
|
||||
class MockEventDispatcher {
|
||||
handlers: Record<string, (data: unknown) => Promise<void>> = {};
|
||||
|
||||
register(handlers: Record<string, (data: unknown) => Promise<void>>): this {
|
||||
this.handlers = handlers;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
class MockWSClient {
|
||||
start(params: { readonly eventDispatcher?: unknown }): void {
|
||||
starts.push(params);
|
||||
}
|
||||
}
|
||||
return { starts, MockEventDispatcher, MockWSClient };
|
||||
});
|
||||
|
||||
vi.mock("@larksuiteoapi/node-sdk", () => ({
|
||||
AppType: { SelfBuild: "SelfBuild" },
|
||||
Domain: { Feishu: "Feishu" },
|
||||
LoggerLevel: { info: "info" },
|
||||
Client: vi.fn(),
|
||||
EventDispatcher: larkMock.MockEventDispatcher,
|
||||
WSClient: larkMock.MockWSClient,
|
||||
}));
|
||||
|
||||
describe("Feishu card action callbacks", () => {
|
||||
beforeEach(() => {
|
||||
larkMock.starts.length = 0;
|
||||
});
|
||||
|
||||
it("acks card action callbacks before the business handler settles", async () => {
|
||||
const action = deferred<void>();
|
||||
const onCardAction = vi.fn(async () => action.promise);
|
||||
const logger = silentLogger();
|
||||
|
||||
startFeishuListenerWithClient(
|
||||
{ appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" },
|
||||
fakeClient(),
|
||||
logger,
|
||||
async () => {},
|
||||
onCardAction,
|
||||
);
|
||||
|
||||
const handler = cardActionHandler();
|
||||
const handlerDone = handler(makeCardActionEvent()).then(() => "done" as const);
|
||||
const earlyResult = await Promise.race([handlerDone, delay(25).then(() => "timeout" as const)]);
|
||||
|
||||
action.resolve();
|
||||
await handlerDone;
|
||||
|
||||
expect(earlyResult).toBe("done");
|
||||
expect(onCardAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs background card action failures after acking", async () => {
|
||||
const logger = silentLogger();
|
||||
const err = new Error("handler failed");
|
||||
|
||||
startFeishuListenerWithClient(
|
||||
{ appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" },
|
||||
fakeClient(),
|
||||
logger,
|
||||
async () => {},
|
||||
async () => {
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
|
||||
await cardActionHandler()(makeCardActionEvent());
|
||||
await vi.waitFor(() => {
|
||||
expect(logger.error).toHaveBeenCalledWith({ err }, "feishu card action handler threw");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function cardActionHandler(): (data: unknown) => Promise<void> {
|
||||
const start = larkMock.starts[0];
|
||||
if (start === undefined) throw new Error("WSClient.start was not called");
|
||||
const dispatcher = start.eventDispatcher as { readonly handlers?: Record<string, (data: unknown) => Promise<void>> };
|
||||
const handler = dispatcher.handlers?.["card.action.trigger"];
|
||||
if (handler === undefined) throw new Error("card.action.trigger handler was not registered");
|
||||
return handler;
|
||||
}
|
||||
|
||||
function makeCardActionEvent(): CardActionEvent {
|
||||
return {
|
||||
operator: { open_id: "ou_user" },
|
||||
action: { value: { interrupt_run: "run-1" }, tag: "button" },
|
||||
context: { open_chat_id: "chat-1", open_message_id: "message-1" },
|
||||
};
|
||||
}
|
||||
|
||||
function fakeClient(): FeishuRuntime["client"] {
|
||||
return {} as FeishuRuntime["client"];
|
||||
}
|
||||
|
||||
function silentLogger(): FastifyBaseLogger {
|
||||
return {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
child() { return this; },
|
||||
level: "silent",
|
||||
} as unknown as FastifyBaseLogger;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
readonly promise: Promise<T>;
|
||||
readonly resolve: (value: T) => void;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
Reference in New Issue
Block a user