2026 03 12 S211 Unified Rich Messaging Phase1

Documentation for Plans

S211 — Unified Rich Messaging Architecture (Phase 1) Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a channel-agnostic rich message type system with WhatsApp and Web Chat adapters, enabling list pickers, cards, carousels, CTA deep links, and intelligent response type selection across all channels from a single abstraction.

Architecture: Discriminated union RichMessage types flow from a RichMessageComposerService (heuristic analysis of agent output) → AdapterRegistryService resolves the correct ChannelAdapter → adapter renders to channel-native payload (Twilio Content API for WhatsApp, generative-ui JSON blocks for Web). The frontend already has a GenerativeUIComponentCatalog that dynamically renders registered Angular components from JSON blocks in chat messages — we add 5 new components (list-picker, card, carousel, CTA button, quick-reply) and register them in the catalog seed.

Tech Stack: NestJS (backend-vitto-api), Angular 20+ (standalone, OnPush, signals), Twilio Content API, Jest, TypeScript discriminated unions

Spec/Handoff: docs/english/Plans/S210.5: Vitto Unified Rich Messaging and Interactive WhatsApp Experience.md

Commit tag convention: All commits in this plan use [S211.X] — the implementor MUST substitute X with their actual sub-session number from the session manifest per SAGP v1.2.

Key codebase facts the implementor needs:


File Structure

New Files (Backend)

File Responsibility
APIs/backend-vitto-api/src/app/engine/rich-messages/rich-message.types.ts Discriminated union of all RichMessage types + RichMessageButton
APIs/backend-vitto-api/src/app/engine/rich-messages/rich-message-composer.service.ts Analyzes agent output text → selects optimal RichMessage type
APIs/backend-vitto-api/src/app/engine/adapters/channel-adapter.interface.ts ChannelAdapter interface + ChannelAdapterContext + ChannelAdapterResult
APIs/backend-vitto-api/src/app/engine/adapters/whatsapp-channel.adapter.ts Converts RichMessage → Twilio Content API calls
APIs/backend-vitto-api/src/app/engine/adapters/web-chat-channel.adapter.ts Converts RichMessage → generative-ui JSON blocks in markdown
APIs/backend-vitto-api/src/app/engine/adapters/adapter-registry.service.ts Maps channel type string → ChannelAdapter instance
APIs/backend-vitto-api/src/app/engine/__tests__/rich-message-composer.spec.ts Tests for composer heuristics
APIs/backend-vitto-api/src/app/engine/__tests__/whatsapp-adapter.spec.ts Tests for WhatsApp adapter rendering
APIs/backend-vitto-api/src/app/engine/__tests__/web-chat-adapter.spec.ts Tests for Web adapter generative-ui output
APIs/backend-vitto-api/src/app/engine/__tests__/adapter-registry.spec.ts Tests for adapter resolution

New Files (Frontend)

File Responsibility
libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-list-picker.component.ts Interactive list with selectable items grouped in sections
libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-card.component.ts Card with title, subtitle, optional media, action buttons
libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-carousel.component.ts Horizontal scroll of cards
libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-cta-button.component.ts Call-to-action button that opens a URL
libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-quick-reply.component.ts Row of tappable quick reply chips

Modified Files

File Change
APIs/backend-vitto-api/src/app/engine/engine.module.ts Register AdapterRegistryService, RichMessageComposerService, WhatsAppChannelAdapter, WebChatChannelAdapter
APIs/backend-vitto-api/src/app/engine/channel-processing.service.ts Wire composer + adapter into executeUnifiedAgentLoop() response flow
libs/libs-ai/vitto-chat/src/lib/generative-ui/catalog-seed.ts Register 5 new rich message components

Chunk 1: RichMessage Types + Channel Adapter Interface + AdapterRegistry

Task 1: Define RichMessage Type System

Files:

// APIs/backend-vitto-api/src/app/engine/rich-messages/rich-message.types.ts

/**
 * S211: Channel-agnostic rich message type system.
 * Discriminated union — each type carries structured data without channel-specific formatting.
 * Adapters convert these to channel-native payloads (Twilio, generative-ui, Block Kit, etc.)
 */

// ── Button Types ──

export interface RichMessageButton {
  type: 'quick_reply' | 'url' | 'phone';
  title: string;
  url?: string;
  phone?: string;
  id?: string;
}

// ── Message Types ──

export type RichMessageType =
  | 'text'
  | 'quick_reply'
  | 'list_picker'
  | 'card'
  | 'carousel'
  | 'cta'
  | 'media'
  | 'location';

interface BaseRichMessage {
  type: RichMessageType;
  /** Plain text fallback for channels that can't render this rich type */
  fallbackText: string;
}

export interface TextMessage extends BaseRichMessage {
  type: 'text';
  text: string;
}

export interface QuickReplyMessage extends BaseRichMessage {
  type: 'quick_reply';
  text: string;
  /** Max 3 buttons for WhatsApp, max 20 chars per title */
  buttons: Array<{ title: string; id?: string }>;
}

export interface ListPickerSection {
  title: string;
  items: Array<{ id: string; title: string; description?: string }>;
}

export interface ListPickerMessage extends BaseRichMessage {
  type: 'list_picker';
  body: string;
  /** Text shown on the button that opens the list (max 20 chars) */
  buttonText: string;
  /** WhatsApp: max 10 items total across sections */
  sections: ListPickerSection[];
}

export interface CardMessage extends BaseRichMessage {
  type: 'card';
  title: string;
  subtitle?: string;
  body?: string;
  mediaUrl?: string;
  /** Max 3 buttons for WhatsApp */
  buttons: RichMessageButton[];
}

export interface CarouselCard {
  title: string;
  body?: string;
  mediaUrl?: string;
  /** Max 2 buttons per card for WhatsApp */
  buttons: RichMessageButton[];
}

export interface CarouselMessage extends BaseRichMessage {
  type: 'carousel';
  body: string;
  /** WhatsApp: 2-10 cards */
  cards: CarouselCard[];
}

export interface CTAMessage extends BaseRichMessage {
  type: 'cta';
  text: string;
  buttons: Array<{ title: string; url: string }>;
}

export interface MediaMessage extends BaseRichMessage {
  type: 'media';
  mediaUrl: string;
  mediaType: 'image' | 'video' | 'audio' | 'document';
  caption?: string;
}

export interface LocationMessage extends BaseRichMessage {
  type: 'location';
  latitude: number;
  longitude: number;
  label?: string;
  address?: string;
}

/** Discriminated union of all rich message types */
export type RichMessage =
  | TextMessage
  | QuickReplyMessage
  | ListPickerMessage
  | CardMessage
  | CarouselMessage
  | CTAMessage
  | MediaMessage
  | LocationMessage;

// ── Factory helpers ──

export function createTextMessage(text: string): TextMessage {
  return { type: 'text', text, fallbackText: text };
}

export function createQuickReplyMessage(
  text: string,
  buttons: Array<{ title: string; id?: string }>,
): QuickReplyMessage {
  return {
    type: 'quick_reply',
    text,
    buttons: buttons.slice(0, 3),
    fallbackText: text,
  };
}

export function createListPickerMessage(
  body: string,
  buttonText: string,
  sections: ListPickerSection[],
): ListPickerMessage {
  return {
    type: 'list_picker',
    body,
    buttonText: buttonText.substring(0, 20),
    sections,
    fallbackText: body,
  };
}

export function createCardMessage(
  title: string,
  buttons: RichMessageButton[],
  opts?: { subtitle?: string; body?: string; mediaUrl?: string },
): CardMessage {
  return {
    type: 'card',
    title,
    subtitle: opts?.subtitle,
    body: opts?.body,
    mediaUrl: opts?.mediaUrl,
    buttons: buttons.slice(0, 3),
    fallbackText: `${title}${opts?.subtitle ? ` — ${opts.subtitle}` : ''}`,
  };
}

export function createCarouselMessage(
  body: string,
  cards: CarouselCard[],
): CarouselMessage {
  return {
    type: 'carousel',
    body,
    cards: cards.slice(0, 10),
    fallbackText: body,
  };
}

export function createCTAMessage(
  text: string,
  buttons: Array<{ title: string; url: string }>,
): CTAMessage {
  return { type: 'cta', text, buttons, fallbackText: text };
}

Run: npx tsc --noEmit --project APIs/backend-vitto-api/tsconfig.lib.json 2>&1 | head -20 Expected: No errors from rich-message.types.ts (other pre-existing errors are OK)

git add APIs/backend-vitto-api/src/app/engine/rich-messages/rich-message.types.ts
git commit -m "[S211.X] feat: add channel-agnostic RichMessage type system"

Task 2: Define ChannelAdapter Interface

Files:

// APIs/backend-vitto-api/src/app/engine/adapters/channel-adapter.interface.ts

/**
 * S211: Channel adapter interface.
 * Each adapter converts a RichMessage into channel-native format.
 *
 * WhatsApp adapter: calls Twilio Content API, returns { alreadySent: true }
 * Web adapter: returns generative-ui JSON blocks, ChannelProcessing injects into message content
 * Slack adapter (future): converts to Block Kit
 */

import { RichMessage } from '../rich-messages/rich-message.types';

export interface ChannelAdapterContext {
  channelId: string;
  channelType: string;
  userId?: string;
  spaceId?: string;
  metadata?: Record<string, unknown>;
}

export interface ChannelAdapterResult {
  /** Whether the adapter successfully handled the message */
  success: boolean;
  /** Rendered output */
  rendered?: {
    /** Replacement text content (web: generative-ui blocks prepended to original text) */
    text?: string;
    /** True if the adapter already sent the message (WhatsApp Content API sends directly) */
    alreadySent?: boolean;
  };
  error?: string;
}

export interface ChannelAdapter {
  /** Which channel this adapter handles */
  readonly channelType: string;

  /**
   * Check if this adapter can render the given rich message type.
   * Returns false for 'text' — text messages need no special rendering.
   */
  supports(message: RichMessage): boolean;

  /**
   * Render the rich message for this channel.
   * For WhatsApp: calls Twilio APIs and returns { alreadySent: true }.
   * For Web: returns { text: '<generative-ui block>' } to be injected into response.
   */
  render(
    message: RichMessage,
    context: ChannelAdapterContext,
  ): Promise<ChannelAdapterResult>;
}
git add APIs/backend-vitto-api/src/app/engine/adapters/channel-adapter.interface.ts
git commit -m "[S211.X] feat: add ChannelAdapter interface for rich message rendering"

Task 3: Build AdapterRegistry Service + Test

Files:

// APIs/backend-vitto-api/src/app/engine/__tests__/adapter-registry.spec.ts

import { AdapterRegistryService } from '../adapters/adapter-registry.service';
import { ChannelAdapter, ChannelAdapterContext, ChannelAdapterResult } from '../adapters/channel-adapter.interface';
import { RichMessage, createTextMessage, createQuickReplyMessage } from '../rich-messages/rich-message.types';

class MockWhatsAppAdapter implements ChannelAdapter {
  readonly channelType = 'whatsapp';
  supports(msg: RichMessage): boolean { return msg.type !== 'text'; }
  async render(msg: RichMessage, ctx: ChannelAdapterContext): Promise<ChannelAdapterResult> {
    return { success: true, rendered: { alreadySent: true } };
  }
}

class MockWebAdapter implements ChannelAdapter {
  readonly channelType = 'web';
  supports(msg: RichMessage): boolean { return msg.type !== 'text'; }
  async render(msg: RichMessage, ctx: ChannelAdapterContext): Promise<ChannelAdapterResult> {
    return { success: true, rendered: { text: '```generative-ui\n{}\n```' } };
  }
}

describe('AdapterRegistryService', () => {
  let registry: AdapterRegistryService;

  beforeEach(() => {
    registry = new AdapterRegistryService();
    registry.register(new MockWhatsAppAdapter());
    registry.register(new MockWebAdapter());
  });

  it('should resolve the correct adapter for a channel type', () => {
    const adapter = registry.getAdapter('whatsapp');
    expect(adapter).toBeDefined();
    expect(adapter!.channelType).toBe('whatsapp');
  });

  it('should return undefined for unregistered channels', () => {
    expect(registry.getAdapter('slack')).toBeUndefined();
  });

  it('should check if a channel has a registered adapter', () => {
    expect(registry.hasAdapter('whatsapp')).toBe(true);
    expect(registry.hasAdapter('discord')).toBe(false);
  });

  it('should list all registered channel types', () => {
    const types = registry.getRegisteredChannels();
    expect(types).toContain('whatsapp');
    expect(types).toContain('web');
    expect(types).toHaveLength(2);
  });
});

Run: yarn nx test backend-vitto-api --testPathPattern="adapter-registry" 2>&1 | tail -10 Expected: FAIL — cannot find module ../adapters/adapter-registry.service

// APIs/backend-vitto-api/src/app/engine/adapters/adapter-registry.service.ts

import { Injectable, Logger, Optional } from '@nestjs/common';
import { ChannelAdapter } from './channel-adapter.interface';

/**
 * S211: Registry that maps channel type → ChannelAdapter.
 * Adapters self-register via @Optional() injection + onModuleInit.
 */
@Injectable()
export class AdapterRegistryService {
  private readonly logger = new Logger('AdapterRegistry');
  private readonly adapters = new Map<string, ChannelAdapter>();

  register(adapter: ChannelAdapter): void {
    this.adapters.set(adapter.channelType, adapter);
    this.logger.log(`Registered adapter: ${adapter.channelType}`);
  }

  getAdapter(channelType: string): ChannelAdapter | undefined {
    return this.adapters.get(channelType);
  }

  hasAdapter(channelType: string): boolean {
    return this.adapters.has(channelType);
  }

  getRegisteredChannels(): string[] {
    return Array.from(this.adapters.keys());
  }
}

Run: yarn nx test backend-vitto-api --testPathPattern="adapter-registry" 2>&1 | tail -10 Expected: PASS — 4 tests passing

git add APIs/backend-vitto-api/src/app/engine/adapters/adapter-registry.service.ts \
       APIs/backend-vitto-api/src/app/engine/__tests__/adapter-registry.spec.ts
git commit -m "[S211.X] feat: add AdapterRegistryService for channel adapter resolution"

Chunk 2: WhatsApp Adapter + RichMessage Composer

Task 4: Build WhatsApp Channel Adapter + Test

Files:

Key dependency: ChannelGatewayService already has sendWhatsAppQuickReply(), sendWhatsAppCarousel(), sendWhatsAppSticker(). For list pickers and cards, use the TwilioContentApiService (injected into gateway or used directly).

// APIs/backend-vitto-api/src/app/engine/__tests__/whatsapp-adapter.spec.ts

import { WhatsAppChannelAdapter } from '../adapters/whatsapp-channel.adapter';
import {
  createTextMessage,
  createQuickReplyMessage,
  createListPickerMessage,
  createCardMessage,
  createCTAMessage,
  createCarouselMessage,
} from '../rich-messages/rich-message.types';

describe('WhatsAppChannelAdapter', () => {
  let adapter: WhatsAppChannelAdapter;
  let mockGateway: any;
  let mockContentApi: any;

  beforeEach(() => {
    mockGateway = {
      sendWhatsAppQuickReply: jest.fn().mockResolvedValue(true),
      sendWhatsAppCarousel: jest.fn().mockResolvedValue(true),
      sendResponse: jest.fn().mockResolvedValue(true),
      twilio: {
        sendWhatsAppMessage: jest.fn().mockResolvedValue({ success: true }),
        getPlatformConfig: jest.fn().mockReturnValue({ accountSid: 'AC123', authToken: 'token123' }),
      },
    };
    mockContentApi = {
      createListPicker: jest.fn().mockResolvedValue('HXLP123'),
      createCard: jest.fn().mockResolvedValue('HXCARD123'),
      // Note: sendContentMessage is private on TwilioContentApiService — the adapter
      // sends via gateway.twilio.sendWhatsAppMessage({ contentSid }) instead
    };

    adapter = new WhatsAppChannelAdapter(mockGateway, mockContentApi);
  });

  const ctx = { channelId: '+1234567890', channelType: 'whatsapp' };

  it('should NOT support text messages (no special rendering needed)', () => {
    expect(adapter.supports(createTextMessage('hello'))).toBe(false);
  });

  it('should support quick_reply messages', () => {
    const msg = createQuickReplyMessage('Pick one', [{ title: 'Yes' }, { title: 'No' }]);
    expect(adapter.supports(msg)).toBe(true);
  });

  it('should render quick_reply via gateway.sendWhatsAppQuickReply', async () => {
    const msg = createQuickReplyMessage('Pick one', [{ title: 'Yes' }, { title: 'No' }]);
    const result = await adapter.render(msg, ctx);

    expect(result.success).toBe(true);
    expect(result.rendered?.alreadySent).toBe(true);
    expect(mockGateway.sendWhatsAppQuickReply).toHaveBeenCalledWith(
      '+1234567890', 'Pick one', ['Yes', 'No'],
    );
  });

  it('should render list_picker via TwilioContentApiService', async () => {
    const msg = createListPickerMessage(
      'Choose a module',
      'View Options',
      [{ title: 'Modules', items: [{ id: 'mod1', title: 'Players', description: 'Player management' }] }],
    );
    const result = await adapter.render(msg, ctx);

    expect(result.success).toBe(true);
    expect(result.rendered?.alreadySent).toBe(true);
    expect(mockContentApi.createListPicker).toHaveBeenCalledWith(
      expect.objectContaining({ body: 'Choose a module', buttonText: 'View Options' }),
    );
  });

  it('should render cta as a card with URL buttons', async () => {
    const msg = createCTAMessage('Module ready!', [{ title: 'Open in GoGrab', url: 'https://app.gograb.space/modules/123' }]);
    const result = await adapter.render(msg, ctx);

    expect(result.success).toBe(true);
    expect(mockContentApi.createCard).toHaveBeenCalledWith(
      expect.objectContaining({
        title: 'Module ready!',
        buttons: expect.arrayContaining([
          expect.objectContaining({ type: 'url', title: 'Open in GoGrab', url: 'https://app.gograb.space/modules/123' }),
        ]),
      }),
    );
  });

  it('should fall back to text on Content API failure', async () => {
    mockContentApi.createListPicker.mockResolvedValue(null);
    const msg = createListPickerMessage('Choose', 'Options', [{ title: 'S', items: [{ id: '1', title: 'A' }] }]);
    const result = await adapter.render(msg, ctx);

    // Falls back gracefully — sends fallback text
    expect(result.success).toBe(true);
    expect(mockGateway.sendResponse).toHaveBeenCalledWith(
      expect.objectContaining({ text: 'Choose', channelType: 'whatsapp' }),
    );
  });
});

Run: yarn nx test backend-vitto-api --testPathPattern="whatsapp-adapter" 2>&1 | tail -10 Expected: FAIL — cannot find module ../adapters/whatsapp-channel.adapter

// APIs/backend-vitto-api/src/app/engine/adapters/whatsapp-channel.adapter.ts

import { Injectable, Logger, Optional } from '@nestjs/common';
import { ChannelAdapter, ChannelAdapterContext, ChannelAdapterResult } from './channel-adapter.interface';
import {
  RichMessage,
  QuickReplyMessage,
  ListPickerMessage,
  CardMessage,
  CarouselMessage,
  CTAMessage,
} from '../rich-messages/rich-message.types';
import { ChannelGatewayService } from '../../connectivity/channel-gateway.service';
import { TwilioContentApiService } from '@libs-api/messaging-core';

/**
 * S211: Converts RichMessage → WhatsApp-native format via Twilio APIs.
 *
 * - quick_reply → ChannelGateway.sendWhatsAppQuickReply (existing, works)
 * - list_picker → TwilioContentApiService.createListPicker + send via Content SID
 * - card → TwilioContentApiService.createCard + send via Content SID
 * - carousel → ChannelGateway.sendWhatsAppCarousel (existing, works)
 * - cta → Rendered as a card with URL-type buttons
 * - text → Not handled (adapter.supports returns false)
 *
 * All methods send directly and return { alreadySent: true }.
 * On failure, falls back to sending the fallbackText as plain text.
 */
@Injectable()
export class WhatsAppChannelAdapter implements ChannelAdapter {
  private readonly logger = new Logger('WhatsAppAdapter');
  readonly channelType = 'whatsapp';

  constructor(
    private readonly gateway: ChannelGatewayService,
    @Optional() private readonly contentApi?: TwilioContentApiService,
  ) {}

  supports(message: RichMessage): boolean {
    return message.type !== 'text';
  }

  async render(
    message: RichMessage,
    context: ChannelAdapterContext,
  ): Promise<ChannelAdapterResult> {
    try {
      switch (message.type) {
        case 'quick_reply':
          return this.renderQuickReply(message, context);
        case 'list_picker':
          return this.renderListPicker(message, context);
        case 'card':
          return this.renderCard(message, context);
        case 'carousel':
          return this.renderCarousel(message, context);
        case 'cta':
          return this.renderCTA(message, context);
        default:
          // media, location — fall through to text
          return this.sendFallbackText(message, context);
      }
    } catch (err) {
      this.logger.warn(`WhatsApp render failed for ${message.type}: ${(err as Error).message}`);
      return this.sendFallbackText(message, context);
    }
  }

  private async renderQuickReply(
    message: QuickReplyMessage,
    context: ChannelAdapterContext,
  ): Promise<ChannelAdapterResult> {
    const titles = message.buttons.map(b => b.title.substring(0, 20));
    const success = await this.gateway.sendWhatsAppQuickReply(
      context.channelId, message.text, titles,
    );
    if (!success) return this.sendFallbackText(message, context);
    return { success: true, rendered: { alreadySent: true } };
  }

  private async renderListPicker(
    message: ListPickerMessage,
    context: ChannelAdapterContext,
  ): Promise<ChannelAdapterResult> {
    if (!this.contentApi) return this.sendFallbackText(message, context);

    const contentSid = await this.contentApi.createListPicker({
      body: message.body,
      buttonText: message.buttonText,
      sections: message.sections,
    });

    if (!contentSid) return this.sendFallbackText(message, context);
    return this.sendContentSid(contentSid, context);
  }

  private async renderCard(
    message: CardMessage,
    context: ChannelAdapterContext,
  ): Promise<ChannelAdapterResult> {
    if (!this.contentApi) return this.sendFallbackText(message, context);

    const contentSid = await this.contentApi.createCard({
      title: message.title,
      subtitle: message.subtitle,
      mediaUrl: message.mediaUrl,
      buttons: message.buttons.map(b => ({
        type: b.type,
        title: b.title,
        url: b.url,
        phone: b.phone,
        id: b.id,
      })),
    });

    if (!contentSid) return this.sendFallbackText(message, context);
    return this.sendContentSid(contentSid, context);
  }

  private async renderCarousel(
    message: CarouselMessage,
    context: ChannelAdapterContext,
  ): Promise<ChannelAdapterResult> {
    const success = await this.gateway.sendWhatsAppCarousel(
      context.channelId,
      message.body,
      message.cards.map(card => ({
        title: card.title,
        body: card.body,
        mediaUrl: card.mediaUrl,
        buttons: card.buttons.map(b => ({
          type: b.type === 'url' ? 'url' as const : 'quick_reply' as const,
          title: b.title,
          url: b.url,
          id: b.id,
        })),
      })),
    );

    if (!success) return this.sendFallbackText(message, context);
    return { success: true, rendered: { alreadySent: true } };
  }

  private async renderCTA(
    message: CTAMessage,
    context: ChannelAdapterContext,
  ): Promise<ChannelAdapterResult> {
    if (!this.contentApi) return this.sendFallbackText(message, context);

    // Render CTA as a card with URL-type buttons
    const contentSid = await this.contentApi.createCard({
      title: message.text.substring(0, 60),
      buttons: message.buttons.slice(0, 3).map(b => ({
        type: 'url' as const,
        title: b.title,
        url: b.url,
      })),
    });

    if (!contentSid) return this.sendFallbackText(message, context);
    return this.sendContentSid(contentSid, context);
  }

  /** Send a Content SID message via Twilio Messages API */
  private async sendContentSid(
    contentSid: string,
    context: ChannelAdapterContext,
  ): Promise<ChannelAdapterResult> {
    const result = await this.gateway.twilio.sendWhatsAppMessage(
      context.channelId, '', undefined, { contentSid },
    );
    if (!result.success) {
      this.logger.warn(`Content SID send failed: ${result.error}`);
      return { success: false, error: result.error };
    }
    return { success: true, rendered: { alreadySent: true } };
  }

  /** Send plain text fallback when rich rendering fails */
  private async sendFallbackText(
    message: RichMessage,
    context: ChannelAdapterContext,
  ): Promise<ChannelAdapterResult> {
    await this.gateway.sendResponse({
      channelType: 'whatsapp',
      channelId: context.channelId,
      text: message.fallbackText,
      metadata: context.metadata,
    });
    return { success: true, rendered: { alreadySent: true } };
  }
}

Run: yarn nx test backend-vitto-api --testPathPattern="whatsapp-adapter" 2>&1 | tail -15 Expected: PASS — 6 tests passing

git add APIs/backend-vitto-api/src/app/engine/adapters/whatsapp-channel.adapter.ts \
       APIs/backend-vitto-api/src/app/engine/__tests__/whatsapp-adapter.spec.ts
git commit -m "[S211.X] feat: add WhatsAppChannelAdapter with Twilio Content API integration"

Task 5: Build RichMessage Composer Service + Test

Files:

This replaces the heuristic logic from WhatsAppResponseComposerService (which remains for backward compat) with a channel-agnostic version that produces RichMessage objects.

// APIs/backend-vitto-api/src/app/engine/__tests__/rich-message-composer.spec.ts

import { RichMessageComposerService } from '../rich-messages/rich-message-composer.service';

describe('RichMessageComposerService', () => {
  let composer: RichMessageComposerService;

  beforeEach(() => {
    composer = new RichMessageComposerService();
  });

  describe('compose()', () => {
    it('should return TextMessage for short responses', () => {
      const result = composer.compose('OK, done!', []);
      expect(result.type).toBe('text');
    });

    it('should return QuickReplyMessage when response ends with a yes/no question', () => {
      const result = composer.compose(
        'I found the module configuration. Would you like me to update it?',
        [],
      );
      expect(result.type).toBe('quick_reply');
      if (result.type === 'quick_reply') {
        expect(result.buttons.length).toBeGreaterThanOrEqual(2);
        expect(result.buttons.length).toBeLessThanOrEqual(3);
      }
    });

    it('should return CardMessage when task_complete tool was used with long output', () => {
      const longOutput = 'The Players module has been successfully generated with 5 fields: name, age, position, team, and jersey number. The module includes a list view, detail view, and form view with validation. All files have been created and the module is ready for review.';
      const result = composer.compose(longOutput, ['task_complete']);
      expect(result.type).toBe('card');
    });

    it('should return ListPickerMessage when response contains 3+ numbered options', () => {
      const listOutput = `Here are the available modules you can create:

1. **Player Management** — Track team rosters and stats
2. **Match Scheduler** — Schedule and manage games
3. **Training Log** — Record training sessions
4. **Equipment Inventory** — Track team equipment
5. **Tournament Bracket** — Manage tournament brackets

Which one would you like to create?`;
      const result = composer.compose(listOutput, []);
      expect(result.type).toBe('list_picker');
      if (result.type === 'list_picker') {
        expect(result.sections[0].items.length).toBe(5);
      }
    });

    it('should return CTAMessage when task_complete includes a URL-like reference', () => {
      const output = 'Module generated successfully! You can view it at /spaces/abc/modules/players';
      const result = composer.compose(output, ['task_complete'], {
        platformUrl: 'https://app.gograb.space',
      });
      expect(result.type).toBe('cta');
      if (result.type === 'cta') {
        expect(result.buttons[0].url).toContain('https://app.gograb.space');
      }
    });

    it('should always include a fallbackText', () => {
      const cases = [
        { text: 'short', tools: [] as string[] },
        { text: 'Would you like me to proceed with the changes?', tools: [] as string[] },
        { text: 'A long task result ' + 'x'.repeat(200), tools: ['task_complete'] },
      ];
      for (const c of cases) {
        const result = composer.compose(c.text, c.tools);
        expect(result.fallbackText).toBeTruthy();
      }
    });

    it('should return TextMessage as default for ambiguous content', () => {
      const result = composer.compose(
        'The weather in Miami is currently 82°F with partly cloudy skies.',
        [],
      );
      expect(result.type).toBe('text');
    });
  });
});

Run: yarn nx test backend-vitto-api --testPathPattern="rich-message-composer" 2>&1 | tail -10 Expected: FAIL — cannot find module

// APIs/backend-vitto-api/src/app/engine/rich-messages/rich-message-composer.service.ts

import { Injectable, Logger } from '@nestjs/common';
import {
  RichMessage,
  createTextMessage,
  createQuickReplyMessage,
  createListPickerMessage,
  createCardMessage,
  createCTAMessage,
} from './rich-message.types';

export interface ComposerOptions {
  /** Base platform URL for CTA deep links (e.g., 'https://app.gograb.space') */
  platformUrl?: string;
}

/**
 * S211: Analyzes agent loop output and selects the optimal RichMessage type.
 * Heuristic-based only — no LLM call. Must be instant and free.
 *
 * Decision order (first match wins):
 * 1. Short text (<30 chars) → TextMessage
 * 2. task_complete + URL path → CTAMessage with deep link
 * 3. 3+ numbered items + trailing question → ListPickerMessage
 * 4. Ends with yes/no question → QuickReplyMessage
 * 5. task_complete + long output → CardMessage
 * 6. Ends with any question → QuickReplyMessage (generic)
 * 7. Default → TextMessage
 */
@Injectable()
export class RichMessageComposerService {
  private readonly logger = new Logger('RichMessageComposer');

  compose(
    text: string,
    toolCallNames: string[],
    options?: ComposerOptions,
  ): RichMessage {
    const trimmed = text.trim();

    // 1. Very short → plain text
    if (trimmed.length < 30) {
      return createTextMessage(trimmed);
    }

    const hasTaskComplete = toolCallNames.includes('task_complete');

    // 2. Task complete with URL path → CTA deep link
    if (hasTaskComplete && options?.platformUrl) {
      const pathMatch = trimmed.match(/(?:\/spaces\/[^\s]+|\/modules\/[^\s]+|\/admin\/[^\s]+|\/vitto\/[^\s]+)/);
      if (pathMatch) {
        const url = `${options.platformUrl}${pathMatch[0]}`;
        return createCTAMessage(trimmed, [
          { title: 'Open in GoGrab', url },
        ]);
      }
    }

    // 3. Numbered list with 3+ items → ListPicker
    const listItems = this.extractNumberedList(trimmed);
    if (listItems.length >= 3) {
      return createListPickerMessage(
        this.extractPreListText(trimmed),
        'View Options',
        [{
          title: 'Options',
          items: listItems.slice(0, 10).map((item, i) => ({
            id: `opt_${i}`,
            title: item.title.substring(0, 24),
            description: item.description?.substring(0, 72),
          })),
        }],
      );
    }

    // 4. Yes/no question → QuickReply
    if (this.endsWithQuestion(trimmed)) {
      const buttons = this.inferQuestionButtons(trimmed);
      return createQuickReplyMessage(trimmed, buttons);
    }

    // 5. Task complete with long output → Card
    if (hasTaskComplete && trimmed.length > 100) {
      const title = this.extractTitle(trimmed);
      return createCardMessage(title, [
        { type: 'quick_reply', title: 'Thanks!', id: 'ack' },
        { type: 'quick_reply', title: 'Do more', id: 'more' },
      ], { body: trimmed.substring(0, 300) });
    }

    // 6. Default → plain text
    return createTextMessage(trimmed);
  }

  // ── Private helpers ──

  private endsWithQuestion(text: string): boolean {
    return text.slice(-150).trim().endsWith('?');
  }

  private inferQuestionButtons(text: string): Array<{ title: string; id?: string }> {
    const lower = text.toLowerCase();
    if (/\b(would you|do you|should i|want me to|shall i|can i|ready)\b/.test(lower)) {
      return [{ title: 'Yes, please', id: 'yes' }, { title: 'No thanks', id: 'no' }, { title: 'Tell me more', id: 'more' }];
    }
    if (/\b(which|what would you prefer|option)\b/.test(lower)) {
      return [{ title: 'First option', id: 'first' }, { title: 'Second option', id: 'second' }, { title: 'Not sure', id: 'unsure' }];
    }
    return [{ title: 'Yes', id: 'yes' }, { title: 'No', id: 'no' }, { title: 'More details', id: 'more' }];
  }

  /**
   * Extract numbered items from text.
   * Matches patterns like: "1. **Title** — Description" or "1. Title - Description"
   */
  private extractNumberedList(text: string): Array<{ title: string; description?: string }> {
    const lines = text.split('\n');
    const items: Array<{ title: string; description?: string }> = [];

    for (const line of lines) {
      const match = line.match(/^\s*\d+\.\s+\*{0,2}([^*\n—–-]+)\*{0,2}\s*(?:[—–-]\s*(.+))?$/);
      if (match) {
        items.push({
          title: match[1].trim(),
          description: match[2]?.trim(),
        });
      }
    }

    return items;
  }

  /** Get the text before the first numbered item */
  private extractPreListText(text: string): string {
    const firstItemIndex = text.search(/^\s*1\.\s+/m);
    if (firstItemIndex <= 0) return text.substring(0, 100);
    return text.substring(0, firstItemIndex).trim() || text.substring(0, 100);
  }

  private extractTitle(text: string): string {
    const firstSentence = text.match(/^[^.!?]+[.!?]/);
    if (firstSentence && firstSentence[0].length <= 60) {
      return firstSentence[0].trim();
    }
    return text.substring(0, 57) + '...';
  }
}

Run: yarn nx test backend-vitto-api --testPathPattern="rich-message-composer" 2>&1 | tail -15 Expected: PASS — 7 tests passing

git add APIs/backend-vitto-api/src/app/engine/rich-messages/rich-message-composer.service.ts \
       APIs/backend-vitto-api/src/app/engine/__tests__/rich-message-composer.spec.ts
git commit -m "[S211.X] feat: add RichMessageComposerService with heuristic type selection"

Chunk 3: Frontend Angular Rich Message Components

Task 6: Build Rich List Picker Component

Files:

Follow the same pattern as SkillPreviewCardComponent — standalone, OnPush, input() signals, inline template+styles.

// libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-list-picker.component.ts

/**
 * S211: Rich List Picker — renders selectable options grouped in sections.
 * Rendered via generative-ui when Vitto presents a list of choices.
 *
 * ```generative-ui
 * { "type": "generative-ui", "component": "rich-list-picker", "props": {
 *   "body": "Choose a module to create:",
 *   "sections": [{ "title": "Modules", "items": [{ "id": "m1", "title": "Players", "description": "Manage team rosters" }] }]
 * }}
 * ```
 */

import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';

export interface ListPickerItem {
  id: string;
  title: string;
  description?: string;
}

export interface ListPickerSection {
  title: string;
  items: ListPickerItem[];
}

@Component({
  selector: 'vitto-rich-list-picker',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (body()) {
      <p class="rlp__body">{{ body() }}</p>
    }
    @for (section of sections(); track section.title) {
      <div class="rlp__section">
        <span class="rlp__section-title">{{ section.title }}</span>
        @for (item of section.items; track item.id) {
          <button class="rlp__item" (click)="itemSelected.emit(item)">
            <span class="rlp__item-title">{{ item.title }}</span>
            @if (item.description) {
              <span class="rlp__item-desc">{{ item.description }}</span>
            }
          </button>
        }
      </div>
    }
  `,
  styles: [`
    :host {
      display: block;
      margin: 6px 0;
    }
    .rlp__body {
      margin: 0 0 8px;
      font-size: 0.8125rem;
      color: #cbd5e1;
    }
    .rlp__section {
      display: flex;
      flex-direction: column;
      gap: 4px;
      margin-bottom: 8px;
    }
    .rlp__section-title {
      font-size: 0.6875rem;
      font-weight: 600;
      text-transform: uppercase;
      letter-spacing: 0.04em;
      color: #64748b;
      padding: 0 4px;
    }
    .rlp__item {
      display: flex;
      flex-direction: column;
      gap: 2px;
      padding: 8px 12px;
      border-radius: 8px;
      border: 1px solid rgba(99, 102, 241, 0.15);
      background: rgba(99, 102, 241, 0.06);
      cursor: pointer;
      text-align: left;
      transition: background 0.15s;

      &:hover {
        background: rgba(99, 102, 241, 0.12);
        border-color: rgba(99, 102, 241, 0.3);
      }
    }
    .rlp__item-title {
      font-size: 0.8125rem;
      font-weight: 500;
      color: #e2e8f0;
    }
    .rlp__item-desc {
      font-size: 0.75rem;
      color: #94a3b8;
    }
  `],
})
export class RichListPickerComponent {
  readonly body = input<string>('');
  readonly sections = input<ListPickerSection[]>([]);
  readonly itemSelected = output<ListPickerItem>();
}
git add libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-list-picker.component.ts
git commit -m "[S211.X] feat: add RichListPickerComponent for generative-ui catalog"

Task 7: Build Rich Card Component

Files:

// libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-card.component.ts

/**
 * S211: Rich Card — renders a card with title, subtitle, body, optional media, and action buttons.
 *
 * ```generative-ui
 * { "type": "generative-ui", "component": "rich-card", "props": {
 *   "title": "Players Module", "subtitle": "Generated successfully",
 *   "body": "5 fields, 3 views", "buttons": [{ "title": "Open", "url": "/modules/123" }]
 * }}
 * ```
 */

import { Component, ChangeDetectionStrategy, input } from '@angular/core';

export interface CardButton {
  type?: 'quick_reply' | 'url' | 'phone';
  title: string;
  url?: string;
}

@Component({
  selector: 'vitto-rich-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="rc">
      @if (mediaUrl()) {
        <img class="rc__media" [src]="mediaUrl()" [alt]="title()" loading="lazy" />
      }
      <div class="rc__content">
        <h4 class="rc__title">{{ title() }}</h4>
        @if (subtitle()) {
          <span class="rc__subtitle">{{ subtitle() }}</span>
        }
        @if (body()) {
          <p class="rc__body">{{ body() }}</p>
        }
        @if (buttons().length > 0) {
          <div class="rc__actions">
            @for (btn of buttons(); track btn.title) {
              @if (btn.url) {
                <a class="rc__btn rc__btn--url" [href]="btn.url" target="_blank" rel="noopener">{{ btn.title }}</a>
              } @else {
                <button class="rc__btn rc__btn--reply">{{ btn.title }}</button>
              }
            }
          </div>
        }
      </div>
    </div>
  `,
  styles: [`
    :host { display: block; margin: 6px 0; }
    .rc {
      border-radius: 10px;
      border: 1px solid rgba(99, 102, 241, 0.2);
      background: rgba(99, 102, 241, 0.06);
      overflow: hidden;
    }
    .rc__media {
      width: 100%;
      max-height: 180px;
      object-fit: cover;
    }
    .rc__content { padding: 12px 14px; }
    .rc__title {
      margin: 0 0 4px;
      font-size: 0.875rem;
      font-weight: 600;
      color: #e2e8f0;
    }
    .rc__subtitle {
      font-size: 0.75rem;
      color: #a5b4fc;
      display: block;
      margin-bottom: 6px;
    }
    .rc__body {
      margin: 0 0 10px;
      font-size: 0.8125rem;
      color: #94a3b8;
      line-height: 1.4;
    }
    .rc__actions {
      display: flex;
      gap: 6px;
      flex-wrap: wrap;
    }
    .rc__btn {
      padding: 6px 14px;
      border-radius: 6px;
      font-size: 0.75rem;
      font-weight: 500;
      cursor: pointer;
      text-decoration: none;
      transition: background 0.15s;
    }
    .rc__btn--url {
      background: rgba(99, 102, 241, 0.2);
      color: #a5b4fc;
      border: 1px solid rgba(99, 102, 241, 0.3);
      &:hover { background: rgba(99, 102, 241, 0.3); }
    }
    .rc__btn--reply {
      background: rgba(255, 255, 255, 0.06);
      color: #cbd5e1;
      border: 1px solid rgba(255, 255, 255, 0.1);
      &:hover { background: rgba(255, 255, 255, 0.1); }
    }
  `],
})
export class RichCardComponent {
  readonly title = input<string>('');
  readonly subtitle = input<string>('');
  readonly body = input<string>('');
  readonly mediaUrl = input<string>('');
  readonly buttons = input<CardButton[]>([]);
}
git add libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-card.component.ts
git commit -m "[S211.X] feat: add RichCardComponent for generative-ui catalog"

Task 8: Build Rich Carousel Component

Files:

// libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-carousel.component.ts

/**
 * S211: Rich Carousel — horizontally scrollable card strip.
 *
 * ```generative-ui
 * { "type": "generative-ui", "component": "rich-carousel", "props": {
 *   "body": "Here are the available templates:",
 *   "cards": [{ "title": "Blank", "body": "Start fresh" }, { "title": "CRM", "body": "Customer management" }]
 * }}
 * ```
 */

import { Component, ChangeDetectionStrategy, input } from '@angular/core';

export interface CarouselCard {
  title: string;
  body?: string;
  mediaUrl?: string;
  buttons?: Array<{ title: string; url?: string }>;
}

@Component({
  selector: 'vitto-rich-carousel',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (body()) {
      <p class="rcr__body">{{ body() }}</p>
    }
    <div class="rcr__strip">
      @for (card of cards(); track card.title) {
        <div class="rcr__card">
          @if (card.mediaUrl) {
            <img class="rcr__media" [src]="card.mediaUrl" [alt]="card.title" loading="lazy" />
          }
          <h5 class="rcr__title">{{ card.title }}</h5>
          @if (card.body) {
            <p class="rcr__desc">{{ card.body }}</p>
          }
          @if (card.buttons?.length) {
            <div class="rcr__actions">
              @for (btn of card.buttons; track btn.title) {
                @if (btn.url) {
                  <a class="rcr__btn" [href]="btn.url" target="_blank" rel="noopener">{{ btn.title }}</a>
                } @else {
                  <button class="rcr__btn">{{ btn.title }}</button>
                }
              }
            </div>
          }
        </div>
      }
    </div>
  `,
  styles: [`
    :host { display: block; margin: 6px 0; }
    .rcr__body {
      margin: 0 0 8px;
      font-size: 0.8125rem;
      color: #cbd5e1;
    }
    .rcr__strip {
      display: flex;
      gap: 8px;
      overflow-x: auto;
      padding-bottom: 4px;
      scroll-snap-type: x mandatory;
      -webkit-overflow-scrolling: touch;
      scrollbar-width: thin;
    }
    .rcr__card {
      flex: 0 0 200px;
      scroll-snap-align: start;
      border-radius: 10px;
      border: 1px solid rgba(99, 102, 241, 0.2);
      background: rgba(99, 102, 241, 0.06);
      padding: 10px;
      overflow: hidden;
    }
    .rcr__media {
      width: 100%;
      height: 100px;
      object-fit: cover;
      border-radius: 6px;
      margin-bottom: 8px;
    }
    .rcr__title {
      margin: 0 0 4px;
      font-size: 0.8125rem;
      font-weight: 600;
      color: #e2e8f0;
    }
    .rcr__desc {
      margin: 0 0 8px;
      font-size: 0.75rem;
      color: #94a3b8;
      line-height: 1.3;
    }
    .rcr__actions { display: flex; gap: 4px; flex-wrap: wrap; }
    .rcr__btn {
      padding: 4px 10px;
      border-radius: 5px;
      font-size: 0.6875rem;
      font-weight: 500;
      background: rgba(99, 102, 241, 0.15);
      color: #a5b4fc;
      border: 1px solid rgba(99, 102, 241, 0.2);
      cursor: pointer;
      text-decoration: none;
      &:hover { background: rgba(99, 102, 241, 0.25); }
    }
  `],
})
export class RichCarouselComponent {
  readonly body = input<string>('');
  readonly cards = input<CarouselCard[]>([]);
}
git add libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-carousel.component.ts
git commit -m "[S211.X] feat: add RichCarouselComponent for generative-ui catalog"

Task 9: Build Rich CTA Button + Quick Reply Components

Files:

// libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-cta-button.component.ts

/**
 * S211: CTA Button — deep link "Open in GoGrab" button.
 *
 * ```generative-ui
 * { "type": "generative-ui", "component": "rich-cta-button", "props": {
 *   "text": "Module generated!", "buttons": [{ "title": "Open in GoGrab", "url": "/modules/123" }]
 * }}
 * ```
 */

import { Component, ChangeDetectionStrategy, input } from '@angular/core';

@Component({
  selector: 'vitto-rich-cta-button',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (text()) {
      <p class="cta__text">{{ text() }}</p>
    }
    <div class="cta__actions">
      @for (btn of buttons(); track btn.title) {
        <a class="cta__btn" [href]="btn.url" target="_blank" rel="noopener">
          <span class="material-icons cta__icon">open_in_new</span>
          {{ btn.title }}
        </a>
      }
    </div>
  `,
  styles: [`
    :host { display: block; margin: 6px 0; }
    .cta__text {
      margin: 0 0 8px;
      font-size: 0.8125rem;
      color: #cbd5e1;
    }
    .cta__actions { display: flex; gap: 8px; flex-wrap: wrap; }
    .cta__btn {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      padding: 8px 16px;
      border-radius: 8px;
      font-size: 0.8125rem;
      font-weight: 600;
      background: rgba(99, 102, 241, 0.2);
      color: #a5b4fc;
      border: 1px solid rgba(99, 102, 241, 0.3);
      text-decoration: none;
      cursor: pointer;
      transition: all 0.15s;
      &:hover {
        background: rgba(99, 102, 241, 0.3);
        transform: translateY(-1px);
      }
    }
    .cta__icon { font-size: 16px; }
  `],
})
export class RichCTAButtonComponent {
  readonly text = input<string>('');
  readonly buttons = input<Array<{ title: string; url: string }>>([]);
}
// libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-quick-reply.component.ts

/**
 * S211: Quick Reply Chips — tappable reply suggestions rendered inline.
 *
 * ```generative-ui
 * { "type": "generative-ui", "component": "rich-quick-reply", "props": {
 *   "buttons": [{ "title": "Yes, please" }, { "title": "No thanks" }, { "title": "Tell me more" }]
 * }}
 * ```
 */

import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';

@Component({
  selector: 'vitto-rich-quick-reply',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (text()) {
      <p class="qr__text">{{ text() }}</p>
    }
    <div class="qr__row">
      @for (btn of buttons(); track btn.title) {
        <button class="qr__chip" (click)="selected.emit(btn.title)">{{ btn.title }}</button>
      }
    </div>
  `,
  styles: [`
    :host { display: block; margin: 6px 0; }
    .qr__text {
      margin: 0 0 6px;
      font-size: 0.8125rem;
      color: #cbd5e1;
    }
    .qr__row { display: flex; gap: 6px; flex-wrap: wrap; }
    .qr__chip {
      padding: 6px 14px;
      border-radius: 16px;
      font-size: 0.8125rem;
      font-weight: 500;
      background: rgba(99, 102, 241, 0.1);
      color: #a5b4fc;
      border: 1px solid rgba(99, 102, 241, 0.25);
      cursor: pointer;
      transition: all 0.15s;
      &:hover {
        background: rgba(99, 102, 241, 0.2);
        border-color: rgba(99, 102, 241, 0.4);
      }
    }
  `],
})
export class RichQuickReplyComponent {
  readonly text = input<string>('');
  readonly buttons = input<Array<{ title: string; id?: string }>>([]);
  readonly selected = output<string>();
}
git add libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-cta-button.component.ts \
       libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-quick-reply.component.ts
git commit -m "[S211.X] feat: add RichCTAButtonComponent and RichQuickReplyComponent"

Task 10: Register All Rich Components in Catalog Seed

Files:

Add imports and catalog.register() calls for each new component. The existing SkillPreviewCardComponent and AlertBadgeComponent registrations stay untouched.

// Add these imports at the top (after existing imports):
import { RichListPickerComponent } from './components/rich-list-picker.component';
import { RichCardComponent } from './components/rich-card.component';
import { RichCarouselComponent } from './components/rich-carousel.component';
import { RichCTAButtonComponent } from './components/rich-cta-button.component';
import { RichQuickReplyComponent } from './components/rich-quick-reply.component';

// Add these registrations inside seedGenerativeUICatalog() after existing ones:

  // S211: Rich messaging components
  catalog.register({
    id: 'rich-list-picker',
    label: 'List Picker',
    description: 'Renders a list of selectable options grouped in sections. Use when presenting 3+ choices.',
    component: RichListPickerComponent,
    expectedProps: ['sections'],
    category: 'general',
  });

  catalog.register({
    id: 'rich-card',
    label: 'Rich Card',
    description: 'Renders a card with title, subtitle, body, optional media, and action buttons. Use for task results, summaries, or structured information.',
    component: RichCardComponent,
    expectedProps: ['title'],
    category: 'general',
  });

  catalog.register({
    id: 'rich-carousel',
    label: 'Carousel',
    description: 'Renders a horizontal strip of scrollable cards. Use when presenting multiple comparable items (templates, modules, results).',
    component: RichCarouselComponent,
    expectedProps: ['cards'],
    category: 'general',
  });

  catalog.register({
    id: 'rich-cta-button',
    label: 'CTA Button',
    description: 'Renders prominent call-to-action buttons with URLs. Use for "Open in GoGrab" deep links after task completion.',
    component: RichCTAButtonComponent,
    expectedProps: ['buttons'],
    category: 'general',
  });

  catalog.register({
    id: 'rich-quick-reply',
    label: 'Quick Reply',
    description: 'Renders a row of tappable quick reply chips with optional prompt text. Use when offering 2-3 follow-up options.',
    component: RichQuickReplyComponent,
    expectedProps: ['buttons'],  // text is optional — renders above chips if provided
    category: 'general',
  });

Run: npx tsc --noEmit --project libs/libs-ai/vitto-chat/tsconfig.lib.json 2>&1 | head -20 Expected: No errors from the new components or catalog-seed.ts

git add libs/libs-ai/vitto-chat/src/lib/generative-ui/catalog-seed.ts
git commit -m "[S211.X] feat: register 5 rich message components in GenerativeUI catalog"

Chunk 4: Web Chat Adapter + ChannelProcessing Wiring + Engine Registration

Task 11: Build Web Chat Channel Adapter + Test

Files:

The web adapter converts RichMessage → generative-ui JSON blocks that MessageBodyComponent on the frontend already knows how to parse and render.

// APIs/backend-vitto-api/src/app/engine/__tests__/web-chat-adapter.spec.ts

import { WebChatChannelAdapter } from '../adapters/web-chat-channel.adapter';
import {
  createQuickReplyMessage,
  createListPickerMessage,
  createCardMessage,
  createCTAMessage,
  createTextMessage,
  createCarouselMessage,
} from '../rich-messages/rich-message.types';

describe('WebChatChannelAdapter', () => {
  let adapter: WebChatChannelAdapter;

  beforeEach(() => {
    adapter = new WebChatChannelAdapter();
  });

  const ctx = { channelId: 'web-session-123', channelType: 'web' };

  it('should NOT support text messages', () => {
    expect(adapter.supports(createTextMessage('hello'))).toBe(false);
  });

  it('should support quick_reply messages', () => {
    const msg = createQuickReplyMessage('Pick one', [{ title: 'Yes' }, { title: 'No' }]);
    expect(adapter.supports(msg)).toBe(true);
  });

  it('should render quick_reply as generative-ui block with component "rich-quick-reply"', async () => {
    const msg = createQuickReplyMessage('Pick one?', [
      { title: 'Yes', id: 'yes' },
      { title: 'No', id: 'no' },
    ]);
    const result = await adapter.render(msg, ctx);

    expect(result.success).toBe(true);
    expect(result.rendered?.text).toContain('```generative-ui');
    expect(result.rendered?.text).toContain('"component": "rich-quick-reply"');
    expect(result.rendered?.text).toContain('Pick one?');
    expect(result.rendered?.alreadySent).toBeUndefined();
  });

  it('should render list_picker as generative-ui block with component "rich-list-picker"', async () => {
    const msg = createListPickerMessage(
      'Choose a module',
      'View',
      [{ title: 'Options', items: [{ id: 'm1', title: 'Players' }] }],
    );
    const result = await adapter.render(msg, ctx);

    expect(result.success).toBe(true);
    const text = result.rendered?.text || '';
    expect(text).toContain('"component": "rich-list-picker"');
    expect(text).toContain('Choose a module');
    expect(text).toContain('Players');
  });

  it('should render card as generative-ui block with component "rich-card"', async () => {
    const msg = createCardMessage('Players Module', [{ type: 'url', title: 'Open', url: '/modules/123' }], {
      subtitle: 'Generated',
      body: '5 fields',
    });
    const result = await adapter.render(msg, ctx);

    expect(result.success).toBe(true);
    const text = result.rendered?.text || '';
    expect(text).toContain('"component": "rich-card"');
    expect(text).toContain('Players Module');
  });

  it('should render cta as generative-ui block with component "rich-cta-button"', async () => {
    const msg = createCTAMessage('Module ready!', [{ title: 'Open', url: 'https://app.gograb.space/mod/123' }]);
    const result = await adapter.render(msg, ctx);

    expect(result.success).toBe(true);
    const text = result.rendered?.text || '';
    expect(text).toContain('"component": "rich-cta-button"');
    expect(text).toContain('https://app.gograb.space/mod/123');
  });

  it('should render carousel as generative-ui block', async () => {
    const msg = createCarouselMessage('Templates:', [
      { title: 'Blank', buttons: [] },
      { title: 'CRM', buttons: [] },
    ]);
    const result = await adapter.render(msg, ctx);

    expect(result.success).toBe(true);
    const text = result.rendered?.text || '';
    expect(text).toContain('"component": "rich-carousel"');
    expect(text).toContain('Blank');
    expect(text).toContain('CRM');
  });

  it('should prepend generative-ui block BEFORE the fallback text', async () => {
    const msg = createQuickReplyMessage('What next?', [{ title: 'Yes' }]);
    const result = await adapter.render(msg, ctx);

    const text = result.rendered?.text || '';
    // Split on the closing fence — everything before is the GUI block, after is fallback
    const closingFenceIndex = text.indexOf('```\n\n');
    expect(closingFenceIndex).toBeGreaterThan(0);
    const afterBlock = text.substring(closingFenceIndex + 5);
    // The fallback text should appear after the generative-ui block
    expect(afterBlock).toContain('What next?');
    // And the block itself should start with the opening fence
    expect(text.startsWith('```generative-ui')).toBe(true);
  });
});

Run: yarn nx test backend-vitto-api --testPathPattern="web-chat-adapter" 2>&1 | tail -10 Expected: FAIL — cannot find module

// APIs/backend-vitto-api/src/app/engine/adapters/web-chat-channel.adapter.ts

import { Injectable, Logger } from '@nestjs/common';
import { ChannelAdapter, ChannelAdapterContext, ChannelAdapterResult } from './channel-adapter.interface';
import {
  RichMessage,
  QuickReplyMessage,
  ListPickerMessage,
  CardMessage,
  CarouselMessage,
  CTAMessage,
} from '../rich-messages/rich-message.types';

/**
 * S211: Converts RichMessage → generative-ui JSON blocks for web chat.
 *
 * The frontend MessageBodyComponent already parses ```generative-ui blocks
 * and renders them via GenerativeUIComponentCatalog + VittoRenderComponent.
 * This adapter just serializes the RichMessage props into that format.
 *
 * Unlike WhatsApp adapter, this does NOT send anything — it returns rendered.text
 * which ChannelProcessingService prepends to the response content.
 */
@Injectable()
export class WebChatChannelAdapter implements ChannelAdapter {
  private readonly logger = new Logger('WebChatAdapter');
  readonly channelType = 'web';

  supports(message: RichMessage): boolean {
    return message.type !== 'text';
  }

  async render(
    message: RichMessage,
    context: ChannelAdapterContext,
  ): Promise<ChannelAdapterResult> {
    try {
      const componentId = this.resolveComponentId(message);
      if (!componentId) {
        return { success: true, rendered: { text: message.fallbackText } };
      }

      const props = this.buildProps(message);
      const guiBlock = this.formatGenerativeUIBlock(componentId, props);

      // Prepend generative-ui block before the fallback text
      const fullText = `${guiBlock}\n\n${message.fallbackText}`;

      return { success: true, rendered: { text: fullText } };
    } catch (err) {
      this.logger.warn(`Web render failed for ${message.type}: ${(err as Error).message}`);
      return { success: true, rendered: { text: message.fallbackText } };
    }
  }

  private resolveComponentId(message: RichMessage): string | null {
    switch (message.type) {
      case 'quick_reply': return 'rich-quick-reply';
      case 'list_picker': return 'rich-list-picker';
      case 'card': return 'rich-card';
      case 'carousel': return 'rich-carousel';
      case 'cta': return 'rich-cta-button';
      default: return null;
    }
  }

  private buildProps(message: RichMessage): Record<string, unknown> {
    switch (message.type) {
      case 'quick_reply': {
        const m = message as QuickReplyMessage;
        return { text: m.text, buttons: m.buttons };
      }
      case 'list_picker': {
        const m = message as ListPickerMessage;
        return { body: m.body, sections: m.sections };
      }
      case 'card': {
        const m = message as CardMessage;
        return {
          title: m.title,
          subtitle: m.subtitle,
          body: m.body,
          mediaUrl: m.mediaUrl,
          buttons: m.buttons,
        };
      }
      case 'carousel': {
        const m = message as CarouselMessage;
        return { body: m.body, cards: m.cards };
      }
      case 'cta': {
        const m = message as CTAMessage;
        return { text: m.text, buttons: m.buttons };
      }
      default:
        return {};
    }
  }

  private formatGenerativeUIBlock(
    componentId: string,
    props: Record<string, unknown>,
  ): string {
    const instruction = {
      type: 'generative-ui',
      component: componentId,
      props,
    };
    return '```generative-ui\n' + JSON.stringify(instruction, null, 2) + '\n```';
  }
}

Run: yarn nx test backend-vitto-api --testPathPattern="web-chat-adapter" 2>&1 | tail -15 Expected: PASS — 7 tests passing

git add APIs/backend-vitto-api/src/app/engine/adapters/web-chat-channel.adapter.ts \
       APIs/backend-vitto-api/src/app/engine/__tests__/web-chat-adapter.spec.ts
git commit -m "[S211.X] feat: add WebChatChannelAdapter with generative-ui serialization"

Task 12: Register Services in Engine Module

Files:

Read APIs/backend-vitto-api/src/app/engine/engine.module.ts — find the providers: [...] array.

Add at the top:

import { AdapterRegistryService } from './adapters/adapter-registry.service';
import { WhatsAppChannelAdapter } from './adapters/whatsapp-channel.adapter';
import { WebChatChannelAdapter } from './adapters/web-chat-channel.adapter';
import { RichMessageComposerService } from './rich-messages/rich-message-composer.service';

Add to the providers array:

AdapterRegistryService,
WhatsAppChannelAdapter,
WebChatChannelAdapter,
RichMessageComposerService,

Run: npx tsc --noEmit --project APIs/backend-vitto-api/tsconfig.lib.json 2>&1 | head -20 Expected: No new errors

git add APIs/backend-vitto-api/src/app/engine/engine.module.ts
git commit -m "[S211.X] feat: register rich messaging services in EngineModule"

Task 13: Wire Rich Messaging into ChannelProcessingService

Files:

This is the critical integration point. After the agent loop produces text output, the composer analyzes it, and the adapter renders the rich message for the specific channel.

Integration point: Inside executeUnifiedAgentLoop(), after line ~291 (await this.routeTaskResponse(task, sseSubject)) — but BEFORE routing, we intercept the output to compose a RichMessage and render it.

Read lines around the existing quick reply button logic (lines 305-316) and response routing (line 291).

Add to constructor parameters (with @Optional() for backward-compatible DI):

@Optional() private readonly richComposer?: RichMessageComposerService,
@Optional() private readonly adapterRegistry?: AdapterRegistryService,

Add imports at the top:

import { RichMessageComposerService } from './rich-messages/rich-message-composer.service';
import { AdapterRegistryService } from './adapters/adapter-registry.service';

Adapters register themselves via AdapterRegistryService.onModuleInit() using a CHANNEL_ADAPTERS injection token.

NOTE: This requires RE-OPENING adapter-registry.service.ts (already committed in Task 3). Add a constructor and OnModuleInit implementation:

// In adapter-registry.service.ts, add these imports:
import { Inject, OnModuleInit } from '@nestjs/common';
import { ChannelAdapter } from './channel-adapter.interface';

// Change the class declaration to implement OnModuleInit:
export class AdapterRegistryService implements OnModuleInit {
  // ...existing fields...

  constructor(
    @Optional() @Inject('CHANNEL_ADAPTERS') private readonly injectedAdapters?: ChannelAdapter[],
  ) {}

  onModuleInit() {
    if (this.injectedAdapters) {
      for (const adapter of this.injectedAdapters) {
        this.register(adapter);
      }
    }
  }

  // ...existing methods stay unchanged...
}

And in engine.module.ts add a factory provider:

{
  provide: 'CHANNEL_ADAPTERS',
  useFactory: (wa: WhatsAppChannelAdapter, web: WebChatChannelAdapter) => [wa, web],
  inject: [WhatsAppChannelAdapter, WebChatChannelAdapter],
},

After the agent loop result is captured (line ~281, task.cost.iterations = result.iterations;) and BEFORE await this.routeTaskResponse(task, sseSubject), add:

// S211: Compose rich message and render via channel adapter
const richOutput = await this.composeAndRenderRichMessage(
  result.output, result.toolCalls.map((t: { toolName: string }) => t.toolName),
  channel, ctx,
);
if (richOutput) {
  // Replace the raw output with adapter-rendered output
  // For web: generative-ui blocks prepended. For WhatsApp: adapter already sent.
  if (richOutput.rendered?.text) {
    result.output = richOutput.rendered.text;
  }
  if (richOutput.rendered?.alreadySent) {
    // Skip the main text send in routeTaskResponse — adapter already sent the rich version
    // But still update the phase output for SSE/persistence
    task.phases[task.phases.length - 1].output = result.output;
  }
}

Add the helper method:

/**
 * S211: Compose a RichMessage from agent output and render via channel adapter.
 * Returns null if no rich rendering is needed (plain text or no adapter).
 */
private async composeAndRenderRichMessage(
  output: string,
  toolCallNames: string[],
  channel: string,
  ctx: Record<string, unknown>,
): Promise<{ rendered?: { text?: string; alreadySent?: boolean } } | null> {
  if (!this.richComposer || !this.adapterRegistry) return null;

  const adapter = this.adapterRegistry.getAdapter(channel);
  if (!adapter) return null;

  const richMessage = this.richComposer.compose(output, toolCallNames, {
    platformUrl: this.config.get<string>('PLATFORM_URL', 'https://app.gograb.space'),
  });

  // text messages don't need adapter rendering
  if (!adapter.supports(richMessage)) return null;

  try {
    return await adapter.render(richMessage, {
      channelId: ctx.channelId as string,
      channelType: channel,
      userId: ctx.userId as string,
      spaceId: ctx.spaceId as string,
      metadata: ctx.metadata as Record<string, unknown>,
    });
  } catch (err) {
    this.logger.warn(`Rich message render failed: ${(err as Error).message}`);
    return null;
  }
}

The existing quick reply code at lines 306-316 sends WhatsApp quick replies for ALL responses. With the new system, if the RichMessageComposerService already produced a quick_reply type and the WhatsApp adapter already sent it, we should skip the old path. Add a guard:

// Quick reply buttons — skip if rich adapter already sent a rich message
if (channel === 'whatsapp' && ctx.channelId && result.output && !richOutput?.rendered?.alreadySent) {
  // ... existing quick reply logic unchanged ...
}

Create: APIs/backend-vitto-api/src/app/engine/__tests__/rich-message-integration.spec.ts

// APIs/backend-vitto-api/src/app/engine/__tests__/rich-message-integration.spec.ts

import { RichMessageComposerService } from '../rich-messages/rich-message-composer.service';
import { AdapterRegistryService } from '../adapters/adapter-registry.service';
import { WebChatChannelAdapter } from '../adapters/web-chat-channel.adapter';

/**
 * S211: Integration test — verifies the full compose → adapt flow.
 * Uses the real ComposerService and WebChatAdapter (no mocks).
 */
describe('Rich Message Integration: Compose → Adapt', () => {
  let composer: RichMessageComposerService;
  let registry: AdapterRegistryService;

  beforeEach(() => {
    composer = new RichMessageComposerService();
    registry = new AdapterRegistryService();
    registry.register(new WebChatChannelAdapter());
  });

  it('should compose a list picker from numbered items and render as generative-ui', async () => {
    const output = `Here are your options:\n\n1. Players — Manage rosters\n2. Matches — Schedule games\n3. Training — Log sessions`;
    const richMessage = composer.compose(output, []);
    expect(richMessage.type).toBe('list_picker');

    const adapter = registry.getAdapter('web');
    expect(adapter).toBeDefined();
    expect(adapter!.supports(richMessage)).toBe(true);

    const result = await adapter!.render(richMessage, { channelId: 'test', channelType: 'web' });
    expect(result.success).toBe(true);
    expect(result.rendered?.text).toContain('"component": "rich-list-picker"');
    expect(result.rendered?.text).toContain('Players');
  });

  it('should compose a quick reply from a question and render as generative-ui', async () => {
    const output = 'I found the configuration file. Would you like me to update it?';
    const richMessage = composer.compose(output, []);
    expect(richMessage.type).toBe('quick_reply');

    const adapter = registry.getAdapter('web');
    const result = await adapter!.render(richMessage, { channelId: 'test', channelType: 'web' });
    expect(result.success).toBe(true);
    expect(result.rendered?.text).toContain('"component": "rich-quick-reply"');
  });

  it('should pass through plain text when no adapter exists for channel', async () => {
    const output = 'Hello there!'; // Short — will be TextMessage
    const richMessage = composer.compose(output, []);
    expect(richMessage.type).toBe('text');

    const adapter = registry.getAdapter('web');
    // text type is NOT supported by web adapter
    expect(adapter!.supports(richMessage)).toBe(false);
  });

  it('should return null for channels with no registered adapter', () => {
    const adapter = registry.getAdapter('slack');
    expect(adapter).toBeUndefined();
  });
});

Run: yarn nx test backend-vitto-api --testPathPattern="rich-message-integration" 2>&1 | tail -15 Expected: PASS — 4 tests passing

Run: yarn nx test backend-vitto-api --testPathPattern="channel-processing|rich-message|adapter" 2>&1 | tail -20 Expected: All tests pass

git add APIs/backend-vitto-api/src/app/engine/channel-processing.service.ts \
       APIs/backend-vitto-api/src/app/engine/adapters/adapter-registry.service.ts \
       APIs/backend-vitto-api/src/app/engine/engine.module.ts \
       APIs/backend-vitto-api/src/app/engine/__tests__/rich-message-integration.spec.ts
git commit -m "[S211.X] feat: wire rich message composition into ChannelProcessingService"

Task 14: End-to-End Verification

This is not a code task — it's a manual verification checklist.

Run: yarn nx build backend-vitto-api 2>&1 | tail -10 Expected: Build succeeds

Run: yarn nx test backend-vitto-api --testPathPattern="rich-message|adapter" 2>&1 | tail -20 Expected: All tests pass (adapter-registry, whatsapp-adapter, web-chat-adapter, rich-message-composer)

Run: npx tsc --noEmit --project libs/libs-ai/vitto-chat/tsconfig.lib.json 2>&1 | head -20 Expected: No errors from new components

log_decision({
  type: 'architecture',
  question: 'How should rich messages be rendered across WhatsApp and Web channels?',
  chosen: 'Discriminated union RichMessage types → RichMessageComposerService (heuristic analysis) → AdapterRegistryService → per-channel ChannelAdapter. WhatsApp adapter calls Twilio Content API. Web adapter serializes to generative-ui JSON blocks parsed by existing MessageBodyComponent.',
  reasoning: 'Leverages existing TwilioContentApiService (already supports list pickers/cards/carousels) and existing GenerativeUIComponentCatalog (already renders dynamic components from JSON). New code is minimal — just the glue layer between agent output and channel-specific rendering.',
  confidence: 0.92,
})

Verify nothing is unstaged:

git status

If any files were missed, stage and commit them with the appropriate message.


Chunk 5: Fix Escalation Alert Spam (URGENT Production Bug)

The escalation cron in ProactiveMonitorService.escalateUnresolvedCriticalAlerts() sends one WhatsApp message per unresolved alert document instead of grouping by root cause. It also re-escalates every 30 minutes with no dedup, and bypasses the dispatcher safety guards. Result: users get flooded with identical plain text walls.

Task 15: Fix Escalation Grouping and Dedup

Files:

Three bugs to fix:

  1. No grouping: sends 1 message per alert doc. Fix: group by checkName, send 1 consolidated message per root cause
  2. No escalation dedup: re-sends every 30 min. Fix: add escalationCache Map tracking checkName last escalation time, skip if escalated within 6h
  3. Bypasses dispatcher: calls channelGateway.sendResponse() directly. Fix: route through ProactiveDispatcherService.dispatch()

Create APIs/backend-vitto-api/src/app/proactive/__tests__/escalation-grouping.spec.ts that:

Run: yarn nx test backend-vitto-api --testPathPattern="escalation-grouping" Expected: FAIL (current code sends 3 messages, calls gateway directly)

Replace lines 362-422 with:

  1. Add private readonly escalationCache = new Map<string, number>() field
  2. Group unresolvedCritical results by checkName using a Map<string, alert[]>
  3. For each group: check escalationCache skip if escalated within 6h
  4. Build ONE consolidated message with count, oldest alert details, suggested action
  5. Route through this.dispatcher.dispatch() with triggerName: 'escalation', severity: 'critical', bypassGuards: false
  6. Set escalationCache.set(checkName, Date.now()) after dispatching

Key code for the grouping loop:

const grouped = new Map<string, typeof unresolvedCritical>();
for (const alert of unresolvedCritical) {
  const key = alert.checkName;
  if (!grouped.has(key)) grouped.set(key, []);
  grouped.get(key)!.push(alert);
}

for (const [checkName, alerts] of grouped) {
  const lastEscalated = this.escalationCache.get(checkName);
  if (lastEscalated && Date.now() - lastEscalated < ESCALATION_THRESHOLD_MS) {
    continue; // Already escalated recently
  }

  const count = alerts.length;
  const oldest = alerts.reduce((a, b) =>
    ((a as any).createdAt < (b as any).createdAt ? a : b));

  const title = count === 1
    ? `ESCALATION: Unresolved Critical Alert \u2014 ${checkName}`
    : `ESCALATION: ${count} Unresolved Critical Alerts \u2014 ${checkName}`;

  // Route through dispatcher (dedup + frequency cap + quiet hours)
  if (this.dispatcher) {
    const prefs = await this.notifPrefsModel.find({ enabled: true }).lean().exec();
    for (const pref of prefs) {
      await this.dispatcher.dispatch({
        userId: pref.userId,
        triggerName: 'escalation',
        severity: 'critical',
        title,
        message: /* consolidated details */,
        metadata: { checkName, alertCount: count, escalation: true },
        bypassGuards: false,
      }).catch(() => {});
    }
  }

  this.escalationCache.set(checkName, Date.now());
}

Run: yarn nx test backend-vitto-api --testPathPattern="escalation-grouping" Expected: PASS - 3 tests

Run: yarn nx test backend-vitto-api --testPathPattern="proactive" Expected: All pass

git add APIs/backend-vitto-api/src/app/proactive/proactive-monitor.service.ts \
       APIs/backend-vitto-api/src/app/proactive/__tests__/escalation-grouping.spec.ts
git commit -m "[S211.X] fix: group escalation alerts by root cause, add dedup, route through dispatcher"

Task 16: Format Dispatcher Alerts as Rich CardMessage

Files:

Upgrade the dispatcher to render alerts as CardMessage with action buttons when a channel adapter is available.

import { AdapterRegistryService } from '../engine/adapters/adapter-registry.service';
import { createCardMessage, RichMessage } from '../engine/rich-messages/rich-message.types';

// Constructor: add @Optional() private readonly adapterRegistry?: AdapterRegistryService
private composeRichAlert(severity: string, title: string, message: string): RichMessage {
  const emoji = severity === 'critical' ? '\u{1F6A8}' : severity === 'warning' ? '\u{26A0}\u{FE0F}' : '\u{2139}\u{FE0F}';
  return createCardMessage(
    `${emoji} ${title}`,
    [
      { type: 'quick_reply', title: 'Acknowledge', id: 'ack_alert' },
      { type: 'quick_reply', title: 'Mute 1h', id: 'mute_1h' },
    ],
    { body: message.substring(0, 300), subtitle: severity.toUpperCase() },
  );
}

In the channel delivery loop, try adapter first, fall back to plain text:

let sent = false;
if (this.adapterRegistry) {
  const adapter = this.adapterRegistry.getAdapter(channel.type);
  if (adapter) {
    const richMsg = this.composeRichAlert(severity, title, message);
    if (adapter.supports(richMsg)) {
      const result = await adapter.render(richMsg, {
        channelId: channel.id, channelType: channel.type, userId,
      });
      sent = result.success && !!result.rendered?.alreadySent;
    }
  }
}
if (!sent) {
  await this.channelGateway.sendResponse({ /* existing plain text path */ });
}
git add APIs/backend-vitto-api/src/app/proactive/proactive-dispatcher.service.ts
git commit -m "[S211.X] feat: format proactive alerts as rich CardMessage with action buttons"

Summary — What This Plan Produces

Deliverable Files Tests
RichMessage type system (8 types + factory helpers) 1 file Type-safe, tested via composer tests
ChannelAdapter interface 1 file Interface only
AdapterRegistryService 1 file 4 tests
WhatsAppChannelAdapter 1 file 6 tests
WebChatChannelAdapter 1 file 7 tests
RichMessageComposerService 1 file 7 tests
5 Angular generative-ui components 5 files Compile-verified, catalog-registered
Catalog seed registration 1 modified file —
Integration test (compose → adapt flow) 1 file 4 tests
ChannelProcessingService wiring 1 modified file Existing tests unbroken
Engine module registration 1 modified file —
FIX: Escalation alert grouping + dedup 1 modified + 1 test file 3 tests
FIX: Dispatcher rich card formatting 1 modified file —
Total 15 new + 5 modified 31 new tests

What This Plan Does NOT Cover (Deferred to Later Sub-Sessions)