Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 128 additions & 1 deletion src/channels/chat-sdk-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
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';

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']);
Expand Down Expand Up @@ -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 changes: 55 additions & 0 deletions src/channels/chat-sdk-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
CardText,
Actions,
Button,
LinkButton,
type CardChild,
type Adapter,
type ConcurrencyStrategy,
type Message as ChatMessage,
Expand Down Expand Up @@ -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;
Expand Down
Loading