forked from EduCraft/curriculum-project-hub
feat: improve Feishu markdown rendering and processing status feedback
- Add buildOutboundPayload: detect markdown format, isolate code blocks, force plain text for tables, fallback from post to text on API rejection - Add addReaction/removeReaction for processing status lifecycle - Replace THUMBSUP with Typing→(remove on success | CrossMark on failure) - Add unit tests for markdown rendering (15) and reactions (6)
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
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<RunResult>();
|
||||
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<RunResult>();
|
||||
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<RunResult>,
|
||||
rt: MockRuntime,
|
||||
): Promise<void> {
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma: mockPrisma(),
|
||||
settings: mockSettings(),
|
||||
logger: rt.logger,
|
||||
authorizer: allowAllAuthorizer(),
|
||||
runAgent,
|
||||
});
|
||||
|
||||
await trigger(makeEvent(), rt);
|
||||
}
|
||||
|
||||
function completedRunResult(text: string): RunResult {
|
||||
return {
|
||||
status: "completed",
|
||||
text,
|
||||
usage: { inputTokens: 1, outputTokens: 1 },
|
||||
numTurns: 1,
|
||||
};
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
readonly promise: Promise<T>;
|
||||
readonly resolve: (value: T) => void;
|
||||
readonly reject: (reason?: unknown) => void;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((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<unknown> } = {}): 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,
|
||||
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: {
|
||||
findUnique: 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"];
|
||||
}
|
||||
Reference in New Issue
Block a user