From 392655db6544086602c113a2a404cc42e6d15961 Mon Sep 17 00:00:00 2001 From: baokimho Date: Tue, 15 Sep 2026 21:22:05 +0300 Subject: [PATCH] fix(flow): preserve comment anchor in getUserData --- apps/worker/__tests__/flow.test.ts | 68 ++++++++++++++++++- apps/worker/__tests__/get-user-data.test.ts | 34 ++++++++++ .../src/integration/handlers/flow-utils.ts | 4 +- .../src/integration/handlers/get-user-data.ts | 1 + 4 files changed, 104 insertions(+), 3 deletions(-) diff --git a/apps/worker/__tests__/flow.test.ts b/apps/worker/__tests__/flow.test.ts index 4f67a651f5..69f20d3739 100644 --- a/apps/worker/__tests__/flow.test.ts +++ b/apps/worker/__tests__/flow.test.ts @@ -587,7 +587,7 @@ describe("seekConnectedNode", () => { }) describe("MESSAGE_PRODUCING_STEP_TYPES", () => { - test("matches exactly the step types mapped to sendFlowMessage in flowStepHandlers", async () => { + test("matches sendFlowMessage steps plus getUserData prompt-production exception", async () => { const { MESSAGE_PRODUCING_STEP_TYPES } = await import( "../src/integration/handlers/flow-utils" ) @@ -602,7 +602,71 @@ describe("MESSAGE_PRODUCING_STEP_TYPES", () => { ) expect(new Set(MESSAGE_PRODUCING_STEP_TYPES)).toEqual( - actualMessageProducingTypes, + new Set([...actualMessageProducingTypes, "getUserData"]), + ) + }) +}) + +describe("executeMultipleSteps — comment anchor lifecycle", () => { + test("getUserData receives unspent anchor and marks it spent for subsequent steps", async () => { + const { flowStepHandlers } = await import( + "../src/integration/handlers/step" + ) + const getUserDataSpy = mockSpy( + flowStepHandlers, + "getUserData", + ).mockResolvedValue({ + status: "success", + result: null, + }) + const sendTextSpy = mockSpy(flowStepHandlers, "sendText").mockResolvedValue( + undefined, + ) + + const anchor = { + commentId: "comment-1", + replyChannel: "private" as const, + } + const result = await executeMultipleSteps({ + ...makeBaseProps(), + commentAnchor: anchor, + steps: [makeStep("getUserData"), makeStep("sendText")], + }) + + expect(getUserDataSpy).toHaveBeenCalledWith( + expect.objectContaining({ commentAnchor: anchor }), + ) + expect(sendTextSpy).toHaveBeenCalledWith( + expect.objectContaining({ commentAnchor: { ...anchor, spent: true } }), + ) + expect(result?.commentAnchor).toEqual({ ...anchor, spent: true }) + }) + + test("getUserData after sendText receives spent anchor", async () => { + const { flowStepHandlers } = await import( + "../src/integration/handlers/step" + ) + mockSpy(flowStepHandlers, "sendText").mockResolvedValue(undefined) + const getUserDataSpy = mockSpy( + flowStepHandlers, + "getUserData", + ).mockResolvedValue({ + status: "wait", + result: null, + }) + const anchor = { + commentId: "comment-1", + replyChannel: "private" as const, + } + + await executeMultipleSteps({ + ...makeBaseProps(), + commentAnchor: anchor, + steps: [makeStep("sendText"), makeStep("getUserData")], + }) + + expect(getUserDataSpy).toHaveBeenCalledWith( + expect.objectContaining({ commentAnchor: { ...anchor, spent: true } }), ) }) }) diff --git a/apps/worker/__tests__/get-user-data.test.ts b/apps/worker/__tests__/get-user-data.test.ts index 926db8feaa..9e47f5032d 100644 --- a/apps/worker/__tests__/get-user-data.test.ts +++ b/apps/worker/__tests__/get-user-data.test.ts @@ -290,6 +290,40 @@ describe("getUserData — validation logic", () => { lastMessage.current = null }) + test.each([ + ["unspent", { commentId: "comment-1", replyChannel: "private" as const }], + [ + "spent", + { commentId: "comment-1", replyChannel: "private" as const, spent: true }, + ], + ])("forwards %s comment anchor through flow message queue", async (_label, commentAnchor) => { + const props = { + ...makeProps(ReplyFormat.email, {}, 0), + ctx: undefined, + commentAnchor, + } + + await getUserData(props) + + expect(chatQueueAdd).toHaveBeenCalledWith("sendFlowMessage", { + type: "sendFlowMessage", + data: expect.objectContaining({ commentAnchor }), + }) + }) + + test("does not include comment anchor for normal flow prompt", async () => { + await getUserData({ + ...makeProps(ReplyFormat.email, {}, 0), + ctx: undefined, + }) + + const [, job] = chatQueueAdd.mock.calls[0] as [ + string, + { data: Record }, + ] + expect(job.data).not.toHaveProperty("commentAnchor") + }) + test("anchors the message lookup on conversation.lastActivityAt, not contactInbox", async () => { lastMessage.current = makeIncomingMessage({ text: "user@example.com" }) const props = makeProps(ReplyFormat.email) diff --git a/apps/worker/src/integration/handlers/flow-utils.ts b/apps/worker/src/integration/handlers/flow-utils.ts index 9411b16efb..a746f6940c 100644 --- a/apps/worker/src/integration/handlers/flow-utils.ts +++ b/apps/worker/src/integration/handlers/flow-utils.ts @@ -66,11 +66,13 @@ export type HeavyStepProps = ExecuteStepProps & { /** * Step types that actually send an outgoing message via `sendFlowMessage` * (see the `flowStepHandlers` map in `./step.ts` — keep this set in sync with - * every entry mapped to `sendFlowMessage` there). Used to decide which step + * every entry mapped to `sendFlowMessage` there, plus prompt-producing + * `getUserData`). Used to decide which step * "claims" a pending `commentAnchor` (comment-triggered private-reply flow) as * its first outgoing message. */ export const MESSAGE_PRODUCING_STEP_TYPES = new Set([ + stepTypes.enum.getUserData, stepTypes.enum.sendText, stepTypes.enum.sendImage, stepTypes.enum.sendMultipleImages, diff --git a/apps/worker/src/integration/handlers/get-user-data.ts b/apps/worker/src/integration/handlers/get-user-data.ts index b7fa21d05b..0ed67b6219 100644 --- a/apps/worker/src/integration/handlers/get-user-data.ts +++ b/apps/worker/src/integration/handlers/get-user-data.ts @@ -526,6 +526,7 @@ async function sendMessage( flowVersionId, step: promptStep, metadata: props.metadata, + ...(props.commentAnchor ? { commentAnchor: props.commentAnchor } : {}), ...(props.appointmentId ? { appointmentId: props.appointmentId } : {}), }) }