Files
Claude-Code-Workflow/ccw/docs/a2ui-integration.md
catlog22 345437415f Add end-to-end tests for workspace switching and backend tests for ask_question tool
- Implemented E2E tests for workspace switching functionality, covering scenarios such as switching workspaces, data isolation, language preference maintenance, and UI updates.
- Added tests to ensure workspace data is cleared on logout and handles unsaved changes during workspace switches.
- Created comprehensive backend tests for the ask_question tool, validating question creation, execution, answer handling, cancellation, and timeout scenarios.
- Included edge case tests to ensure robustness against duplicate questions and invalid answers.
2026-01-31 16:02:20 +08:00

17 KiB

A2UI Integration Guide for CCW Developers

This guide explains how to integrate and extend the A2UI (AI-to-UI) system in the CCW application.

Table of Contents

  1. Architecture Overview
  2. WebSocket Integration
  3. Notification System
  4. Creating Custom Components
  5. Backend Tool Integration
  6. Testing
  7. Troubleshooting

Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                         CCW Frontend                         │
├─────────────────────────────────────────────────────────────┤
│                                                               │
│  ┌────────────────┐      ┌─────────────────┐              │
│  │ WebSocket      │─────▶│ A2UI Parser     │              │
│  │ Handler        │      │ (validation)    │              │
│  └────────────────┘      └────────┬────────┘              │
│                                   │                         │
│                                   ▼                         │
│  ┌────────────────┐      ┌─────────────────┐              │
│  │ Notification   │◀─────│ A2UI Component   │              │
│  │ Store          │      │ Registry         │              │
│  └────────────────┘      └────────┬────────┘              │
│                                   │                         │
│                                   ▼                         │
│  ┌────────────────┐      ┌─────────────────┐              │
│  │ UI Components  │◀─────│ A2UI Renderer   │              │
│  │ (Dialogs,      │      │ (React)          │              │
│  │  Panels)       │      └─────────────────┘              │
│  └────────────────┘                                        │
│                                                               │
└─────────────────────────────────────────────────────────────┘
                              │
                              │ WebSocket
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                         CCW Backend                          │
├─────────────────────────────────────────────────────────────┤
│  ┌────────────────┐      ┌─────────────────┐              │
│  │ MCP Tools      │─────▶│ A2UI Types      │              │
│  │ (ask_question, │      │ (Zod schemas)    │              │
│  │  etc.)         │      └─────────────────┘              │
│  └────────────────┘                                        │
└─────────────────────────────────────────────────────────────┘

WebSocket Integration

Message Flow

The A2UI system uses WebSocket messages for bidirectional communication:

Frontend to Backend (Actions)

// Send action via WebSocket
const sendA2UIAction = (
  actionId: string,
  surfaceId: string,
  parameters: Record<string, unknown>
) => {
  const event = new CustomEvent('a2ui-action', {
    detail: {
      type: 'a2ui-action',
      actionId,
      surfaceId,
      parameters,
    },
  });
  window.dispatchEvent(event);
  
  // WebSocket handler picks up this event
};

Backend to Frontend (Surface Updates)

// WebSocket message handler processes incoming messages
interface A2UISurfaceMessage {
  type: 'a2ui-surface';
  surfaceId: string;
  title?: string;
  surface: SurfaceUpdate;
}

// In useWebSocket hook
useEffect(() => {
  ws.addEventListener('message', (event) => {
    const message = JSON.parse(event.data);
    
    if (message.type === 'a2ui-surface') {
      // Add to notification store
      addA2UINotification(message.surface, message.title);
    }
  });
}, [ws]);

Notification System

A2UI Notifications

A2UI surfaces appear as notifications in the notification panel:

// Add A2UI notification
import { useNotificationStore } from '@/stores/notificationStore';

const { addA2UINotification } = useNotificationStore();

addA2UINotification(surfaceUpdate, 'Notification Title');

State Management

A2UI state is managed through the notification store:

// Update A2UI state
updateA2UIState(surfaceId, {
  counter: 5,
  userInput: 'value',
});

Ask Question Dialog

The ask_question tool uses a dedicated dialog:

// Set current question (triggered by WebSocket)
setCurrentQuestion({
  surfaceId: 'question-123',
  title: 'Confirmation Required',
  questions: [
    {
      id: 'q1',
      type: 'confirm',
      question: 'Do you want to continue?',
      required: true,
    },
  ],
});

// Dialog renders automatically
// {currentQuestion && <AskQuestionDialog payload={currentQuestion} />}

Creating Custom Components

Step 1: Create Component Renderer

Create file: src/packages/a2ui-runtime/renderer/components/A2UICustom.tsx

import React, { useState, useCallback } from 'react';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveTextContent } from '../A2UIRenderer';

// Define component config interface (for TypeScript)
interface CustomComponentConfig {
  title: { literalString: string } | { path: string };
  dataSource: { literalString: string } | { path: string };
  onRefresh: { actionId: string; parameters?: Record<string, unknown> };
}

export const A2UICustom: ComponentRenderer = ({ 
  component, 
  state, 
  onAction, 
  resolveBinding 
}) => {
  const customComp = component as { Custom: CustomComponentConfig };
  const { Custom: config } = customComp;

  // Resolve title (literal or binding)
  const title = resolveTextContent(config.title, resolveBinding);
  
  // Resolve data source
  const dataSource = resolveTextContent(config.dataSource, resolveBinding);

  // Local state
  const [isLoading, setIsLoading] = useState(false);

  // Handle refresh action
  const handleRefresh = useCallback(() => {
    setIsLoading(true);
    onAction(config.onRefresh.actionId, {
      ...config.onRefresh.parameters,
      dataSource,
    });
    // Reset loading after action (implementation dependent)
    setTimeout(() => setIsLoading(false), 1000);
  }, [onAction, config.onRefresh, dataSource]);

  return (
    <div className="a2ui-custom p-4 border rounded-md">
      <h3 className="text-lg font-semibold mb-2">{title}</h3>
      <div className="text-sm">
        Data source: {dataSource}
      </div>
      <button
        onClick={handleRefresh}
        disabled={isLoading}
        className="mt-2 px-3 py-1 bg-primary text-primary-foreground rounded"
      >
        {isLoading ? 'Loading...' : 'Refresh'}
      </button>
    </div>
  );
};

Step 2: Register Component

Update: src/packages/a2ui-runtime/renderer/components/registry.ts

import { A2UICustom } from './A2UICustom';

export function registerBuiltInComponents(): void {
  // ... existing registrations
  
  // Register custom component
  a2uiRegistry.register('Custom', A2UICustom);
}

Step 3: Export Component

Update: src/packages/a2ui-runtime/renderer/components/index.ts

export * from './A2UICustom';

Step 4: Add Type Definition (Optional)

Update: src/packages/a2ui-runtime/core/A2UITypes.ts

// Add schema
export const CustomComponentSchema = z.object({
  Custom: z.object({
    title: TextContentSchema,
    dataSource: TextContentSchema,
    onRefresh: ActionSchema,
    description: z.string().optional(),
  }),
});

// Add to ComponentSchema union
export const ComponentSchema: z.ZodType<
  | z.infer<typeof TextComponentSchema>
  | z.infer<typeof ButtonComponentSchema>
  // ... other components
  | z.infer<typeof CustomComponentSchema>
> = z.union([
  // ... existing schemas
  CustomComponentSchema,
]);

// Add to TypeScript type
export type CustomComponent = z.infer<typeof CustomComponentSchema>;
export type A2UIComponent = z.infer<typeof ComponentSchema>;

// Add to discriminated union
export type A2UIComponentType =
  | 'Text' | 'Button' | // ... existing types
  | 'Custom';

Backend Tool Integration

Creating an A2UI Tool

Create file: src/tools/my-a2ui-tool.ts

import { z } from 'zod';
import type { ToolSchema, ToolResult } from '../types/tool.js';
import type { SurfaceUpdate } from '../core/a2ui/A2UITypes.js';

// Generate A2UI surface for your tool
function generateSurface(params: MyToolParams): {
  surfaceUpdate: SurfaceUpdate;
} {
  const components: unknown[] = [
    {
      id: 'title',
      component: {
        Text: {
          text: { literalString: params.title || 'My Tool' },
          usageHint: 'h3',
        },
      },
    },
    // Add more components...
  ];

  return {
    surfaceUpdate: {
      surfaceId: `my-tool-${Date.now()}`,
      components,
      initialState: {
        toolName: 'my-tool',
        timestamp: Date.now(),
      },
    },
  };
}

// Execute tool
export async function execute(params: MyToolParams): Promise<ToolResult> {
  try {
    // Generate surface
    const { surfaceUpdate } = generateSurface(params);

    // TODO: Send surface via WebSocket to frontend
    // For now, return surface in result
    return {
      success: true,
      result: {
        surfaceId: surfaceUpdate.surfaceId,
        surface: surfaceUpdate,
      },
    };
  } catch (error) {
    return {
      success: false,
      error: error instanceof Error ? error.message : String(error),
    };
  }
}

// Tool schema
export const schema: ToolSchema = {
  name: 'my_a2ui_tool',
  description: 'Description of what your tool does',
  inputSchema: {
    type: 'object',
    properties: {
      title: { type: 'string', description: 'Title for the A2UI surface' },
      // Add more properties...
    },
    required: ['title'],
  },
};

Handling Actions from Frontend

Create handler in backend:

// In WebSocket handler or tool manager
function handleA2UIAction(
  actionId: string,
  surfaceId: string,
  parameters: Record<string, unknown>
): void {
  // Find pending question or surface
  const pending = pendingSurfaces.get(surfaceId);
  
  if (!pending) {
    console.warn(`No surface found for ID: ${surfaceId}`);
    return;
  }

  // Process action based on actionId
  switch (actionId) {
    case 'confirm':
      // Handle confirmation
      pending.resolve({
        success: true,
        surfaceId,
        cancelled: false,
        answers: [parameters],
        timestamp: new Date().toISOString(),
      });
      break;
      
    case 'cancel':
      // Handle cancellation
      pending.resolve({
        success: false,
        surfaceId,
        cancelled: true,
        answers: [],
        timestamp: new Date().toISOString(),
        error: 'User cancelled',
      });
      break;
      
    default:
      // Handle custom actions
      console.log(`Action ${actionId} with params:`, parameters);
  }
  
  // Clean up
  pendingSurfaces.delete(surfaceId);
}

Testing

Unit Tests

Test component renderers:

// src/packages/a2ui-runtime/__tests__/components/A2UICustom.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { A2UICustom } from '../A2UICustom';
import type { A2UIComponent } from '@/packages/a2ui-runtime/core/A2UITypes';

describe('A2UICustom', () => {
  it('should render with title', () => {
    const component: A2UIComponent = {
      Custom: {
        title: { literalString: 'Test Title' },
        dataSource: { literalString: 'test-source' },
        onRefresh: { actionId: 'refresh' },
      },
    };

    const props = {
      component,
      state: {},
      onAction: vi.fn(),
      resolveBinding: vi.fn(),
    };

    render(<A2UICustom {...props} />);
    expect(screen.getByText('Test Title')).toBeInTheDocument();
  });
});

E2E Tests

Test A2UI flow in browser:

// tests/e2e/my-a2ui-tool.spec.ts
import { test, expect } from '@playwright/test';

test('My A2UI Tool E2E', async ({ page }) => {
  await page.goto('/', { waitUntil: 'networkidle' });

  // Simulate WebSocket message
  await page.evaluate(() => {
    const event = new CustomEvent('ws-message', {
      detail: {
        type: 'a2ui-surface',
        surfaceId: 'test-custom',
        title: 'Custom Tool',
        surface: {
          surfaceId: 'test-custom',
          components: [
            {
              id: 'custom',
              component: {
                Custom: {
                  title: { literalString: 'Test Custom' },
                  dataSource: { literalString: 'data' },
                  onRefresh: { actionId: 'refresh' },
                },
              },
            },
          ],
          initialState: {},
        },
      },
    });
    window.dispatchEvent(event);
  });

  // Verify rendering
  await expect(page.getByText('Test Custom')).toBeVisible();
  await expect(page.getByText('Data source: data')).toBeVisible();
});

Troubleshooting

Common Issues

1. Component Not Rendering

Symptoms: A2UI surface received but component not visible

Solutions:

  • Verify component is registered in registry
  • Check console for warnings about unknown component types
  • Ensure component returns valid JSX
// Debug: Check if registered
console.log(a2uiRegistry.getRegisteredTypes());
// Should include your component type

2. State Not Updating

Symptoms: State changes not reflected in UI

Solutions:

  • Verify binding path matches state key
  • Check resolveBinding implementation
  • Ensure updateA2UIState is called with correct surfaceId
// Debug: Log state changes
const handleAction = (actionId: string, params: any) => {
  console.log('Action:', actionId, 'Params:', params);
  onAction(actionId, params);
};

3. Actions Not Being Sent

Symptoms: Clicking buttons doesn't trigger backend action

Solutions:

  • Check WebSocket connection status
  • Verify action event listener is attached
  • Ensure actionId matches backend handler
// Debug: Monitor a2ui-action events
window.addEventListener('a2ui-action', (e) => {
  console.log('A2UI Action:', (e as CustomEvent).detail);
});

4. Parse Errors

Symptoms: Surface updates fail validation

Solutions:

  • Validate JSON structure against A2UI spec
  • Check Zod error details
  • Use safeParse for debugging
// Debug: Safe parse with details
const result = a2uiParser.safeParse(jsonString);
if (!result.success) {
  console.error('Parse error:', result.error);
  // Get detailed Zod errors
}

Development Tools

React DevTools

Inspect A2UI component hierarchy:

// Add data attributes for debugging
<div data-a2ui-component="Custom" data-surface-id={surfaceId}>
  {/* component content */}
</div>

Logging

Enable verbose logging in development:

// In development mode
if (process.env.NODE_ENV === 'development') {
  console.log('[A2UI] Rendering component:', componentType);
  console.log('[A2UI] Surface:', surface);
  console.log('[A2UI] State:', state);
}

Performance Considerations

  1. Lazy Load Components: For complex custom components, use React.lazy

  2. Debounce Actions: Prevent rapid action firing

import { debounce } from 'lodash-es';

const debouncedAction = debounce(onAction, 300);
  1. Memoize Renders: Use React.memo for expensive components
export const A2UICustom = React.memo<ComponentRenderer>(({ /* ... */ }) => {
  // component implementation
});

Additional Resources

For questions or issues, please refer to the main CCW documentation.