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:
2026-07-08 16:31:07 +08:00
parent a00e4f9871
commit 6227e1931b
5 changed files with 693 additions and 9 deletions
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, it, vi } from "vitest";
import {
_buildMarkdownPostRows,
buildOutboundPayload,
patchTextMessage,
sendTextMessage,
type FeishuRuntime,
} from "../../src/feishu/client.js";
describe("Feishu markdown outbound payloads", () => {
it("uses text messages for plain text", () => {
const payload = buildOutboundPayload("hello from cph");
expect(payload.msgType).toBe("text");
expect(JSON.parse(payload.content)).toEqual({ text: "hello from cph" });
});
it("keeps fenced code blocks in separate post rows", () => {
const content = "Before\n```ts\nconst x = 1;\n```\nAfter";
expect(_buildMarkdownPostRows(content)).toEqual([
[{ tag: "md", text: "Before\n" }],
[{ tag: "md", text: "```ts\nconst x = 1;\n```" }],
[{ tag: "md", text: "\nAfter" }],
]);
});
it("forces markdown tables to plain text", () => {
const content = "| A | B |\n|---|---|\n| 1 | 2 |";
const payload = buildOutboundPayload(content);
expect(payload.msgType).toBe("text");
expect(JSON.parse(payload.content)).toEqual({ text: content });
});
it("uses post messages for markdown headers, bold text, and lists", () => {
const content = "# Title\n\nThis is **important**.\n\n- first\n- second";
const payload = buildOutboundPayload(content);
expect(payload.msgType).toBe("post");
expect(JSON.parse(payload.content)).toEqual({
zh_cn: {
content: [[{ tag: "md", text: content }]],
},
});
});
it.each([
["header", "# Heading"],
["list", "- item"],
["code block", "```ts\nconst x = 1;\n```"],
["inline code", "Use `code` here"],
["bold", "**bold**"],
["italic", "_italic_"],
["strikethrough", "~~removed~~"],
["link", "[docs](https://example.com)"],
["blockquote", "> quoted"],
])("detects markdown %s syntax", (_name, content) => {
expect(buildOutboundPayload(content).msgType).toBe("post");
});
it("falls back from rejected post payloads to stripped plain text", async () => {
const messageCreate = vi
.fn()
.mockRejectedValueOnce(new Error("content format of the post type is incorrect"))
.mockResolvedValueOnce({ data: { message_id: "message-1" } });
const rt = mockRuntime({ messageCreate });
await expect(sendTextMessage(rt, "chat-1", "# Title\n\n**bold** and `code`")).resolves.toBe("message-1");
expect(messageCreate).toHaveBeenCalledTimes(2);
expect(messageCreate).toHaveBeenNthCalledWith(1, {
params: { receive_id_type: "chat_id" },
data: {
receive_id: "chat-1",
msg_type: "post",
content: JSON.stringify({
zh_cn: {
content: [[{ tag: "md", text: "# Title\n\n**bold** and `code`" }]],
},
}),
},
});
expect(messageCreate).toHaveBeenNthCalledWith(2, {
params: { receive_id_type: "chat_id" },
data: {
receive_id: "chat-1",
msg_type: "text",
content: JSON.stringify({ text: "Title\n\nbold and code" }),
},
});
});
it("patches markdown post rows as separate interactive card elements", async () => {
const patch = vi.fn(async () => ({}));
const rt = mockRuntime({ patch });
await patchTextMessage(rt, "message-1", "Before\n```ts\nconst x = 1;\n```\nAfter");
expect(patch).toHaveBeenCalledWith({
path: { message_id: "message-1" },
params: { msg_type: "interactive" },
data: {
content: JSON.stringify({
elements: [
{ tag: "div", text: { tag: "lark_md", content: "Before\n" } },
{ tag: "div", text: { tag: "lark_md", content: "```ts\nconst x = 1;\n```" } },
{ tag: "div", text: { tag: "lark_md", content: "\nAfter" } },
],
}),
},
});
});
});
function mockRuntime(options: {
readonly messageCreate?: (payload: unknown) => Promise<unknown>;
readonly patch?: (payload: unknown) => Promise<unknown>;
}): FeishuRuntime {
return {
client: {
im: {
v1: {
message: {
create: options.messageCreate ?? vi.fn(async () => ({ data: { message_id: "message-1" } })),
patch: options.patch ?? vi.fn(async () => ({})),
},
},
},
} as unknown as FeishuRuntime["client"],
logger: {
info() {},
warn() {},
error() {},
debug() {},
fatal() {},
child() { return this; },
level: "silent",
} as unknown as FeishuRuntime["logger"],
};
}
+298
View File
@@ -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"];
}