From 0ac8073e3467677e32d5905981e634a5feaa34fb Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 11 Jun 2026 13:52:45 +0300 Subject: [PATCH] fix(chat-sdk-bridge): record the acting user on resolved approval cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a button on an approval/question card is clicked, the bridge edits the card down to the title and the selected answer — but not who clicked it. In shared channels every member sees the same resolved card, so the audit trail of which user approved or rejected is lost the moment the buttons disappear. Append an actor byline (" — ", falling back to fullName) to the edited card markdown. The shared chat.onAction handler covers every Chat SDK webhook platform; cards edited for actors with no resolvable name stay byline-free. Co-Authored-By: Claude Fable 5 --- src/channels/chat-sdk-bridge-byline.test.ts | 112 ++++++++++++++++++++ src/channels/chat-sdk-bridge.ts | 6 +- 2 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 src/channels/chat-sdk-bridge-byline.test.ts diff --git a/src/channels/chat-sdk-bridge-byline.test.ts b/src/channels/chat-sdk-bridge-byline.test.ts new file mode 100644 index 000000000..28bbcdcad --- /dev/null +++ b/src/channels/chat-sdk-bridge-byline.test.ts @@ -0,0 +1,112 @@ +/** + * Approval-card actor byline in the Chat SDK bridge. + * + * Drives the bridge's real onAction handler through the real Chat SDK + * dispatch (`chat.processAction`): `bridge.setup()` registers the handler on + * a real Chat instance, which the test captures from the webhook-server + * registration (mocked so no HTTP server binds a port). After a button click + * the bridge edits the card; the edit must append " — " so shared + * channels see who resolved an approval. Goes red if the byLine concatenation + * is removed from the edited markdown. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Adapter, Chat } from 'chat'; + +const captured = vi.hoisted(() => ({ chat: null as unknown })); + +vi.mock('../webhook-server.js', () => ({ + registerWebhookAdapter: vi.fn((chat: unknown) => { + captured.chat = chat; + }), +})); + +import { closeDb, initTestDb, runMigrations } from '../db/index.js'; +import type { ChannelSetup } from './adapter.js'; +import { createChatSdkBridge } from './chat-sdk-bridge.js'; + +interface CapturedEdit { + threadId: string; + messageId: string; + markdown: string; +} + +function makeAdapter(edits: CapturedEdit[]): Adapter { + return { + name: 'stub', + initialize: async () => {}, + channelIdFromThreadId: (threadId: string) => `stub:${threadId}`, + editMessage: async (threadId: string, messageId: string, content: { markdown: string }) => { + edits.push({ threadId, messageId, markdown: content.markdown }); + }, + } as unknown as Adapter; +} + +async function fireAction(user: Record): Promise<{ edits: CapturedEdit[]; actions: string[] }> { + const edits: CapturedEdit[] = []; + const actions: string[] = []; + const adapter = makeAdapter(edits); + const bridge = createChatSdkBridge({ adapter, supportsThreads: false }); + + await bridge.setup({ + onInbound: async () => {}, + onInboundEvent: async () => {}, + onMetadata: () => {}, + onAction: (questionId: string, selectedOption: string, userId: string) => { + actions.push(`${questionId}:${selectedOption}:${userId}`); + }, + } as ChannelSetup); + + const chat = captured.chat as Chat; + expect(chat).toBeTruthy(); + await chat.processAction( + { + actionId: 'ncq:q-1:approve', + adapter, + messageId: 'msg-1', + raw: {}, + threadId: 'T-1', + user: user as never, + value: 'approve', + }, + undefined, + ); + return { edits, actions }; +} + +beforeEach(() => { + captured.chat = null; + const db = initTestDb(); + runMigrations(db); +}); + +afterEach(() => { + closeDb(); +}); + +describe('chat-sdk-bridge approval-card byline', () => { + it('appends the acting user to the edited card markdown', async () => { + const { edits, actions } = await fireAction({ userId: 'U1', userName: 'gavriel', fullName: 'Gavriel C' }); + + expect(edits).toHaveLength(1); + expect(edits[0].threadId).toBe('T-1'); + expect(edits[0].messageId).toBe('msg-1'); + expect(edits[0].markdown).toContain('approve — gavriel'); + expect(actions).toEqual(['q-1:approve:U1']); + }); + + it('falls back to fullName when userName is missing', async () => { + const { edits } = await fireAction({ userId: 'U2', fullName: 'Gavriel C' }); + + expect(edits).toHaveLength(1); + expect(edits[0].markdown).toContain('— Gavriel C'); + }); + + it('omits the byline when the actor has no name', async () => { + const { edits } = await fireAction({ userId: 'U3' }); + + expect(edits).toHaveLength(1); + expect(edits[0].markdown).not.toContain('—'); + expect(edits[0].markdown).toContain('approve'); + }); +}); diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index efeb32f16..64071d35e 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -284,11 +284,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter const matched = render?.options.find((o) => o.value === selectedOption); const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)'; - // Update the card to show the selected answer and remove buttons + // Update the card to show the selected answer, who acted, and remove buttons + const actorName = event.user?.userName || event.user?.fullName || ''; + const byLine = actorName ? ` — ${actorName}` : ''; try { const tid = event.threadId; await adapter.editMessage(tid, event.messageId, { - markdown: `${title}\n\n${selectedLabel}`, + markdown: `${title}\n\n${selectedLabel}${byLine}`, }); } catch (err) { log.warn('Failed to update card after action', { err });