fix(channels): support display cards (send_card) in Chat SDK bridge

The send_card MCP tool wrote outbound rows with type='card' but the
chat-sdk-bridge deliver() had no branch for them, so the payload fell
through to the text fallback (where text is undefined) and silently
returned without calling the adapter. delivery.ts then marked the
message delivered with platformMsgId=undefined and the user saw nothing.

Add a dedicated card branch mirroring the ask_question structure:
- Build Card from title, description, and string-or-{text} children
- Render only URL actions as LinkButtons (send_card is fire-and-forget
  per its docstring, so callback buttons would have nowhere to land)
- Drop empty cards with a warn log instead of posting blank
- Fall back text: content.fallbackText > description > title

Affects every Chat SDK adapter that goes through the bridge: Discord,
Telegram, Slack, Teams, GChat, GitHub, Linear, WhatsApp Cloud, iMessage,
Matrix, Webex, Resend.

Tests: adds five cases covering normal render, action filtering,
link-button rendering, empty-card skip, and a regression check that
non-card chat-sdk payloads still flow through the text branch.

Closes #2263
This commit is contained in:
glifocat
2026-05-05 00:24:37 +02:00
parent 1404f7feb6
commit 32dba601fe
2 changed files with 194 additions and 1 deletions
+131 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { Adapter } from 'chat';
import type { Adapter, AdapterPostableMessage, RawMessage } from 'chat';
import { createChatSdkBridge, splitForLimit } from './chat-sdk-bridge.js';
@@ -8,6 +8,23 @@ function stubAdapter(partial: Partial<Adapter>): Adapter {
return { name: 'stub', ...partial } as unknown as Adapter;
}
interface PostCall {
threadId: string;
message: AdapterPostableMessage;
}
function makePostCapture() {
const calls: PostCall[] = [];
const postMessage = async (
threadId: string,
message: AdapterPostableMessage,
): Promise<RawMessage<unknown>> => {
calls.push({ threadId, message });
return { id: 'msg-stub', threadId, raw: {} };
};
return { calls, postMessage };
}
describe('splitForLimit', () => {
it('returns a single chunk when text fits', () => {
expect(splitForLimit('short text', 100)).toEqual(['short text']);
@@ -78,3 +95,116 @@ describe('createChatSdkBridge', () => {
expect(typeof bridge.subscribe).toBe('function');
});
});
describe('createChatSdkBridge.deliver — display cards (send_card)', () => {
// The send_card MCP tool writes outbound rows with `{ type: 'card', card, fallbackText }`.
// Before this branch existed the bridge silently dropped them: cards have no
// `text` / `markdown`, so the trailing fallback `if (text)` was false and the
// function returned without calling the adapter. These tests pin the contract
// for the dedicated card branch.
it('renders title, description, and string children, then posts via the adapter', async () => {
const { calls, postMessage } = makePostCapture();
const bridge = createChatSdkBridge({
adapter: stubAdapter({ postMessage }),
supportsThreads: false,
});
const id = await bridge.deliver('telegram:42', null, {
kind: 'chat-sdk',
content: {
type: 'card',
card: {
title: 'Daily',
description: 'Your plate today',
children: ['• item one', '• item two'],
},
fallbackText: 'Daily: your plate',
},
});
expect(id).toBe('msg-stub');
expect(calls).toHaveLength(1);
const msg = calls[0].message as { card?: unknown; fallbackText?: string };
expect(msg.fallbackText).toBe('Daily: your plate');
expect(msg.card).toBeDefined();
});
it('drops actions without url (send_card is fire-and-forget; non-URL buttons would have nowhere to land)', async () => {
const { calls, postMessage } = makePostCapture();
const bridge = createChatSdkBridge({
adapter: stubAdapter({ postMessage }),
supportsThreads: false,
});
await bridge.deliver('discord:guild:chan', null, {
kind: 'chat-sdk',
content: {
type: 'card',
card: {
title: 'Card',
description: 'has only label-only actions',
actions: [{ label: 'Add' }, { label: 'Skip' }],
},
},
});
expect(calls).toHaveLength(1);
// Cast through the public Card shape to read the children we set
const msg = calls[0].message as { card?: { children?: Array<{ type?: string }> } };
const childTypes = (msg.card?.children ?? []).map((c) => c.type);
expect(childTypes).not.toContain('actions');
});
it('renders url actions as link buttons inside an Actions row', async () => {
const { calls, postMessage } = makePostCapture();
const bridge = createChatSdkBridge({
adapter: stubAdapter({ postMessage }),
supportsThreads: false,
});
await bridge.deliver('discord:guild:chan', null, {
kind: 'chat-sdk',
content: {
type: 'card',
card: {
title: 'Docs',
actions: [{ label: 'Open', url: 'https://example.com' }, { label: 'No-link' }],
},
},
});
const msg = calls[0].message as {
card?: { children?: Array<{ type?: string; children?: Array<{ type?: string; url?: string }> }> };
};
const actionsRow = msg.card?.children?.find((c) => c.type === 'actions');
expect(actionsRow).toBeDefined();
const buttons = actionsRow?.children ?? [];
expect(buttons).toHaveLength(1);
expect(buttons[0].type).toBe('link-button');
expect(buttons[0].url).toBe('https://example.com');
});
it('skips delivery when the card has neither title nor body content', async () => {
const { calls, postMessage } = makePostCapture();
const bridge = createChatSdkBridge({
adapter: stubAdapter({ postMessage }),
supportsThreads: false,
});
const id = await bridge.deliver('telegram:42', null, {
kind: 'chat-sdk',
content: { type: 'card', card: {} },
});
expect(id).toBeUndefined();
expect(calls).toHaveLength(0);
});
it('falls through to the text branch for non-card chat-sdk payloads (no regression)', async () => {
const { calls, postMessage } = makePostCapture();
const bridge = createChatSdkBridge({
adapter: stubAdapter({ postMessage }),
supportsThreads: false,
});
await bridge.deliver('telegram:42', null, {
kind: 'chat-sdk',
content: { text: 'plain hello' },
});
expect(calls).toHaveLength(1);
const msg = calls[0].message as { markdown?: string };
expect(msg.markdown).toBe('plain hello');
});
});
+63
View File
@@ -12,6 +12,8 @@ import {
CardText,
Actions,
Button,
LinkButton,
type CardChild,
type Adapter,
type ConcurrencyStrategy,
type Message as ChatMessage,
@@ -399,6 +401,67 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
return result?.id;
}
// Display card (send_card MCP tool) — returns immediately, no callback flow.
// Non-URL actions are dropped: send_card's contract is fire-and-forget, so a
// callback button would have nowhere to land. URL actions render as link buttons.
if (content.type === 'card' && content.card && typeof content.card === 'object') {
const cardSpec = content.card as Record<string, unknown>;
const title = (cardSpec.title as string) || '';
const fallbackText =
(content.fallbackText as string) ||
(cardSpec.description as string) ||
title ||
'';
const cardChildren: CardChild[] = [];
if (typeof cardSpec.description === 'string' && cardSpec.description) {
cardChildren.push(CardText(cardSpec.description));
}
if (Array.isArray(cardSpec.children)) {
for (const child of cardSpec.children) {
if (typeof child === 'string' && child) {
cardChildren.push(CardText(child));
} else if (
child &&
typeof child === 'object' &&
typeof (child as Record<string, unknown>).text === 'string'
) {
cardChildren.push(CardText((child as Record<string, string>).text));
}
}
}
if (Array.isArray(cardSpec.actions)) {
const linkButtons = (cardSpec.actions as Array<Record<string, unknown>>)
.filter(
(a) => typeof a.url === 'string' && a.url && typeof a.label === 'string' && a.label,
)
.map((a) => {
const style = a.style;
const safeStyle: 'primary' | 'danger' | 'default' | undefined =
style === 'primary' || style === 'danger' || style === 'default'
? style
: undefined;
return LinkButton({
label: a.label as string,
url: a.url as string,
style: safeStyle,
});
});
if (linkButtons.length > 0) {
cardChildren.push(Actions(linkButtons));
}
}
if (cardChildren.length === 0 && !title) {
log.warn('send_card payload empty, skipping delivery');
return;
}
const card = Card({ title, children: cardChildren });
const result = await adapter.postMessage(tid, { card, fallbackText });
return result?.id;
}
// Normal message
const rawText = (content.markdown as string) || (content.text as string);
const text = rawText ? transformText(rawText) : rawText;