Merge pull request #2265 from glifocat/fix/send-card-bridge

fix(channels): support display cards (send_card) in Chat SDK bridge
This commit is contained in:
glifocat
2026-05-05 17:03:56 +02:00
committed by GitHub
2 changed files with 183 additions and 1 deletions
+128 -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,20 @@ 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 +92,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');
});
});
+55
View File
@@ -12,6 +12,8 @@ import {
CardText,
Actions,
Button,
LinkButton,
type CardChild,
type Adapter,
type ConcurrencyStrategy,
type Message as ChatMessage,
@@ -399,6 +401,59 @@ 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;