diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..79041fe --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,20 @@ +# AGENTS.md —— agent 操作手册(全 repo) + +本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。 + +## 这个 repo 是什么 + +- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。 +- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。 + +## 纪律 + +1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。 + +2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。 + +3. **改 `spec/` 必须保持其 `lake build` 通过。** 在 `spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿。 + +4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。 + +5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。 diff --git a/hub/src/feishu/client.ts b/hub/src/feishu/client.ts index 6ecd1df..18f86ec 100644 --- a/hub/src/feishu/client.ts +++ b/hub/src/feishu/client.ts @@ -2,7 +2,7 @@ * Feishu lark client + long-connection event dispatcher. * * Handles: ws event receiving (im.message.receive_v1 + card.action.trigger), - * message sending (text-as-card for patchability), streaming patch, emoji + * message sending, streaming patch via interactive cards, emoji * reactions, file download/upload, and the card action callback. */ import * as lark from "@larksuiteoapi/node-sdk"; @@ -21,6 +21,11 @@ export interface FeishuRuntime { readonly logger: FastifyBaseLogger; } +export interface OutboundPayload { + readonly msgType: string; + readonly content: string; +} + /** Raw `im.message.receive_v1` event payload. */ export interface MessageReceiveEvent { /** Feishu's SDK dispatcher flattens v2 headers onto the top-level payload. */ @@ -64,19 +69,92 @@ export interface CardActionEvent { // --- Message helpers --- -/** Send a text-as-card message (looks like text, but patchable). */ +export function buildOutboundPayload(content: string): OutboundPayload { + if (containsMarkdownTable(content)) { + return { msgType: "text", content: JSON.stringify({ text: content }) }; + } + if (containsMarkdownSyntax(content)) { + return { + msgType: "post", + content: JSON.stringify({ zh_cn: { content: _buildMarkdownPostRows(content) } }), + }; + } + return { msgType: "text", content: JSON.stringify({ text: content }) }; +} + +export function _buildMarkdownPostRows(content: string): unknown[][] { + if (!content.includes("```")) { + return [[{ tag: "md", text: content }]]; + } + + const rows: unknown[][] = []; + const codeBlockPattern = /```[\s\S]*?```/g; + let cursor = 0; + let match: RegExpExecArray | null; + while ((match = codeBlockPattern.exec(content)) !== null) { + if (match.index > cursor) { + rows.push([{ tag: "md", text: content.slice(cursor, match.index) }]); + } + rows.push([{ tag: "md", text: match[0] }]); + cursor = match.index + match[0].length; + } + + if (rows.length === 0) { + return [[{ tag: "md", text: content }]]; + } + if (cursor < content.length) { + rows.push([{ tag: "md", text: content.slice(cursor) }]); + } + return rows; +} + +export function _stripMarkdownToPlainText(text: string): string { + return text + .replace(/```[^\n`]*\n?([\s\S]*?)```/g, "$1") + .replace(/`([^`\n]+)`/g, "$1") + .replace(/!\[([^\]\n]*)\]\([^)]+\)/g, "$1") + .replace(/\[([^\]\n]+)\]\([^)]+\)/g, "$1") + .replace(/^ {0,3}#{1,6}\s+/gm, "") + .replace(/^ {0,3}>\s?/gm, "") + .replace(/^(\s*)[-*+]\s+/gm, "$1") + .replace(/^(\s*)\d+[.)]\s+/gm, "$1") + .replace(/\*\*([^*\n]+)\*\*/g, "$1") + .replace(/__([^_\n]+)__/g, "$1") + .replace(/~~([^~\n]+)~~/g, "$1") + .replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2") + .replace(/(^|[^_])_([^_\n]+)_/g, "$1$2"); +} + +/** Send a text message using the best Feishu message type for the content. */ export async function sendTextMessage(rt: FeishuRuntime, chatId: string, text: string): Promise { - const card = { elements: [{ tag: "div", text: { tag: "lark_md", content: text } }] }; + const payload = buildOutboundPayload(text); const client = rt.client as unknown as { im: { v1: { message: { create: (p: unknown) => Promise } } }; }; try { const res = await client.im.v1.message.create({ params: { receive_id_type: "chat_id" }, - data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) }, + data: { receive_id: chatId, msg_type: payload.msgType, content: payload.content }, }); return messageIdFromResponse(res); - } catch { return null; } + } catch (e) { + if (payload.msgType === "post" && isPostContentFormatError(e)) { + try { + const res = await client.im.v1.message.create({ + params: { receive_id_type: "chat_id" }, + data: { + receive_id: chatId, + msg_type: "text", + content: JSON.stringify({ text: _stripMarkdownToPlainText(text) }), + }, + }); + return messageIdFromResponse(res); + } catch { + return null; + } + } + return null; + } } /** Send a plain text message (fire-and-forget). */ @@ -86,7 +164,8 @@ export async function sendText(rt: FeishuRuntime, chatId: string, text: string): /** Patch a text-as-card message in-place (streaming updates). */ export async function patchTextMessage(rt: FeishuRuntime, messageId: string, text: string): Promise { - const card = { elements: [{ tag: "div", text: { tag: "lark_md", content: text } }] }; + const payload = buildOutboundPayload(text); + const card = buildInteractiveCardFromOutboundPayload(payload); const client = rt.client as unknown as { im: { v1: { message: { patch: (p: unknown) => Promise } } }; }; @@ -114,6 +193,39 @@ export async function reactToMessage(rt: FeishuRuntime, messageId: string, emoji } } +/** Add a reaction to a message, returning the reaction_id (needed for later removal). */ +export async function addReaction(rt: FeishuRuntime, messageId: string, emojiType: string): Promise { + try { + const res = await rt.client.request({ + method: "POST", + url: `/open-apis/im/v1/messages/${messageId}/reactions`, + data: { reaction_type: { emoji_type: emojiType } }, + }) as ReactionCreateResponse; + const reactionId = reactionIdFromResponse(res); + if (reactionId === null) { + rt.logger.warn({ messageId, emojiType }, "addReaction failed: response missing reaction_id"); + } + return reactionId; + } catch (e) { + rt.logger.warn({ messageId, emojiType, err: e instanceof Error ? e.message : String(e) }, "addReaction failed"); + return null; + } +} + +/** Remove a previously added reaction by its reaction_id. */ +export async function removeReaction(rt: FeishuRuntime, messageId: string, reactionId: string): Promise { + try { + await rt.client.request({ + method: "DELETE", + url: `/open-apis/im/v1/messages/${messageId}/reactions/${reactionId}`, + }); + return true; + } catch (e) { + rt.logger.warn({ messageId, reactionId, err: e instanceof Error ? e.message : String(e) }, "removeReaction failed"); + return false; + } +} + // --- File helpers --- /** Download a file/image from a Feishu message to a local path. */ @@ -180,6 +292,11 @@ type FileCreateResponse = { readonly data?: { readonly file_key?: string | undefined } | undefined; } | null; +type ReactionCreateResponse = { + readonly reaction_id?: string | undefined; + readonly data?: { readonly reaction_id?: string | undefined } | undefined; +} | null; + function messageIdFromResponse(res: MessageCreateResponse): string | null { return res?.data?.message_id ?? res?.message_id ?? null; } @@ -188,6 +305,106 @@ function fileKeyFromResponse(res: FileCreateResponse): string | undefined { return res?.file_key ?? res?.data?.file_key; } +function reactionIdFromResponse(res: ReactionCreateResponse): string | null { + return res?.data?.reaction_id ?? res?.reaction_id ?? null; +} + +function containsMarkdownSyntax(content: string): boolean { + const patterns = [ + /^ {0,3}#{1,6}\s+\S/m, + /^ {0,3}>\s+\S/m, + /^ {0,3}(?:[-*+]\s+\S|\d+[.)]\s+\S)/m, + /```/, + /`[^`\n]+`/, + /\*\*[^*\n]+\*\*/, + /__[^_\n]+__/, + /~~[^~\n]+~~/, + /(^|[^*])\*[^*\s][^*\n]*\*(?!\*)/, + /(^|[^_])_[^_\s][^_\n]*_(?!_)/, + /\[[^\]\n]+\]\([^)]+\)/, + ]; + return patterns.some((pattern) => pattern.test(content)); +} + +function containsMarkdownTable(content: string): boolean { + const lines = content.split(/\r?\n/); + for (let i = 0; i < lines.length - 1; i += 1) { + const current = lines[i]; + const next = lines[i + 1]; + if (current !== undefined && next !== undefined && isTableHeaderLine(current) && isTableSeparatorLine(next)) { + return true; + } + } + return false; +} + +function isTableHeaderLine(line: string): boolean { + const trimmed = line.trim(); + return trimmed.startsWith("|") && trimmed.includes("|", 1); +} + +function isTableSeparatorLine(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed.startsWith("|")) return false; + const cells = trimmed.replace(/^\|/, "").replace(/\|$/, "").split("|"); + return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim())); +} + +function buildInteractiveCardFromOutboundPayload(payload: OutboundPayload): { elements: Array<{ tag: "div"; text: { tag: "lark_md"; content: string } }> } { + return { + elements: rowsFromOutboundPayload(payload).map((row) => ({ + tag: "div", + text: { tag: "lark_md", content: row.map(markdownTextFromPostElement).join("") }, + })), + }; +} + +function rowsFromOutboundPayload(payload: OutboundPayload): unknown[][] { + if (payload.msgType !== "post") { + return [[{ tag: "md", text: textFromTextPayload(payload.content) }]]; + } + try { + const parsed = JSON.parse(payload.content) as { zh_cn?: { content?: unknown } }; + return Array.isArray(parsed.zh_cn?.content) ? parsed.zh_cn.content as unknown[][] : [[{ tag: "md", text: "" }]]; + } catch { + return [[{ tag: "md", text: "" }]]; + } +} + +function textFromTextPayload(content: string): string { + try { + const parsed = JSON.parse(content) as { text?: unknown }; + return typeof parsed.text === "string" ? parsed.text : content; + } catch { + return content; + } +} + +function markdownTextFromPostElement(element: unknown): string { + if (typeof element !== "object" || element === null) return ""; + const text = (element as { text?: unknown }).text; + return typeof text === "string" ? text : ""; +} + +function isPostContentFormatError(error: unknown): boolean { + return errorText(error).includes("content format of the post type is incorrect"); +} + +function errorText(error: unknown): string { + if (error instanceof Error) { + return `${error.message} ${errorText((error as { response?: unknown }).response)}`; + } + if (typeof error === "string") return error; + if (typeof error === "object" && error !== null) { + try { + return JSON.stringify(error); + } catch { + return String(error); + } + } + return String(error); +} + // --- Lark client + ws listener --- export function createLarkClient(config: FeishuConfig): lark.Client { diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index 0c323d0..eff9aa7 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -11,7 +11,7 @@ import type { Prisma, PrismaClient } from "@prisma/client"; import { z } from "zod"; import type { FastifyBaseLogger } from "fastify"; -import { sendText, sendTextMessage, patchTextMessage, reactToMessage, downloadMessageFile, type FeishuRuntime, type MessageReceiveEvent } from "./client.js"; +import { sendText, sendTextMessage, patchTextMessage, reactToMessage, addReaction, removeReaction, downloadMessageFile, type FeishuRuntime, type MessageReceiveEvent } from "./client.js"; import { runAgent as defaultRunAgent, type ProjectContext, type RunRequest, type RunResult } from "../agent/runner.js"; import type { RuntimeSettings } from "../settings/runtime.js"; import { acquireLock, currentLockRunId, releaseLock } from "../lock.js"; @@ -309,8 +309,11 @@ export function makeTriggerHandler(deps: TriggerDeps) { workspaceDir: project.workspaceDir, }; - // Emoji reaction on the user's message (acknowledge receipt). - await reactToMessage(rt, msg.message_id, "THUMBSUP"); + const processingReactionId = await addReaction(rt, msg.message_id, "Typing"); + const removeProcessingReaction = async (): Promise => { + if (processingReactionId === null) return false; + return removeReaction(rt, msg.message_id, processingReactionId); + }; // Stream agent response as text-as-card messages. Lazy-init: don't send // anything until the first text-delta arrives (avoids empty placeholder). @@ -381,8 +384,13 @@ export function makeTriggerHandler(deps: TriggerDeps) { action: "run.finished", metadata: { status: result.status, deliveredFiles }, }); + await removeProcessingReaction(); }) .catch(async (e) => { + const removedProcessingReaction = await removeProcessingReaction(); + if (removedProcessingReaction) { + await addReaction(rt, msg.message_id, "CrossMark"); + } try { await deps.prisma.agentRun.update({ where: { id: run.id }, diff --git a/hub/test/unit/feishu-markdown.test.ts b/hub/test/unit/feishu-markdown.test.ts new file mode 100644 index 0000000..087bc37 --- /dev/null +++ b/hub/test/unit/feishu-markdown.test.ts @@ -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; + readonly patch?: (payload: unknown) => Promise; +}): 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"], + }; +} diff --git a/hub/test/unit/feishu-reactions.test.ts b/hub/test/unit/feishu-reactions.test.ts new file mode 100644 index 0000000..b171e21 --- /dev/null +++ b/hub/test/unit/feishu-reactions.test.ts @@ -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(); + 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, + }); + + 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, + 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"]; +}