diff --git a/CHANGELOG.md b/CHANGELOG.md index 11667df4b..edac39166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. a whole: the Bots under it fall back to an allowlist with nothing on it, so they reach nothing until the rule is corrected under Admin → Enterprise. +### A group reply the owner allows still reaches the Bot it names + +A reply held until its owner allowed it to be shown in a group was written into the transcript and then stopped. The same reply allowed immediately was handed to the Bot it named. Allowing it now hands it on the same way. + +**Before upgrading.** Four things change for an existing deployment: + ### The app offers help self-hosting OpenBot, until you close it A slim bar at the top of the signed-in app links to CopilotKit's engineers for help self-hosting diff --git a/server/src/channels/group.ts b/server/src/channels/group.ts index 4ea6187a5..5529cafdc 100644 --- a/server/src/channels/group.ts +++ b/server/src/channels/group.ts @@ -1019,8 +1019,15 @@ export function createGroupConversations(deps: { messageId: row.id, agentIds: [agentId], text: held.text, + ...chainFrom(row.details), }; await deps.activity?.(shown, agentId, held.text, `group:${row.id}`); + // The same reply allowed immediately would have been handed to the Bots it names. + // Holding it for the owner's permission must not drop that handoff. + const bots = await orderedRoster(row.ownerUserId, row.channelId); + const bot = bots?.find((candidate) => candidate.id === agentId); + if (bots && bot) + await relay(shown, bot, row.id, held.text, bots, runIdFor(row.id)); return { replyText: held.text }; } try { diff --git a/server/tests/group-conversations.integration.test.ts b/server/tests/group-conversations.integration.test.ts index 7eba9fb16..02c875396 100644 --- a/server/tests/group-conversations.integration.test.ts +++ b/server/tests/group-conversations.integration.test.ts @@ -580,6 +580,58 @@ describe("group conversations in PostgreSQL", () => { text: "From your notes: the launch slipped.", }); }); + test("a reply held for the owner's permission still hands off to the Bot it names", async () => { + const owner = await person(); + const teammate = await person(); + const ada = await bot(owner, "Ada"); + const grace = await bot(owner, "Grace"); + let decision: "pending" | "approved" = "pending"; + const privateShare = createPrivateShareCheck({ + approvals: { + open: async () => + ({ id: `${prefix}-held-relay`, status: decision }) as never, + list: async () => [], + rules: async () => [], + }, + }); + const { conversations, audit } = service({ + replies: { + [ada]: () => "Over to you, @Grace.", + [grace]: () => "On it.", + }, + privateShare, + }); + const channel = await conversations.create(owner.id, { + agentIds: [ada, grace], + }); + const [row] = await database + .select({ email: users.email }) + .from(users) + .where(eq(users.id, teammate.id)); + await conversations.addMember(owner.id, channel.id, { email: row?.email }); + await conversations.send(owner.id, channel.id, { + id: `${prefix}-held-relay-send`, + text: "ask grace", + agentId: ada, + }); + await drain(conversations); + expect( + audit.filter((event) => event.eventType === "agent.handoff_offered"), + ).toHaveLength(0); + + decision = "approved"; + await conversations.continueWaiting(`${prefix}-held-relay`, async () => { + throw new Error("a held reply is shown, not re-run"); + }); + await drain(conversations); + expect( + audit.filter((event) => event.eventType === "agent.handoff_offered"), + ).toHaveLength(1); + const after = await conversations.list(owner.id, channel.id); + expect(after.messages.some((message) => message.text === "On it.")).toBe( + true, + ); + }); test("while a reply waits on the owner's permission, other members do not see it stream in", async () => { const owner = await person(); const teammate = await person();