forked from bai/curriculum-project-hub
fix(hub): 保留飞书回复上下文
This commit is contained in:
@@ -23,9 +23,17 @@ export interface FeishuRuntime {
|
|||||||
|
|
||||||
/** Raw `im.message.receive_v1` event payload. */
|
/** Raw `im.message.receive_v1` event payload. */
|
||||||
export interface MessageReceiveEvent {
|
export interface MessageReceiveEvent {
|
||||||
|
/** Feishu's SDK dispatcher flattens v2 headers onto the top-level payload. */
|
||||||
|
readonly event_id?: string;
|
||||||
|
readonly event_type?: string;
|
||||||
readonly header?: { readonly event_id?: string; readonly event_type?: string };
|
readonly header?: { readonly event_id?: string; readonly event_type?: string };
|
||||||
readonly message: {
|
readonly message: {
|
||||||
readonly message_id: string;
|
readonly message_id: string;
|
||||||
|
readonly root_id?: string;
|
||||||
|
readonly parent_id?: string;
|
||||||
|
readonly thread_id?: string;
|
||||||
|
readonly create_time?: string;
|
||||||
|
readonly update_time?: string;
|
||||||
readonly chat_id: string;
|
readonly chat_id: string;
|
||||||
readonly chat_type: string;
|
readonly chat_type: string;
|
||||||
readonly message_type: string;
|
readonly message_type: string;
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance } from "@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { sendFile, type FeishuRuntime } from "./client.js";
|
import { sendFile, type FeishuRuntime } from "./client.js";
|
||||||
import { resolveDeliverableFile } from "./fileDelivery.js";
|
import { resolveDeliverableFile } from "./fileDelivery.js";
|
||||||
|
import { readFeishuContext } from "./read.js";
|
||||||
|
|
||||||
export interface FileDeliveryToolOptions {
|
export interface FileDeliveryToolOptions {
|
||||||
readonly rt: FeishuRuntime;
|
readonly rt: FeishuRuntime;
|
||||||
readonly chatId: string;
|
readonly chatId: string;
|
||||||
|
readonly projectId: string;
|
||||||
|
readonly runId: string;
|
||||||
readonly workspaceDir: string;
|
readonly workspaceDir: string;
|
||||||
readonly onDelivered?: (path: string) => void;
|
readonly onDelivered?: (path: string) => void;
|
||||||
}
|
}
|
||||||
@@ -18,7 +21,8 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
|||||||
instructions:
|
instructions:
|
||||||
"Use send_file when the user asks to receive, resend, download, or attach a file. " +
|
"Use send_file when the user asks to receive, resend, download, or attach a file. " +
|
||||||
"Do not claim a file was sent unless send_file returns success. " +
|
"Do not claim a file was sent unless send_file returns success. " +
|
||||||
"If a generated file path is uncertain, list or inspect the workspace first, then call send_file with the actual path.",
|
"If a generated file path is uncertain, list or inspect the workspace first, then call send_file with the actual path. " +
|
||||||
|
"Use feishu_read_context when the trigger context contains a reply, thread, or message anchor and more Feishu context is needed.",
|
||||||
tools: [
|
tools: [
|
||||||
tool(
|
tool(
|
||||||
"send_file",
|
"send_file",
|
||||||
@@ -51,6 +55,28 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
|||||||
},
|
},
|
||||||
{ alwaysLoad: true },
|
{ alwaysLoad: true },
|
||||||
),
|
),
|
||||||
|
tool(
|
||||||
|
"feishu_read_context",
|
||||||
|
"Read Feishu context for the current project's bound chat. Use anchor ids from the trigger context; this tool never reads arbitrary chats.",
|
||||||
|
{
|
||||||
|
anchor: z.enum(["trigger_message", "status_card", "reply", "thread"]).describe("Which kind of Feishu anchor to read."),
|
||||||
|
id: z.string().describe("The message/thread/status anchor id to read."),
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
const result = await readFeishuContext(
|
||||||
|
{ chat_id: options.chatId, anchor: args.anchor, id: args.id },
|
||||||
|
{
|
||||||
|
runId: options.runId,
|
||||||
|
projectId: options.projectId,
|
||||||
|
boundChatId: options.chatId,
|
||||||
|
workspaceDir: options.workspaceDir,
|
||||||
|
},
|
||||||
|
options.rt,
|
||||||
|
);
|
||||||
|
return { content: [{ type: "text", text: result }] };
|
||||||
|
},
|
||||||
|
{ alwaysLoad: true },
|
||||||
|
),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,14 @@ interface LarkMessage {
|
|||||||
message_id?: string;
|
message_id?: string;
|
||||||
msg_type?: string;
|
msg_type?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
body?: { content?: string };
|
||||||
create_time?: string;
|
create_time?: string;
|
||||||
sender?: { id?: string; sender_type?: string };
|
sender?: { id?: string; sender_type?: string };
|
||||||
chat_id?: string;
|
chat_id?: string;
|
||||||
|
parent_id?: string;
|
||||||
parent_message_id?: string;
|
parent_message_id?: string;
|
||||||
root_id?: string;
|
root_id?: string;
|
||||||
|
thread_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImV1Message {
|
interface ImV1Message {
|
||||||
@@ -84,14 +87,17 @@ export async function readFeishuContext(
|
|||||||
|
|
||||||
/** Project a lark message down to the fields the model actually needs. */
|
/** Project a lark message down to the fields the model actually needs. */
|
||||||
function compact(m: LarkMessage): Record<string, unknown> {
|
function compact(m: LarkMessage): Record<string, unknown> {
|
||||||
|
const parentId = m.parent_id ?? m.parent_message_id;
|
||||||
return {
|
return {
|
||||||
message_id: m.message_id,
|
message_id: m.message_id,
|
||||||
msg_type: m.msg_type,
|
msg_type: m.msg_type,
|
||||||
content: m.content,
|
content: m.body?.content ?? m.content,
|
||||||
create_time: m.create_time,
|
create_time: m.create_time,
|
||||||
|
chat_id: m.chat_id,
|
||||||
sender_id: m.sender?.id,
|
sender_id: m.sender?.id,
|
||||||
parent_message_id: m.parent_message_id,
|
parent_id: parentId,
|
||||||
|
parent_message_id: parentId,
|
||||||
root_id: m.root_id,
|
root_id: m.root_id,
|
||||||
|
thread_id: m.thread_id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+105
-6
@@ -19,6 +19,7 @@ import { createPermissionAuthorizer, type AuthorizationDecision, type Permission
|
|||||||
import { writeAudit } from "../audit.js";
|
import { writeAudit } from "../audit.js";
|
||||||
import { PatchableTextStream } from "./textStream.js";
|
import { PatchableTextStream } from "./textStream.js";
|
||||||
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
|
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
|
||||||
|
import { readFeishuContext } from "./read.js";
|
||||||
|
|
||||||
interface TriggerDeps {
|
interface TriggerDeps {
|
||||||
readonly prisma: PrismaClient;
|
readonly prisma: PrismaClient;
|
||||||
@@ -45,7 +46,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
// would spawn a second AgentRun — the lock would refuse it, but a FAILED
|
// would spawn a second AgentRun — the lock would refuse it, but a FAILED
|
||||||
// run row + a busy reply would leak. Record the event_id up front; if it
|
// run row + a busy reply would leak. Record the event_id up front; if it
|
||||||
// already exists, this is a redelivery → stop.
|
// already exists, this is a redelivery → stop.
|
||||||
const eventId = event.header?.event_id;
|
const eventId = feishuEventId(event);
|
||||||
if (eventId !== undefined && eventId !== "") {
|
if (eventId !== undefined && eventId !== "") {
|
||||||
const existing = await deps.prisma.feishuEventReceipt.findUnique({
|
const existing = await deps.prisma.feishuEventReceipt.findUnique({
|
||||||
where: { eventId },
|
where: { eventId },
|
||||||
@@ -58,7 +59,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
await deps.prisma.feishuEventReceipt.create({
|
await deps.prisma.feishuEventReceipt.create({
|
||||||
data: {
|
data: {
|
||||||
eventId,
|
eventId,
|
||||||
eventType: event.header?.event_type ?? "im.message.receive_v1",
|
eventType: feishuEventType(event),
|
||||||
messageId: msg.message_id,
|
messageId: msg.message_id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -227,6 +228,14 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
// is enforced inside runAgent when it builds the SDK tools record. `tools:
|
// is enforced inside runAgent when it builds the SDK tools record. `tools:
|
||||||
// undefined` means the full registered set (unrestricted role).
|
// undefined` means the full registered set (unrestricted role).
|
||||||
const systemPrompt = withFileDeliveryInstructions(role?.systemPrompt);
|
const systemPrompt = withFileDeliveryInstructions(role?.systemPrompt);
|
||||||
|
const feishuTriggerContext = await buildFeishuTriggerContext({
|
||||||
|
msg,
|
||||||
|
chatId,
|
||||||
|
projectId,
|
||||||
|
workspaceDir: project.workspaceDir,
|
||||||
|
rt,
|
||||||
|
});
|
||||||
|
const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext);
|
||||||
|
|
||||||
// ADR-0017: provider+model-bound session. Reuse if one exists for this
|
// ADR-0017: provider+model-bound session. Reuse if one exists for this
|
||||||
// (project, provider, model) triple; otherwise create.
|
// (project, provider, model) triple; otherwise create.
|
||||||
@@ -260,14 +269,24 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
requestedByUserId: roleDecision.actorUserId ?? null,
|
requestedByUserId: roleDecision.actorUserId ?? null,
|
||||||
entrypoint: "FEISHU",
|
entrypoint: "FEISHU",
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
prompt: agentPrompt,
|
prompt: promptForAgent,
|
||||||
model,
|
model,
|
||||||
provider: providerId,
|
provider: providerId,
|
||||||
metadata: { roleId },
|
metadata: agentRunMetadata(roleId, agentPrompt, feishuTriggerContext),
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.created", metadata: { roleId, model, prompt: agentPrompt.slice(0, 200) } });
|
await writeAudit(deps.prisma, {
|
||||||
|
runId: run.id,
|
||||||
|
projectId,
|
||||||
|
action: "run.created",
|
||||||
|
metadata: {
|
||||||
|
roleId,
|
||||||
|
model,
|
||||||
|
prompt: agentPrompt.slice(0, 200),
|
||||||
|
...(feishuTriggerContext === null ? {} : { feishuTriggerContext }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// ADR-0002: acquire the project lock for this run.
|
// ADR-0002: acquire the project lock for this run.
|
||||||
try {
|
try {
|
||||||
@@ -303,6 +322,8 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
const fileDeliveryMcpServer = createFileDeliveryMcpServer({
|
const fileDeliveryMcpServer = createFileDeliveryMcpServer({
|
||||||
rt,
|
rt,
|
||||||
chatId,
|
chatId,
|
||||||
|
projectId,
|
||||||
|
runId: run.id,
|
||||||
workspaceDir: project.workspaceDir,
|
workspaceDir: project.workspaceDir,
|
||||||
onDelivered: (path) => {
|
onDelivered: (path) => {
|
||||||
deliveredFiles.push(path);
|
deliveredFiles.push(path);
|
||||||
@@ -310,7 +331,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
runAgent({
|
runAgent({
|
||||||
prompt: agentPrompt,
|
prompt: promptForAgent,
|
||||||
model,
|
model,
|
||||||
project: projectCtx,
|
project: projectCtx,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
@@ -417,6 +438,84 @@ function mergeSessionMetadata(metadata: unknown, patch: SessionMetadata): Prisma
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function feishuEventId(event: MessageReceiveEvent): string | undefined {
|
||||||
|
return nonEmpty(event.event_id) ?? nonEmpty(event.header?.event_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function feishuEventType(event: MessageReceiveEvent): string {
|
||||||
|
return nonEmpty(event.event_type) ?? nonEmpty(event.header?.event_type) ?? "im.message.receive_v1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonEmpty(value: string | undefined): string | undefined {
|
||||||
|
return value === undefined || value === "" ? undefined : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildFeishuTriggerContextArgs {
|
||||||
|
readonly msg: MessageReceiveEvent["message"];
|
||||||
|
readonly chatId: string;
|
||||||
|
readonly projectId: string;
|
||||||
|
readonly workspaceDir: string;
|
||||||
|
readonly rt: FeishuRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildFeishuTriggerContext(args: BuildFeishuTriggerContextArgs): Promise<Prisma.InputJsonObject | null> {
|
||||||
|
const replyToMessageId = nonEmpty(args.msg.parent_id);
|
||||||
|
const rootId = nonEmpty(args.msg.root_id);
|
||||||
|
const threadId = nonEmpty(args.msg.thread_id);
|
||||||
|
if (replyToMessageId === undefined && rootId === undefined && threadId === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context: Record<string, Prisma.InputJsonValue> = {
|
||||||
|
trigger_message_id: args.msg.message_id,
|
||||||
|
};
|
||||||
|
if (replyToMessageId !== undefined) context["reply_to_message_id"] = replyToMessageId;
|
||||||
|
if (rootId !== undefined) context["root_id"] = rootId;
|
||||||
|
if (threadId !== undefined) context["thread_id"] = threadId;
|
||||||
|
|
||||||
|
if (replyToMessageId !== undefined) {
|
||||||
|
const rawReplyContext = await readFeishuContext(
|
||||||
|
{ chat_id: args.chatId, anchor: "reply", id: replyToMessageId },
|
||||||
|
{
|
||||||
|
runId: "pending",
|
||||||
|
projectId: args.projectId,
|
||||||
|
boundChatId: args.chatId,
|
||||||
|
workspaceDir: args.workspaceDir,
|
||||||
|
},
|
||||||
|
args.rt,
|
||||||
|
);
|
||||||
|
context["replied_to_message"] = parseJsonForMetadata(rawReplyContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context as Prisma.InputJsonObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonForMetadata(text: string): Prisma.InputJsonValue {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as Prisma.InputJsonValue;
|
||||||
|
} catch {
|
||||||
|
return { error: "invalid json", raw: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withFeishuTriggerContext(prompt: string, context: Prisma.InputJsonObject | null): string {
|
||||||
|
if (context === null) return prompt;
|
||||||
|
return `Feishu trigger context:\n${JSON.stringify(context, null, 2)}\n\nUser request:\n${prompt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentRunMetadata(
|
||||||
|
roleId: string,
|
||||||
|
rawPrompt: string,
|
||||||
|
feishuTriggerContext: Prisma.InputJsonObject | null,
|
||||||
|
): Prisma.InputJsonObject {
|
||||||
|
const metadata: Record<string, Prisma.InputJsonValue> = { roleId };
|
||||||
|
if (feishuTriggerContext !== null) {
|
||||||
|
metadata["rawPrompt"] = rawPrompt;
|
||||||
|
metadata["feishuTriggerContext"] = feishuTriggerContext;
|
||||||
|
}
|
||||||
|
return metadata as Prisma.InputJsonObject;
|
||||||
|
}
|
||||||
|
|
||||||
function auditDecision(decision: AuthorizationDecision): Record<string, unknown> {
|
function auditDecision(decision: AuthorizationDecision): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
allowed: decision.allowed,
|
allowed: decision.allowed,
|
||||||
|
|||||||
@@ -63,6 +63,21 @@ export interface MockFeishuRuntime extends FeishuRuntime {
|
|||||||
readonly sentCards: unknown[];
|
readonly sentCards: unknown[];
|
||||||
readonly sentPatches: unknown[];
|
readonly sentPatches: unknown[];
|
||||||
readonly reactions: Array<{ readonly messageId: string; readonly emoji: string }>;
|
readonly reactions: Array<{ readonly messageId: string; readonly emoji: string }>;
|
||||||
|
readonly readableMessages: Map<string, MockFeishuMessage>;
|
||||||
|
readonly threadMessages: Map<string, readonly MockFeishuMessage[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockFeishuMessage {
|
||||||
|
readonly message_id?: string;
|
||||||
|
readonly root_id?: string;
|
||||||
|
readonly parent_id?: string;
|
||||||
|
readonly thread_id?: string;
|
||||||
|
readonly msg_type?: string;
|
||||||
|
readonly create_time?: string;
|
||||||
|
readonly chat_id?: string;
|
||||||
|
readonly sender?: { readonly id?: string; readonly sender_type?: string };
|
||||||
|
readonly body?: { readonly content?: string };
|
||||||
|
readonly content?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mockFeishuRuntime(): MockFeishuRuntime {
|
export function mockFeishuRuntime(): MockFeishuRuntime {
|
||||||
@@ -70,6 +85,8 @@ export function mockFeishuRuntime(): MockFeishuRuntime {
|
|||||||
const sentCards: unknown[] = [];
|
const sentCards: unknown[] = [];
|
||||||
const sentPatches: unknown[] = [];
|
const sentPatches: unknown[] = [];
|
||||||
const reactions: Array<{ messageId: string; emoji: string }> = [];
|
const reactions: Array<{ messageId: string; emoji: string }> = [];
|
||||||
|
const readableMessages = new Map<string, MockFeishuMessage>();
|
||||||
|
const threadMessages = new Map<string, readonly MockFeishuMessage[]>();
|
||||||
// Mock the client — sendText/sendCard are in client.ts, not on the runtime
|
// Mock the client — sendText/sendCard are in client.ts, not on the runtime
|
||||||
// object directly. We patch by providing a runtime whose client is a stub;
|
// object directly. We patch by providing a runtime whose client is a stub;
|
||||||
// the actual send functions cast through shape, so a minimal stub works.
|
// the actual send functions cast through shape, so a minimal stub works.
|
||||||
@@ -114,6 +131,20 @@ export function mockFeishuRuntime(): MockFeishuRuntime {
|
|||||||
if (text !== null) sentTexts.push(text);
|
if (text !== null) sentTexts.push(text);
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
|
get: async (p: unknown) => {
|
||||||
|
const payload = p as { path?: { message_id?: string } };
|
||||||
|
const messageId = payload.path?.message_id ?? "";
|
||||||
|
const message = readableMessages.get(messageId);
|
||||||
|
return { data: { items: message === undefined ? [] : [message] } };
|
||||||
|
},
|
||||||
|
list: async (p: unknown) => {
|
||||||
|
const payload = p as { params?: { container_id?: string } };
|
||||||
|
const containerId = payload.params?.container_id ?? "";
|
||||||
|
const items =
|
||||||
|
threadMessages.get(containerId) ??
|
||||||
|
[...readableMessages.values()].filter((message) => message.parent_id === containerId);
|
||||||
|
return { data: { items } };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -123,6 +154,8 @@ export function mockFeishuRuntime(): MockFeishuRuntime {
|
|||||||
sentCards,
|
sentCards,
|
||||||
sentPatches,
|
sentPatches,
|
||||||
reactions,
|
reactions,
|
||||||
|
readableMessages,
|
||||||
|
threadMessages,
|
||||||
};
|
};
|
||||||
return rt;
|
return rt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,6 +336,86 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
expect(receipts[0]?.eventId).toBe("evt-dedup-1");
|
expect(receipts[0]?.eventId).toBe("evt-dedup-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("dedups flattened websocket events by top-level event_id", async () => {
|
||||||
|
await seedProject("proj-13b", "chat-13b");
|
||||||
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||||
|
const event: MessageReceiveEvent = {
|
||||||
|
...makeEvent("chat-13b", "@_user_1 写教案"),
|
||||||
|
event_id: "evt-flat-1",
|
||||||
|
event_type: "im.message.receive_v1",
|
||||||
|
};
|
||||||
|
|
||||||
|
await trigger(event, rt);
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
expect(await prisma.agentRun.findMany()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await trigger(
|
||||||
|
{
|
||||||
|
...event,
|
||||||
|
message: { ...event.message, message_id: "m_flat_redelivery" },
|
||||||
|
},
|
||||||
|
rt,
|
||||||
|
);
|
||||||
|
|
||||||
|
const runs = await prisma.agentRun.findMany();
|
||||||
|
expect(runs).toHaveLength(1);
|
||||||
|
const receipts = await prisma.feishuEventReceipt.findMany();
|
||||||
|
expect(receipts).toHaveLength(1);
|
||||||
|
expect(receipts[0]?.eventId).toBe("evt-flat-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds replied-to message context when @bot is used in a Feishu reply", async () => {
|
||||||
|
await seedProject("proj-13c", "chat-13c");
|
||||||
|
rt.readableMessages.set("m-parent", {
|
||||||
|
message_id: "m-parent",
|
||||||
|
root_id: "m-root",
|
||||||
|
thread_id: "thread-1",
|
||||||
|
msg_type: "text",
|
||||||
|
chat_id: "chat-13c",
|
||||||
|
sender: { id: "ou_teacher_2", sender_type: "user" },
|
||||||
|
body: { content: JSON.stringify({ text: "上一条需求: 把第二题改成探究题。" }) },
|
||||||
|
});
|
||||||
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||||
|
const baseEvent = makeEvent("chat-13c", "@_user_1 继续这个改法");
|
||||||
|
const event: MessageReceiveEvent = {
|
||||||
|
...baseEvent,
|
||||||
|
message: {
|
||||||
|
...baseEvent.message,
|
||||||
|
message_id: "m-child",
|
||||||
|
root_id: "m-root",
|
||||||
|
parent_id: "m-parent",
|
||||||
|
thread_id: "thread-1",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await trigger(event, rt);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(runAgentCalls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
const prompt = runAgentCalls[0]?.prompt ?? "";
|
||||||
|
expect(prompt).toContain("Feishu trigger context");
|
||||||
|
expect(prompt).toContain("reply_to_message_id");
|
||||||
|
expect(prompt).toContain("m-parent");
|
||||||
|
expect(prompt).toContain("thread-1");
|
||||||
|
expect(prompt).toContain("上一条需求");
|
||||||
|
expect(prompt).toContain("User request:\n继续这个改法");
|
||||||
|
|
||||||
|
const run = await prisma.agentRun.findFirst();
|
||||||
|
expect(run?.prompt).toBe(prompt);
|
||||||
|
expect(run?.metadata).toMatchObject({
|
||||||
|
roleId: "draft",
|
||||||
|
rawPrompt: "继续这个改法",
|
||||||
|
feishuTriggerContext: {
|
||||||
|
trigger_message_id: "m-child",
|
||||||
|
reply_to_message_id: "m-parent",
|
||||||
|
root_id: "m-root",
|
||||||
|
thread_id: "thread-1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("writes audit entries across the run lifecycle", async () => {
|
it("writes audit entries across the run lifecycle", async () => {
|
||||||
await seedProject("proj-14", "chat-14");
|
await seedProject("proj-14", "chat-14");
|
||||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFeishuContext } from "../../src/feishu/read.js";
|
||||||
|
import type { FeishuRuntime } from "../../src/feishu/client.js";
|
||||||
|
|
||||||
|
describe("readFeishuContext", () => {
|
||||||
|
it("compacts Feishu message.get replies with parent_id, thread_id, and body.content", async () => {
|
||||||
|
const rt = {
|
||||||
|
client: {
|
||||||
|
im: {
|
||||||
|
v1: {
|
||||||
|
message: {
|
||||||
|
get: async () => ({
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
message_id: "m-child",
|
||||||
|
root_id: "m-root",
|
||||||
|
parent_id: "m-parent",
|
||||||
|
thread_id: "thread-1",
|
||||||
|
msg_type: "text",
|
||||||
|
body: { content: JSON.stringify({ text: "parent text" }) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
list: async () => ({ data: { items: [] } }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logger: { warn() {}, info() {}, error() {}, debug() {} },
|
||||||
|
} as unknown as FeishuRuntime;
|
||||||
|
|
||||||
|
const raw = await readFeishuContext(
|
||||||
|
{ chat_id: "chat-1", anchor: "reply", id: "m-child" },
|
||||||
|
{ runId: "run-1", projectId: "proj-1", boundChatId: "chat-1", workspaceDir: "/tmp/proj-1" },
|
||||||
|
rt,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(JSON.parse(raw)).toMatchObject({
|
||||||
|
message_id: "m-child",
|
||||||
|
content: JSON.stringify({ text: "parent text" }),
|
||||||
|
parent_id: "m-parent",
|
||||||
|
parent_message_id: "m-parent",
|
||||||
|
root_id: "m-root",
|
||||||
|
thread_id: "thread-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user