- 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.
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
- Architecture Overview
- WebSocket Integration
- Notification System
- Creating Custom Components
- Backend Tool Integration
- Testing
- 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
resolveBindingimplementation - Ensure
updateA2UIStateis 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
safeParsefor 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
-
Lazy Load Components: For complex custom components, use React.lazy
-
Debounce Actions: Prevent rapid action firing
import { debounce } from 'lodash-es';
const debouncedAction = debounce(onAction, 300);
- 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.