import { describe, expect, it, vi } from "vitest"; import type { PrismaClient } from "@prisma/client"; import { InMemoryModelRegistry } from "../../src/agent/models.js"; import type { RunRequest, RunResult } from "../../src/agent/runner.js"; import type { FeishuRuntime, MessageReceiveEvent } from "../../src/feishu/client.js"; import { addReaction, removeReaction } from "../../src/feishu/client.js"; import { makeTriggerHandler } from "../../src/feishu/trigger.js"; import type { PermissionAuthorizer } from "../../src/permission.js"; import type { RuntimeSettings } from "../../src/settings/runtime.js"; describe("Feishu reactions", () => { it("addReaction returns reaction_id on success", async () => { const request = vi.fn(async () => ({ data: { reaction_id: "reaction-1" } })); const rt = mockRuntime({ request }); await expect(addReaction(rt, "message-1", "Typing")).resolves.toBe("reaction-1"); expect(request).toHaveBeenCalledWith({ method: "POST", url: "/open-apis/im/v1/messages/message-1/reactions", data: { reaction_type: { emoji_type: "Typing" } }, }); }); it("addReaction returns null on API failure", async () => { const rt = mockRuntime({ request: vi.fn(async () => { throw new Error("api failed"); }), }); await expect(addReaction(rt, "message-1", "Typing")).resolves.toBeNull(); }); it("removeReaction returns true on success", async () => { const request = vi.fn(async () => ({})); const rt = mockRuntime({ request }); await expect(removeReaction(rt, "message-1", "reaction-1")).resolves.toBe(true); expect(request).toHaveBeenCalledWith({ method: "DELETE", url: "/open-apis/im/v1/messages/message-1/reactions/reaction-1", }); }); it("removeReaction returns false on API failure", async () => { const rt = mockRuntime({ request: vi.fn(async () => { throw new Error("api failed"); }), }); await expect(removeReaction(rt, "message-1", "reaction-1")).resolves.toBe(false); }); it("adds Typing on start and removes it on success", async () => { const run = deferred(); const rt = mockRuntime(); const runAgent = vi.fn((req: RunRequest) => { req.onStream?.({ type: "text-delta", text: "done" }); return run.promise; }); await triggerWithRunAgent(runAgent, rt); expect(rt.reactionRequests).toEqual([ { kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" }, ]); run.resolve(completedRunResult("done")); await vi.waitFor(() => { expect(rt.reactionRequests).toEqual([ { kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" }, { kind: "remove", messageId: "message-1", reactionId: "reaction-1" }, ]); }); }); it("replaces Typing with CrossMark on failure", async () => { const run = deferred(); const rt = mockRuntime(); const runAgent = vi.fn(() => run.promise); await triggerWithRunAgent(runAgent, rt); run.reject(new Error("agent failed")); await vi.waitFor(() => { expect(rt.reactionRequests).toEqual([ { kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" }, { kind: "remove", messageId: "message-1", reactionId: "reaction-1" }, { kind: "add", messageId: "message-1", emoji: "CrossMark", reactionId: "reaction-2" }, ]); }); }); }); async function triggerWithRunAgent( runAgent: (req: RunRequest) => Promise, rt: MockRuntime, ): Promise { const trigger = makeTriggerHandler({ prisma: mockPrisma(), settings: mockSettings(), logger: rt.logger, authorizer: allowAllAuthorizer(), runAgent, messageBatcherOptions: { maxMessages: 1 }, }); await trigger(makeEvent(), rt); } function completedRunResult(text: string): RunResult { return { status: "completed", text, usage: { inputTokens: 1, outputTokens: 1 }, numTurns: 1, }; } interface Deferred { readonly promise: Promise; readonly resolve: (value: T) => void; readonly reject: (reason?: unknown) => void; } function deferred(): Deferred { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" }; function makeEvent(): MessageReceiveEvent { return { message: { message_id: "message-1", chat_id: "chat-1", chat_type: "group", message_type: "text", content: JSON.stringify({ text: "@_user_1 do work" }), mentions: [bot], }, sender: { sender_id: { open_id: "ou_user" }, sender_type: "user" }, }; } type ReactionRequest = | { readonly kind: "add"; readonly messageId: string; readonly emoji: string; readonly reactionId: string } | { readonly kind: "remove"; readonly messageId: string; readonly reactionId: string }; interface MockRuntime extends FeishuRuntime { readonly reactionRequests: ReactionRequest[]; } function mockRuntime(options: { readonly request?: (payload: unknown) => Promise } = {}): MockRuntime { const reactionRequests: ReactionRequest[] = []; let nextReactionId = 1; const request = options.request ?? vi.fn(async (payload: unknown) => { const requestPayload = payload as { method?: string; url?: string; data?: { reaction_type?: { emoji_type?: string } } }; const addMatch = requestPayload.url?.match(/\/messages\/([^/]+)\/reactions$/); if (requestPayload.method === "POST" && addMatch?.[1] !== undefined) { const reactionId = `reaction-${nextReactionId}`; nextReactionId += 1; reactionRequests.push({ kind: "add", messageId: addMatch[1], emoji: requestPayload.data?.reaction_type?.emoji_type ?? "", reactionId, }); return { data: { reaction_id: reactionId } }; } const removeMatch = requestPayload.url?.match(/\/messages\/([^/]+)\/reactions\/([^/]+)$/); if (requestPayload.method === "DELETE" && removeMatch?.[1] !== undefined && removeMatch[2] !== undefined) { reactionRequests.push({ kind: "remove", messageId: removeMatch[1], reactionId: removeMatch[2] }); } return {}; }); return { client: { request, im: { v1: { message: { create: vi.fn(async () => ({ data: { message_id: "reply-message-1" } })), patch: vi.fn(async () => ({})), }, }, }, } as unknown as FeishuRuntime["client"], logger: silentLogger(), reactionRequests, }; } function mockSettings(): RuntimeSettings { const registry = new InMemoryModelRegistry( [{ id: "mock-model", label: "Mock", toolCapable: true }], [{ id: "draft", label: "Draft", defaultModel: "mock-model" }], ); return { async provider(providerId) { return { id: providerId, baseUrl: "https://example.invalid", authToken: "test-token", anthropicApiKey: "", sdkEnv: {}, }; }, async modelRegistry() { return registry; }, async runPolicy() { return { maxTurns: 1 }; }, }; } function allowAllAuthorizer(): PermissionAuthorizer { return { async can(req) { return { allowed: true, reason: "test allow", action: req.action, resource: req.resource, actor: req.actor, organizationId: "org_test_default", actorUserId: "user-1", principals: [{ type: "USER", id: "ou_user" }], requiredRole: "EDIT", effectiveRole: "EDIT", }; }, }; } function mockPrisma(): PrismaClient { let lock: { projectId: string; runId: string } | null = null; const session = { id: "session-1", metadata: {} }; return { feishuEventReceipt: { findUnique: vi.fn(async () => null), create: vi.fn(async () => ({ id: "receipt-1" })), }, projectGroupBinding: { findFirst: vi.fn(async () => ({ projectId: "project-1" })), }, project: { findUnique: vi.fn(async () => ({ workspaceDir: "/tmp/cph-project" })), }, agentSession: { findFirst: vi.fn(async () => null), create: vi.fn(async () => session), update: vi.fn(async () => session), }, agentRun: { create: vi.fn(async () => ({ id: "run-1" })), findUnique: vi.fn(async () => null), update: vi.fn(async () => ({ id: "run-1" })), }, projectAgentLock: { findUnique: vi.fn(async () => (lock === null ? null : { runId: lock.runId })), create: vi.fn(async (args: { data: { projectId: string; runId: string } }) => { lock = { projectId: args.data.projectId, runId: args.data.runId }; return lock; }), deleteMany: vi.fn(async () => { lock = null; return { count: 1 }; }), }, auditEntry: { create: vi.fn(async () => ({ id: "audit-1" })), }, } as unknown as PrismaClient; } function silentLogger(): FeishuRuntime["logger"] { return { info() {}, warn() {}, error() {}, debug() {}, fatal() {}, child() { return this; }, level: "silent", } as unknown as FeishuRuntime["logger"]; }