diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index cb8bcbd..0ca7cf5 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -187,6 +187,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { projectId, workspaceDir: project.workspaceDir, rt, + senderOpenId, }); const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext); @@ -241,7 +242,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { model, prompt: agentPrompt.slice(0, 200), sender: await senderAuditMetadata(rt, senderOpenId), - ...(feishuTriggerContext === null ? {} : { feishuTriggerContext }), + feishuTriggerContext, }, }); @@ -836,18 +837,18 @@ interface BuildFeishuTriggerContextArgs { readonly projectId: string; readonly workspaceDir: string; readonly rt: FeishuRuntime; + readonly senderOpenId: string; } -async function buildFeishuTriggerContext(args: BuildFeishuTriggerContextArgs): Promise { +async function buildFeishuTriggerContext(args: BuildFeishuTriggerContextArgs): Promise { 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 = { trigger_message_id: args.msg.message_id, + chat_id: args.chatId, + sender: await senderAuditMetadata(args.rt, args.senderOpenId), }; if (replyToMessageId !== undefined) context["reply_to_message_id"] = replyToMessageId; if (rootId !== undefined) context["root_id"] = rootId; @@ -878,24 +879,29 @@ function parseJsonForMetadata(text: string): Prisma.InputJsonValue { } } -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 withFeishuTriggerContext(prompt: string, context: Prisma.InputJsonObject): string { + return `Feishu trigger context:\n${JSON.stringify(context, null, 2)}\n\nUser request from ${senderLabelFromContext(context)}:\n${prompt}`; } function agentRunMetadata( roleId: string, rawPrompt: string, - feishuTriggerContext: Prisma.InputJsonObject | null, + feishuTriggerContext: Prisma.InputJsonObject, ): Prisma.InputJsonObject { - const metadata: Record = { roleId }; - if (feishuTriggerContext !== null) { - metadata["rawPrompt"] = rawPrompt; - metadata["feishuTriggerContext"] = feishuTriggerContext; - } + const metadata: Record = { roleId, rawPrompt, feishuTriggerContext }; return metadata as Prisma.InputJsonObject; } +function senderLabelFromContext(context: Prisma.InputJsonObject): string { + const sender = context["sender"]; + if (typeof sender !== "object" || sender === null || Array.isArray(sender)) return "unknown sender"; + const senderObject = sender as Record; + const name = senderObject["name"]; + if (typeof name === "string" && name.trim() !== "") return name.trim(); + const openId = senderObject["open_id"]; + return typeof openId === "string" && openId.trim() !== "" ? openId.trim() : "unknown sender"; +} + function auditDecision(decision: AuthorizationDecision): Record { return { allowed: decision.allowed, diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index 585fd0a..df6bd1b 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -50,6 +50,12 @@ function makeEvent(chatId: string, text: string, senderOpenId = "ou_test_user", }; } +function expectPromptFromSender(prompt: string | undefined, senderOpenId: string, rawPrompt: string): void { + expect(prompt).toContain("Feishu trigger context"); + expect(prompt).toContain(`"open_id": "${senderOpenId}"`); + expect(prompt).toContain(`User request from ${senderOpenId}:\n${rawPrompt}`); +} + function createMockRunAgent(calls: RunRequest[] = []): TestRunner { return async (req) => { calls.push(req); @@ -145,10 +151,42 @@ describe("trigger full lifecycle (integration)", () => { const runs = await prisma.agentRun.findMany(); expect(runs).toHaveLength(1); expect(runs[0]?.status).toBe("COMPLETED"); - expect(runs[0]?.prompt).toBe("第一段\n第二段"); + expectPromptFromSender(runs[0]?.prompt, "ou_test_user", "第一段\n第二段"); }); expect(runAgentCalls).toHaveLength(1); - expect(runAgentCalls[0]?.prompt).toBe("第一段\n第二段"); + expectPromptFromSender(runAgentCalls[0]?.prompt, "ou_test_user", "第一段\n第二段"); + }); + + it("keeps the project session shared while labeling each sender in the prompt", async () => { + await seedProject("proj-speaker", "chat-speaker"); + await prisma.permissionGrant.create({ + data: { + resourceType: "PROJECT", + resourceId: "proj-speaker", + principalType: "FEISHU_CHAT", + principalId: "chat-speaker", + role: "EDIT", + }, + }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); + + await trigger(makeEvent("chat-speaker", "@_user_1 Alice 的需求", "ou_alice"), rt); + await vi.waitFor(() => { + expect(runAgentCalls).toHaveLength(1); + }); + await vi.waitFor(async () => { + expect(await prisma.projectAgentLock.findMany()).toHaveLength(0); + }); + + await trigger(makeEvent("chat-speaker", "@_user_1 Bob 的需求", "ou_bob"), rt); + await vi.waitFor(() => { + expect(runAgentCalls).toHaveLength(2); + }); + + expectPromptFromSender(runAgentCalls[0]?.prompt, "ou_alice", "Alice 的需求"); + expectPromptFromSender(runAgentCalls[1]?.prompt, "ou_bob", "Bob 的需求"); + const sessions = await prisma.agentSession.findMany(); + expect(sessions).toHaveLength(1); }); it("/new bypasses message batching", async () => { @@ -351,7 +389,7 @@ describe("trigger full lifecycle (integration)", () => { await vi.waitFor(() => { expect(runAgentCalls).toHaveLength(2); }); - expect(runAgentCalls[1]?.prompt).toBe("第二个请求"); + expectPromptFromSender(runAgentCalls[1]?.prompt, "ou_test_user", "第二个请求"); secondRun.resolve(completedRunResult("second done", "sdk-session-second")); await vi.waitFor(async () => { @@ -485,7 +523,7 @@ describe("trigger full lifecycle (integration)", () => { await vi.waitFor(async () => { const runs = await prisma.agentRun.findMany(); expect(runs).toHaveLength(1); - expect(runs[0]?.prompt).toBe("/unknown"); + expectPromptFromSender(runs[0]?.prompt, "ou_test_user", "/unknown"); }); }); @@ -531,7 +569,7 @@ describe("trigger full lifecycle (integration)", () => { await vi.waitFor(async () => { const runs = await prisma.agentRun.findMany(); expect(runs).toHaveLength(1); - expect(runs[0]?.prompt).toBe("写第三单元"); + expectPromptFromSender(runs[0]?.prompt, "ou_test_user", "写第三单元"); expect(runs[0]?.metadata).toMatchObject({ roleId: "draft" }); }); }); @@ -651,7 +689,7 @@ describe("trigger full lifecycle (integration)", () => { expect(prompt).toContain("m-parent"); expect(prompt).toContain("thread-1"); expect(prompt).toContain("上一条需求"); - expect(prompt).toContain("User request:\n继续这个改法"); + expectPromptFromSender(prompt, "ou_test_user", "继续这个改法"); const run = await prisma.agentRun.findFirst(); expect(run?.prompt).toBe(prompt); @@ -660,6 +698,8 @@ describe("trigger full lifecycle (integration)", () => { rawPrompt: "继续这个改法", feishuTriggerContext: { trigger_message_id: "m-child", + chat_id: "chat-13c", + sender: { open_id: "ou_test_user" }, reply_to_message_id: "m-parent", root_id: "m-root", thread_id: "thread-1",