wip: complete rework first round

This commit is contained in:
2026-02-26 09:27:22 +01:00
parent c70f4b9154
commit affd62ca79
78 changed files with 2635 additions and 4053 deletions

View File

@@ -3,6 +3,7 @@
"npx vitest": true, "npx vitest": true,
"npx tsc": true, "npx tsc": true,
"git remote": true, "git remote": true,
"npx asar": true "npx asar": true,
"npx tsx": true
} }
} }

86
API.md
View File

@@ -3073,7 +3073,6 @@ result = await bds.tags.sync_from_posts()
- [chat.validateApiKey](#chatvalidateapikey) - [chat.validateApiKey](#chatvalidateapikey)
- [chat.setApiKey](#chatsetapikey) - [chat.setApiKey](#chatsetapikey)
- [chat.getApiKey](#chatgetapikey) - [chat.getApiKey](#chatgetapikey)
- [chat.getProtocolHealth](#chatgetprotocolhealth)
- [chat.getAvailableModels](#chatgetavailablemodels) - [chat.getAvailableModels](#chatgetavailablemodels)
- [chat.setDefaultModel](#chatsetdefaultmodel) - [chat.setDefaultModel](#chatsetdefaultmodel)
- [chat.getSystemPrompt](#chatgetsystemprompt) - [chat.getSystemPrompt](#chatgetsystemprompt)
@@ -3207,41 +3206,6 @@ result = await bds.chat.get_api_key()
} }
``` ```
### chat.getProtocolHealth
Get AGUI protocol telemetry health snapshot.
**Parameters**
- None
**Response specification**
- Return type: `ProtocolTelemetrySnapshot`
- Data structures: `ProtocolTelemetrySnapshot`
**Example call**
```python
from bds_api import bds
result = await bds.chat.get_protocol_health()
```
**Example response**
```python
{
'totalTurns': 0,
'validEnvelopeTurns': 0,
'repairAttempts': 0,
'fallbackTurns': 0,
'blockedActionCount': 0,
'parseValidityRate': 0,
'repairRate': 0,
'fallbackRate': 0
}
```
### chat.getAvailableModels ### chat.getAvailableModels
Get available chat models and selected default. Get available chat models and selected default.
@@ -3522,8 +3486,7 @@ Send message to chat conversation.
**Response specification** **Response specification**
- Return type: `{ success: boolean; message?: string; envelope?: ProtocolResponseEnvelope; protocolVersion?: '2.0'; traceId?: string; warnings?: string[]; error?: string }` - Return type: `{ success: boolean; message?: string; error?: string }`
- Data structures: `ProtocolResponseEnvelope`
**Example call** **Example call**
@@ -3535,18 +3498,7 @@ result = await bds.chat.send_message(conversation_id='conversation-1', message='
**Example response** **Example response**
```python ```python
[ {}
{
'protocolVersion': None,
'assistantText': 'value',
'ui': [],
'intent': None,
'needsInput': False,
'actions': [],
'confidence': 0,
'traceId': 'value'
}
]
``` ```
### chat.abortMessage ### chat.abortMessage
@@ -4129,40 +4081,6 @@ A declarative assistant action exposed to the UI runtime.
[↑ Back to Table of contents](#table-of-contents) [↑ Back to Table of contents](#table-of-contents)
### ProtocolResponseEnvelope
Canonical AGUI response envelope returned from chat.sendMessage.
**Fields**
- protocolVersion (`'2.0'`, required): Envelope protocol version.
- assistantText (`string`, required): Assistant text content rendered in transcript.
- ui (`{ specVersion: '1'; elements: unknown[] }`, optional): Optional structured UI payload.
- intent (`'analyze' | 'ask_input' | 'propose_action' | 'execute_action' | 'summarize'`, required): Turn intent classification.
- needsInput (`{ required: boolean; fields: ProtocolNeedsInputField[] }`, required): Clarification requirements for next step.
- actions (`ProtocolAction[]`, required): Declarative actions available for this turn.
- confidence (`number`, required): Model confidence score from 0 to 1.
- traceId (`string`, required): Trace id for observability and debugging.
[↑ Back to Table of contents](#table-of-contents)
### ProtocolTelemetrySnapshot
Aggregated protocol telemetry metrics for AGUI response health.
**Fields**
- totalTurns (`number`, required): Total number of recorded assistant turns.
- validEnvelopeTurns (`number`, required): Turns with schema-valid protocol envelopes.
- repairAttempts (`number`, required): Number of response repair attempts.
- fallbackTurns (`number`, required): Turns that used protocol fallback response.
- blockedActionCount (`number`, required): Count of actions blocked by policy.
- parseValidityRate (`number`, required): Ratio of valid envelopes to total turns.
- repairRate (`number`, required): Ratio of repair attempts to total turns.
- fallbackRate (`number`, required): Ratio of fallback turns to total turns.
[↑ Back to Table of contents](#table-of-contents)
--- ---
Generated from contract at 2026-02-25T00:00:00.000Z. Generated from contract at 2026-02-25T00:00:00.000Z.

64
src/main/a2ui/catalog.ts Normal file
View File

@@ -0,0 +1,64 @@
/**
* A2UI Component Catalog for bDS
*
* Defines which A2UI component types the bDS client supports.
* This catalog is used to:
* 1. Inform the LLM (via system prompt) what UI components are available
* 2. Validate incoming A2UI messages
* 3. Map component types to React renderers
*/
import type { A2UICatalogEntry, A2UIComponentType } from './types';
import { BDS_CATALOG_ID } from './types';
const CATALOG_ENTRIES: A2UICatalogEntry[] = [
{ type: 'text', description: 'Text block with Markdown support' },
{ type: 'button', description: 'Clickable button that dispatches an action' },
{ type: 'card', description: 'Card with title, subtitle, body, and action buttons' },
{ type: 'chart', description: 'Bar, line, or pie chart visualization', custom: true },
{ type: 'table', description: 'Data table with columns and rows', custom: true },
{ type: 'textField', description: 'Text input field with data binding' },
{ type: 'checkBox', description: 'Checkbox input with data binding' },
{ type: 'dateTimeInput', description: 'Date/time picker input' },
{ type: 'choicePicker', description: 'Select/dropdown with options' },
{ type: 'image', description: 'Image with optional caption and click action' },
{ type: 'tabs', description: 'Tabbed container for organizing content' },
{ type: 'metric', description: 'Key-value metric display', custom: true },
{ type: 'list', description: 'Ordered or unordered item list' },
{ type: 'form', description: 'Form container with fields and submit button', custom: true },
{ type: 'row', description: 'Horizontal layout container' },
{ type: 'column', description: 'Vertical layout container' },
{ type: 'divider', description: 'Visual separator' },
];
const catalogMap = new Map<A2UIComponentType, A2UICatalogEntry>();
for (const entry of CATALOG_ENTRIES) {
catalogMap.set(entry.type, entry);
}
export function getCatalogEntries(): A2UICatalogEntry[] {
return [...CATALOG_ENTRIES];
}
export function isSupportedComponentType(type: string): type is A2UIComponentType {
return catalogMap.has(type as A2UIComponentType);
}
export function getCatalogEntry(type: A2UIComponentType): A2UICatalogEntry | undefined {
return catalogMap.get(type);
}
export function getCatalogId(): string {
return BDS_CATALOG_ID;
}
/**
* Build a description of supported components for inclusion in the LLM system prompt.
*/
export function buildCatalogDescription(): string {
const lines = CATALOG_ENTRIES.map((entry) => {
const suffix = entry.custom ? ' (custom)' : '';
return ` - ${entry.type}: ${entry.description}${suffix}`;
});
return `Supported UI component types:\n${lines.join('\n')}`;
}

393
src/main/a2ui/generator.ts Normal file
View File

@@ -0,0 +1,393 @@
/**
* A2UI Generator
*
* Converts tool call results from the LLM into A2UI server messages.
* Each render_* tool call produces a set of A2UI messages:
* - createSurface (if new surface needed)
* - updateComponents (add/update components)
* - updateDataModel (set data values)
*/
import { v4 as uuidv4 } from 'uuid';
import type {
A2UIServerMessage,
A2UIComponent,
} from './types';
function makeId(prefix: string): string {
return `${prefix}-${uuidv4().slice(0, 8)}`;
}
function createSurfaceMessages(
conversationId: string,
components: A2UIComponent[],
rootIds: string[],
dataEntries?: Array<{ path: string; value: unknown }>,
): A2UIServerMessage[] {
const surfaceId = makeId('surface');
const messages: A2UIServerMessage[] = [
{
type: 'createSurface',
surfaceId,
conversationId,
},
{
type: 'updateComponents',
surfaceId,
components,
rootIds,
},
];
if (dataEntries) {
for (const entry of dataEntries) {
messages.push({
type: 'updateDataModel',
surfaceId,
path: entry.path,
value: entry.value,
});
}
}
return messages;
}
// ---- Tool argument interfaces ----
export interface RenderChartArgs {
chartType: 'bar' | 'line' | 'pie';
title?: string;
series: Array<{ label: string; value: number }>;
}
export interface RenderTableArgs {
title?: string;
columns: string[];
rows: string[][];
}
export interface RenderFormField {
key: string;
label: string;
inputType: 'text' | 'textarea' | 'select' | 'checkbox' | 'date' | 'number';
placeholder?: string;
defaultValue?: string | number | boolean;
options?: Array<{ label: string; value: string }>;
required?: boolean;
}
export interface RenderFormArgs {
title?: string;
fields: RenderFormField[];
submitLabel: string;
submitAction?: string;
}
export interface RenderCardArgs {
title: string;
body: string;
subtitle?: string;
actions?: Array<{ label: string; action: string; payload?: Record<string, unknown> }>;
}
export interface RenderMetricArgs {
label: string;
value: string;
}
export interface RenderListArgs {
title?: string;
items: string[];
}
export interface RenderTabArgs {
label: string;
content: Array<{
type: string;
[key: string]: unknown;
}>;
}
export interface RenderTabsArgs {
tabs: RenderTabArgs[];
}
// ---- Generators ----
export function generateChart(
conversationId: string,
args: RenderChartArgs,
): A2UIServerMessage[] {
const chartId = makeId('chart');
const component: A2UIComponent = {
id: chartId,
type: 'chart',
properties: {
chartType: args.chartType,
title: args.title,
},
dataBinding: '/chartData',
};
return createSurfaceMessages(
conversationId,
[component],
[chartId],
[{ path: '/chartData', value: args.series }],
);
}
export function generateTable(
conversationId: string,
args: RenderTableArgs,
): A2UIServerMessage[] {
const tableId = makeId('table');
const components: A2UIComponent[] = [
{
id: tableId,
type: 'table',
properties: {
title: args.title,
columns: args.columns,
},
dataBinding: '/tableRows',
},
];
return createSurfaceMessages(
conversationId,
components,
[tableId],
[{ path: '/tableRows', value: args.rows }],
);
}
export function generateForm(
conversationId: string,
args: RenderFormArgs,
): A2UIServerMessage[] {
const formId = makeId('form');
const fieldComponents: A2UIComponent[] = [];
const fieldIds: string[] = [];
for (const field of args.fields) {
const fieldId = makeId('field');
fieldIds.push(fieldId);
let componentType: A2UIComponent['type'] = 'textField';
if (field.inputType === 'checkbox') {
componentType = 'checkBox';
} else if (field.inputType === 'date') {
componentType = 'dateTimeInput';
} else if (field.inputType === 'select') {
componentType = 'choicePicker';
}
fieldComponents.push({
id: fieldId,
type: componentType,
properties: {
key: field.key,
label: field.label,
inputType: field.inputType,
placeholder: field.placeholder,
defaultValue: field.defaultValue,
options: field.options,
required: field.required,
},
dataBinding: `/formData/${field.key}`,
});
}
const submitId = makeId('submit');
fieldComponents.push({
id: submitId,
type: 'button',
properties: {
label: args.submitLabel,
},
actions: [
{
eventType: 'click',
action: args.submitAction || 'submitForm',
payload: { formId },
},
],
});
const formComponent: A2UIComponent = {
id: formId,
type: 'form',
properties: {
title: args.title,
submitLabel: args.submitLabel,
},
children: [...fieldIds, submitId],
};
// Set initial data model values for fields with defaults
const dataEntries: Array<{ path: string; value: unknown }> = [];
for (const field of args.fields) {
if (field.defaultValue !== undefined) {
dataEntries.push({ path: `/formData/${field.key}`, value: field.defaultValue });
}
}
return createSurfaceMessages(
conversationId,
[formComponent, ...fieldComponents],
[formId],
dataEntries.length > 0 ? dataEntries : undefined,
);
}
export function generateCard(
conversationId: string,
args: RenderCardArgs,
): A2UIServerMessage[] {
const cardId = makeId('card');
const cardActions = args.actions?.map((a) => ({
eventType: 'click',
action: a.action,
payload: a.payload,
}));
const component: A2UIComponent = {
id: cardId,
type: 'card',
properties: {
title: args.title,
body: args.body,
subtitle: args.subtitle,
},
actions: cardActions,
};
return createSurfaceMessages(conversationId, [component], [cardId]);
}
export function generateMetric(
conversationId: string,
args: RenderMetricArgs,
): A2UIServerMessage[] {
const metricId = makeId('metric');
const component: A2UIComponent = {
id: metricId,
type: 'metric',
properties: {
label: args.label,
value: args.value,
},
};
return createSurfaceMessages(conversationId, [component], [metricId]);
}
export function generateList(
conversationId: string,
args: RenderListArgs,
): A2UIServerMessage[] {
const listId = makeId('list');
const component: A2UIComponent = {
id: listId,
type: 'list',
properties: {
title: args.title,
},
dataBinding: '/listItems',
};
return createSurfaceMessages(
conversationId,
[component],
[listId],
[{ path: '/listItems', value: args.items }],
);
}
export function generateTabs(
conversationId: string,
args: RenderTabsArgs,
): A2UIServerMessage[] {
const tabsId = makeId('tabs');
const tabComponents: A2UIComponent[] = [];
const tabIds: string[] = [];
for (const tab of args.tabs) {
const tabId = makeId('tab');
tabIds.push(tabId);
const childComponents: A2UIComponent[] = [];
const childIds: string[] = [];
for (const contentItem of tab.content) {
const childId = makeId('child');
childIds.push(childId);
childComponents.push({
id: childId,
type: contentItem.type as A2UIComponent['type'],
properties: { ...contentItem, type: undefined },
});
}
tabComponents.push({
id: tabId,
type: 'column',
properties: { label: tab.label },
children: childIds,
});
tabComponents.push(...childComponents);
}
const tabsComponent: A2UIComponent = {
id: tabsId,
type: 'tabs',
properties: {
tabLabels: args.tabs.map((t) => t.label),
},
children: tabIds,
};
return createSurfaceMessages(
conversationId,
[tabsComponent, ...tabComponents],
[tabsId],
);
}
// ---- Tool name to generator dispatch ----
const GENERATORS: Record<string, (conversationId: string, args: Record<string, unknown>) => A2UIServerMessage[]> = {
render_chart: (cid, args) => generateChart(cid, args as unknown as RenderChartArgs),
render_table: (cid, args) => generateTable(cid, args as unknown as RenderTableArgs),
render_form: (cid, args) => generateForm(cid, args as unknown as RenderFormArgs),
render_card: (cid, args) => generateCard(cid, args as unknown as RenderCardArgs),
render_metric: (cid, args) => generateMetric(cid, args as unknown as RenderMetricArgs),
render_list: (cid, args) => generateList(cid, args as unknown as RenderListArgs),
render_tabs: (cid, args) => generateTabs(cid, args as unknown as RenderTabsArgs),
};
/**
* Check if a tool name is a UI-rendering tool.
*/
export function isRenderTool(toolName: string): boolean {
return toolName in GENERATORS;
}
/**
* Generate A2UI messages for a render tool call.
* Returns null if the tool name is not a render tool.
*/
export function generateFromToolCall(
conversationId: string,
toolName: string,
toolArgs: Record<string, unknown>,
): A2UIServerMessage[] | null {
const generator = GENERATORS[toolName];
if (!generator) {
return null;
}
return generator(conversationId, toolArgs);
}

134
src/main/a2ui/types.ts Normal file
View File

@@ -0,0 +1,134 @@
/**
* A2UI v0.9 types for bDS
*
* Implements the core A2UI protocol concepts:
* - JSONL streaming via IPC (not HTTP/SSE)
* - 4 server message types: createSurface, updateComponents, updateDataModel, deleteSurface
* - Flat component model with ID references
* - Data binding via JSON Pointer paths (RFC 6901)
* - Actions dispatched from client back to server
*
* @see https://a2ui.org
*/
// ---- Component Types ----
export type A2UIComponentType =
| 'text'
| 'button'
| 'card'
| 'chart'
| 'table'
| 'form'
| 'textField'
| 'checkBox'
| 'dateTimeInput'
| 'choicePicker'
| 'image'
| 'tabs'
| 'metric'
| 'list'
| 'row'
| 'column'
| 'divider';
export interface A2UIComponent {
id: string;
type: A2UIComponentType;
properties: Record<string, unknown>;
/** JSON Pointer path for data binding (RFC 6901) */
dataBinding?: string;
/** Ordered child component IDs */
children?: string[];
/** Actions this component can dispatch */
actions?: A2UIComponentAction[];
}
export interface A2UIComponentAction {
eventType: string;
action: string;
payload?: Record<string, unknown>;
/** Policy for this action: silent = no confirm, confirm = ask user, danger = warn */
policy?: 'silent' | 'confirm' | 'danger';
}
// ---- Server Messages (main → renderer) ----
export interface A2UICreateSurface {
type: 'createSurface';
surfaceId: string;
conversationId: string;
metadata?: Record<string, unknown>;
}
export interface A2UIUpdateComponents {
type: 'updateComponents';
surfaceId: string;
components: A2UIComponent[];
/** Root component IDs for top-level rendering order */
rootIds?: string[];
}
export interface A2UIUpdateDataModel {
type: 'updateDataModel';
surfaceId: string;
/** JSON Pointer path (RFC 6901) */
path: string;
value: unknown;
}
export interface A2UIDeleteSurface {
type: 'deleteSurface';
surfaceId: string;
}
export type A2UIServerMessage =
| A2UICreateSurface
| A2UIUpdateComponents
| A2UIUpdateDataModel
| A2UIDeleteSurface;
// ---- Client Actions (renderer → main) ----
export interface A2UIClientAction {
surfaceId: string;
componentId: string;
action: string;
payload?: Record<string, unknown>;
}
// ---- Surface State (renderer-side) ----
export interface A2UISurfaceState {
surfaceId: string;
conversationId: string;
components: Map<string, A2UIComponent>;
rootIds: string[];
dataModel: Record<string, unknown>;
metadata?: Record<string, unknown>;
}
// ---- Resolved Component Tree (for rendering) ----
export interface A2UIResolvedComponent {
id: string;
type: A2UIComponentType;
properties: Record<string, unknown>;
/** JSON Pointer path for data binding (carried from raw component) */
dataBinding?: string;
/** Resolved value from data binding */
boundValue?: unknown;
actions?: A2UIComponentAction[];
children: A2UIResolvedComponent[];
}
// ---- Catalog ----
export interface A2UICatalogEntry {
type: A2UIComponentType;
description: string;
/** Whether this is a standard A2UI component or a custom bDS extension */
custom?: boolean;
}
export const BDS_CATALOG_ID = 'bds-blogging-v1';

View File

@@ -1,94 +0,0 @@
import type { AgentSurface, ProtocolCapabilitySnapshot } from '../protocol/types';
interface CapabilityRegistryOptions {
disabledActions?: string[];
disabledWidgets?: string[];
disabledTools?: string[];
}
interface CapabilitySnapshotInput {
surface: AgentSurface;
}
const COMMON_WIDGETS = [
'text',
'metric',
'list',
'table',
'action',
'chart',
'form',
'input',
'datePicker',
'card',
'image',
'tabs',
] as const;
const COMMON_ACTIONS = [
'openSettings',
'openPost',
'openMedia',
'openPanel',
'setActiveView',
'toggleSidebar',
'togglePanel',
'toggleAssistantSidebar',
] as const;
const COMMON_TOOLS = [
'search_posts',
'read_post',
'list_posts',
'get_media',
'list_media',
'update_post_metadata',
'update_media_metadata',
'list_tags',
'list_categories',
'view_image',
'get_post_backlinks',
'get_post_outlinks',
'get_post_media',
'get_media_posts',
] as const;
function unique(values: string[]): string[] {
return Array.from(new Set(values));
}
export class CapabilityRegistryService {
private readonly disabledActions: Set<string>;
private readonly disabledWidgets: Set<string>;
private readonly disabledTools: Set<string>;
constructor(options: CapabilityRegistryOptions = {}) {
this.disabledActions = new Set(options.disabledActions ?? []);
this.disabledWidgets = new Set(options.disabledWidgets ?? []);
this.disabledTools = new Set(options.disabledTools ?? []);
}
getSnapshot(input: CapabilitySnapshotInput): ProtocolCapabilitySnapshot {
const widgets = COMMON_WIDGETS.filter((widget) => !this.disabledWidgets.has(widget));
const surfaceActions = input.surface === 'tab'
? COMMON_ACTIONS.filter((action) => action !== 'toggleAssistantSidebar')
: COMMON_ACTIONS.filter((action) => action !== 'toggleSidebar');
const actions = surfaceActions.filter((action) => !this.disabledActions.has(action));
const tools = COMMON_TOOLS.filter((tool) => !this.disabledTools.has(tool));
const disabled = unique([
...Array.from(this.disabledActions).map((action) => `action:${action}`),
...Array.from(this.disabledWidgets).map((widget) => `widget:${widget}`),
...Array.from(this.disabledTools).map((tool) => `tool:${tool}`),
]);
return {
widgets: [...widgets],
actions: [...actions],
tools: [...tools],
disabled,
};
}
}

View File

@@ -1,63 +0,0 @@
export interface ProtocolTurnTelemetryInput {
validEnvelope: boolean;
repairAttempted: boolean;
fallbackUsed: boolean;
blockedActions: number;
}
export interface ProtocolTelemetrySnapshot {
totalTurns: number;
validEnvelopeTurns: number;
repairAttempts: number;
fallbackTurns: number;
blockedActionCount: number;
parseValidityRate: number;
repairRate: number;
fallbackRate: number;
}
export class ProtocolTelemetryService {
private totalTurns = 0;
private validEnvelopeTurns = 0;
private repairAttempts = 0;
private fallbackTurns = 0;
private blockedActionCount = 0;
recordTurn(input: ProtocolTurnTelemetryInput): void {
this.totalTurns += 1;
if (input.validEnvelope) {
this.validEnvelopeTurns += 1;
}
if (input.repairAttempted) {
this.repairAttempts += 1;
}
if (input.fallbackUsed) {
this.fallbackTurns += 1;
}
this.blockedActionCount += input.blockedActions;
}
getSnapshot(): ProtocolTelemetrySnapshot {
const denominator = this.totalTurns || 1;
return {
totalTurns: this.totalTurns,
validEnvelopeTurns: this.validEnvelopeTurns,
repairAttempts: this.repairAttempts,
fallbackTurns: this.fallbackTurns,
blockedActionCount: this.blockedActionCount,
parseValidityRate: this.validEnvelopeTurns / denominator,
repairRate: this.repairAttempts / denominator,
fallbackRate: this.fallbackTurns / denominator,
};
}
}
let protocolTelemetryService: ProtocolTelemetryService | null = null;
export function getProtocolTelemetryService(): ProtocolTelemetryService {
if (!protocolTelemetryService) {
protocolTelemetryService = new ProtocolTelemetryService();
}
return protocolTelemetryService;
}

View File

@@ -1,30 +0,0 @@
export type ActionPolicyLevel = 'silent' | 'confirm' | 'danger';
export interface ActionPolicyResolution {
level: ActionPolicyLevel;
requiresConfirmation: boolean;
}
const ACTION_POLICY_MAP: Record<string, ActionPolicyLevel> = {
openPost: 'silent',
openMedia: 'silent',
openPanel: 'silent',
setActiveView: 'silent',
toggleSidebar: 'silent',
togglePanel: 'silent',
toggleAssistantSidebar: 'silent',
openSettings: 'confirm',
updatePostMetadata: 'confirm',
updateMediaMetadata: 'confirm',
submitNeedsInput: 'confirm',
deletePost: 'danger',
deleteMedia: 'danger',
};
export function resolveActionPolicy(action: string): ActionPolicyResolution {
const level = ACTION_POLICY_MAP[action] ?? 'danger';
return {
level,
requiresConfirmation: level !== 'silent',
};
}

View File

@@ -1,9 +0,0 @@
import type { ProtocolValidationError } from './types';
export function createProtocolValidationError(message: string, details?: string[]): ProtocolValidationError {
return {
code: 'AGUI_PROTOCOL_VALIDATION_ERROR',
message,
details,
};
}

View File

@@ -1,357 +0,0 @@
import { randomUUID } from 'crypto';
import type {
AgentSurface,
ProtocolCapabilitySnapshot,
ProtocolIntent,
ProtocolResponseEnvelope,
ProtocolValidationError,
} from './types';
import { validateProtocolResponseEnvelope } from './validator';
import { extractAssistantUiSpec, normalizeAssistantUiSpec } from './uiSpecParser';
import { assistantPanelSpecSchema } from './uiSchema';
import { resolveActionPolicy } from '../policy/actionPolicy';
export interface ProtocolResponseBuildInput {
rawAssistantOutput: string;
surface: AgentSurface;
capabilities: ProtocolCapabilitySnapshot;
}
export interface ProtocolResponseBuildResult {
envelope: ProtocolResponseEnvelope;
traceId: string;
repairAttempted: boolean;
warnings: string[];
validationError?: ProtocolValidationError;
}
export class ProtocolResponseBuilder {
build(input: ProtocolResponseBuildInput): ProtocolResponseBuildResult {
const warnings: string[] = [];
const directEnvelope = this.parseCanonicalEnvelope(input.rawAssistantOutput);
if (directEnvelope) {
const sanitizedDirectEnvelope = this.sanitizeUiPayload(directEnvelope, warnings);
const normalizedDirectEnvelope = this.applyActionPolicies(sanitizedDirectEnvelope);
const { filteredEnvelope, warnings: capabilityWarnings } = this.applyCapabilityGuards(normalizedDirectEnvelope, input.capabilities);
warnings.push(...capabilityWarnings);
const validated = validateProtocolResponseEnvelope(filteredEnvelope);
if (validated.ok && validated.value) {
return {
envelope: validated.value,
traceId: validated.value.traceId,
repairAttempted: false,
warnings,
};
}
const fallback = this.fallbackEnvelope(input.rawAssistantOutput);
return {
envelope: fallback,
traceId: fallback.traceId,
repairAttempted: true,
warnings,
validationError: validated.error,
};
}
const repaired = this.repairRawEnvelope(input.rawAssistantOutput);
if (repaired) {
const sanitizedRepairedEnvelope = this.sanitizeUiPayload(repaired, warnings);
const normalizedRepairedEnvelope = this.applyActionPolicies(sanitizedRepairedEnvelope);
const { filteredEnvelope, warnings: capabilityWarnings } = this.applyCapabilityGuards(normalizedRepairedEnvelope, input.capabilities);
warnings.push(...capabilityWarnings);
const validated = validateProtocolResponseEnvelope(filteredEnvelope);
if (validated.ok && validated.value) {
return {
envelope: validated.value,
traceId: validated.value.traceId,
repairAttempted: true,
warnings,
};
}
}
const parsedUi = extractAssistantUiSpec(input.rawAssistantOutput);
const jsonLikeOutput = input.rawAssistantOutput.trim().startsWith('{')
|| input.rawAssistantOutput.trim().startsWith('[');
const baseEnvelope: ProtocolResponseEnvelope = {
protocolVersion: '2.0',
assistantText: parsedUi.assistantText,
ui: parsedUi.ui || undefined,
intent: jsonLikeOutput
? 'summarize'
: this.deriveIntent(parsedUi.assistantText, Boolean(parsedUi.ui), false),
needsInput: {
required: false,
fields: [],
},
actions: [],
confidence: 0.7,
traceId: randomUUID(),
};
const sanitizedBaseEnvelope = this.sanitizeUiPayload(baseEnvelope, warnings);
const normalizedBaseEnvelope = this.applyActionPolicies(sanitizedBaseEnvelope);
const { filteredEnvelope, warnings: capabilityWarnings } = this.applyCapabilityGuards(normalizedBaseEnvelope, input.capabilities);
warnings.push(...capabilityWarnings);
const validated = validateProtocolResponseEnvelope(filteredEnvelope);
if (validated.ok && validated.value) {
return {
envelope: validated.value,
traceId: validated.value.traceId,
repairAttempted: false,
warnings,
};
}
const fallback = this.fallbackEnvelope(input.rawAssistantOutput);
return {
envelope: fallback,
traceId: fallback.traceId,
repairAttempted: true,
warnings,
validationError: validated.error,
};
}
private sanitizeUiPayload(envelope: ProtocolResponseEnvelope, warnings: string[]): ProtocolResponseEnvelope {
if (!envelope.ui) {
return envelope;
}
const parsedUi = assistantPanelSpecSchema.safeParse(envelope.ui);
if (parsedUi.success) {
return {
...envelope,
ui: parsedUi.data,
};
}
const normalizedUi = normalizeAssistantUiSpec(envelope.ui);
if (normalizedUi) {
warnings.push('Normalized non-canonical ui payload to canonical AGUI schema');
return {
...envelope,
ui: normalizedUi,
};
}
warnings.push('Invalid ui payload removed from response envelope');
return {
...envelope,
ui: undefined,
};
}
private extractJsonFromMarkdown(raw: string): string {
const trimmed = raw.trim();
const match = trimmed.match(/```(?:[a-zA-Z0-9_-]+)?\s*([\s\S]*?)```/i);
if (match) {
return match[1].trim();
}
return trimmed;
}
private parseCanonicalEnvelope(raw: string): ProtocolResponseEnvelope | null {
try {
const jsonString = this.extractJsonFromMarkdown(raw);
const parsed = JSON.parse(jsonString);
const validated = validateProtocolResponseEnvelope(parsed);
return validated.ok && validated.value ? validated.value : null;
} catch {
return null;
}
}
private repairRawEnvelope(raw: string): ProtocolResponseEnvelope | null {
try {
const jsonString = this.extractJsonFromMarkdown(raw);
const parsed = JSON.parse(jsonString) as Record<string, unknown>;
const looksLikeEnvelope = Boolean(
parsed.assistantText
|| parsed.assistant_text
|| parsed.intent
|| parsed.needsInput
|| parsed.needs_input
|| parsed.actions
|| parsed.ui,
);
if (!looksLikeEnvelope) {
return null;
}
const repaired: Record<string, unknown> = {
protocolVersion: parsed.protocolVersion ?? parsed.protocol_version ?? '2.0',
assistantText: parsed.assistantText ?? parsed.assistant_text ?? '',
ui: parsed.ui,
intent: parsed.intent ?? 'summarize',
needsInput: parsed.needsInput ?? parsed.needs_input ?? { required: false, fields: [] },
actions: parsed.actions ?? [],
confidence: typeof parsed.confidence === 'number' ? parsed.confidence : 0.6,
traceId: parsed.traceId ?? parsed.trace_id ?? randomUUID(),
};
const validated = validateProtocolResponseEnvelope(repaired);
return validated.ok && validated.value ? validated.value : null;
} catch {
return null;
}
}
private deriveIntent(text: string, hasUi: boolean, needsInput: boolean): ProtocolIntent {
if (needsInput) {
return 'ask_input';
}
if (hasUi) {
return 'propose_action';
}
if (text.trim().length === 0) {
return 'summarize';
}
return 'analyze';
}
private applyCapabilityGuards(
envelope: ProtocolResponseEnvelope,
capabilities: ProtocolCapabilitySnapshot,
): { filteredEnvelope: ProtocolResponseEnvelope; warnings: string[] } {
const warnings: string[] = [];
const filteredActions = envelope.actions.filter((action) => {
const supported = capabilities.actions.includes(action.action);
if (!supported) {
warnings.push(`Blocked unsupported action: ${action.action}`);
}
return supported;
});
const filteredUiElements = envelope.ui?.elements.filter((element) => {
const typedElement = element as { type?: string };
const elementType = typedElement?.type;
if (!elementType) {
return true;
}
const supported = capabilities.widgets.includes(elementType);
if (!supported) {
warnings.push(`Blocked unsupported widget: ${elementType}`);
}
return supported;
});
return {
filteredEnvelope: {
...envelope,
ui: envelope.ui && filteredUiElements
? {
specVersion: '1',
elements: filteredUiElements,
}
: envelope.ui,
actions: filteredActions,
},
warnings,
};
}
private applyActionPolicies(envelope: ProtocolResponseEnvelope): ProtocolResponseEnvelope {
const actionList = envelope.actions.length > 0
? envelope.actions
: this.extractActionsFromUi(envelope.ui?.elements ?? []);
const normalizedActions = actionList.map((action, index) => {
const policy = resolveActionPolicy(action.action);
return {
id: action.id || `agui-action-${index + 1}`,
action: action.action,
label: action.label,
payload: action.payload,
policy: policy.level,
requiresConfirmation: policy.requiresConfirmation,
};
});
return {
...envelope,
actions: normalizedActions,
};
}
private extractActionsFromUi(elements: unknown[]): Array<{
id: string;
action: string;
label?: string;
payload?: Record<string, unknown>;
}> {
const extracted: Array<{
id: string;
action: string;
label?: string;
payload?: Record<string, unknown>;
}> = [];
const walk = (nodes: unknown[], parentId: string) => {
nodes.forEach((node, index) => {
const typedNode = node as Record<string, unknown>;
const type = typeof typedNode?.type === 'string' ? typedNode.type : '';
const nodeId = `${parentId}-${index + 1}`;
if ((type === 'action' || type === 'input' || type === 'datePicker' || type === 'form' || type === 'image') && typeof typedNode.action === 'string') {
extracted.push({
id: `ui-${nodeId}`,
action: typedNode.action,
label: typeof typedNode.label === 'string' ? typedNode.label : undefined,
payload: typedNode.payload as Record<string, unknown> | undefined,
});
}
if (type === 'card' && Array.isArray(typedNode.actions)) {
typedNode.actions.forEach((cardAction, cardActionIndex) => {
const typedCardAction = cardAction as Record<string, unknown>;
if (typeof typedCardAction.action === 'string') {
extracted.push({
id: `ui-${nodeId}-card-${cardActionIndex + 1}`,
action: typedCardAction.action,
label: typeof typedCardAction.label === 'string' ? typedCardAction.label : undefined,
payload: typedCardAction.payload as Record<string, unknown> | undefined,
});
}
});
}
if (type === 'tabs' && Array.isArray(typedNode.tabs)) {
typedNode.tabs.forEach((tabNode, tabIndex) => {
const typedTab = tabNode as Record<string, unknown>;
if (Array.isArray(typedTab.elements)) {
walk(typedTab.elements, `${nodeId}-tab-${tabIndex + 1}`);
}
});
}
});
};
walk(elements, 'root');
return extracted;
}
private fallbackEnvelope(rawAssistantOutput: string): ProtocolResponseEnvelope {
return {
protocolVersion: '2.0',
assistantText: rawAssistantOutput,
intent: 'summarize',
needsInput: {
required: false,
fields: [],
},
actions: [],
confidence: 0.3,
traceId: randomUUID(),
};
}
}

View File

@@ -1,82 +0,0 @@
export type AgentSurface = 'tab' | 'sidebar';
export type ProtocolIntent =
| 'analyze'
| 'ask_input'
| 'propose_action'
| 'execute_action'
| 'summarize';
export type ActionPolicyLevel = 'silent' | 'confirm' | 'danger';
export interface ProtocolNeedsInputField {
key: string;
label: string;
inputType: 'text' | 'textarea' | 'select' | 'checkbox' | 'date' | 'number';
required?: boolean;
options?: Array<{ label: string; value: string }>;
placeholder?: string;
defaultValue?: string | number | boolean;
}
export interface ProtocolNeedsInput {
required: boolean;
fields: ProtocolNeedsInputField[];
}
export interface ProtocolAction {
id: string;
action: string;
label?: string;
payload?: Record<string, unknown>;
policy: ActionPolicyLevel;
requiresConfirmation: boolean;
}
export interface ProtocolUiSpec {
specVersion: '1';
elements: unknown[];
}
export interface ProtocolResponseEnvelope {
protocolVersion: '2.0';
assistantText: string;
ui?: ProtocolUiSpec;
intent: ProtocolIntent;
needsInput: ProtocolNeedsInput;
actions: ProtocolAction[];
confidence: number;
traceId: string;
}
export interface ProtocolRequestMessage {
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
}
export interface ProtocolCapabilitySnapshot {
widgets: string[];
actions: string[];
tools: string[];
disabled?: string[];
}
export interface ProtocolRequestEnvelope {
protocolVersion: '2.0';
surface: AgentSurface;
messages: ProtocolRequestMessage[];
context: Record<string, unknown>;
capabilities: ProtocolCapabilitySnapshot;
}
export interface ProtocolValidationError {
code: 'AGUI_PROTOCOL_VALIDATION_ERROR';
message: string;
details?: string[];
}
export interface ProtocolValidationResult<T> {
ok: boolean;
value?: T;
error?: ProtocolValidationError;
}

View File

@@ -1,154 +0,0 @@
import { z } from 'zod';
const inputTypeSchema = z.enum(['text', 'textarea', 'select', 'checkbox', 'date', 'number']);
const inputOptionSchema = z.object({
label: z.string().min(1),
value: z.string(),
}).strict();
const textElementSchema = z.object({
type: z.literal('text'),
text: z.string().min(1),
}).strict();
const metricElementSchema = z.object({
type: z.literal('metric'),
label: z.string().min(1),
value: z.string().min(1),
}).strict();
const listElementSchema = z.object({
type: z.literal('list'),
title: z.string().optional(),
items: z.array(z.string().min(1)).min(1),
}).strict();
const tableElementSchema = z.object({
type: z.literal('table'),
columns: z.array(z.string().min(1)).min(1),
rows: z.array(z.array(z.string())).min(1),
}).strict();
const actionElementSchema = z.object({
type: z.literal('action'),
label: z.string().min(1),
action: z.string().min(1),
payload: z.record(z.string(), z.unknown()).optional(),
}).strict();
const chartElementSchema = z.object({
type: z.literal('chart'),
chartType: z.enum(['bar', 'line', 'pie']),
title: z.string().min(1).optional(),
series: z.array(z.object({
label: z.string().min(1),
value: z.number(),
}).strict()).min(1),
}).strict();
const inputElementSchema = z.object({
type: z.literal('input'),
key: z.string().min(1),
label: z.string().min(1),
inputType: inputTypeSchema,
placeholder: z.string().optional(),
defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional(),
options: z.array(inputOptionSchema).optional(),
action: z.string().min(1).optional(),
submitLabel: z.string().min(1).optional(),
payload: z.record(z.string(), z.unknown()).optional(),
}).strict();
const datePickerElementSchema = z.object({
type: z.literal('datePicker'),
key: z.string().min(1),
label: z.string().min(1),
defaultValue: z.string().optional(),
min: z.string().optional(),
max: z.string().optional(),
action: z.string().min(1).optional(),
submitLabel: z.string().min(1).optional(),
payload: z.record(z.string(), z.unknown()).optional(),
}).strict();
const formFieldSchema = z.object({
key: z.string().min(1),
label: z.string().min(1),
inputType: inputTypeSchema,
placeholder: z.string().optional(),
defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional(),
options: z.array(inputOptionSchema).optional(),
required: z.boolean().optional(),
}).strict();
const formElementSchema = z.object({
type: z.literal('form'),
formId: z.string().min(1),
title: z.string().optional(),
submitLabel: z.string().min(1),
action: z.string().min(1),
payload: z.record(z.string(), z.unknown()).optional(),
fields: z.array(formFieldSchema).min(1),
}).strict();
const cardActionSchema = z.object({
label: z.string().min(1),
action: z.string().min(1),
payload: z.record(z.string(), z.unknown()).optional(),
}).strict();
const cardElementSchema = z.object({
type: z.literal('card'),
title: z.string().min(1),
body: z.string().min(1),
subtitle: z.string().optional(),
actions: z.array(cardActionSchema).optional(),
}).strict();
const imageElementSchema = z.object({
type: z.literal('image'),
src: z.string().min(1),
alt: z.string().optional(),
caption: z.string().optional(),
action: z.string().min(1).optional(),
payload: z.record(z.string(), z.unknown()).optional(),
}).strict();
let assistantPanelElementSchemaRef: z.ZodTypeAny;
const tabsElementSchema: z.ZodTypeAny = z.lazy(() => z.object({
type: z.literal('tabs'),
widgetId: z.string().min(1).optional(),
defaultTabId: z.string().min(1).optional(),
tabs: z.array(z.object({
id: z.string().min(1),
label: z.string().min(1),
elements: z.array(assistantPanelElementSchemaRef).min(1),
}).strict()).min(1),
}).strict());
assistantPanelElementSchemaRef = z.union([
textElementSchema,
metricElementSchema,
listElementSchema,
tableElementSchema,
actionElementSchema,
chartElementSchema,
inputElementSchema,
formElementSchema,
datePickerElementSchema,
cardElementSchema,
imageElementSchema,
tabsElementSchema,
]);
export const assistantPanelElementSchema = assistantPanelElementSchemaRef;
export const assistantPanelSpecSchema = z.object({
specVersion: z.literal('1'),
elements: z.array(assistantPanelElementSchema).min(1),
}).strict();
export type AssistantPanelElement = z.infer<typeof assistantPanelElementSchema>;
export type AssistantPanelSpec = z.infer<typeof assistantPanelSpecSchema>;

View File

@@ -1,270 +0,0 @@
import { assistantPanelSpecSchema, type AssistantPanelSpec } from './uiSchema';
function toRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function normalizeChartElement(record: Record<string, unknown>): Record<string, unknown> {
const chartType = record.chartType;
const normalized: Record<string, unknown> = {
type: 'chart',
chartType: chartType === 'line' || chartType === 'pie' ? chartType : 'bar',
};
if (typeof record.title === 'string' && record.title.trim().length > 0) {
normalized.title = record.title;
}
if (Array.isArray(record.series)) {
const series = record.series
.map((entry) => {
const item = toRecord(entry);
if (!item || typeof item.label !== 'string' || typeof item.value !== 'number') {
return null;
}
return {
label: item.label,
value: item.value,
};
})
.filter((entry): entry is { label: string; value: number } => Boolean(entry));
if (series.length > 0) {
normalized.series = series;
return normalized;
}
}
const dataRecord = toRecord(record.data);
if (!dataRecord) {
return normalized;
}
const labels = Array.isArray(dataRecord.labels) ? dataRecord.labels : [];
const datasets = Array.isArray(dataRecord.datasets) ? dataRecord.datasets : [];
const firstDataset = toRecord(datasets[0]);
const values = Array.isArray(firstDataset?.data) ? firstDataset?.data : [];
if (labels.length === 0 || values.length === 0) {
return normalized;
}
const series = labels
.map((label, index) => ({
label: String(label),
value: Number(values[index]),
}))
.filter((entry) => Number.isFinite(entry.value));
if (series.length === 0) {
return normalized;
}
normalized.series = series;
return normalized;
}
function normalizeTabContent(tabValue: unknown): Record<string, unknown>[] {
if (Array.isArray(tabValue)) {
return tabValue
.map((entry) => normalizeElement(entry))
.filter((entry): entry is Record<string, unknown> => Boolean(entry));
}
const normalized = normalizeElement(tabValue);
return normalized ? [normalized] : [];
}
function normalizeTabsElement(record: Record<string, unknown>): Record<string, unknown> | null {
const tabs = Array.isArray(record.tabs) ? record.tabs : [];
const normalizedTabs = tabs
.map((tabValue, tabIndex) => {
const tabRecord = toRecord(tabValue);
if (!tabRecord) {
return null;
}
const id = typeof tabRecord.id === 'string' && tabRecord.id.trim().length > 0
? tabRecord.id
: `tab-${tabIndex + 1}`;
const label = typeof tabRecord.label === 'string' && tabRecord.label.trim().length > 0
? tabRecord.label
: typeof tabRecord.title === 'string' && tabRecord.title.trim().length > 0
? tabRecord.title
: id;
const elements = Array.isArray(tabRecord.elements)
? normalizeTabContent(tabRecord.elements)
: normalizeTabContent(tabRecord.content);
if (elements.length === 0) {
return null;
}
return { id, label, elements };
})
.filter((entry): entry is { id: string; label: string; elements: Record<string, unknown>[] } => Boolean(entry));
if (normalizedTabs.length === 0) {
return null;
}
return {
...record,
tabs: normalizedTabs,
};
}
function normalizeElement(value: unknown): Record<string, unknown> | null {
const record = toRecord(value);
if (!record) {
return null;
}
const type = typeof record.type === 'string' ? record.type : '';
if (type === 'text' && typeof record.content === 'string' && typeof record.text !== 'string') {
return { type: 'text', text: record.content };
}
if (type === 'markdown') {
const textValue = typeof record.content === 'string'
? record.content
: typeof record.text === 'string'
? record.text
: '';
if (!textValue.trim()) {
return null;
}
return {
type: 'text',
text: textValue,
};
}
if (type === 'chart') {
return normalizeChartElement(record);
}
if (type === 'tabs') {
return normalizeTabsElement(record);
}
return record;
}
function normalizeCandidate(parsed: unknown): AssistantPanelSpec | null {
const canonicalResult = assistantPanelSpecSchema.safeParse(parsed);
if (canonicalResult.success) {
return canonicalResult.data;
}
const record = toRecord(parsed);
if (!record) {
return null;
}
if (record.protocolVersion === '2.0' && record.ui) {
return normalizeCandidate(record.ui);
}
if (record.type === 'tab' && record.content) {
return normalizeCandidate(record.content);
}
if (record.type === 'tabs') {
const tabsElement = normalizeTabsElement(record);
if (!tabsElement) {
return null;
}
const asSpec = {
specVersion: '1' as const,
elements: [tabsElement],
};
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
return normalizedResult.success ? normalizedResult.data : null;
}
if (Array.isArray(record.elements)) {
const normalizedElements = record.elements
.map((element) => normalizeElement(element))
.filter((element): element is Record<string, unknown> => Boolean(element));
if (normalizedElements.length === 0) {
return null;
}
const asSpec = {
specVersion: '1' as const,
elements: normalizedElements,
};
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
return normalizedResult.success ? normalizedResult.data : null;
}
const normalizedElement = normalizeElement(record);
if (!normalizedElement) {
return null;
}
const asSpec = {
specVersion: '1' as const,
elements: [normalizedElement],
};
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
return normalizedResult.success ? normalizedResult.data : null;
}
function parseSpecCandidate(raw: string): AssistantPanelSpec | null {
try {
const parsed = JSON.parse(raw);
return normalizeCandidate(parsed);
} catch {
return null;
}
}
export function normalizeAssistantUiSpec(input: unknown): AssistantPanelSpec | null {
return normalizeCandidate(input);
}
export interface ParsedAssistantUiResult {
assistantText: string;
ui: AssistantPanelSpec | null;
}
export function extractAssistantUiSpec(message: string): ParsedAssistantUiResult {
const trimmed = message.trim();
const fencedMatches = [...trimmed.matchAll(/```(?:[a-zA-Z0-9_-]+)?\s*([\s\S]*?)```/gi)];
for (const match of fencedMatches) {
const candidate = match[1]?.trim();
if (!candidate) {
continue;
}
const parsed = parseSpecCandidate(candidate);
if (parsed) {
const assistantText = trimmed.replace(match[0], '').trim();
return {
assistantText,
ui: parsed,
};
}
}
const parsedWholeMessage = parseSpecCandidate(trimmed);
return {
assistantText: parsedWholeMessage ? '' : trimmed,
ui: parsedWholeMessage,
};
}

View File

@@ -1,112 +0,0 @@
import { z } from 'zod';
import type {
ProtocolRequestEnvelope,
ProtocolResponseEnvelope,
ProtocolValidationResult,
} from './types';
import { createProtocolValidationError } from './errors';
const needsInputFieldSchema = z.object({
key: z.string().min(1),
label: z.string().min(1),
inputType: z.enum(['text', 'textarea', 'select', 'checkbox', 'date', 'number']),
required: z.boolean().optional(),
options: z.array(z.object({ label: z.string().min(1), value: z.string() })).optional(),
placeholder: z.string().optional(),
defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional(),
}).strict();
const needsInputSchema = z.object({
required: z.boolean(),
fields: z.array(needsInputFieldSchema),
}).strict();
const protocolActionSchema = z.object({
id: z.string().min(1),
action: z.string().min(1),
label: z.string().optional(),
payload: z.record(z.string(), z.unknown()).optional(),
policy: z.enum(['silent', 'confirm', 'danger']),
requiresConfirmation: z.boolean(),
}).strict();
const protocolUiSchema = z.object({
specVersion: z.literal('1'),
elements: z.array(z.unknown()),
}).strict();
const protocolResponseEnvelopeSchema = z.object({
protocolVersion: z.literal('2.0'),
assistantText: z.string(),
ui: protocolUiSchema.optional(),
intent: z.enum(['analyze', 'ask_input', 'propose_action', 'execute_action', 'summarize']),
needsInput: needsInputSchema,
actions: z.array(protocolActionSchema),
confidence: z.number().min(0).max(1),
traceId: z.string().min(1),
}).strict().superRefine((value, context) => {
if (value.needsInput.required && value.needsInput.fields.length === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['needsInput', 'fields'],
message: 'needsInput.fields must include at least one field when needsInput.required is true',
});
}
});
const protocolRequestEnvelopeSchema = z.object({
protocolVersion: z.literal('2.0'),
surface: z.enum(['tab', 'sidebar']),
messages: z.array(z.object({ role: z.enum(['user', 'assistant', 'system', 'tool']), content: z.string() }).strict()),
context: z.record(z.string(), z.unknown()),
capabilities: z.object({
widgets: z.array(z.string().min(1)),
actions: z.array(z.string().min(1)),
tools: z.array(z.string().min(1)),
disabled: z.array(z.string().min(1)).optional(),
}).strict(),
}).strict();
function toErrorMessage(prefix: string, issues: z.ZodIssue[]): string {
const firstIssue = issues[0];
const issuePath = firstIssue.path.length > 0 ? firstIssue.path.join('.') : 'root';
return `${prefix}: ${issuePath} ${firstIssue.message}`;
}
export function validateProtocolResponseEnvelope(input: unknown): ProtocolValidationResult<ProtocolResponseEnvelope> {
const parsed = protocolResponseEnvelopeSchema.safeParse(input);
if (!parsed.success) {
return {
ok: false,
error: createProtocolValidationError(
toErrorMessage('Invalid protocol response envelope', parsed.error.issues),
parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`),
),
};
}
return {
ok: true,
value: parsed.data,
};
}
export function validateProtocolRequestEnvelope(input: unknown): ProtocolValidationResult<ProtocolRequestEnvelope> {
const parsed = protocolRequestEnvelopeSchema.safeParse(input);
if (!parsed.success) {
return {
ok: false,
error: createProtocolValidationError(
toErrorMessage('Invalid protocol request envelope', parsed.error.issues),
parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`),
),
};
}
return {
ok: true,
value: parsed.data,
};
}
export type { ProtocolRequestEnvelope, ProtocolResponseEnvelope } from './types';

View File

@@ -1,50 +0,0 @@
import type { AgentTurnState } from './turnStateMachine';
export interface WorkflowCheckpoint {
conversationId: string;
state: AgentTurnState;
pendingFields: string[];
lastTraceId: string;
updatedAt: string;
}
export interface WorkflowCheckpointSettingsAdapter {
getSetting(key: string): Promise<string | null>;
setSetting(key: string, value: string): Promise<void>;
}
function keyForConversation(conversationId: string): string {
return `agui.workflow.${conversationId}`;
}
export class WorkflowCheckpointStore {
private readonly adapter: WorkflowCheckpointSettingsAdapter;
constructor(adapter: WorkflowCheckpointSettingsAdapter) {
this.adapter = adapter;
}
async save(checkpoint: WorkflowCheckpoint): Promise<void> {
await this.adapter.setSetting(
keyForConversation(checkpoint.conversationId),
JSON.stringify(checkpoint),
);
}
async load(conversationId: string): Promise<WorkflowCheckpoint | null> {
const raw = await this.adapter.getSetting(keyForConversation(conversationId));
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as WorkflowCheckpoint;
if (!parsed || parsed.conversationId !== conversationId) {
return null;
}
return parsed;
} catch {
return null;
}
}
}

View File

@@ -1,45 +0,0 @@
export type AgentTurnState =
| 'planning'
| 'awaiting_input'
| 'executing'
| 'observing'
| 'completed';
interface TurnStateEnvelopeInput {
intent: 'analyze' | 'ask_input' | 'propose_action' | 'execute_action' | 'summarize';
needsInput: {
required: boolean;
fields: Array<{ key: string }>;
};
}
interface TransitionInput {
previousState: AgentTurnState;
envelope: TurnStateEnvelopeInput;
}
export class AgentTurnStateMachine {
transition(input: TransitionInput): AgentTurnState {
if (input.envelope.needsInput.required && input.envelope.needsInput.fields.length > 0) {
return 'awaiting_input';
}
if (input.envelope.intent === 'execute_action') {
return 'executing';
}
if (input.envelope.intent === 'propose_action') {
return 'observing';
}
if (input.envelope.intent === 'summarize') {
return 'completed';
}
if (input.previousState === 'awaiting_input') {
return 'executing';
}
return 'planning';
}
}

View File

@@ -305,7 +305,7 @@ Your role is to help users manage their blog posts and media files using ONLY th
IMPORTANT: You do NOT have access to the internet, real-time data, or any external services. IMPORTANT: You do NOT have access to the internet, real-time data, or any external services.
You can ONLY access information through the tools listed below. Do not claim otherwise. You can ONLY access information through the tools listed below. Do not claim otherwise.
Available Tools: Available Data Tools:
- search_posts: Search blog posts using full-text search. Supports category/tag filters. - search_posts: Search blog posts using full-text search. Supports category/tag filters.
- read_post: Read the full content and metadata of a specific post by ID. - read_post: Read the full content and metadata of a specific post by ID.
- list_posts: List posts with optional filtering by status, category, or tags. - list_posts: List posts with optional filtering by status, category, or tags.
@@ -321,24 +321,24 @@ Available Tools:
- get_post_media: Get media files linked to a post (featured images, galleries). - get_post_media: Get media files linked to a post (featured images, galleries).
- get_media_posts: Get posts that use a specific media file. - get_media_posts: Get posts that use a specific media file.
Available UI Render Tools (use these to show rich interactive elements):
- render_chart: Show data as a bar, line, or pie chart. Use when presenting statistics or comparisons.
- render_table: Show data in a structured table. Use for tabular comparisons and listings.
- render_form: Show an interactive form to collect user input (e.g., metadata edits, settings).
- render_card: Show an information card with title, body, and action buttons.
- render_metric: Show a single KPI or statistic prominently.
- render_list: Show a bulleted list of items.
- render_tabs: Organize information into switchable tabs.
When answering questions: When answering questions:
1. USE THE TOOLS to find information. Never make up data about posts or media. 1. USE THE TOOLS to find information. Never make up data about posts or media.
2. If asked about something outside your tools (weather, news, websites), explain that you can only access the user's local blog content. 2. If asked about something outside your tools (weather, news, websites), explain that you can only access the user's local blog content.
3. Be concise and helpful. Format post information clearly when displaying it. 3. Be concise and helpful. Format post information clearly when displaying it.
4. If a search returns no results, suggest alternative queries or filters. 4. If a search returns no results, suggest alternative queries or filters.
5. When asked to describe or analyze an image, use the view_image tool to see the actual image content. 5. When asked to describe or analyze an image, use the view_image tool to see the actual image content.
6. When presenting data, statistics, or comparisons, prefer using render tools (render_chart, render_table, render_metric) to show rich interactive UI instead of plain text.
Agentic UI Contract: 7. When you need user input for a multi-field operation, use render_form to present a structured form.
- You may include structured UI payloads in your assistant response so the app can render interactive widgets. 8. Use render_card with action buttons when presenting items the user might want to navigate to (e.g., posts, media).`;
- You DO have the ability to return interactive AGUI payloads (including bar charts) as JSON, even though you cannot draw bitmap images.
- When the user asks for a chart or guided workflow, prefer returning a valid AGUI payload over refusing.
- Place the AGUI payload in the "ui" field of the protocol response envelope. DO NOT output markdown code blocks containing JSON.
- Prefer actionable widgets (cards, forms, tabs, inputs, metrics, tables, charts) when they reduce follow-up friction.
- Keep textual guidance and UI semantically consistent.
- Include only valid, supported action names. Supported actions include: openSettings, openPost, openMedia, openPanel, setActiveView, toggleSidebar, togglePanel, toggleAssistantSidebar.
- Supported element types include: text, metric, list, table, action, chart, form, input, datePicker, card, image, tabs.
- For tabs elements, include each tab with id, label, and nested elements.
- Never invent unsupported specVersion values or unsupported element/action names.`;
} }
/** /**

View File

@@ -16,13 +16,8 @@ import { ChatEngine } from './ChatEngine';
import { PostEngine } from './PostEngine'; import { PostEngine } from './PostEngine';
import { MediaEngine } from './MediaEngine'; import { MediaEngine } from './MediaEngine';
import { getPostMediaEngine } from './PostMediaEngine'; import { getPostMediaEngine } from './PostMediaEngine';
import { ProtocolResponseBuilder } from '../agentic/protocol/responseBuilder'; import { isRenderTool, generateFromToolCall } from '../a2ui/generator';
import { CapabilityRegistryService } from '../agentic/capabilities/registry'; import type { A2UIServerMessage } from '../a2ui/types';
import { validateProtocolRequestEnvelope, validateProtocolResponseEnvelope } from '../agentic/protocol/validator';
import type { ProtocolResponseEnvelope } from '../agentic/protocol/types';
import { AgentTurnStateMachine, type AgentTurnState } from '../agentic/workflow/turnStateMachine';
import { WorkflowCheckpointStore } from '../agentic/workflow/checkpointStore';
import { getProtocolTelemetryService } from '../agentic/observability/protocolTelemetry';
// OpenCode Zen API endpoints // OpenCode Zen API endpoints
const ZEN_ANTHROPIC_URL = 'https://opencode.ai/zen/v1/messages'; const ZEN_ANTHROPIC_URL = 'https://opencode.ai/zen/v1/messages';
@@ -79,15 +74,12 @@ export interface SendMessageOptions {
onDelta?: (delta: string) => void; onDelta?: (delta: string) => void;
onToolCall?: (toolCall: { name: string; args: unknown }) => void; onToolCall?: (toolCall: { name: string; args: unknown }) => void;
onToolResult?: (result: { name: string; result: unknown }) => void; onToolResult?: (result: { name: string; result: unknown }) => void;
onA2UIMessage?: (message: A2UIServerMessage) => void;
} }
export interface SendMessageResult { export interface SendMessageResult {
success: boolean; success: boolean;
message?: string; message?: string;
envelope?: ProtocolResponseEnvelope;
protocolVersion?: '2.0';
traceId?: string;
warnings?: string[];
error?: string; error?: string;
toolCalls?: Array<{ name: string; args: unknown }>; toolCalls?: Array<{ name: string; args: unknown }>;
} }
@@ -142,22 +134,9 @@ export class OpenCodeManager {
private postEngine: PostEngine; private postEngine: PostEngine;
private mediaEngine: MediaEngine; private mediaEngine: MediaEngine;
private getMainWindow: () => BrowserWindow | null; private getMainWindow: () => BrowserWindow | null;
private protocolResponseBuilder: ProtocolResponseBuilder;
private capabilityRegistry: CapabilityRegistryService;
private turnStateMachine: AgentTurnStateMachine;
private workflowCheckpointStore: WorkflowCheckpointStore;
private apiKey: string = ''; private apiKey: string = '';
private abortControllers: Map<string, AbortController> = new Map(); private abortControllers: Map<string, AbortController> = new Map();
private readonly protocolBoundaryInstructions = `Protocol response requirements (strict):
- Return a single JSON object that matches this exact envelope schema:
{"protocolVersion":"2.0","assistantText":"string","ui":{"specVersion":"1","elements":[]}?,"intent":"analyze|ask_input|propose_action|execute_action|summarize","needsInput":{"required":boolean,"fields":[]},"actions":[],"confidence":number,"traceId":"string"}
- Do not return any top-level shape other than this envelope.
- Do not use legacy top-level keys like title/widgets/tabs/content/data/widgets.
- ui, if present, must use specVersion "1" and canonical element structures only.
- DO NOT output markdown code blocks containing JSON. The entire response must be the JSON envelope.
- If uncertain, return an envelope with assistantText and empty actions/ui rather than alternative JSON formats.`;
constructor( constructor(
chatEngine: ChatEngine, chatEngine: ChatEngine,
postEngine: PostEngine, postEngine: PostEngine,
@@ -168,13 +147,6 @@ export class OpenCodeManager {
this.postEngine = postEngine; this.postEngine = postEngine;
this.mediaEngine = mediaEngine; this.mediaEngine = mediaEngine;
this.getMainWindow = getMainWindow; this.getMainWindow = getMainWindow;
this.protocolResponseBuilder = new ProtocolResponseBuilder();
this.capabilityRegistry = new CapabilityRegistryService();
this.turnStateMachine = new AgentTurnStateMachine();
this.workflowCheckpointStore = new WorkflowCheckpointStore({
getSetting: async (key: string) => this.chatEngine.getSetting(key),
setSetting: async (key: string, value: string) => this.chatEngine.setSetting(key, value),
});
} }
/** /**
@@ -271,7 +243,7 @@ export class OpenCodeManager {
userMessage: string, userMessage: string,
options: SendMessageOptions = {} options: SendMessageOptions = {}
): Promise<SendMessageResult> { ): Promise<SendMessageResult> {
const { metadata, onDelta, onToolCall, onToolResult } = options; const { metadata, onDelta, onToolCall, onToolResult, onA2UIMessage } = options;
try { try {
const readyCheck = await this.checkReady(); const readyCheck = await this.checkReady();
@@ -303,52 +275,30 @@ export class OpenCodeManager {
// Get system prompt // Get system prompt
const systemMessage = conversation.messages.find(m => m.role === 'system'); const systemMessage = conversation.messages.find(m => m.role === 'system');
const systemPrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt(); const systemPrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt();
const protocolSystemPrompt = `${systemPrompt}\n\n${this.protocolBoundaryInstructions}`;
// Build message history from DB (excluding system messages) // Build message history from DB (excluding system messages)
const dbMessages = conversation.messages.filter(m => m.role !== 'system'); const dbMessages = conversation.messages.filter(m => m.role !== 'system');
const surface = metadata?.surface || 'tab';
const capabilities = this.capabilityRegistry.getSnapshot({ surface });
const requestEnvelope = {
protocolVersion: '2.0' as const,
surface,
messages: dbMessages
.filter((message) => message.role === 'user' || message.role === 'assistant' || message.role === 'system' || message.role === 'tool')
.map((message) => ({
role: message.role,
content: message.content || '',
})),
context: {
conversationId,
modelId,
},
capabilities,
};
const requestValidation = validateProtocolRequestEnvelope(requestEnvelope);
if (!requestValidation.ok) {
return {
success: false,
error: requestValidation.error?.message || 'Invalid protocol request envelope',
};
}
const surfaceHint = metadata?.surface
? `\n\n[Client UI surface: ${metadata.surface}. Render response UI for this surface while keeping content functionally equivalent.]`
: '';
const capabilityHint = `\n\n[Protocol request envelope]\n${JSON.stringify(requestEnvelope, null, 2)}`;
const userMessageForModel = `${userMessage}${surfaceHint}${capabilityHint}`;
// Add the new user message // Add the new user message
dbMessages.push({ dbMessages.push({
conversationId, conversationId,
role: 'user', role: 'user',
content: userMessageForModel, content: userMessage,
createdAt: new Date(), createdAt: new Date(),
}); });
let fullResponse = ''; let fullResponse = '';
const toolCallsCollected: Array<{ name: string; args: unknown }> = []; const toolCallsCollected: Array<{ name: string; args: unknown }> = [];
// Wrap onA2UIMessage emission for render tools
const emitA2UIMessages = (messages: A2UIServerMessage[]) => {
if (onA2UIMessage) {
for (const msg of messages) {
onA2UIMessage(msg);
}
}
};
const requestProvider = async ( const requestProvider = async (
prompt: string, prompt: string,
messages: Array<{ role: string; content?: string; toolCalls?: string; toolCallId?: string }>, messages: Array<{ role: string; content?: string; toolCalls?: string; toolCallId?: string }>,
@@ -360,6 +310,8 @@ export class OpenCodeManager {
messages, messages,
abortController.signal, abortController.signal,
{ onDelta, onToolCall, onToolResult }, { onDelta, onToolCall, onToolResult },
conversationId,
emitA2UIMessages,
); );
} }
@@ -369,12 +321,14 @@ export class OpenCodeManager {
messages, messages,
abortController.signal, abortController.signal,
{ onDelta, onToolCall, onToolResult }, { onDelta, onToolCall, onToolResult },
conversationId,
emitA2UIMessages,
); );
}; };
try { try {
console.log('[OpenCodeManager] Sending to provider:', provider, 'model:', modelId); console.log('[OpenCodeManager] Sending to provider:', provider, 'model:', modelId);
const firstResult = await requestProvider(protocolSystemPrompt, dbMessages); const firstResult = await requestProvider(systemPrompt, dbMessages);
fullResponse = firstResult.content; fullResponse = firstResult.content;
toolCallsCollected.push(...firstResult.toolCalls); toolCallsCollected.push(...firstResult.toolCalls);
console.log('[OpenCodeManager] fullResponse length:', fullResponse.length); console.log('[OpenCodeManager] fullResponse length:', fullResponse.length);
@@ -384,92 +338,16 @@ export class OpenCodeManager {
if (!isAborted) { if (!isAborted) {
throw error; throw error;
} }
// On abort, keep whatever was streamed so far (already in fullResponse or empty)
} finally { } finally {
this.abortControllers.delete(conversationId); this.abortControllers.delete(conversationId);
} }
const isCanonicalProtocolEnvelope = (() => { // Save assistant response to history
try {
const parsed = JSON.parse(fullResponse);
const validated = validateProtocolResponseEnvelope(parsed);
return validated.ok;
} catch {
return false;
}
})();
let protocolResult = this.protocolResponseBuilder.build({
rawAssistantOutput: fullResponse,
surface,
capabilities,
});
if (!isCanonicalProtocolEnvelope && fullResponse.trim().length > 0 && !abortController.signal.aborted) {
const retryReason = protocolResult.validationError?.message || 'previous output was not a canonical protocol envelope';
const retryPrompt = `Your previous output failed protocol validation: ${retryReason}.\nReturn ONLY one valid protocol envelope JSON object and nothing else.`;
const retryMessages = [
...dbMessages,
{
conversationId,
role: 'assistant',
content: fullResponse,
createdAt: new Date(),
},
{
conversationId,
role: 'user',
content: retryPrompt,
createdAt: new Date(),
},
];
try {
const retryResult = await requestProvider(protocolSystemPrompt, retryMessages);
fullResponse = retryResult.content;
toolCallsCollected.push(...retryResult.toolCalls);
protocolResult = this.protocolResponseBuilder.build({
rawAssistantOutput: fullResponse,
surface,
capabilities,
});
} catch (error) {
console.error('[OpenCodeManager] Protocol retry failed:', (error as Error).message);
}
}
const previousCheckpoint = await this.workflowCheckpointStore.load(conversationId);
const previousState: AgentTurnState = previousCheckpoint?.state || 'planning';
const nextState = this.turnStateMachine.transition({
previousState,
envelope: {
intent: protocolResult.envelope.intent,
needsInput: protocolResult.envelope.needsInput,
},
});
await this.workflowCheckpointStore.save({
conversationId,
state: nextState,
pendingFields: protocolResult.envelope.needsInput.fields.map((field) => field.key),
lastTraceId: protocolResult.envelope.traceId,
updatedAt: new Date().toISOString(),
});
const blockedActionWarnings = protocolResult.warnings.filter((warning) => warning.includes('Blocked unsupported action'));
getProtocolTelemetryService().recordTurn({
validEnvelope: !protocolResult.validationError,
repairAttempted: protocolResult.repairAttempted,
fallbackUsed: Boolean(protocolResult.validationError),
blockedActions: blockedActionWarnings.length,
});
// Save normalized assistant response to history so transcript does not render raw protocol JSON.
if (fullResponse) { if (fullResponse) {
await this.chatEngine.addMessage({ await this.chatEngine.addMessage({
conversationId, conversationId,
role: 'assistant', role: 'assistant',
content: protocolResult.envelope.assistantText, content: fullResponse,
toolCalls: toolCallsCollected.length > 0 ? JSON.stringify(toolCallsCollected) : undefined, toolCalls: toolCallsCollected.length > 0 ? JSON.stringify(toolCallsCollected) : undefined,
createdAt: new Date(), createdAt: new Date(),
}); });
@@ -485,11 +363,7 @@ export class OpenCodeManager {
return { return {
success: true, success: true,
message: protocolResult.envelope.assistantText, message: fullResponse,
envelope: protocolResult.envelope,
protocolVersion: protocolResult.envelope.protocolVersion,
traceId: protocolResult.traceId,
warnings: protocolResult.warnings,
toolCalls: toolCallsCollected.length > 0 ? toolCallsCollected : undefined, toolCalls: toolCallsCollected.length > 0 ? toolCallsCollected : undefined,
}; };
} catch (error) { } catch (error) {
@@ -510,7 +384,9 @@ export class OpenCodeManager {
onDelta?: (delta: string) => void; onDelta?: (delta: string) => void;
onToolCall?: (toolCall: { name: string; args: unknown }) => void; onToolCall?: (toolCall: { name: string; args: unknown }) => void;
onToolResult?: (result: { name: string; result: unknown }) => void; onToolResult?: (result: { name: string; result: unknown }) => void;
} },
conversationId: string,
emitA2UIMessages: (messages: A2UIServerMessage[]) => void,
): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> { ): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> {
const tools = this.getToolDefinitions(); const tools = this.getToolDefinitions();
const allToolCalls: Array<{ name: string; args: unknown }> = []; const allToolCalls: Array<{ name: string; args: unknown }> = [];
@@ -601,6 +477,29 @@ export class OpenCodeManager {
callbacks.onToolCall({ name: toolName, args: toolArgs }); callbacks.onToolCall({ name: toolName, args: toolArgs });
} }
// Check if this is a render tool — generate A2UI messages instead of executing
if (isRenderTool(toolName)) {
const a2uiMessages = generateFromToolCall(
conversationId,
toolName,
toolArgs as Record<string, unknown>,
);
if (a2uiMessages) {
emitA2UIMessages(a2uiMessages);
}
if (callbacks.onToolResult) {
callbacks.onToolResult({ name: toolName, result: { success: true, rendered: true } });
}
toolResults.push({
type: 'tool_result',
tool_use_id: toolUseId,
content: JSON.stringify({ success: true, rendered: true }),
});
continue;
}
// Execute the tool // Execute the tool
const result = await this.executeTool(toolName, toolArgs as Record<string, unknown>); const result = await this.executeTool(toolName, toolArgs as Record<string, unknown>);
@@ -673,7 +572,9 @@ export class OpenCodeManager {
onDelta?: (delta: string) => void; onDelta?: (delta: string) => void;
onToolCall?: (toolCall: { name: string; args: unknown }) => void; onToolCall?: (toolCall: { name: string; args: unknown }) => void;
onToolResult?: (result: { name: string; result: unknown }) => void; onToolResult?: (result: { name: string; result: unknown }) => void;
} },
conversationId: string,
emitA2UIMessages: (messages: A2UIServerMessage[]) => void,
): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> { ): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> {
// Build OpenAI-format messages // Build OpenAI-format messages
const messages: Array<Record<string, unknown>> = [ const messages: Array<Record<string, unknown>> = [
@@ -787,6 +688,25 @@ export class OpenCodeManager {
callbacks.onToolCall({ name: toolName, args: toolArgs }); callbacks.onToolCall({ name: toolName, args: toolArgs });
} }
// Check if this is a render tool
if (isRenderTool(toolName)) {
const a2uiMessages = generateFromToolCall(conversationId, toolName, toolArgs);
if (a2uiMessages) {
emitA2UIMessages(a2uiMessages);
}
if (callbacks.onToolResult) {
callbacks.onToolResult({ name: toolName, result: { success: true, rendered: true } });
}
messages.push({
role: 'tool',
content: JSON.stringify({ success: true, rendered: true }),
tool_call_id: toolCall.id,
});
continue;
}
const result = await this.executeTool(toolName, toolArgs); const result = await this.executeTool(toolName, toolArgs);
if (callbacks.onToolResult) { if (callbacks.onToolResult) {
@@ -978,6 +898,156 @@ export class OpenCodeManager {
required: ['mediaId'], required: ['mediaId'],
}, },
}, },
// ── A2UI Render Tools ──
{
name: 'render_chart',
description: 'Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization. The chart will be displayed as a rich UI element in the conversation.',
input_schema: {
type: 'object',
properties: {
chartType: { type: 'string', enum: ['bar', 'line', 'pie'], description: 'The type of chart to render' },
title: { type: 'string', description: 'Optional chart title' },
series: {
type: 'array',
items: {
type: 'object',
properties: {
label: { type: 'string', description: 'Data point label' },
value: { type: 'number', description: 'Data point value' },
},
required: ['label', 'value'],
},
description: 'Array of data points with label and value',
},
},
required: ['chartType', 'series'],
},
},
{
name: 'render_table',
description: 'Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information. The table will be displayed as a rich UI element.',
input_schema: {
type: 'object',
properties: {
title: { type: 'string', description: 'Optional table title' },
columns: { type: 'array', items: { type: 'string' }, description: 'Column header names' },
rows: { type: 'array', items: { type: 'array', items: { type: 'string' } }, description: 'Table rows, each row is an array of cell values' },
},
required: ['columns', 'rows'],
},
},
{
name: 'render_form',
description: 'Render an interactive form in the chat UI. Use this when you need to collect structured input from the user, such as metadata updates, configuration, or multi-field data entry.',
input_schema: {
type: 'object',
properties: {
title: { type: 'string', description: 'Optional form title' },
fields: {
type: 'array',
items: {
type: 'object',
properties: {
key: { type: 'string', description: 'Field identifier' },
label: { type: 'string', description: 'Field label shown to user' },
inputType: { type: 'string', enum: ['text', 'textarea', 'select', 'checkbox', 'date', 'number'], description: 'Type of input control' },
placeholder: { type: 'string', description: 'Placeholder text' },
defaultValue: { description: 'Default value for the field' },
options: { type: 'array', items: { type: 'object', properties: { label: { type: 'string' }, value: { type: 'string' } }, required: ['label', 'value'] }, description: 'Options for select fields' },
required: { type: 'boolean', description: 'Whether the field is required' },
},
required: ['key', 'label', 'inputType'],
},
description: 'Form fields to display',
},
submitLabel: { type: 'string', description: 'Label for the submit button' },
submitAction: { type: 'string', description: 'Action to dispatch on submit' },
},
required: ['fields', 'submitLabel'],
},
},
{
name: 'render_card',
description: 'Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item with a title, body, and optional action buttons.',
input_schema: {
type: 'object',
properties: {
title: { type: 'string', description: 'Card title' },
body: { type: 'string', description: 'Card body text (supports markdown)' },
subtitle: { type: 'string', description: 'Optional subtitle' },
actions: {
type: 'array',
items: {
type: 'object',
properties: {
label: { type: 'string', description: 'Button label' },
action: { type: 'string', description: 'Action name to dispatch (e.g., openPost, openMedia)' },
payload: { type: 'object', description: 'Optional action payload' },
},
required: ['label', 'action'],
},
description: 'Optional action buttons on the card',
},
},
required: ['title', 'body'],
},
},
{
name: 'render_metric',
description: 'Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label, such as post counts, statistics, or status indicators.',
input_schema: {
type: 'object',
properties: {
label: { type: 'string', description: 'Metric label' },
value: { type: 'string', description: 'Metric value (displayed prominently)' },
},
required: ['label', 'value'],
},
},
{
name: 'render_list',
description: 'Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.',
input_schema: {
type: 'object',
properties: {
title: { type: 'string', description: 'Optional list title' },
items: { type: 'array', items: { type: 'string' }, description: 'List items' },
},
required: ['items'],
},
},
{
name: 'render_tabs',
description: 'Render a tabbed interface in the chat UI. Use this when you want to organize information into multiple tabs that the user can switch between.',
input_schema: {
type: 'object',
properties: {
tabs: {
type: 'array',
items: {
type: 'object',
properties: {
label: { type: 'string', description: 'Tab label' },
content: {
type: 'array',
items: {
type: 'object',
properties: {
type: { type: 'string', enum: ['text', 'metric', 'list'], description: 'Content type' },
},
required: ['type'],
},
description: 'Content items within the tab',
},
},
required: ['label', 'content'],
},
description: 'Array of tabs',
},
},
required: ['tabs'],
},
},
]; ];
} }

View File

@@ -8,7 +8,6 @@ import { OpenCodeManager } from '../engine/OpenCodeManager';
import { getPostEngine } from '../engine/PostEngine'; import { getPostEngine } from '../engine/PostEngine';
import { getMediaEngine } from '../engine/MediaEngine'; import { getMediaEngine } from '../engine/MediaEngine';
import { getDatabase } from '../database'; import { getDatabase } from '../database';
import { getProtocolTelemetryService } from '../agentic/observability/protocolTelemetry';
let chatEngine: ChatEngine | null = null; let chatEngine: ChatEngine | null = null;
let openCodeManager: OpenCodeManager | null = null; let openCodeManager: OpenCodeManager | null = null;
@@ -136,10 +135,6 @@ export function registerChatHandlers(): void {
// ============ Chat Settings ============ // ============ Chat Settings ============
ipcMain.handle('chat:getProtocolHealth', async () => {
return getProtocolTelemetryService().getSnapshot();
});
// Get available models // Get available models
ipcMain.handle('chat:getAvailableModels', async () => { ipcMain.handle('chat:getAvailableModels', async () => {
try { try {
@@ -283,6 +278,11 @@ export function registerChatHandlers(): void {
mainWindow.webContents.send('chat-tool-result', { conversationId, result }); mainWindow.webContents.send('chat-tool-result', { conversationId, result });
} }
}, },
onA2UIMessage: (message) => {
if (mainWindow) {
mainWindow.webContents.send('a2ui-message', { conversationId, message });
}
},
}); });
return result; return result;
@@ -379,6 +379,20 @@ export function registerChatHandlers(): void {
return { success: false, error: (error as Error).message }; return { success: false, error: (error as Error).message };
} }
}); });
// ============ A2UI Actions ============
ipcMain.handle('a2ui:dispatch', async (_, action: { surfaceId: string; componentId: string; action: string; payload?: Record<string, unknown> }) => {
try {
console.log('[Chat IPC] A2UI action dispatched:', action);
// Currently, A2UI actions are handled client-side (navigation, UI toggles).
// Server-side action handling can be added here in the future.
return { success: true };
} catch (error) {
console.error('[Chat IPC] Error dispatching A2UI action:', error);
return { success: false, error: (error as Error).message };
}
});
} }
/** /**

View File

@@ -286,7 +286,6 @@ export const electronAPI: ElectronAPI = {
getApiKey: () => ipcRenderer.invoke('chat:getApiKey'), getApiKey: () => ipcRenderer.invoke('chat:getApiKey'),
// Settings // Settings
getProtocolHealth: () => ipcRenderer.invoke('chat:getProtocolHealth'),
getAvailableModels: () => ipcRenderer.invoke('chat:getAvailableModels'), getAvailableModels: () => ipcRenderer.invoke('chat:getAvailableModels'),
setDefaultModel: (modelId: string) => ipcRenderer.invoke('chat:setDefaultModel', modelId), setDefaultModel: (modelId: string) => ipcRenderer.invoke('chat:setDefaultModel', modelId),
getSystemPrompt: () => ipcRenderer.invoke('chat:getSystemPrompt'), getSystemPrompt: () => ipcRenderer.invoke('chat:getSystemPrompt'),
@@ -334,6 +333,14 @@ export const electronAPI: ElectronAPI = {
ipcRenderer.on('chat-title-updated', subscription); ipcRenderer.on('chat-title-updated', subscription);
return () => ipcRenderer.removeListener('chat-title-updated', subscription); return () => ipcRenderer.removeListener('chat-title-updated', subscription);
}, },
// A2UI streaming
onA2UIMessage: (callback: (data: { conversationId: string; message: import('./a2ui/types').A2UIServerMessage }) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; message: import('./a2ui/types').A2UIServerMessage }) => callback(data);
ipcRenderer.on('a2ui-message', subscription);
return () => ipcRenderer.removeListener('a2ui-message', subscription);
},
dispatchA2UIAction: (action: import('./a2ui/types').A2UIClientAction) => ipcRenderer.invoke('a2ui:dispatch', action),
}, },
// Event listeners // Event listeners

View File

@@ -435,52 +435,9 @@ export interface ChatSendMetadata {
surface?: 'tab' | 'sidebar'; surface?: 'tab' | 'sidebar';
} }
export interface ProtocolNeedsInputField { // A2UI types imported for use in ElectronAPI and re-exported for renderer
key: string; import type { A2UIServerMessage, A2UIClientAction } from '../a2ui/types';
label: string; export type { A2UIServerMessage, A2UIClientAction };
inputType: 'text' | 'textarea' | 'select' | 'checkbox' | 'date' | 'number';
required?: boolean;
options?: Array<{ label: string; value: string }>;
placeholder?: string;
defaultValue?: string | number | boolean;
}
export interface ProtocolAction {
id: string;
action: string;
label?: string;
payload?: Record<string, unknown>;
policy: 'silent' | 'confirm' | 'danger';
requiresConfirmation: boolean;
}
export interface ProtocolResponseEnvelope {
protocolVersion: '2.0';
assistantText: string;
ui?: {
specVersion: '1';
elements: unknown[];
};
intent: 'analyze' | 'ask_input' | 'propose_action' | 'execute_action' | 'summarize';
needsInput: {
required: boolean;
fields: ProtocolNeedsInputField[];
};
actions: ProtocolAction[];
confidence: number;
traceId: string;
}
export interface ProtocolTelemetrySnapshot {
totalTurns: number;
validEnvelopeTurns: number;
repairAttempts: number;
fallbackTurns: number;
blockedActionCount: number;
parseValidityRate: number;
repairRate: number;
fallbackRate: number;
}
export interface SiteValidationReport { export interface SiteValidationReport {
sitemapPath: string; sitemapPath: string;
@@ -764,7 +721,6 @@ export interface ElectronAPI {
getApiKey: () => Promise<ChatApiKeyStatus>; getApiKey: () => Promise<ChatApiKeyStatus>;
// Settings // Settings
getProtocolHealth: () => Promise<ProtocolTelemetrySnapshot>;
getAvailableModels: () => Promise<{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }>; getAvailableModels: () => Promise<{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }>;
setDefaultModel: (modelId: string) => Promise<{ success: boolean; error?: string }>; setDefaultModel: (modelId: string) => Promise<{ success: boolean; error?: string }>;
getSystemPrompt: () => Promise<{ success: boolean; prompt?: string; error?: string }>; getSystemPrompt: () => Promise<{ success: boolean; prompt?: string; error?: string }>;
@@ -778,7 +734,7 @@ export interface ElectronAPI {
deleteConversation: (id: string) => Promise<boolean>; deleteConversation: (id: string) => Promise<boolean>;
// Messaging // Messaging
sendMessage: (conversationId: string, message: string, metadata?: ChatSendMetadata) => Promise<{ success: boolean; message?: string; envelope?: ProtocolResponseEnvelope; protocolVersion?: '2.0'; traceId?: string; warnings?: string[]; error?: string }>; sendMessage: (conversationId: string, message: string, metadata?: ChatSendMetadata) => Promise<{ success: boolean; message?: string; error?: string }>;
addSystemEvent: (conversationId: string, content: string) => Promise<{ success: boolean; error?: string }>; addSystemEvent: (conversationId: string, content: string) => Promise<{ success: boolean; error?: string }>;
abortMessage: (conversationId: string) => Promise<void>; abortMessage: (conversationId: string) => Promise<void>;
getHistory: (conversationId: string) => Promise<ChatMessage[]>; getHistory: (conversationId: string) => Promise<ChatMessage[]>;
@@ -796,6 +752,10 @@ export interface ElectronAPI {
onToolCall: (callback: (data: ChatToolCall) => void) => () => void; onToolCall: (callback: (data: ChatToolCall) => void) => () => void;
onToolResult: (callback: (data: ChatToolResult) => void) => () => void; onToolResult: (callback: (data: ChatToolResult) => void) => () => void;
onTitleUpdated: (callback: (data: ChatTitleUpdate) => void) => () => void; onTitleUpdated: (callback: (data: ChatTitleUpdate) => void) => () => void;
// A2UI streaming
onA2UIMessage: (callback: (data: { conversationId: string; message: A2UIServerMessage }) => void) => () => void;
dispatchA2UIAction: (action: A2UIClientAction) => Promise<{ success: boolean; error?: string }>;
}; };
on: (channel: string, callback: (...args: unknown[]) => void) => () => void; on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
once: (channel: string, callback: (...args: unknown[]) => void) => void; once: (channel: string, callback: (...args: unknown[]) => void) => void;

View File

@@ -0,0 +1,101 @@
/**
* A2UI Renderer
*
* Maps A2UI resolved component trees to React components.
* Uses the component catalog to look up renderers for each component type.
*/
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../main/a2ui/types';
import { A2UIText } from './components/A2UIText';
import { A2UIButton } from './components/A2UIButton';
import { A2UICard } from './components/A2UICard';
import { A2UIChart } from './components/A2UIChart';
import { A2UITable } from './components/A2UITable';
import { A2UIForm } from './components/A2UIForm';
import { A2UITextField } from './components/A2UITextField';
import { A2UICheckBox } from './components/A2UICheckBox';
import { A2UIDateTimeInput } from './components/A2UIDateTimeInput';
import { A2UIChoicePicker } from './components/A2UIChoicePicker';
import { A2UIImage } from './components/A2UIImage';
import { A2UITabs } from './components/A2UITabs';
import { A2UIMetric } from './components/A2UIMetric';
import { A2UIList } from './components/A2UIList';
import { A2UIRow } from './components/A2UIRow';
import { A2UIColumn } from './components/A2UIColumn';
import { A2UIDivider } from './components/A2UIDivider';
export interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
type ComponentRenderer = React.FC<A2UIComponentProps>;
const COMPONENT_REGISTRY: Record<string, ComponentRenderer> = {
text: A2UIText,
button: A2UIButton,
card: A2UICard,
chart: A2UIChart,
table: A2UITable,
form: A2UIForm,
textField: A2UITextField,
checkBox: A2UICheckBox,
dateTimeInput: A2UIDateTimeInput,
choicePicker: A2UIChoicePicker,
image: A2UIImage,
tabs: A2UITabs,
metric: A2UIMetric,
list: A2UIList,
row: A2UIRow,
column: A2UIColumn,
divider: A2UIDivider,
};
interface A2UIRendererProps {
surfaceId: string;
tree: A2UIResolvedComponent[];
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
}
export const A2UIRenderer: React.FC<A2UIRendererProps> = ({
surfaceId,
tree,
onAction,
onDataChange,
}) => {
const renderComponent = (component: A2UIResolvedComponent): React.ReactNode => {
const Renderer = COMPONENT_REGISTRY[component.type];
if (!Renderer) {
return null;
}
const renderChildren = (children: A2UIResolvedComponent[]): React.ReactNode =>
children.map(renderComponent);
return (
<Renderer
key={component.id}
component={component}
surfaceId={surfaceId}
onAction={onAction}
onDataChange={onDataChange}
renderChildren={renderChildren}
/>
);
};
if (tree.length === 0) {
return null;
}
return (
<div className="a2ui-surface assistant-panel-controls chat-surface-section">
{tree.map(renderComponent)}
</div>
);
};

View File

@@ -0,0 +1,244 @@
/**
* A2UI Surface Manager
*
* Client-side state manager that processes incoming A2UI server messages
* and maintains surface state (component buffer, data model, component tree).
*
* This is a pure state manager with no React dependency — it can be tested
* independently and wrapped by a React hook.
*/
import type {
A2UIServerMessage,
A2UISurfaceState,
A2UIResolvedComponent,
} from '../../main/a2ui/types';
export type SurfaceChangeListener = (surfaceId: string) => void;
export class A2UISurfaceManager {
private surfaces = new Map<string, A2UISurfaceState>();
private listeners: SurfaceChangeListener[] = [];
/**
* Process an incoming A2UI server message.
*/
processMessage(message: A2UIServerMessage): void {
switch (message.type) {
case 'createSurface':
this.surfaces.set(message.surfaceId, {
surfaceId: message.surfaceId,
conversationId: message.conversationId,
components: new Map(),
rootIds: [],
dataModel: {},
metadata: message.metadata,
});
this.notify(message.surfaceId);
break;
case 'updateComponents': {
const surface = this.surfaces.get(message.surfaceId);
if (!surface) {
return;
}
for (const component of message.components) {
surface.components.set(component.id, component);
}
if (message.rootIds) {
surface.rootIds = message.rootIds;
}
this.notify(message.surfaceId);
break;
}
case 'updateDataModel': {
const surface = this.surfaces.get(message.surfaceId);
if (!surface) {
return;
}
setValueAtPointer(surface.dataModel, message.path, message.value);
this.notify(message.surfaceId);
break;
}
case 'deleteSurface':
this.surfaces.delete(message.surfaceId);
this.notify(message.surfaceId);
break;
}
}
/**
* Get all active surface IDs for a conversation.
*/
getSurfaceIds(conversationId: string): string[] {
const ids: string[] = [];
for (const [surfaceId, state] of this.surfaces) {
if (state.conversationId === conversationId) {
ids.push(surfaceId);
}
}
return ids;
}
/**
* Get raw surface state.
*/
getSurface(surfaceId: string): A2UISurfaceState | undefined {
return this.surfaces.get(surfaceId);
}
/**
* Resolve the component tree for a surface.
* Converts flat component buffer + ID references into a nested tree.
*/
resolveTree(surfaceId: string): A2UIResolvedComponent[] {
const surface = this.surfaces.get(surfaceId);
if (!surface) {
return [];
}
return surface.rootIds
.map((id) => this.resolveComponent(surface, id))
.filter((c): c is A2UIResolvedComponent => c !== null);
}
/**
* Update the local data model value (for input binding).
*/
updateLocalData(surfaceId: string, path: string, value: unknown): void {
const surface = this.surfaces.get(surfaceId);
if (!surface) {
return;
}
setValueAtPointer(surface.dataModel, path, value);
this.notify(surfaceId);
}
/**
* Get the data model for a surface.
*/
getDataModel(surfaceId: string): Record<string, unknown> {
return this.surfaces.get(surfaceId)?.dataModel ?? {};
}
/**
* Delete all surfaces for a conversation.
*/
clearConversation(conversationId: string): void {
const toDelete: string[] = [];
for (const [surfaceId, state] of this.surfaces) {
if (state.conversationId === conversationId) {
toDelete.push(surfaceId);
}
}
for (const surfaceId of toDelete) {
this.surfaces.delete(surfaceId);
this.notify(surfaceId);
}
}
/**
* Subscribe to surface changes.
*/
onChange(listener: SurfaceChangeListener): () => void {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter((l) => l !== listener);
};
}
private notify(surfaceId: string): void {
for (const listener of this.listeners) {
listener(surfaceId);
}
}
private resolveComponent(
surface: A2UISurfaceState,
componentId: string,
): A2UIResolvedComponent | null {
const component = surface.components.get(componentId);
if (!component) {
return null;
}
const children = (component.children ?? [])
.map((childId) => this.resolveComponent(surface, childId))
.filter((c): c is A2UIResolvedComponent => c !== null);
let boundValue: unknown = undefined;
if (component.dataBinding) {
boundValue = getValueAtPointer(surface.dataModel, component.dataBinding);
}
return {
id: component.id,
type: component.type,
properties: component.properties,
dataBinding: component.dataBinding,
boundValue,
actions: component.actions,
children,
};
}
}
/**
* Get a value from a JSON object using a JSON Pointer (RFC 6901).
*/
export function getValueAtPointer(
obj: Record<string, unknown>,
pointer: string,
): unknown {
if (!pointer || pointer === '/') {
return obj;
}
const parts = pointer.split('/').filter(Boolean);
let current: unknown = obj;
for (const part of parts) {
const key = part.replace(/~1/g, '/').replace(/~0/g, '~');
if (current === null || current === undefined || typeof current !== 'object') {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current;
}
/**
* Set a value in a JSON object using a JSON Pointer (RFC 6901).
*/
export function setValueAtPointer(
obj: Record<string, unknown>,
pointer: string,
value: unknown,
): void {
if (!pointer || pointer === '/') {
return;
}
const parts = pointer.split('/').filter(Boolean);
let current: Record<string, unknown> = obj;
for (let i = 0; i < parts.length - 1; i++) {
const key = parts[i].replace(/~1/g, '/').replace(/~0/g, '~');
if (!current[key] || typeof current[key] !== 'object') {
current[key] = {};
}
current = current[key] as Record<string, unknown>;
}
const lastKey = parts[parts.length - 1].replace(/~1/g, '/').replace(/~0/g, '~');
current[lastKey] = value;
}

View File

@@ -0,0 +1,41 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UIButton: React.FC<A2UIComponentProps> = ({ component, surfaceId, onAction }) => {
const label = String(component.properties.label ?? '');
const handleClick = () => {
const actionDef = component.actions?.[0];
if (!actionDef) {
return;
}
if (actionDef.policy === 'confirm' || actionDef.policy === 'danger') {
const confirmed = window.confirm(label || actionDef.action);
if (!confirmed) {
return;
}
}
onAction({
surfaceId,
componentId: component.id,
action: actionDef.action,
payload: actionDef.payload,
});
};
return (
<button type="button" onClick={handleClick}>
{label}
</button>
);
};

View File

@@ -0,0 +1,54 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UICard: React.FC<A2UIComponentProps> = ({ component, surfaceId, onAction }) => {
const title = String(component.properties.title ?? '');
const body = String(component.properties.body ?? '');
const subtitle = component.properties.subtitle as string | undefined;
const actions = component.actions ?? [];
const triggerAction = (actionDef: typeof actions[number]) => {
if (actionDef.policy === 'confirm' || actionDef.policy === 'danger') {
const confirmed = window.confirm(actionDef.action);
if (!confirmed) {
return;
}
}
onAction({
surfaceId,
componentId: component.id,
action: actionDef.action,
payload: actionDef.payload,
});
};
return (
<article className="assistant-panel-card">
<h4>{title}</h4>
{subtitle && <p className="assistant-panel-card-subtitle">{subtitle}</p>}
<p>{body}</p>
{actions.length > 0 && (
<div className="assistant-panel-card-actions">
{actions.map((actionDef, index) => (
<button
key={`${component.id}-action-${index}`}
type="button"
onClick={() => triggerAction(actionDef)}
>
{String(actionDef.payload?.label ?? actionDef.action)}
</button>
))}
</div>
)}
</article>
);
};

View File

@@ -0,0 +1,36 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
interface SeriesEntry {
label: string;
value: number;
}
export const A2UIChart: React.FC<A2UIComponentProps> = ({ component }) => {
const chartType = String(component.properties.chartType ?? 'bar');
const title = component.properties.title as string | undefined;
const series = (component.boundValue as SeriesEntry[]) ?? [];
const maxValue = Math.max(...series.map((entry) => entry.value), 0);
return (
<div className="assistant-panel-chart">
{title && <p className="assistant-panel-chart-title">{title}</p>}
<div className="assistant-panel-chart-type">{chartType}</div>
{series.map((entry, index) => (
<div key={`${component.id}-series-${index}`} className="assistant-panel-chart-item">
<span>{entry.label}</span>
<progress value={entry.value} max={maxValue || 1} />
<span>{entry.value}</span>
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,32 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UICheckBox: React.FC<A2UIComponentProps> = ({ component, surfaceId, onDataChange }) => {
const label = String(component.properties.label ?? '');
const checked = Boolean(component.boundValue ?? false);
const handleChange = (newChecked: boolean) => {
if (onDataChange && component.dataBinding) {
onDataChange(surfaceId, component.dataBinding, newChecked);
}
};
return (
<label className="assistant-panel-checkbox">
<input
type="checkbox"
checked={checked}
onChange={(e) => handleChange(e.target.checked)}
/>
<span>{label}</span>
</label>
);
};

View File

@@ -0,0 +1,44 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
interface ChoiceOption {
label: string;
value: string;
}
export const A2UIChoicePicker: React.FC<A2UIComponentProps> = ({ component, surfaceId, onDataChange }) => {
const label = String(component.properties.label ?? '');
const options = (component.properties.options as ChoiceOption[]) ?? [];
const value = String(component.boundValue ?? options[0]?.value ?? '');
const handleChange = (newValue: string) => {
if (onDataChange && component.dataBinding) {
onDataChange(surfaceId, component.dataBinding, newValue);
}
};
return (
<div className="assistant-panel-widget-block">
<label className="assistant-panel-widget-label">{label}</label>
<select
className="assistant-panel-widget-input chat-surface-input"
value={value}
onChange={(e) => handleChange(e.target.value)}
>
{options.map((option) => (
<option key={`${component.id}-opt-${option.value}`} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
};

View File

@@ -0,0 +1,18 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UIColumn: React.FC<A2UIComponentProps> = ({ component, renderChildren }) => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{renderChildren?.(component.children)}
</div>
);
};

View File

@@ -0,0 +1,37 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UIDateTimeInput: React.FC<A2UIComponentProps> = ({ component, surfaceId, onDataChange }) => {
const label = String(component.properties.label ?? '');
const min = component.properties.min as string | undefined;
const max = component.properties.max as string | undefined;
const value = String(component.boundValue ?? '');
const handleChange = (newValue: string) => {
if (onDataChange && component.dataBinding) {
onDataChange(surfaceId, component.dataBinding, newValue);
}
};
return (
<div className="assistant-panel-widget-block">
<label className="assistant-panel-widget-label">{label}</label>
<input
className="assistant-panel-widget-input chat-surface-input"
type="date"
value={value}
min={min}
max={max}
onChange={(e) => handleChange(e.target.value)}
/>
</div>
);
};

View File

@@ -0,0 +1,14 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UIDivider: React.FC<A2UIComponentProps> = () => {
return <hr />;
};

View File

@@ -0,0 +1,21 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UIForm: React.FC<A2UIComponentProps> = ({ component, renderChildren }) => {
const title = component.properties.title as string | undefined;
return (
<div className="assistant-panel-form">
{title && <p className="assistant-panel-form-title">{title}</p>}
{renderChildren?.(component.children)}
</div>
);
};

View File

@@ -0,0 +1,48 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UIImage: React.FC<A2UIComponentProps> = ({ component, surfaceId, onAction }) => {
const src = String(component.properties.src ?? '');
const alt = String(component.properties.alt ?? '');
const caption = component.properties.caption as string | undefined;
const actionDef = component.actions?.[0];
const handleClick = () => {
if (!actionDef) {
return;
}
if (actionDef.policy === 'confirm' || actionDef.policy === 'danger') {
const confirmed = window.confirm(caption || alt || actionDef.action);
if (!confirmed) {
return;
}
}
onAction({
surfaceId,
componentId: component.id,
action: actionDef.action,
payload: actionDef.payload,
});
};
return (
<figure className="assistant-panel-image">
<img
src={src}
alt={alt}
onClick={handleClick}
/>
{caption && <figcaption>{caption}</figcaption>}
</figure>
);
};

View File

@@ -0,0 +1,26 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UIList: React.FC<A2UIComponentProps> = ({ component }) => {
const title = component.properties.title as string | undefined;
const items = (component.boundValue as string[]) ?? [];
return (
<div>
{title && <p>{title}</p>}
<ul>
{items.map((item, index) => (
<li key={`${component.id}-item-${index}`}>{item}</li>
))}
</ul>
</div>
);
};

View File

@@ -0,0 +1,22 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UIMetric: React.FC<A2UIComponentProps> = ({ component }) => {
const label = String(component.properties.label ?? '');
const value = String(component.properties.value ?? '');
return (
<div className="assistant-panel-metric">
<span className="assistant-panel-metric-label">{label}</span>
<strong className="assistant-panel-metric-value">{value}</strong>
</div>
);
};

View File

@@ -0,0 +1,18 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UIRow: React.FC<A2UIComponentProps> = ({ component, renderChildren }) => {
return (
<div style={{ display: 'flex', flexDirection: 'row', gap: '8px' }}>
{renderChildren?.(component.children)}
</div>
);
};

View File

@@ -0,0 +1,40 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UITable: React.FC<A2UIComponentProps> = ({ component }) => {
const columns = (component.properties.columns as string[]) ?? [];
const rows = (component.boundValue as string[][]) ?? [];
const title = component.properties.title as string | undefined;
return (
<div>
{title && <p>{title}</p>}
<table className="assistant-panel-table">
<thead>
<tr>
{columns.map((column, colIndex) => (
<th key={`${component.id}-col-${colIndex}`}>{column}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => (
<tr key={`${component.id}-row-${rowIndex}`}>
{row.map((cell, cellIndex) => (
<td key={`${component.id}-cell-${rowIndex}-${cellIndex}`}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};

View File

@@ -0,0 +1,37 @@
import React, { useState } from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UITabs: React.FC<A2UIComponentProps> = ({ component, renderChildren }) => {
const tabLabels = (component.properties.tabLabels as string[]) ?? [];
const [activeTab, setActiveTab] = useState(0);
const children = component.children;
return (
<div className="assistant-panel-tabs">
<div className="assistant-panel-tab-strip">
{tabLabels.map((label, index) => (
<button
key={`${component.id}-tab-${index}`}
type="button"
className={`assistant-panel-tab-button ${index === activeTab ? 'active' : ''}`}
onClick={() => setActiveTab(index)}
>
{label}
</button>
))}
</div>
<div className="assistant-panel-tab-panel">
{children[activeTab] && renderChildren?.([children[activeTab]])}
</div>
</div>
);
};

View File

@@ -0,0 +1,16 @@
import React from 'react';
import Markdown from 'marked-react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UIText: React.FC<A2UIComponentProps> = ({ component }) => {
const text = String(component.properties.text ?? '');
return <Markdown>{text}</Markdown>;
};

View File

@@ -0,0 +1,51 @@
import React from 'react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
export const A2UITextField: React.FC<A2UIComponentProps> = ({ component, surfaceId, onDataChange }) => {
const label = String(component.properties.label ?? '');
const placeholder = (component.properties.placeholder as string) ?? '';
const inputType = component.properties.inputType as string | undefined;
const value = String(component.boundValue ?? '');
const handleChange = (newValue: string) => {
if (onDataChange && component.dataBinding) {
onDataChange(surfaceId, component.dataBinding, newValue);
}
};
if (inputType === 'textarea') {
return (
<div className="assistant-panel-widget-block">
<label className="assistant-panel-widget-label">{label}</label>
<textarea
className="assistant-panel-widget-input chat-surface-input"
value={value}
placeholder={placeholder}
onChange={(e) => handleChange(e.target.value)}
rows={3}
/>
</div>
);
}
return (
<div className="assistant-panel-widget-block">
<label className="assistant-panel-widget-label">{label}</label>
<input
className="assistant-panel-widget-input chat-surface-input"
type="text"
value={value}
placeholder={placeholder}
onChange={(e) => handleChange(e.target.value)}
/>
</div>
);
};

View File

@@ -0,0 +1,17 @@
export { A2UIText } from './A2UIText';
export { A2UIButton } from './A2UIButton';
export { A2UICard } from './A2UICard';
export { A2UIChart } from './A2UIChart';
export { A2UITable } from './A2UITable';
export { A2UIForm } from './A2UIForm';
export { A2UITextField } from './A2UITextField';
export { A2UICheckBox } from './A2UICheckBox';
export { A2UIDateTimeInput } from './A2UIDateTimeInput';
export { A2UIChoicePicker } from './A2UIChoicePicker';
export { A2UIImage } from './A2UIImage';
export { A2UITabs } from './A2UITabs';
export { A2UIMetric } from './A2UIMetric';
export { A2UIList } from './A2UIList';
export { A2UIRow } from './A2UIRow';
export { A2UIColumn } from './A2UIColumn';
export { A2UIDivider } from './A2UIDivider';

View File

@@ -0,0 +1,111 @@
/**
* React hook for A2UI surface state.
*
* Wraps A2UISurfaceManager and provides reactive state for React components.
* Subscribes to IPC events and feeds messages into the surface manager.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { A2UISurfaceManager } from './A2UISurfaceManager';
import type { A2UIResolvedComponent, A2UIServerMessage, A2UIClientAction } from '../../main/a2ui/types';
interface UseA2UISurfaceInput {
conversationId: string | null;
}
interface UseA2UISurfaceResult {
/** All active surface trees for this conversation */
surfaces: Array<{ surfaceId: string; tree: A2UIResolvedComponent[] }>;
/** Dispatch an action back to the main process */
dispatchAction: (action: A2UIClientAction) => void;
/** Update a local data binding (for form inputs) */
updateLocalData: (surfaceId: string, path: string, value: unknown) => void;
/** Get the data model for a surface */
getDataModel: (surfaceId: string) => Record<string, unknown>;
/** Clear all surfaces for the conversation */
clearSurfaces: () => void;
}
export function useA2UISurface(input: UseA2UISurfaceInput): UseA2UISurfaceResult {
const { conversationId } = input;
const managerRef = useRef<A2UISurfaceManager>(new A2UISurfaceManager());
const [renderTick, setRenderTick] = useState(0);
// Subscribe to surface changes
useEffect(() => {
const manager = managerRef.current;
const unsubscribe = manager.onChange(() => {
setRenderTick((prev) => prev + 1);
});
return unsubscribe;
}, []);
// Subscribe to A2UI IPC events
useEffect(() => {
if (!conversationId) {
return;
}
const unsubscribe = window.electronAPI?.chat.onA2UIMessage?.((data: { conversationId: string; message: A2UIServerMessage }) => {
if (data.conversationId === conversationId) {
managerRef.current.processMessage(data.message);
}
});
return () => {
unsubscribe?.();
};
}, [conversationId]);
// Clear surfaces when conversation changes
useEffect(() => {
return () => {
if (conversationId) {
managerRef.current.clearConversation(conversationId);
}
};
}, [conversationId]);
const surfaces = useMemo(() => {
// renderTick ensures this recalculates on surface changes
void renderTick;
if (!conversationId) {
return [];
}
const manager = managerRef.current;
const surfaceIds = manager.getSurfaceIds(conversationId);
return surfaceIds.map((surfaceId) => ({
surfaceId,
tree: manager.resolveTree(surfaceId),
}));
}, [conversationId, renderTick]);
const dispatchAction = useCallback((action: A2UIClientAction) => {
window.electronAPI?.chat.dispatchA2UIAction?.(action);
}, []);
const updateLocalData = useCallback((surfaceId: string, path: string, value: unknown) => {
managerRef.current.updateLocalData(surfaceId, path, value);
}, []);
const getDataModel = useCallback((surfaceId: string) => {
return managerRef.current.getDataModel(surfaceId);
}, []);
const clearSurfaces = useCallback(() => {
if (conversationId) {
managerRef.current.clearConversation(conversationId);
}
}, [conversationId]);
return {
surfaces,
dispatchAction,
updateLocalData,
getDataModel,
clearSurfaces,
};
}

View File

@@ -1,173 +0,0 @@
.assistant-panel-controls {
display: flex;
flex-direction: column;
gap: 8px;
}
.assistant-panel-metric {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 8px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
}
.assistant-panel-metric-label {
font-size: 12px;
opacity: 0.85;
}
.assistant-panel-metric-value {
font-size: 14px;
}
.assistant-panel-table {
width: 100%;
border-collapse: collapse;
}
.assistant-panel-table th,
.assistant-panel-table td {
border: 1px solid var(--vscode-panel-border);
padding: 6px;
font-size: 12px;
text-align: left;
}
.assistant-panel-widget-block {
display: flex;
flex-direction: column;
gap: 6px;
}
.assistant-panel-widget-label {
font-size: 12px;
opacity: 0.9;
}
.assistant-panel-widget-input {
width: 100%;
padding: 8px;
}
.assistant-panel-checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.assistant-panel-chart {
display: flex;
flex-direction: column;
gap: 6px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
padding: 8px;
}
.assistant-panel-chart-title {
margin: 0;
font-weight: 600;
}
.assistant-panel-chart-type {
font-size: 11px;
text-transform: uppercase;
opacity: 0.7;
}
.assistant-panel-chart-item {
display: grid;
grid-template-columns: minmax(48px, auto) 1fr auto;
gap: 8px;
align-items: center;
font-size: 12px;
}
.assistant-panel-chart-item progress {
width: 100%;
}
.assistant-panel-form {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
padding: 8px;
}
.assistant-panel-form-title {
margin: 0;
font-weight: 600;
}
.assistant-panel-card {
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.assistant-panel-card h4,
.assistant-panel-card p {
margin: 0;
}
.assistant-panel-card-subtitle {
font-size: 12px;
opacity: 0.8;
}
.assistant-panel-card-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.assistant-panel-image {
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.assistant-panel-image img {
max-width: 100%;
border-radius: 6px;
border: 1px solid var(--vscode-panel-border);
}
.assistant-panel-image figcaption {
font-size: 12px;
opacity: 0.85;
}
.assistant-panel-tabs {
display: flex;
flex-direction: column;
gap: 8px;
}
.assistant-panel-tab-strip {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.assistant-panel-tab-button.active {
border-color: var(--vscode-focusBorder);
}
.assistant-panel-tab-panel {
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@@ -1,310 +0,0 @@
import React, { useState } from 'react';
import type { AssistantPanelElement } from '../../navigation/assistantPanelSpec';
import './AssistantPanelControls.css';
interface AssistantPanelControlsProps {
elements: AssistantPanelElement[];
onAction: (action: string, payload?: Record<string, unknown>) => void;
actionPolicies?: Record<string, 'silent' | 'confirm' | 'danger'>;
}
export const AssistantPanelControls: React.FC<AssistantPanelControlsProps> = ({ elements, onAction, actionPolicies = {} }) => {
const [widgetValues, setWidgetValues] = useState<Record<string, unknown>>({});
const [activeTabByWidget, setActiveTabByWidget] = useState<Record<string, string>>({});
const setWidgetValue = (key: string, value: unknown) => {
setWidgetValues((previous) => ({
...previous,
[key]: value,
}));
};
const getWidgetValue = (key: string, defaultValue?: unknown) =>
Object.prototype.hasOwnProperty.call(widgetValues, key) ? widgetValues[key] : defaultValue;
const triggerAction = (action: string, payload?: Record<string, unknown>, label?: string) => {
const policy = actionPolicies[action] || 'silent';
if (policy !== 'silent') {
const confirmationText = label || action;
const confirmed = window.confirm(confirmationText);
if (!confirmed) {
return;
}
}
onAction(action, payload);
};
const renderInputControl = (
key: string,
label: string,
inputType: 'text' | 'textarea' | 'select' | 'checkbox' | 'date' | 'number',
options?: Array<{ label: string; value: string }>,
placeholder?: string,
defaultValue?: string | number | boolean,
) => {
if (inputType === 'textarea') {
return (
<textarea
className="assistant-panel-widget-input chat-surface-input"
value={String(getWidgetValue(key, defaultValue ?? ''))}
placeholder={placeholder}
onChange={(event) => setWidgetValue(key, event.target.value)}
rows={3}
/>
);
}
if (inputType === 'select') {
return (
<select
className="assistant-panel-widget-input chat-surface-input"
value={String(getWidgetValue(key, defaultValue ?? (options?.[0]?.value ?? '')))}
onChange={(event) => setWidgetValue(key, event.target.value)}
>
{(options ?? []).map((option) => (
<option key={`${key}-${option.value}`} value={option.value}>{option.label}</option>
))}
</select>
);
}
if (inputType === 'checkbox') {
return (
<label className="assistant-panel-checkbox">
<input
type="checkbox"
checked={Boolean(getWidgetValue(key, defaultValue ?? false))}
onChange={(event) => setWidgetValue(key, event.target.checked)}
/>
<span>{label}</span>
</label>
);
}
const type = inputType === 'number' ? 'number' : inputType === 'date' ? 'date' : 'text';
return (
<input
className="assistant-panel-widget-input chat-surface-input"
type={type}
value={String(getWidgetValue(key, defaultValue ?? ''))}
placeholder={placeholder}
onChange={(event) => setWidgetValue(key, event.target.value)}
/>
);
};
const renderPanelElement = (element: AssistantPanelElement, indexPath: string): React.ReactNode => {
if (element.type === 'text') {
return <p key={`assistant-element-${indexPath}`}>{element.text}</p>;
}
if (element.type === 'metric') {
return (
<div key={`assistant-element-${indexPath}`} className="assistant-panel-metric">
<span className="assistant-panel-metric-label">{element.label}</span>
<strong className="assistant-panel-metric-value">{element.value}</strong>
</div>
);
}
if (element.type === 'list') {
return (
<div key={`assistant-element-${indexPath}`}>
{element.title && <p>{element.title}</p>}
<ul>
{element.items.map((item, itemIndex) => <li key={`assistant-list-item-${indexPath}-${itemIndex}`}>{item}</li>)}
</ul>
</div>
);
}
if (element.type === 'table') {
return (
<table key={`assistant-element-${indexPath}`} className="assistant-panel-table">
<thead>
<tr>
{element.columns.map((column, columnIndex) => <th key={`assistant-table-column-${indexPath}-${columnIndex}`}>{column}</th>)}
</tr>
</thead>
<tbody>
{element.rows.map((row, rowIndex) => (
<tr key={`assistant-table-row-${indexPath}-${rowIndex}`}>
{row.map((cell, cellIndex) => <td key={`assistant-table-cell-${indexPath}-${rowIndex}-${cellIndex}`}>{cell}</td>)}
</tr>
))}
</tbody>
</table>
);
}
if (element.type === 'chart') {
const maxValue = Math.max(...element.series.map((item) => item.value), 0);
return (
<div key={`assistant-element-${indexPath}`} className="assistant-panel-chart">
{element.title && <p className="assistant-panel-chart-title">{element.title}</p>}
<div className="assistant-panel-chart-type">{element.chartType}</div>
{element.series.map((entry, seriesIndex) => (
<div key={`assistant-chart-item-${indexPath}-${seriesIndex}`} className="assistant-panel-chart-item">
<span>{entry.label}</span>
<progress value={entry.value} max={maxValue || 1}></progress>
<span>{entry.value}</span>
</div>
))}
</div>
);
}
if (element.type === 'input') {
const currentValue = getWidgetValue(element.key, element.defaultValue);
return (
<div key={`assistant-element-${indexPath}`} className="assistant-panel-widget-block">
{element.inputType !== 'checkbox' && <label className="assistant-panel-widget-label">{element.label}</label>}
{renderInputControl(element.key, element.label, element.inputType, element.options, element.placeholder, element.defaultValue)}
{element.action && element.submitLabel && (
<button
type="button"
onClick={() => triggerAction(element.action!, { ...(element.payload ?? {}), [element.key]: currentValue }, element.submitLabel)}
>
{element.submitLabel}
</button>
)}
</div>
);
}
if (element.type === 'datePicker') {
const currentValue = String(getWidgetValue(element.key, element.defaultValue ?? ''));
return (
<div key={`assistant-element-${indexPath}`} className="assistant-panel-widget-block">
<label className="assistant-panel-widget-label">{element.label}</label>
<input
className="assistant-panel-widget-input chat-surface-input"
type="date"
min={element.min}
max={element.max}
value={currentValue}
onChange={(event) => setWidgetValue(element.key, event.target.value)}
/>
{element.action && element.submitLabel && (
<button
type="button"
onClick={() => triggerAction(element.action!, { ...(element.payload ?? {}), [element.key]: currentValue }, element.submitLabel)}
>
{element.submitLabel}
</button>
)}
</div>
);
}
if (element.type === 'form') {
const onSubmit = () => {
const values = element.fields.reduce<Record<string, unknown>>((accumulator, field) => {
accumulator[field.key] = getWidgetValue(field.key, field.defaultValue);
return accumulator;
}, {});
triggerAction(element.action, {
...(element.payload ?? {}),
formId: element.formId,
values,
}, element.submitLabel);
};
return (
<div key={`assistant-element-${indexPath}`} className="assistant-panel-form">
{element.title && <p className="assistant-panel-form-title">{element.title}</p>}
{element.fields.map((field, fieldIndex) => (
<div key={`assistant-form-field-${indexPath}-${fieldIndex}`} className="assistant-panel-widget-block">
{field.inputType !== 'checkbox' && <label className="assistant-panel-widget-label">{field.label}</label>}
{renderInputControl(field.key, field.label, field.inputType, field.options, field.placeholder, field.defaultValue)}
</div>
))}
<button type="button" onClick={onSubmit}>{element.submitLabel}</button>
</div>
);
}
if (element.type === 'card') {
return (
<article key={`assistant-element-${indexPath}`} className="assistant-panel-card">
<h4>{element.title}</h4>
{element.subtitle && <p className="assistant-panel-card-subtitle">{element.subtitle}</p>}
<p>{element.body}</p>
{element.actions && element.actions.length > 0 && (
<div className="assistant-panel-card-actions">
{element.actions.map((action, actionIndex) => (
<button
key={`assistant-card-action-${indexPath}-${actionIndex}`}
type="button"
onClick={() => triggerAction(action.action, action.payload, action.label)}
>
{action.label}
</button>
))}
</div>
)}
</article>
);
}
if (element.type === 'image') {
return (
<figure key={`assistant-element-${indexPath}`} className="assistant-panel-image">
<img
src={element.src}
alt={element.alt || ''}
onClick={() => {
if (element.action) {
triggerAction(element.action, element.payload, element.caption || element.alt || element.action);
}
}}
/>
{element.caption && <figcaption>{element.caption}</figcaption>}
</figure>
);
}
if (element.type === 'tabs') {
const widgetKey = element.widgetId || `tabs-${indexPath}`;
const activeTabId = activeTabByWidget[widgetKey] || element.defaultTabId || element.tabs[0].id;
const activeTab = element.tabs.find((tab) => tab.id === activeTabId) ?? element.tabs[0];
return (
<div key={`assistant-element-${indexPath}`} className="assistant-panel-tabs">
<div className="assistant-panel-tab-strip">
{element.tabs.map((tab) => (
<button
key={`assistant-tab-${indexPath}-${tab.id}`}
type="button"
className={`assistant-panel-tab-button ${tab.id === activeTab.id ? 'active' : ''}`}
onClick={() => setActiveTabByWidget((previous) => ({ ...previous, [widgetKey]: tab.id }))}
>
{tab.label}
</button>
))}
</div>
<div className="assistant-panel-tab-panel">
{activeTab.elements.map((childElement, childIndex) => renderPanelElement(childElement, `${indexPath}-tab-${childIndex}`))}
</div>
</div>
);
}
return (
<button key={`assistant-element-${indexPath}`} type="button" onClick={() => triggerAction(element.action, element.payload, element.label)}>
{element.label}
</button>
);
};
return (
<div className="assistant-panel-controls chat-surface-section">
{elements.map((element, index) => renderPanelElement(element, `${index}`))}
</div>
);
};
export default AssistantPanelControls;

View File

@@ -1 +0,0 @@
export { AssistantPanelControls } from './AssistantPanelControls';

View File

@@ -3,15 +3,13 @@ import { useAppStore } from '../../store';
import { resolveAssistantEditorContext } from '../../navigation/assistantPromptContext'; import { resolveAssistantEditorContext } from '../../navigation/assistantPromptContext';
import { planAssistantRequest } from '../../navigation/assistantConversation'; import { planAssistantRequest } from '../../navigation/assistantConversation';
import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher'; import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher';
import { extractAssistantResponseContent, type AssistantPanelElement } from '../../navigation/assistantPanelSpec';
import { toClarificationElements } from '../../navigation/protocolNeedsInput';
import { buildActionPoliciesFromEnvelope } from '../../navigation/protocolActionPolicies';
import { ensureConversationId } from '../../navigation/chatSession'; import { ensureConversationId } from '../../navigation/chatSession';
import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode'; import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
import { useChatMessageSender } from '../../navigation/useChatMessageSender'; import { useChatMessageSender } from '../../navigation/useChatMessageSender';
import { useChatSurfaceState } from '../../navigation/useChatSurfaceState'; import { useChatSurfaceState } from '../../navigation/useChatSurfaceState';
import { useA2UISurface } from '../../a2ui/useA2UISurface';
import { A2UIRenderer } from '../../a2ui/A2UIRenderer';
import { ChatTranscript } from '../ChatSurface'; import { ChatTranscript } from '../ChatSurface';
import { AssistantPanelControls } from '../AssistantPanelControls';
import { useI18n } from '../../i18n'; import { useI18n } from '../../i18n';
import '../../styles/chatSurface.css'; import '../../styles/chatSurface.css';
import './AssistantSidebar.css'; import './AssistantSidebar.css';
@@ -23,8 +21,6 @@ export const AssistantSidebar: React.FC = () => {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [conversationId, setConversationId] = useState<string | null>(null); const [conversationId, setConversationId] = useState<string | null>(null);
const [panelElements, setPanelElements] = useState<AssistantPanelElement[]>([]);
const [actionPolicies, setActionPolicies] = useState<Record<string, 'silent' | 'confirm' | 'danger'>>({});
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const { const {
@@ -57,6 +53,10 @@ export const AssistantSidebar: React.FC = () => {
stopStreaming, stopStreaming,
getStreamingContent, getStreamingContent,
} = useChatSurfaceState(); } = useChatSurfaceState();
// A2UI surface rendering
const { surfaces, dispatchAction, updateLocalData } = useA2UISurface({ conversationId });
const activeTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [tabs, activeTabId]); const activeTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [tabs, activeTabId]);
const editorContext = useMemo( const editorContext = useMemo(
@@ -169,24 +169,12 @@ export const AssistantSidebar: React.FC = () => {
throw new Error(sendResult.error || 'Failed to send assistant message'); throw new Error(sendResult.error || 'Failed to send assistant message');
} }
if (sendResult.envelope) { const assistantContent = getStreamingContent() || sendResult.message;
finalizeAssistantTurn(resolvedConversationId, sendResult.envelope.assistantText); if (assistantContent) {
const uiElements = Array.isArray(sendResult.envelope.ui?.elements) finalizeAssistantTurn(resolvedConversationId, assistantContent);
? (sendResult.envelope.ui?.elements as AssistantPanelElement[])
: toClarificationElements(sendResult.envelope.needsInput);
setPanelElements(uiElements);
setActionPolicies(buildActionPoliciesFromEnvelope(sendResult.envelope));
} else { } else {
const assistantContent = getStreamingContent() || sendResult.message; appendAssistantMessage(resolvedConversationId, tr('chat.errorEmptyResponse'));
if (assistantContent) { stopStreaming();
const parsedResponse = extractAssistantResponseContent(assistantContent);
finalizeAssistantTurn(resolvedConversationId, parsedResponse.displayText);
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
setActionPolicies({});
} else {
appendAssistantMessage(resolvedConversationId, tr('chat.errorEmptyResponse'));
stopStreaming();
}
} }
setPrompt(''); setPrompt('');
@@ -199,57 +187,6 @@ export const AssistantSidebar: React.FC = () => {
}; };
const handleAssistantAction = (action: string, payload?: Record<string, unknown>) => { const handleAssistantAction = (action: string, payload?: Record<string, unknown>) => {
if (action === 'submitNeedsInput' && conversationId) {
const values = payload?.values;
if (!values || typeof values !== 'object') {
setActionError(tr('assistantSidebar.error.actionFailed'));
return;
}
const clarificationMessage = `needs_input_response: ${JSON.stringify(values)}`;
beginUserTurn(conversationId, clarificationMessage);
void sendChatMessage({
conversationId,
message: clarificationMessage,
metadata: { surface: 'sidebar' },
}).then((sendResult) => {
if (!sendResult.success) {
appendAssistantMessage(
conversationId,
tr('chat.errorPrefix', { error: sendResult.error || tr('chat.errorNoResponse') }),
);
stopStreaming();
return;
}
if (sendResult.envelope) {
finalizeAssistantTurn(conversationId, sendResult.envelope.assistantText);
const uiElements = Array.isArray(sendResult.envelope.ui?.elements)
? (sendResult.envelope.ui?.elements as AssistantPanelElement[])
: toClarificationElements(sendResult.envelope.needsInput);
setPanelElements(uiElements);
setActionPolicies(buildActionPoliciesFromEnvelope(sendResult.envelope));
return;
}
const assistantContent = getStreamingContent() || sendResult.message;
if (assistantContent) {
const parsedResponse = extractAssistantResponseContent(assistantContent);
finalizeAssistantTurn(conversationId, parsedResponse.displayText);
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
setActionPolicies({});
}
}).catch((error) => {
console.error('Failed to submit assistant clarification:', error);
appendAssistantMessage(conversationId, tr('chat.errorGeneric'));
stopStreaming();
});
return;
}
const result = dispatchAssistantAction( const result = dispatchAssistantAction(
{ {
action, action,
@@ -333,9 +270,15 @@ export const AssistantSidebar: React.FC = () => {
</div> </div>
)} )}
{panelElements.length > 0 && ( {surfaces.map((surface) => (
<AssistantPanelControls elements={panelElements} onAction={handleAssistantAction} actionPolicies={actionPolicies} /> <A2UIRenderer
)} key={surface.surfaceId}
surfaceId={surface.surfaceId}
tree={surface.tree}
onAction={dispatchAction}
onDataChange={updateLocalData}
/>
))}
</div> </div>
); );
}; };

View File

@@ -4,12 +4,10 @@ import { useChatMessageSender } from '../../navigation/useChatMessageSender';
import { useChatSurfaceState } from '../../navigation/useChatSurfaceState'; import { useChatSurfaceState } from '../../navigation/useChatSurfaceState';
import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode'; import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher'; import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher';
import { extractAssistantResponseContent, type AssistantPanelElement } from '../../navigation/assistantPanelSpec'; import { useA2UISurface } from '../../a2ui/useA2UISurface';
import { toClarificationElements } from '../../navigation/protocolNeedsInput'; import { A2UIRenderer } from '../../a2ui/A2UIRenderer';
import { buildActionPoliciesFromEnvelope } from '../../navigation/protocolActionPolicies';
import { useAppStore } from '../../store'; import { useAppStore } from '../../store';
import { ChatTranscript } from '../ChatSurface'; import { ChatTranscript } from '../ChatSurface';
import { AssistantPanelControls } from '../AssistantPanelControls';
import { useI18n } from '../../i18n'; import { useI18n } from '../../i18n';
import '../../styles/chatSurface.css'; import '../../styles/chatSurface.css';
import './ChatPanel.css'; import './ChatPanel.css';
@@ -29,8 +27,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
const [apiKeyInput, setApiKeyInput] = useState(''); const [apiKeyInput, setApiKeyInput] = useState('');
const [apiKeyError, setApiKeyError] = useState(''); const [apiKeyError, setApiKeyError] = useState('');
const [isValidating, setIsValidating] = useState(false); const [isValidating, setIsValidating] = useState(false);
const [panelElements, setPanelElements] = useState<AssistantPanelElement[]>([]);
const [actionPolicies, setActionPolicies] = useState<Record<string, 'silent' | 'confirm' | 'danger'>>({});
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -63,6 +59,9 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
getStreamingContent, getStreamingContent,
} = useChatSurfaceState(); } = useChatSurfaceState();
// A2UI surface rendering
const { surfaces, dispatchAction, updateLocalData } = useA2UISurface({ conversationId });
// Scroll to bottom when messages change // Scroll to bottom when messages change
const scrollToBottom = useCallback(() => { const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -193,37 +192,19 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
// Fall back to the backend result message if streaming didn't capture the content // Fall back to the backend result message if streaming didn't capture the content
const assistantContent = getStreamingContent() || (result.success ? result.message : ''); const assistantContent = getStreamingContent() || (result.success ? result.message : '');
if (result.envelope) { if (assistantContent) {
finalizeAssistantTurn(conversationId, result.envelope.assistantText); finalizeAssistantTurn(conversationId, assistantContent);
const uiElements = Array.isArray(result.envelope.ui?.elements)
? (result.envelope.ui?.elements as AssistantPanelElement[])
: toClarificationElements(result.envelope.needsInput);
setPanelElements(uiElements);
setActionPolicies(buildActionPoliciesFromEnvelope(result.envelope));
} else if (assistantContent) {
const parsedResponse = extractAssistantResponseContent(assistantContent);
finalizeAssistantTurn(conversationId, parsedResponse.displayText);
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
setActionPolicies({});
} else if (!result.success) { } else if (!result.success) {
// Backend returned an error (API failure, model unavailable, etc.)
appendAssistantMessage(conversationId, tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') })); appendAssistantMessage(conversationId, tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') }));
stopStreaming(); stopStreaming();
setPanelElements([]);
setActionPolicies({});
} else { } else {
// No content from streaming AND no error, but also no success message
// This can happen with some models that don't return content properly
appendAssistantMessage(conversationId, tr('chat.errorEmptyResponse')); appendAssistantMessage(conversationId, tr('chat.errorEmptyResponse'));
stopStreaming(); stopStreaming();
setPanelElements([]);
setActionPolicies({});
} }
} catch (error) { } catch (error) {
console.error('Failed to send message:', error); console.error('Failed to send message:', error);
appendAssistantMessage(conversationId, tr('chat.errorGeneric')); appendAssistantMessage(conversationId, tr('chat.errorGeneric'));
stopStreaming(); stopStreaming();
setPanelElements([]);
} finally { } finally {
if (isStreaming) { if (isStreaming) {
stopStreaming(); stopStreaming();
@@ -239,59 +220,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
} }
}; };
const handleNeedsInputSubmit = async (payload?: Record<string, unknown>) => {
const values = payload?.values;
if (!values || typeof values !== 'object') {
setActionError(tr('assistantSidebar.error.actionFailed'));
return;
}
const clarificationMessage = `needs_input_response: ${JSON.stringify(values)}`;
beginUserTurn(conversationId, clarificationMessage);
try {
const result = await sendChatMessage({
conversationId,
message: clarificationMessage,
metadata: { surface: 'tab' },
});
if (!result.success) {
appendAssistantMessage(conversationId, tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') }));
stopStreaming();
return;
}
if (result.envelope) {
finalizeAssistantTurn(conversationId, result.envelope.assistantText);
const uiElements = Array.isArray(result.envelope.ui?.elements)
? (result.envelope.ui?.elements as AssistantPanelElement[])
: toClarificationElements(result.envelope.needsInput);
setPanelElements(uiElements);
setActionPolicies(buildActionPoliciesFromEnvelope(result.envelope));
return;
}
const assistantContent = getStreamingContent() || result.message;
if (assistantContent) {
const parsedResponse = extractAssistantResponseContent(assistantContent);
finalizeAssistantTurn(conversationId, parsedResponse.displayText);
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
setActionPolicies({});
}
} catch (error) {
console.error('Failed to submit clarification:', error);
appendAssistantMessage(conversationId, tr('chat.errorGeneric'));
stopStreaming();
}
};
const handleAssistantAction = (action: string, payload?: Record<string, unknown>) => { const handleAssistantAction = (action: string, payload?: Record<string, unknown>) => {
if (action === 'submitNeedsInput') {
void handleNeedsInputSubmit(payload);
return;
}
const result = dispatchAssistantAction( const result = dispatchAssistantAction(
{ {
action, action,
@@ -441,9 +370,15 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
endRef={messagesEndRef} endRef={messagesEndRef}
/> />
{panelElements.length > 0 && ( {surfaces.map((surface) => (
<AssistantPanelControls elements={panelElements} onAction={handleAssistantAction} actionPolicies={actionPolicies} /> <A2UIRenderer
)} key={surface.surfaceId}
surfaceId={surface.surfaceId}
tree={surface.tree}
onAction={dispatchAction}
onDataChange={updateLocalData}
/>
))}
{actionError && <p className="chat-surface-error">{actionError}</p>} {actionError && <p className="chat-surface-error">{actionError}</p>}
</div> </div>

View File

@@ -1478,12 +1478,6 @@ interface CategoryCount {
count: number; count: number;
} }
interface DashboardProtocolHealth {
blockedActionCount: number;
parseValidityRate: number;
fallbackTurns: number;
}
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const { t: tr, language } = useI18n(); const { t: tr, language } = useI18n();
const { posts, media } = useAppStore(); const { posts, media } = useAppStore();
@@ -1492,7 +1486,6 @@ const Dashboard: React.FC = () => {
const [tagCounts, setTagCounts] = useState<TagCount[]>([]); const [tagCounts, setTagCounts] = useState<TagCount[]>([]);
const [tagColors, setTagColors] = useState<Map<string, string>>(new Map()); const [tagColors, setTagColors] = useState<Map<string, string>>(new Map());
const [categoryCounts, setCategoryCounts] = useState<CategoryCount[]>([]); const [categoryCounts, setCategoryCounts] = useState<CategoryCount[]>([]);
const [protocolHealth, setProtocolHealth] = useState<DashboardProtocolHealth | null>(null);
const uiDateLocale = UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en; const uiDateLocale = UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en;
const monthFormatter = useMemo( const monthFormatter = useMemo(
@@ -1503,25 +1496,17 @@ const Dashboard: React.FC = () => {
useEffect(() => { useEffect(() => {
const loadStats = async () => { const loadStats = async () => {
try { try {
const [ds, ym, tc, cc, colorMap, protocolHealthSnapshot] = await Promise.all([ const [ds, ym, tc, cc, colorMap] = await Promise.all([
window.electronAPI?.posts.getDashboardStats(), window.electronAPI?.posts.getDashboardStats(),
window.electronAPI?.posts.getByYearMonth(), window.electronAPI?.posts.getByYearMonth(),
window.electronAPI?.posts.getTagsWithCounts(), window.electronAPI?.posts.getTagsWithCounts(),
window.electronAPI?.posts.getCategoriesWithCounts(), window.electronAPI?.posts.getCategoriesWithCounts(),
loadTagColorMap(), loadTagColorMap(),
window.electronAPI?.chat.getProtocolHealth(),
]); ]);
if (ds) setStats(ds); if (ds) setStats(ds);
if (ym) setYearMonthData(ym); if (ym) setYearMonthData(ym);
if (tc) setTagCounts(tc); if (tc) setTagCounts(tc);
if (cc) setCategoryCounts(cc); if (cc) setCategoryCounts(cc);
if (protocolHealthSnapshot) {
setProtocolHealth({
blockedActionCount: protocolHealthSnapshot.blockedActionCount,
parseValidityRate: protocolHealthSnapshot.parseValidityRate,
fallbackTurns: protocolHealthSnapshot.fallbackTurns,
});
}
setTagColors(colorMap); setTagColors(colorMap);
} catch (e) { } catch (e) {
console.error('Failed to load dashboard stats:', e); console.error('Failed to load dashboard stats:', e);
@@ -1566,9 +1551,6 @@ const Dashboard: React.FC = () => {
const displayDraftCount = stats?.draftCount ?? 0; const displayDraftCount = stats?.draftCount ?? 0;
const displayPublishedCount = stats?.publishedCount ?? 0; const displayPublishedCount = stats?.publishedCount ?? 0;
const displayArchivedCount = stats?.archivedCount ?? 0; const displayArchivedCount = stats?.archivedCount ?? 0;
const parseValidityPercent = protocolHealth
? `${Math.round(protocolHealth.parseValidityRate * 100)}%`
: '—';
const getPostCountLabel = useCallback((count: number) => { const getPostCountLabel = useCallback((count: number) => {
return tr(count === 1 ? 'dashboard.postCount.one' : 'dashboard.postCount.other', { count }); return tr(count === 1 ? 'dashboard.postCount.one' : 'dashboard.postCount.other', { count });
@@ -1615,14 +1597,6 @@ const Dashboard: React.FC = () => {
<span className="stat-tag">{tr('dashboard.stats.categories', { count: categoryCounts.length })}</span> <span className="stat-tag">{tr('dashboard.stats.categories', { count: categoryCounts.length })}</span>
</div> </div>
</div> </div>
<div className="stat-card">
<div className="stat-number">{parseValidityPercent}</div>
<div className="stat-label">{tr('dashboard.stats.protocolHealth')}</div>
<div className="stat-breakdown">
<span className="stat-tag">{tr('dashboard.stats.blockedActions', { count: protocolHealth?.blockedActionCount ?? 0 })}</span>
<span className="stat-tag">{tr('dashboard.stats.fallbackTurns', { count: protocolHealth?.fallbackTurns ?? 0 })}</span>
</div>
</div>
</div> </div>
{timelineEntries.length > 0 && ( {timelineEntries.length > 0 && (

View File

@@ -28,4 +28,3 @@ export { DocumentationView } from './DocumentationView/DocumentationView';
export { SiteValidationView } from './SiteValidationView'; export { SiteValidationView } from './SiteValidationView';
export { ScriptsView } from './ScriptsView/ScriptsView'; export { ScriptsView } from './ScriptsView/ScriptsView';
export { AssistantSidebar } from './AssistantSidebar'; export { AssistantSidebar } from './AssistantSidebar';
export { AssistantPanelControls } from './AssistantPanelControls';

View File

@@ -156,276 +156,3 @@ export const assistantPanelSpecSchema = z.object({
export type AssistantPanelElement = z.infer<typeof assistantPanelElementSchema>; export type AssistantPanelElement = z.infer<typeof assistantPanelElementSchema>;
export type AssistantPanelSpec = z.infer<typeof assistantPanelSpecSchema>; export type AssistantPanelSpec = z.infer<typeof assistantPanelSpecSchema>;
export interface AssistantResponseContent {
displayText: string;
panelSpec: AssistantPanelSpec | null;
}
function toRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function normalizeChartElement(record: Record<string, unknown>): Record<string, unknown> | null {
const chartType = record.chartType;
const normalized: Record<string, unknown> = {
type: 'chart',
chartType: chartType === 'line' || chartType === 'pie' ? chartType : 'bar',
};
if (typeof record.title === 'string' && record.title.trim().length > 0) {
normalized.title = record.title;
}
if (Array.isArray(record.series)) {
const series = record.series
.map((entry) => {
const item = toRecord(entry);
if (!item || typeof item.label !== 'string' || typeof item.value !== 'number') {
return null;
}
return { label: item.label, value: item.value };
})
.filter((entry): entry is { label: string; value: number } => Boolean(entry));
if (series.length > 0) {
normalized.series = series;
return normalized;
}
}
const dataRecord = toRecord(record.data);
if (!dataRecord) {
return normalized;
}
const labels = Array.isArray(dataRecord.labels) ? dataRecord.labels : [];
const datasets = Array.isArray(dataRecord.datasets) ? dataRecord.datasets : [];
const firstDataset = toRecord(datasets[0]);
const values = Array.isArray(firstDataset?.data) ? firstDataset?.data : [];
if (labels.length === 0 || values.length === 0) {
return normalized;
}
const series = labels
.map((label, index) => ({
label: String(label),
value: Number(values[index]),
}))
.filter((entry) => Number.isFinite(entry.value));
if (series.length === 0) {
return normalized;
}
normalized.series = series;
return normalized;
}
function normalizeTabContent(tabValue: unknown): Record<string, unknown>[] {
if (Array.isArray(tabValue)) {
return tabValue.map((entry) => normalizeElement(entry)).filter((entry): entry is Record<string, unknown> => Boolean(entry));
}
const normalized = normalizeElement(tabValue);
return normalized ? [normalized] : [];
}
function normalizeTabsElement(record: Record<string, unknown>): Record<string, unknown> | null {
const tabs = Array.isArray(record.tabs) ? record.tabs : [];
const normalizedTabs = tabs
.map((tabValue, tabIndex) => {
const tabRecord = toRecord(tabValue);
if (!tabRecord) {
return null;
}
const id = typeof tabRecord.id === 'string' && tabRecord.id.trim().length > 0
? tabRecord.id
: `tab-${tabIndex + 1}`;
const label = typeof tabRecord.label === 'string' && tabRecord.label.trim().length > 0
? tabRecord.label
: typeof tabRecord.title === 'string' && tabRecord.title.trim().length > 0
? tabRecord.title
: id;
const elements = Array.isArray(tabRecord.elements)
? normalizeTabContent(tabRecord.elements)
: normalizeTabContent(tabRecord.content);
if (elements.length === 0) {
return null;
}
return {
id,
label,
elements,
};
})
.filter((entry): entry is { id: string; label: string; elements: Record<string, unknown>[] } => Boolean(entry));
if (normalizedTabs.length === 0) {
return null;
}
return {
...record,
tabs: normalizedTabs,
};
}
function normalizeElement(value: unknown): Record<string, unknown> | null {
const record = toRecord(value);
if (!record) {
return null;
}
const type = typeof record.type === 'string' ? record.type : '';
if (type === 'text' && typeof record.content === 'string' && typeof record.text !== 'string') {
return { type: 'text', text: record.content };
}
if (type === 'markdown') {
const textValue = typeof record.content === 'string' ? record.content : typeof record.text === 'string' ? record.text : '';
if (!textValue.trim()) {
return null;
}
return {
type: 'text',
text: textValue,
};
}
if (type === 'chart') {
return normalizeChartElement(record);
}
if (type === 'tabs') {
return normalizeTabsElement(record);
}
return record;
}
function normalizeCandidate(parsed: unknown): AssistantPanelSpec | null {
const canonicalResult = assistantPanelSpecSchema.safeParse(parsed);
if (canonicalResult.success) {
return canonicalResult.data;
}
const record = toRecord(parsed);
if (!record) {
return null;
}
if (record.protocolVersion === '2.0' && record.ui) {
return normalizeCandidate(record.ui);
}
if (record.type === 'tab' && record.content) {
return normalizeCandidate(record.content);
}
if (record.type === 'tabs') {
const tabsElement = normalizeTabsElement(record);
if (!tabsElement) {
return null;
}
const asSpec = {
specVersion: '1',
elements: [tabsElement],
};
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
return normalizedResult.success ? normalizedResult.data : null;
}
if (Array.isArray(record.elements)) {
const normalizedElements = record.elements
.map((element) => normalizeElement(element))
.filter((element): element is Record<string, unknown> => Boolean(element));
if (normalizedElements.length === 0) {
return null;
}
const asSpec = {
specVersion: '1',
elements: normalizedElements,
};
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
return normalizedResult.success ? normalizedResult.data : null;
}
const normalizedElement = normalizeElement(record);
if (!normalizedElement) {
return null;
}
const asSpec = {
specVersion: '1',
elements: [normalizedElement],
};
const normalizedResult = assistantPanelSpecSchema.safeParse(asSpec);
return normalizedResult.success ? normalizedResult.data : null;
}
function parseSpecCandidate(raw: string): AssistantPanelSpec | null {
try {
const parsed = JSON.parse(raw);
return normalizeCandidate(parsed);
} catch {
return null;
}
}
export function extractAssistantPanelSpec(message: string): AssistantPanelSpec | null {
return extractAssistantResponseContent(message).panelSpec;
}
export function extractAssistantResponseContent(message: string): AssistantResponseContent {
const trimmed = message.trim();
const fencedMatches = [...trimmed.matchAll(/```(?:[a-zA-Z0-9_-]+)?\s*([\s\S]*?)```/gi)];
for (const match of fencedMatches) {
const candidate = match[1]?.trim();
if (!candidate) {
continue;
}
const parsed = parseSpecCandidate(candidate);
if (parsed) {
const displayText = trimmed.replace(match[0], '').trim();
return {
displayText,
panelSpec: parsed,
};
}
}
const parsedWholeMessage = parseSpecCandidate(trimmed);
let displayText = parsedWholeMessage ? '' : trimmed;
if (parsedWholeMessage) {
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
if (parsed.protocolVersion === '2.0' && typeof parsed.assistantText === 'string') {
displayText = parsed.assistantText;
}
} catch {
// no-op
}
}
return {
displayText,
panelSpec: parsedWholeMessage,
};
}

View File

@@ -1,12 +1,10 @@
import type { ProtocolResponseEnvelope } from '../types/electron';
export interface ChatService { export interface ChatService {
createConversation: (title?: string, model?: string) => Promise<{ id: string } | null | undefined>; createConversation: (title?: string, model?: string) => Promise<{ id: string } | null | undefined>;
sendMessage: ( sendMessage: (
conversationId: string, conversationId: string,
message: string, message: string,
metadata?: SendMessageMetadata, metadata?: SendMessageMetadata,
) => Promise<{ success: boolean; message?: string; envelope?: ProtocolResponseEnvelope; protocolVersion?: '2.0'; traceId?: string; warnings?: string[]; error?: string } | null | undefined>; ) => Promise<{ success: boolean; message?: string; error?: string } | null | undefined>;
} }
export interface SendMessageMetadata { export interface SendMessageMetadata {
@@ -29,10 +27,6 @@ export interface SendConversationMessageInput {
export interface SendConversationMessageResult { export interface SendConversationMessageResult {
success: boolean; success: boolean;
message: string; message: string;
envelope?: ProtocolResponseEnvelope;
protocolVersion?: '2.0';
traceId?: string;
warnings?: string[];
error?: string; error?: string;
} }
@@ -75,9 +69,5 @@ export async function sendConversationMessage(
return { return {
success: true, success: true,
message: result.message || '', message: result.message || '',
envelope: result.envelope,
protocolVersion: result.protocolVersion,
traceId: result.traceId,
warnings: result.warnings,
}; };
} }

View File

@@ -1,18 +0,0 @@
import type { ProtocolResponseEnvelope } from '../types/electron';
export type ActionPolicyLevel = 'silent' | 'confirm' | 'danger';
export function buildActionPoliciesFromEnvelope(
envelope: Pick<ProtocolResponseEnvelope, 'actions' | 'needsInput'>,
): Record<string, ActionPolicyLevel> {
const policies = envelope.actions.reduce<Record<string, ActionPolicyLevel>>((accumulator, action) => {
accumulator[action.action] = action.policy;
return accumulator;
}, {});
if (envelope.needsInput.required && envelope.needsInput.fields.length > 0 && !policies.submitNeedsInput) {
policies.submitNeedsInput = 'confirm';
}
return policies;
}

View File

@@ -1,38 +0,0 @@
import type { ProtocolNeedsInputField, ProtocolResponseEnvelope } from '../types/electron';
import type { AssistantPanelElement } from './assistantPanelSpec';
function toFormField(field: ProtocolNeedsInputField): {
key: string;
label: string;
inputType: 'text' | 'textarea' | 'select' | 'checkbox' | 'date' | 'number';
placeholder?: string;
defaultValue?: string | number | boolean;
options?: Array<{ label: string; value: string }>;
required?: boolean;
} {
return {
key: field.key,
label: field.label,
inputType: field.inputType,
placeholder: field.placeholder,
defaultValue: field.defaultValue,
options: field.options,
required: field.required,
};
}
export function toClarificationElements(
needsInput: ProtocolResponseEnvelope['needsInput'],
): AssistantPanelElement[] {
if (!needsInput.required || needsInput.fields.length === 0) {
return [];
}
return [{
type: 'form',
formId: 'agui-needs-input',
submitLabel: needsInput.fields[0].label,
action: 'submitNeedsInput',
fields: needsInput.fields.map(toFormField),
}];
}

View File

@@ -176,7 +176,6 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
method('chat.validateApiKey', 'Validate chat API key and list available models.', [requiredString('apiKey')], '{ isValid: boolean; models: ChatModel[] }'), method('chat.validateApiKey', 'Validate chat API key and list available models.', [requiredString('apiKey')], '{ isValid: boolean; models: ChatModel[] }'),
method('chat.setApiKey', 'Store chat API key.', [requiredString('apiKey')], '{ success: boolean; error?: string }'), method('chat.setApiKey', 'Store chat API key.', [requiredString('apiKey')], '{ success: boolean; error?: string }'),
method('chat.getApiKey', 'Get stored chat API key status.', [], 'ChatApiKeyStatus'), method('chat.getApiKey', 'Get stored chat API key status.', [], 'ChatApiKeyStatus'),
method('chat.getProtocolHealth', 'Get AGUI protocol telemetry health snapshot.', [], 'ProtocolTelemetrySnapshot'),
method('chat.getAvailableModels', 'Get available chat models and selected default.', [], '{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }'), method('chat.getAvailableModels', 'Get available chat models and selected default.', [], '{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }'),
method('chat.setDefaultModel', 'Set default chat model.', [requiredString('modelId')], '{ success: boolean; error?: string }'), method('chat.setDefaultModel', 'Set default chat model.', [requiredString('modelId')], '{ success: boolean; error?: string }'),
method('chat.getSystemPrompt', 'Get configured system prompt.', [], '{ success: boolean; prompt?: string; error?: string }'), method('chat.getSystemPrompt', 'Get configured system prompt.', [], '{ success: boolean; prompt?: string; error?: string }'),
@@ -186,7 +185,7 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
method('chat.getConversation', 'Fetch one chat conversation by id.', [requiredString('id')], 'ChatConversation | null'), method('chat.getConversation', 'Fetch one chat conversation by id.', [requiredString('id')], 'ChatConversation | null'),
method('chat.updateConversation', 'Update chat conversation metadata.', [requiredString('id'), requiredObject('updates')], 'ChatConversation | null'), method('chat.updateConversation', 'Update chat conversation metadata.', [requiredString('id'), requiredObject('updates')], 'ChatConversation | null'),
method('chat.deleteConversation', 'Delete chat conversation by id.', [requiredString('id')], 'boolean'), method('chat.deleteConversation', 'Delete chat conversation by id.', [requiredString('id')], 'boolean'),
method('chat.sendMessage', 'Send message to chat conversation.', [requiredString('conversationId'), requiredString('message'), optionalObject('metadata')], "{ success: boolean; message?: string; envelope?: ProtocolResponseEnvelope; protocolVersion?: '2.0'; traceId?: string; warnings?: string[]; error?: string }"), method('chat.sendMessage', 'Send message to chat conversation.', [requiredString('conversationId'), requiredString('message'), optionalObject('metadata')], '{ success: boolean; message?: string; error?: string }'),
method('chat.abortMessage', 'Abort active streaming chat response.', [requiredString('conversationId')], 'void'), method('chat.abortMessage', 'Abort active streaming chat response.', [requiredString('conversationId')], 'void'),
method('chat.getHistory', 'Get message history for conversation.', [requiredString('conversationId')], 'ChatMessage[]'), method('chat.getHistory', 'Get message history for conversation.', [requiredString('conversationId')], 'ChatMessage[]'),
method('chat.clearMessages', 'Clear messages for conversation.', [requiredString('conversationId')], 'void'), method('chat.clearMessages', 'Clear messages for conversation.', [requiredString('conversationId')], 'void'),
@@ -385,34 +384,6 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
{ name: 'requiresConfirmation', type: 'boolean', required: true, description: 'Whether confirmation is required before dispatch.' }, { name: 'requiresConfirmation', type: 'boolean', required: true, description: 'Whether confirmation is required before dispatch.' },
], ],
}, },
{
name: 'ProtocolResponseEnvelope',
description: 'Canonical AGUI response envelope returned from chat.sendMessage.',
fields: [
{ name: 'protocolVersion', type: "'2.0'", required: true, description: 'Envelope protocol version.' },
{ name: 'assistantText', type: 'string', required: true, description: 'Assistant text content rendered in transcript.' },
{ name: 'ui', type: "{ specVersion: '1'; elements: unknown[] }", required: false, description: 'Optional structured UI payload.' },
{ name: 'intent', type: "'analyze' | 'ask_input' | 'propose_action' | 'execute_action' | 'summarize'", required: true, description: 'Turn intent classification.' },
{ name: 'needsInput', type: '{ required: boolean; fields: ProtocolNeedsInputField[] }', required: true, description: 'Clarification requirements for next step.' },
{ name: 'actions', type: 'ProtocolAction[]', required: true, description: 'Declarative actions available for this turn.' },
{ name: 'confidence', type: 'number', required: true, description: 'Model confidence score from 0 to 1.' },
{ name: 'traceId', type: 'string', required: true, description: 'Trace id for observability and debugging.' },
],
},
{
name: 'ProtocolTelemetrySnapshot',
description: 'Aggregated protocol telemetry metrics for AGUI response health.',
fields: [
{ name: 'totalTurns', type: 'number', required: true, description: 'Total number of recorded assistant turns.' },
{ name: 'validEnvelopeTurns', type: 'number', required: true, description: 'Turns with schema-valid protocol envelopes.' },
{ name: 'repairAttempts', type: 'number', required: true, description: 'Number of response repair attempts.' },
{ name: 'fallbackTurns', type: 'number', required: true, description: 'Turns that used protocol fallback response.' },
{ name: 'blockedActionCount', type: 'number', required: true, description: 'Count of actions blocked by policy.' },
{ name: 'parseValidityRate', type: 'number', required: true, description: 'Ratio of valid envelopes to total turns.' },
{ name: 'repairRate', type: 'number', required: true, description: 'Ratio of repair attempts to total turns.' },
{ name: 'fallbackRate', type: 'number', required: true, description: 'Ratio of fallback turns to total turns.' },
],
},
]; ];
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = { export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {

View File

@@ -638,11 +638,11 @@ describe('ChatEngine', () => {
const result = await chatEngine.getDefaultSystemPrompt(); const result = await chatEngine.getDefaultSystemPrompt();
expect(result).toContain('Blogging Desktop Server'); expect(result).toContain('Blogging Desktop Server');
expect(result).toContain('Available Tools'); expect(result).toContain('Available Data Tools');
expect(result).toContain('Agentic UI Contract'); expect(result).toContain('UI Render Tools');
expect(result).toContain('specVersion'); expect(result).toContain('render_chart');
expect(result).toContain('tabs'); expect(result).toContain('tabs');
expect(result).toContain('openSettings'); expect(result).toContain('render_form');
}); });
it('should return built-in prompt when saved prompt is empty', async () => { it('should return built-in prompt when saved prompt is empty', async () => {

View File

@@ -1,225 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { OpenCodeManager } from '../../src/main/engine/OpenCodeManager';
import { getProtocolTelemetryService } from '../../src/main/agentic/observability/protocolTelemetry';
interface MockConversation {
id: string;
model?: string;
messages: Array<{ role: 'user' | 'assistant' | 'system' | 'tool'; content: string }>;
}
function createChatEngineMock(conversation: MockConversation) {
const settings = new Map<string, string>();
const addMessage = vi.fn().mockResolvedValue(undefined);
return {
getConversation: vi.fn().mockResolvedValue(conversation),
addMessage,
getDefaultSystemPrompt: vi.fn().mockResolvedValue('system prompt'),
getSetting: vi.fn(async (key: string) => settings.get(key) ?? null),
setSetting: vi.fn(async (key: string, value: string) => {
settings.set(key, value);
}),
};
}
describe('OpenCodeManager protocol integration', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('uses protocol envelope flow and persists workflow checkpoint for needs_input turns', async () => {
const conversation: MockConversation = {
id: 'conversation-1',
model: 'gpt-5',
messages: [
{ role: 'system', content: 'system' },
{ role: 'user', content: 'previous user message' },
],
};
const chatEngineMock = createChatEngineMock(conversation);
const manager = new OpenCodeManager(
chatEngineMock as never,
{} as never,
{} as never,
() => null,
);
manager.setApiKey('test-api-key');
const providerSpy = vi.spyOn(manager as never, 'sendOpenAIMessage').mockResolvedValue({
content: JSON.stringify({
protocolVersion: '2.0',
assistantText: 'Please provide a date range.',
intent: 'ask_input',
needsInput: {
required: true,
fields: [{ key: 'dateRange', label: 'Date range', inputType: 'date' }],
},
actions: [],
confidence: 0.82,
traceId: 'trace-needs-input',
}),
toolCalls: [],
});
const telemetryBefore = getProtocolTelemetryService().getSnapshot();
const result = await manager.sendMessage('conversation-1', 'Generate report', {
metadata: { surface: 'tab' },
});
expect(result.success).toBe(true);
expect(result.envelope?.protocolVersion).toBe('2.0');
expect(result.envelope?.needsInput.required).toBe(true);
expect(result.envelope?.needsInput.fields[0]?.key).toBe('dateRange');
expect(providerSpy).toHaveBeenCalledTimes(1);
const providerMessages = providerSpy.mock.calls[0][2] as Array<{ role: string; content?: string }>;
const latestUserMessage = providerMessages[providerMessages.length - 1]?.content ?? '';
expect(latestUserMessage).toContain('[Protocol request envelope]');
expect(latestUserMessage).toContain('"protocolVersion": "2.0"');
expect(latestUserMessage).toContain('"surface": "tab"');
const checkpointKey = 'agui.workflow.conversation-1';
expect(chatEngineMock.setSetting).toHaveBeenCalledWith(
checkpointKey,
expect.any(String),
);
const persistedCheckpoint = await chatEngineMock.getSetting(checkpointKey);
const checkpoint = JSON.parse(persistedCheckpoint as string) as { state: string; pendingFields: string[]; lastTraceId: string };
expect(checkpoint.state).toBe('awaiting_input');
expect(checkpoint.pendingFields).toEqual(['dateRange']);
expect(checkpoint.lastTraceId).toBe('trace-needs-input');
const telemetryAfter = getProtocolTelemetryService().getSnapshot();
expect(telemetryAfter.totalTurns).toBe(telemetryBefore.totalTurns + 1);
expect(telemetryAfter.validEnvelopeTurns).toBe(telemetryBefore.validEnvelopeTurns + 1);
expect(chatEngineMock.addMessage).toHaveBeenCalledWith(expect.objectContaining({
conversationId: 'conversation-1',
role: 'assistant',
content: 'Please provide a date range.',
}));
});
it('blocks unsupported actions and records blocked-action telemetry', async () => {
const conversation: MockConversation = {
id: 'conversation-2',
model: 'gpt-5',
messages: [{ role: 'user', content: 'previous user message' }],
};
const chatEngineMock = createChatEngineMock(conversation);
const manager = new OpenCodeManager(
chatEngineMock as never,
{} as never,
{} as never,
() => null,
);
manager.setApiKey('test-api-key');
vi.spyOn(manager as never, 'sendOpenAIMessage').mockResolvedValue({
content: JSON.stringify({
protocolVersion: '2.0',
assistantText: 'Try toggling sidebar.',
intent: 'propose_action',
needsInput: { required: false, fields: [] },
actions: [
{
id: 'a1',
action: 'toggleAssistantSidebar',
label: 'Toggle assistant sidebar',
policy: 'silent',
requiresConfirmation: false,
},
],
confidence: 0.74,
traceId: 'trace-blocked-action',
}),
toolCalls: [],
});
const telemetryBefore = getProtocolTelemetryService().getSnapshot();
const result = await manager.sendMessage('conversation-2', 'Toggle it', {
metadata: { surface: 'tab' },
});
expect(result.success).toBe(true);
expect(result.envelope?.actions).toEqual([]);
expect(result.warnings?.some((warning) => warning.includes('Blocked unsupported action: toggleAssistantSidebar'))).toBe(true);
const telemetryAfter = getProtocolTelemetryService().getSnapshot();
expect(telemetryAfter.blockedActionCount).toBe(telemetryBefore.blockedActionCount + 1);
});
it('retries once with protocol repair prompt when first output is non-canonical', async () => {
const conversation: MockConversation = {
id: 'conversation-3',
model: 'gpt-5',
messages: [{ role: 'user', content: 'show chart' }],
};
const chatEngineMock = createChatEngineMock(conversation);
const manager = new OpenCodeManager(
chatEngineMock as never,
{} as never,
{} as never,
() => null,
);
manager.setApiKey('test-api-key');
const sendSpy = vi.spyOn(manager as never, 'sendOpenAIMessage')
.mockResolvedValueOnce({
content: JSON.stringify({
title: 'Legacy JSON',
widgets: [{ type: 'chart', chartType: 'bar' }],
}),
toolCalls: [],
})
.mockResolvedValueOnce({
content: JSON.stringify({
protocolVersion: '2.0',
assistantText: 'Here is your chart.',
ui: {
specVersion: '1',
elements: [
{
type: 'chart',
chartType: 'bar',
series: [{ label: '2015', value: 86 }],
},
],
},
intent: 'summarize',
needsInput: { required: false, fields: [] },
actions: [],
confidence: 0.8,
traceId: 'trace-retry-success',
}),
toolCalls: [],
});
const result = await manager.sendMessage('conversation-3', 'Build chart', {
metadata: { surface: 'tab' },
});
expect(result.success).toBe(true);
expect(result.envelope?.traceId).toBe('trace-retry-success');
expect(sendSpy).toHaveBeenCalledTimes(2);
const retryMessages = sendSpy.mock.calls[1]?.[2] as Array<{ role: string; content?: string }>;
const lastMessage = retryMessages[retryMessages.length - 1]?.content ?? '';
expect(lastMessage).toContain('failed protocol validation');
expect(lastMessage).toContain('Return ONLY one valid protocol envelope JSON object');
expect(chatEngineMock.addMessage).toHaveBeenCalledWith(expect.objectContaining({
conversationId: 'conversation-3',
role: 'assistant',
content: 'Here is your chart.',
}));
});
});

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import {
getCatalogEntries,
isSupportedComponentType,
getCatalogEntry,
getCatalogId,
buildCatalogDescription,
} from '../../../src/main/a2ui/catalog';
import { BDS_CATALOG_ID } from '../../../src/main/a2ui/types';
describe('A2UI catalog', () => {
it('returns all 17 catalog entries', () => {
const entries = getCatalogEntries();
expect(entries).toHaveLength(17);
});
it('returns a copy of catalog entries to prevent mutation', () => {
const entries1 = getCatalogEntries();
const entries2 = getCatalogEntries();
expect(entries1).not.toBe(entries2);
expect(entries1).toEqual(entries2);
});
it('recognises all supported component types', () => {
const types = [
'text', 'button', 'card', 'chart', 'table', 'form',
'textField', 'checkBox', 'dateTimeInput', 'choicePicker',
'image', 'tabs', 'metric', 'list', 'row', 'column', 'divider',
];
for (const type of types) {
expect(isSupportedComponentType(type)).toBe(true);
}
});
it('rejects unsupported component types', () => {
expect(isSupportedComponentType('video')).toBe(false);
expect(isSupportedComponentType('slider')).toBe(false);
expect(isSupportedComponentType('')).toBe(false);
});
it('returns catalog entry by type', () => {
const entry = getCatalogEntry('chart');
expect(entry).toEqual({
type: 'chart',
description: 'Bar, line, or pie chart visualization',
custom: true,
});
});
it('returns undefined for unknown type', () => {
expect(getCatalogEntry('unknown' as never)).toBeUndefined();
});
it('returns the bDS catalog ID', () => {
expect(getCatalogId()).toBe(BDS_CATALOG_ID);
expect(getCatalogId()).toBe('bds-blogging-v1');
});
it('builds a catalog description for LLM system prompt', () => {
const description = buildCatalogDescription();
expect(description).toContain('Supported UI component types:');
expect(description).toContain('text: Text block with Markdown support');
expect(description).toContain('chart: Bar, line, or pie chart visualization (custom)');
expect(description).toContain('table: Data table with columns and rows (custom)');
});
it('marks custom components correctly', () => {
const entries = getCatalogEntries();
const customEntries = entries.filter((e) => e.custom);
const customTypes = customEntries.map((e) => e.type);
expect(customTypes).toContain('chart');
expect(customTypes).toContain('table');
expect(customTypes).toContain('metric');
expect(customTypes).toContain('form');
expect(customTypes).not.toContain('text');
expect(customTypes).not.toContain('button');
});
});

View File

@@ -0,0 +1,263 @@
import { describe, expect, it } from 'vitest';
import {
isRenderTool,
generateFromToolCall,
generateChart,
generateTable,
generateForm,
generateCard,
generateMetric,
generateList,
generateTabs,
} from '../../../src/main/a2ui/generator';
import type { A2UIServerMessage } from '../../../src/main/a2ui/types';
describe('A2UI generator', () => {
describe('isRenderTool', () => {
it('returns true for all render tools', () => {
expect(isRenderTool('render_chart')).toBe(true);
expect(isRenderTool('render_table')).toBe(true);
expect(isRenderTool('render_form')).toBe(true);
expect(isRenderTool('render_card')).toBe(true);
expect(isRenderTool('render_metric')).toBe(true);
expect(isRenderTool('render_list')).toBe(true);
expect(isRenderTool('render_tabs')).toBe(true);
});
it('returns false for non-render tools', () => {
expect(isRenderTool('search_posts')).toBe(false);
expect(isRenderTool('get_post')).toBe(false);
expect(isRenderTool('render_unknown')).toBe(false);
expect(isRenderTool('')).toBe(false);
});
});
describe('generateFromToolCall', () => {
it('dispatches to chart generator', () => {
const messages = generateFromToolCall('conv-1', 'render_chart', {
chartType: 'bar',
series: [{ label: 'A', value: 1 }],
});
expect(messages).not.toBeNull();
expect(messages!.length).toBeGreaterThanOrEqual(2);
expect(messages![0].type).toBe('createSurface');
});
it('returns null for unknown tool', () => {
expect(generateFromToolCall('conv-1', 'search_posts', {})).toBeNull();
});
});
describe('generateChart', () => {
it('creates surface with chart component and data binding', () => {
const messages = generateChart('conv-1', {
chartType: 'bar',
title: 'Sales',
series: [
{ label: 'Jan', value: 10 },
{ label: 'Feb', value: 20 },
],
});
expect(messages).toHaveLength(3); // createSurface + updateComponents + updateDataModel
const createMsg = messages[0] as Extract<A2UIServerMessage, { type: 'createSurface' }>;
expect(createMsg.type).toBe('createSurface');
expect(createMsg.conversationId).toBe('conv-1');
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
expect(updateMsg.type).toBe('updateComponents');
expect(updateMsg.components).toHaveLength(1);
expect(updateMsg.components[0].type).toBe('chart');
expect(updateMsg.components[0].properties.chartType).toBe('bar');
expect(updateMsg.components[0].dataBinding).toBe('/chartData');
expect(updateMsg.rootIds).toHaveLength(1);
const dataMsg = messages[2] as Extract<A2UIServerMessage, { type: 'updateDataModel' }>;
expect(dataMsg.type).toBe('updateDataModel');
expect(dataMsg.path).toBe('/chartData');
expect(dataMsg.value).toEqual([
{ label: 'Jan', value: 10 },
{ label: 'Feb', value: 20 },
]);
});
});
describe('generateTable', () => {
it('creates surface with table component and row data', () => {
const messages = generateTable('conv-1', {
title: 'Posts',
columns: ['Title', 'Status'],
rows: [['Hello', 'published'], ['Draft', 'draft']],
});
expect(messages).toHaveLength(3);
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
expect(updateMsg.components[0].type).toBe('table');
expect(updateMsg.components[0].properties.columns).toEqual(['Title', 'Status']);
const dataMsg = messages[2] as Extract<A2UIServerMessage, { type: 'updateDataModel' }>;
expect(dataMsg.path).toBe('/tableRows');
expect(dataMsg.value).toEqual([['Hello', 'published'], ['Draft', 'draft']]);
});
});
describe('generateCard', () => {
it('creates surface with card component', () => {
const messages = generateCard('conv-1', {
title: 'My Card',
body: 'Card body text',
subtitle: 'Optional subtitle',
});
expect(messages).toHaveLength(2); // No data model for card
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
expect(updateMsg.components[0].type).toBe('card');
expect(updateMsg.components[0].properties.title).toBe('My Card');
expect(updateMsg.components[0].properties.body).toBe('Card body text');
});
it('includes card actions when provided', () => {
const messages = generateCard('conv-1', {
title: 'Action Card',
body: 'Has actions',
actions: [{ label: 'Open', action: 'openPost', payload: { postId: 'p1' } }],
});
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
expect(updateMsg.components[0].actions).toEqual([
{ eventType: 'click', action: 'openPost', payload: { postId: 'p1' } },
]);
});
});
describe('generateMetric', () => {
it('creates surface with metric component', () => {
const messages = generateMetric('conv-1', {
label: 'Total Posts',
value: '42',
});
expect(messages).toHaveLength(2);
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
expect(updateMsg.components[0].type).toBe('metric');
expect(updateMsg.components[0].properties.label).toBe('Total Posts');
expect(updateMsg.components[0].properties.value).toBe('42');
});
});
describe('generateList', () => {
it('creates surface with list component and item data', () => {
const messages = generateList('conv-1', {
title: 'Tags',
items: ['react', 'typescript', 'electron'],
});
expect(messages).toHaveLength(3);
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
expect(updateMsg.components[0].type).toBe('list');
const dataMsg = messages[2] as Extract<A2UIServerMessage, { type: 'updateDataModel' }>;
expect(dataMsg.path).toBe('/listItems');
expect(dataMsg.value).toEqual(['react', 'typescript', 'electron']);
});
});
describe('generateForm', () => {
it('creates surface with form, field components, and submit button', () => {
const messages = generateForm('conv-1', {
title: 'Edit Post',
submitLabel: 'Save',
fields: [
{ key: 'title', label: 'Title', inputType: 'text', defaultValue: 'Hello' },
{ key: 'draft', label: 'Draft', inputType: 'checkbox', defaultValue: true },
],
});
// createSurface + updateComponents + 2 updateDataModel (one per default value)
expect(messages).toHaveLength(4);
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
// form + 2 fields + 1 submit button = 4
expect(updateMsg.components).toHaveLength(4);
const formComponent = updateMsg.components.find((c) => c.type === 'form');
expect(formComponent).toBeDefined();
expect(formComponent!.children).toHaveLength(3); // 2 fields + submit button
const textField = updateMsg.components.find((c) => c.type === 'textField');
expect(textField).toBeDefined();
expect(textField!.dataBinding).toBe('/formData/title');
const checkBox = updateMsg.components.find((c) => c.type === 'checkBox');
expect(checkBox).toBeDefined();
expect(checkBox!.dataBinding).toBe('/formData/draft');
const submitButton = updateMsg.components.find((c) => c.type === 'button');
expect(submitButton).toBeDefined();
expect(submitButton!.properties.label).toBe('Save');
});
it('maps select inputType to choicePicker', () => {
const messages = generateForm('conv-1', {
submitLabel: 'Go',
fields: [
{
key: 'status',
label: 'Status',
inputType: 'select',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
},
],
});
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
const picker = updateMsg.components.find((c) => c.type === 'choicePicker');
expect(picker).toBeDefined();
});
it('maps date inputType to dateTimeInput', () => {
const messages = generateForm('conv-1', {
submitLabel: 'Set',
fields: [{ key: 'date', label: 'Date', inputType: 'date' }],
});
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
const dateInput = updateMsg.components.find((c) => c.type === 'dateTimeInput');
expect(dateInput).toBeDefined();
});
});
describe('generateTabs', () => {
it('creates surface with tabs and child components', () => {
const messages = generateTabs('conv-1', {
tabs: [
{
label: 'Overview',
content: [{ type: 'text', text: 'Tab content' }],
},
{
label: 'Details',
content: [{ type: 'metric', label: 'Count', value: '5' }],
},
],
});
expect(messages).toHaveLength(2); // createSurface + updateComponents
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
const tabsComponent = updateMsg.components.find((c) => c.type === 'tabs');
expect(tabsComponent).toBeDefined();
expect(tabsComponent!.children).toHaveLength(2);
expect(tabsComponent!.properties.tabLabels).toEqual(['Overview', 'Details']);
});
});
});

View File

@@ -0,0 +1,345 @@
import { describe, expect, it, vi } from 'vitest';
import {
A2UISurfaceManager,
getValueAtPointer,
setValueAtPointer,
} from '../../../src/renderer/a2ui/A2UISurfaceManager';
import type { A2UIServerMessage, A2UIComponent } from '../../../src/main/a2ui/types';
describe('A2UISurfaceManager', () => {
function createTestComponent(overrides: Partial<A2UIComponent> = {}): A2UIComponent {
return {
id: 'comp-1',
type: 'text',
properties: { text: 'Hello' },
...overrides,
};
}
describe('createSurface', () => {
it('creates a new surface with empty state', () => {
const manager = new A2UISurfaceManager();
manager.processMessage({
type: 'createSurface',
surfaceId: 'surface-1',
conversationId: 'conv-1',
});
const surface = manager.getSurface('surface-1');
expect(surface).toBeDefined();
expect(surface!.conversationId).toBe('conv-1');
expect(surface!.components.size).toBe(0);
expect(surface!.rootIds).toEqual([]);
expect(surface!.dataModel).toEqual({});
});
it('notifies listeners on surface creation', () => {
const manager = new A2UISurfaceManager();
const listener = vi.fn();
manager.onChange(listener);
manager.processMessage({
type: 'createSurface',
surfaceId: 'surface-1',
conversationId: 'conv-1',
});
expect(listener).toHaveBeenCalledWith('surface-1');
});
});
describe('updateComponents', () => {
it('adds components to an existing surface', () => {
const manager = new A2UISurfaceManager();
manager.processMessage({
type: 'createSurface',
surfaceId: 'surface-1',
conversationId: 'conv-1',
});
const component = createTestComponent();
manager.processMessage({
type: 'updateComponents',
surfaceId: 'surface-1',
components: [component],
rootIds: ['comp-1'],
});
const surface = manager.getSurface('surface-1');
expect(surface!.components.size).toBe(1);
expect(surface!.components.get('comp-1')).toEqual(component);
expect(surface!.rootIds).toEqual(['comp-1']);
});
it('ignores updateComponents for non-existent surfaces', () => {
const manager = new A2UISurfaceManager();
const listener = vi.fn();
manager.onChange(listener);
manager.processMessage({
type: 'updateComponents',
surfaceId: 'nonexistent',
components: [createTestComponent()],
rootIds: ['comp-1'],
});
expect(listener).not.toHaveBeenCalled();
});
});
describe('updateDataModel', () => {
it('sets a value in the data model', () => {
const manager = new A2UISurfaceManager();
manager.processMessage({
type: 'createSurface',
surfaceId: 'surface-1',
conversationId: 'conv-1',
});
manager.processMessage({
type: 'updateDataModel',
surfaceId: 'surface-1',
path: '/chartData',
value: [{ label: 'A', value: 1 }],
});
const dataModel = manager.getDataModel('surface-1');
expect(dataModel).toEqual({ chartData: [{ label: 'A', value: 1 }] });
});
it('ignores updateDataModel for non-existent surfaces', () => {
const manager = new A2UISurfaceManager();
manager.processMessage({
type: 'updateDataModel',
surfaceId: 'nonexistent',
path: '/foo',
value: 'bar',
});
expect(manager.getDataModel('nonexistent')).toEqual({});
});
});
describe('deleteSurface', () => {
it('removes a surface', () => {
const manager = new A2UISurfaceManager();
manager.processMessage({
type: 'createSurface',
surfaceId: 'surface-1',
conversationId: 'conv-1',
});
expect(manager.getSurface('surface-1')).toBeDefined();
manager.processMessage({
type: 'deleteSurface',
surfaceId: 'surface-1',
});
expect(manager.getSurface('surface-1')).toBeUndefined();
});
});
describe('getSurfaceIds', () => {
it('returns surface IDs for a specific conversation', () => {
const manager = new A2UISurfaceManager();
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
manager.processMessage({ type: 'createSurface', surfaceId: 's2', conversationId: 'conv-1' });
manager.processMessage({ type: 'createSurface', surfaceId: 's3', conversationId: 'conv-2' });
expect(manager.getSurfaceIds('conv-1')).toEqual(['s1', 's2']);
expect(manager.getSurfaceIds('conv-2')).toEqual(['s3']);
expect(manager.getSurfaceIds('conv-3')).toEqual([]);
});
});
describe('resolveTree', () => {
it('resolves a flat component buffer into a nested tree', () => {
const manager = new A2UISurfaceManager();
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
manager.processMessage({
type: 'updateComponents',
surfaceId: 's1',
components: [
{ id: 'root', type: 'column', properties: {}, children: ['child-1', 'child-2'] },
{ id: 'child-1', type: 'text', properties: { text: 'Hello' } },
{ id: 'child-2', type: 'button', properties: { label: 'Click' } },
],
rootIds: ['root'],
});
const tree = manager.resolveTree('s1');
expect(tree).toHaveLength(1);
expect(tree[0].type).toBe('column');
expect(tree[0].children).toHaveLength(2);
expect(tree[0].children[0].type).toBe('text');
expect(tree[0].children[0].properties.text).toBe('Hello');
expect(tree[0].children[1].type).toBe('button');
});
it('resolves data bindings to bound values', () => {
const manager = new A2UISurfaceManager();
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
manager.processMessage({
type: 'updateComponents',
surfaceId: 's1',
components: [
{ id: 'chart-1', type: 'chart', properties: { chartType: 'bar' }, dataBinding: '/data' },
],
rootIds: ['chart-1'],
});
manager.processMessage({
type: 'updateDataModel',
surfaceId: 's1',
path: '/data',
value: [1, 2, 3],
});
const tree = manager.resolveTree('s1');
expect(tree[0].boundValue).toEqual([1, 2, 3]);
});
it('returns empty array for non-existent surface', () => {
const manager = new A2UISurfaceManager();
expect(manager.resolveTree('nonexistent')).toEqual([]);
});
it('filters out unresolvable child references', () => {
const manager = new A2UISurfaceManager();
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
manager.processMessage({
type: 'updateComponents',
surfaceId: 's1',
components: [
{ id: 'root', type: 'column', properties: {}, children: ['child-1', 'missing'] },
{ id: 'child-1', type: 'text', properties: { text: 'Hello' } },
],
rootIds: ['root'],
});
const tree = manager.resolveTree('s1');
expect(tree[0].children).toHaveLength(1);
});
});
describe('updateLocalData', () => {
it('updates data model and notifies listeners', () => {
const manager = new A2UISurfaceManager();
const listener = vi.fn();
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
manager.onChange(listener);
manager.updateLocalData('s1', '/formData/name', 'John');
expect(manager.getDataModel('s1')).toEqual({ formData: { name: 'John' } });
expect(listener).toHaveBeenCalledWith('s1');
});
it('ignores updates for non-existent surfaces', () => {
const manager = new A2UISurfaceManager();
const listener = vi.fn();
manager.onChange(listener);
manager.updateLocalData('nonexistent', '/foo', 'bar');
expect(listener).not.toHaveBeenCalled();
});
});
describe('clearConversation', () => {
it('removes all surfaces for a conversation', () => {
const manager = new A2UISurfaceManager();
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
manager.processMessage({ type: 'createSurface', surfaceId: 's2', conversationId: 'conv-1' });
manager.processMessage({ type: 'createSurface', surfaceId: 's3', conversationId: 'conv-2' });
manager.clearConversation('conv-1');
expect(manager.getSurfaceIds('conv-1')).toEqual([]);
expect(manager.getSurfaceIds('conv-2')).toEqual(['s3']);
});
});
describe('onChange', () => {
it('returns an unsubscribe function', () => {
const manager = new A2UISurfaceManager();
const listener = vi.fn();
const unsubscribe = manager.onChange(listener);
manager.processMessage({ type: 'createSurface', surfaceId: 's1', conversationId: 'conv-1' });
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
manager.processMessage({ type: 'createSurface', surfaceId: 's2', conversationId: 'conv-1' });
expect(listener).toHaveBeenCalledTimes(1);
});
});
});
describe('JSON Pointer utilities', () => {
describe('getValueAtPointer', () => {
it('returns the root object for empty or "/" pointer', () => {
const obj = { foo: 'bar' };
expect(getValueAtPointer(obj, '')).toBe(obj);
expect(getValueAtPointer(obj, '/')).toBe(obj);
});
it('gets a top-level value', () => {
expect(getValueAtPointer({ name: 'Alice' }, '/name')).toBe('Alice');
});
it('gets a nested value', () => {
const obj = { a: { b: { c: 42 } } };
expect(getValueAtPointer(obj, '/a/b/c')).toBe(42);
});
it('returns undefined for missing paths', () => {
expect(getValueAtPointer({ a: 1 }, '/b')).toBeUndefined();
expect(getValueAtPointer({ a: 1 }, '/a/b/c')).toBeUndefined();
});
it('handles escaped pointer characters', () => {
const obj = { 'a/b': { '~c': 'value' } };
expect(getValueAtPointer(obj, '/a~1b/~0c')).toBe('value');
});
});
describe('setValueAtPointer', () => {
it('sets a top-level value', () => {
const obj: Record<string, unknown> = {};
setValueAtPointer(obj, '/name', 'Alice');
expect(obj.name).toBe('Alice');
});
it('creates intermediate objects for nested paths', () => {
const obj: Record<string, unknown> = {};
setValueAtPointer(obj, '/a/b/c', 42);
expect(obj).toEqual({ a: { b: { c: 42 } } });
});
it('does nothing for empty or root pointer', () => {
const obj = { foo: 'bar' };
setValueAtPointer(obj, '', 'new');
setValueAtPointer(obj, '/', 'new');
expect(obj).toEqual({ foo: 'bar' });
});
it('handles escaped pointer characters', () => {
const obj: Record<string, unknown> = {};
setValueAtPointer(obj, '/a~1b', 'value');
expect(obj['a/b']).toBe('value');
});
});
});

View File

@@ -1,32 +0,0 @@
import { describe, expect, it } from 'vitest';
import { CapabilityRegistryService } from '../../../../src/main/agentic/capabilities/registry';
describe('CapabilityRegistryService', () => {
it('returns per-surface capability differences', () => {
const registry = new CapabilityRegistryService();
const tabCapabilities = registry.getSnapshot({ surface: 'tab' });
const sidebarCapabilities = registry.getSnapshot({ surface: 'sidebar' });
expect(tabCapabilities.widgets).toContain('tabs');
expect(sidebarCapabilities.widgets).toContain('tabs');
expect(tabCapabilities.actions).toContain('toggleSidebar');
expect(sidebarCapabilities.actions).toContain('toggleAssistantSidebar');
expect(tabCapabilities.actions).not.toContain('toggleAssistantSidebar');
});
it('omits disabled capabilities from active lists', () => {
const registry = new CapabilityRegistryService({
disabledActions: ['openSettings'],
disabledWidgets: ['chart'],
disabledTools: ['view_image'],
});
const snapshot = registry.getSnapshot({ surface: 'tab' });
expect(snapshot.actions).not.toContain('openSettings');
expect(snapshot.widgets).not.toContain('chart');
expect(snapshot.tools).not.toContain('view_image');
expect(snapshot.disabled).toEqual(expect.arrayContaining(['action:openSettings', 'widget:chart', 'tool:view_image']));
});
});

View File

@@ -1,58 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
ProtocolTelemetryService,
getProtocolTelemetryService,
} from '../../../../src/main/agentic/observability/protocolTelemetry';
describe('ProtocolTelemetryService', () => {
it('returns zero rates before any turns are recorded', () => {
const telemetry = new ProtocolTelemetryService();
const snapshot = telemetry.getSnapshot();
expect(snapshot.totalTurns).toBe(0);
expect(snapshot.parseValidityRate).toBe(0);
expect(snapshot.repairRate).toBe(0);
expect(snapshot.fallbackRate).toBe(0);
});
it('tracks parse validity, repairs, fallback, and blocked actions', () => {
const telemetry = new ProtocolTelemetryService();
telemetry.recordTurn({
validEnvelope: true,
repairAttempted: false,
fallbackUsed: false,
blockedActions: 0,
});
telemetry.recordTurn({
validEnvelope: true,
repairAttempted: true,
fallbackUsed: false,
blockedActions: 1,
});
telemetry.recordTurn({
validEnvelope: false,
repairAttempted: true,
fallbackUsed: true,
blockedActions: 2,
});
const snapshot = telemetry.getSnapshot();
expect(snapshot.totalTurns).toBe(3);
expect(snapshot.validEnvelopeTurns).toBe(2);
expect(snapshot.repairAttempts).toBe(2);
expect(snapshot.fallbackTurns).toBe(1);
expect(snapshot.blockedActionCount).toBe(3);
expect(snapshot.parseValidityRate).toBeCloseTo(2 / 3, 5);
});
it('returns the same singleton telemetry service instance', () => {
const first = getProtocolTelemetryService();
const second = getProtocolTelemetryService();
expect(first).toBe(second);
});
});

View File

@@ -1,21 +0,0 @@
import { describe, expect, it } from 'vitest';
import { resolveActionPolicy } from '../../../../src/main/agentic/policy/actionPolicy';
describe('action policy', () => {
it('marks dangerous actions as requiring explicit confirmation', () => {
const policy = resolveActionPolicy('deletePost');
expect(policy.level).toBe('danger');
expect(policy.requiresConfirmation).toBe(true);
});
it('marks configurable but safe navigation actions as confirm', () => {
const policy = resolveActionPolicy('openSettings');
expect(policy.level).toBe('confirm');
expect(policy.requiresConfirmation).toBe(true);
});
it('defaults unknown actions to danger', () => {
const policy = resolveActionPolicy('unknownAction');
expect(policy.level).toBe('danger');
});
});

View File

@@ -1,262 +0,0 @@
import { describe, expect, it } from 'vitest';
import { ProtocolResponseBuilder } from '../../../../src/main/agentic/protocol/responseBuilder';
describe('ProtocolResponseBuilder', () => {
it('builds canonical envelope from mixed text + AGUI payload', () => {
const builder = new ProtocolResponseBuilder();
const raw = [
'I found weak months.',
'```json',
'{"specVersion":"1","elements":[{"type":"chart","chartType":"bar","series":[{"label":"Jan","value":10}]}]}',
'```',
].join('\n');
const result = builder.build({
rawAssistantOutput: raw,
surface: 'tab',
capabilities: {
widgets: ['chart'],
actions: ['openPost'],
tools: ['search_posts'],
},
});
expect(result.envelope.ui?.elements).toHaveLength(1);
expect(result.envelope.assistantText).toContain('I found weak months');
expect(result.envelope.protocolVersion).toBe('2.0');
expect(result.repairAttempted).toBe(false);
});
it('repairs non-canonical envelope keys and validates output', () => {
const builder = new ProtocolResponseBuilder();
const raw = JSON.stringify({
protocol_version: '2.0',
assistant_text: 'Need more details',
intent: 'ask_input',
needs_input: {
required: true,
fields: [{ key: 'date', label: 'Date', inputType: 'date' }],
},
actions: [],
confidence: 0.8,
trace_id: 'trace-manual',
});
const result = builder.build({
rawAssistantOutput: raw,
surface: 'sidebar',
capabilities: {
widgets: ['form'],
actions: ['openPost'],
tools: ['search_posts'],
},
});
expect(result.repairAttempted).toBe(true);
expect(result.envelope.assistantText).toBe('Need more details');
expect(result.envelope.needsInput.required).toBe(true);
expect(result.envelope.needsInput.fields).toHaveLength(1);
expect(result.validationError).toBeUndefined();
});
it('falls back to safe summarize envelope when payload is invalid', () => {
const builder = new ProtocolResponseBuilder();
const raw = '{"specVersion":"9","elements":[]}';
const result = builder.build({
rawAssistantOutput: raw,
surface: 'tab',
capabilities: {
widgets: ['chart'],
actions: ['openPost'],
tools: ['search_posts'],
},
});
expect(result.envelope.intent).toBe('summarize');
expect(result.envelope.ui).toBeUndefined();
expect(result.envelope.assistantText).toContain('specVersion');
expect(result.traceId.length).toBeGreaterThan(0);
});
it('blocks actions that are unavailable for the active surface capabilities', () => {
const builder = new ProtocolResponseBuilder();
const raw = JSON.stringify({
protocolVersion: '2.0',
assistantText: 'Open settings?',
intent: 'propose_action',
needsInput: { required: false, fields: [] },
actions: [{
id: 'a1',
action: 'openSettings',
label: 'Open Settings',
policy: 'confirm',
requiresConfirmation: true,
}],
confidence: 0.7,
traceId: 'trace-abc',
});
const result = builder.build({
rawAssistantOutput: raw,
surface: 'tab',
capabilities: {
widgets: ['chart'],
actions: ['openPost'],
tools: ['search_posts'],
},
});
expect(result.envelope.actions).toHaveLength(0);
expect(result.warnings.some((warning) => warning.includes('openSettings'))).toBe(true);
});
it('extracts declarative actions from UI elements and applies policy defaults', () => {
const builder = new ProtocolResponseBuilder();
const raw = JSON.stringify({
protocolVersion: '2.0',
assistantText: 'Choose an option',
intent: 'propose_action',
needsInput: { required: false, fields: [] },
actions: [],
ui: {
specVersion: '1',
elements: [
{
type: 'card',
title: 'Actions',
body: 'Pick one',
actions: [{ label: 'Delete', action: 'deletePost' }],
},
{
type: 'tabs',
tabs: [
{
id: 'first',
label: 'First',
elements: [{ type: 'action', label: 'Open post', action: 'openPost' }],
},
],
},
],
},
confidence: 0.7,
traceId: 'trace-ui-actions',
});
const result = builder.build({
rawAssistantOutput: raw,
surface: 'tab',
capabilities: {
widgets: ['card', 'tabs', 'action'],
actions: ['deletePost', 'openPost'],
tools: ['search_posts'],
},
});
expect(result.envelope.actions).toHaveLength(2);
expect(result.envelope.actions[0]).toEqual(expect.objectContaining({
action: 'deletePost',
policy: 'danger',
requiresConfirmation: true,
}));
expect(result.envelope.actions[1]).toEqual(expect.objectContaining({
action: 'openPost',
policy: 'silent',
requiresConfirmation: false,
}));
});
it('drops invalid ui payloads from canonical envelopes before renderer consumption', () => {
const builder = new ProtocolResponseBuilder();
const raw = JSON.stringify({
protocolVersion: '2.0',
assistantText: 'Here is the result',
intent: 'summarize',
needsInput: { required: false, fields: [] },
actions: [],
ui: {
specVersion: '1',
elements: [
{
type: 'chart',
chartType: 'bar',
},
],
},
confidence: 0.7,
traceId: 'trace-invalid-ui',
});
const result = builder.build({
rawAssistantOutput: raw,
surface: 'tab',
capabilities: {
widgets: ['chart'],
actions: ['openPost'],
tools: ['search_posts'],
},
});
expect(result.envelope.ui).toBeUndefined();
expect(result.warnings.some((warning) => warning.includes('Invalid ui payload'))).toBe(true);
});
it('normalizes non-canonical ui element fields inside canonical envelopes', () => {
const builder = new ProtocolResponseBuilder();
const raw = JSON.stringify({
protocolVersion: '2.0',
assistantText: 'Distribution chart ready.',
ui: {
specVersion: '1',
elements: [
{
type: 'chart',
chartType: 'bar',
data: {
labels: ['aside', 'article'],
datasets: [{ data: [181, 53] }],
},
},
{
type: 'text',
content: 'Category breakdown',
},
],
},
intent: 'summarize',
needsInput: { required: false, fields: [] },
actions: [],
confidence: 0.95,
traceId: 'trace-normalize-ui',
});
const result = builder.build({
rawAssistantOutput: raw,
surface: 'tab',
capabilities: {
widgets: ['chart', 'text'],
actions: ['openPost'],
tools: ['search_posts'],
},
});
const elements = result.envelope.ui?.elements as Array<{ type: string; series?: Array<{ label: string; value: number }>; text?: string }>;
expect(elements).toHaveLength(2);
expect(elements[0]?.type).toBe('chart');
expect(elements[0]?.series).toEqual([
{ label: 'aside', value: 181 },
{ label: 'article', value: 53 },
]);
expect(elements[1]).toEqual({ type: 'text', text: 'Category breakdown' });
expect(result.warnings.some((warning) => warning.includes('Normalized non-canonical ui payload'))).toBe(true);
});
});

View File

@@ -1,93 +0,0 @@
import { describe, expect, it } from 'vitest';
import { extractAssistantUiSpec } from '../../../../src/main/agentic/protocol/uiSpecParser';
describe('extractAssistantUiSpec', () => {
it('extracts fenced JSON spec and preserves assistant text around it', () => {
const input = [
'Here is your dashboard.',
'```json',
'{"specVersion":"1","elements":[{"type":"text","text":"Hello"}]}',
'```',
'Anything else?',
].join('\n');
const result = extractAssistantUiSpec(input);
expect(result.ui).not.toBeNull();
expect(result.ui?.specVersion).toBe('1');
expect(result.ui?.elements).toHaveLength(1);
expect(result.assistantText).toContain('Here is your dashboard.');
expect(result.assistantText).toContain('Anything else?');
});
it('normalizes markdown element into canonical text element', () => {
const input = JSON.stringify({
specVersion: '1',
elements: [{ type: 'markdown', content: '## Title' }],
});
const result = extractAssistantUiSpec(input);
const first = result.ui?.elements[0] as { type: string; text?: string };
expect(result.ui).not.toBeNull();
expect(first.type).toBe('text');
expect(first.text).toBe('## Title');
});
it('normalizes chart data datasets into chart series', () => {
const input = JSON.stringify({
specVersion: '1',
elements: [
{
type: 'chart',
chartType: 'bar',
data: {
labels: ['Jan', 'Feb'],
datasets: [{ data: [10, 20] }],
},
},
],
});
const result = extractAssistantUiSpec(input);
const first = result.ui?.elements[0] as { series?: Array<{ label: string; value: number }>; data?: unknown };
expect(result.ui).not.toBeNull();
expect(first.series).toEqual([
{ label: 'Jan', value: 10 },
{ label: 'Feb', value: 20 },
]);
expect(first.data).toBeUndefined();
});
it('normalizes tabs content to nested elements arrays', () => {
const input = JSON.stringify({
type: 'tabs',
tabs: [
{
title: 'Overview',
content: { type: 'text', text: 'Summary' },
},
],
});
const result = extractAssistantUiSpec(input);
const tabs = result.ui?.elements[0] as {
tabs: Array<{ id: string; label: string; elements: Array<{ type: string; text?: string }> }>;
};
expect(result.ui).not.toBeNull();
expect(tabs.tabs).toHaveLength(1);
expect(tabs.tabs[0].id).toBe('tab-1');
expect(tabs.tabs[0].label).toBe('Overview');
expect(tabs.tabs[0].elements[0]).toEqual({ type: 'text', text: 'Summary' });
});
it('returns plain assistant text when malformed JSON is provided', () => {
const input = '{"specVersion":"1","elements":[{"type":"text"}';
const result = extractAssistantUiSpec(input);
expect(result.ui).toBeNull();
expect(result.assistantText).toBe(input);
});
});

View File

@@ -1,93 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
validateProtocolRequestEnvelope,
validateProtocolResponseEnvelope,
type ProtocolResponseEnvelope,
} from '../../../../src/main/agentic/protocol/validator';
describe('agentic protocol validator', () => {
it('validates canonical response envelope', () => {
const envelope: ProtocolResponseEnvelope = {
protocolVersion: '2.0',
assistantText: 'Done',
intent: 'summarize',
needsInput: {
required: false,
fields: [],
},
actions: [],
confidence: 0.82,
traceId: 'trace-abc',
};
const result = validateProtocolResponseEnvelope(envelope);
expect(result.ok).toBe(true);
expect(result.error).toBeUndefined();
});
it('rejects response envelope with unknown properties in strict mode', () => {
const result = validateProtocolResponseEnvelope({
protocolVersion: '2.0',
assistantText: 'Done',
intent: 'summarize',
needsInput: { required: false, fields: [] },
actions: [],
confidence: 0.8,
traceId: 'trace-abc',
extra: 'nope',
});
expect(result.ok).toBe(false);
expect(result.error?.code).toBe('AGUI_PROTOCOL_VALIDATION_ERROR');
});
it('rejects needsInput.required=true without fields', () => {
const result = validateProtocolResponseEnvelope({
protocolVersion: '2.0',
assistantText: 'Need details',
intent: 'ask_input',
needsInput: { required: true, fields: [] },
actions: [],
confidence: 0.9,
traceId: 'trace-xyz',
});
expect(result.ok).toBe(false);
expect(result.error?.message).toContain('needsInput.fields');
});
it('validates canonical request envelope with capabilities', () => {
const result = validateProtocolRequestEnvelope({
protocolVersion: '2.0',
surface: 'tab',
messages: [{ role: 'user', content: 'Create a chart' }],
context: { projectId: 'project-1' },
capabilities: {
widgets: ['chart', 'form'],
actions: ['openPost'],
tools: ['search_posts'],
},
});
expect(result.ok).toBe(true);
});
it('rejects invalid request envelope and returns structured protocol error', () => {
const result = validateProtocolRequestEnvelope({
protocolVersion: '2.0',
surface: 'tab',
messages: [{ role: 'invalid-role', content: 'Create a chart' }],
context: { projectId: 'project-1' },
capabilities: {
widgets: ['chart'],
actions: ['openPost'],
tools: ['search_posts'],
},
unknown: true,
});
expect(result.ok).toBe(false);
expect(result.error?.code).toBe('AGUI_PROTOCOL_VALIDATION_ERROR');
expect(result.error?.details?.length).toBeGreaterThan(0);
});
});

View File

@@ -1,77 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
WorkflowCheckpointStore,
type WorkflowCheckpointSettingsAdapter,
} from '../../../../src/main/agentic/workflow/checkpointStore';
class InMemorySettingsAdapter implements WorkflowCheckpointSettingsAdapter {
private readonly store = new Map<string, string>();
async getSetting(key: string): Promise<string | null> {
return this.store.get(key) ?? null;
}
async setSetting(key: string, value: string): Promise<void> {
this.store.set(key, value);
}
}
describe('WorkflowCheckpointStore', () => {
it('persists and reloads workflow checkpoints by conversation id', async () => {
const adapter = new InMemorySettingsAdapter();
const store = new WorkflowCheckpointStore(adapter);
await store.save({
conversationId: 'conversation-1',
state: 'awaiting_input',
pendingFields: ['date'],
lastTraceId: 'trace-1',
updatedAt: new Date('2026-02-25T10:00:00.000Z').toISOString(),
});
const loaded = await store.load('conversation-1');
expect(loaded).not.toBeNull();
expect(loaded?.state).toBe('awaiting_input');
expect(loaded?.pendingFields).toEqual(['date']);
expect(loaded?.lastTraceId).toBe('trace-1');
});
it('returns null when no checkpoint exists', async () => {
const adapter = new InMemorySettingsAdapter();
const store = new WorkflowCheckpointStore(adapter);
const loaded = await store.load('missing-conversation');
expect(loaded).toBeNull();
});
it('returns null for malformed checkpoint JSON', async () => {
const adapter = new InMemorySettingsAdapter();
await adapter.setSetting('agui.workflow.conversation-2', '{not-valid-json');
const store = new WorkflowCheckpointStore(adapter);
const loaded = await store.load('conversation-2');
expect(loaded).toBeNull();
});
it('returns null when stored checkpoint conversation id does not match', async () => {
const adapter = new InMemorySettingsAdapter();
await adapter.setSetting(
'agui.workflow.conversation-3',
JSON.stringify({
conversationId: 'other-conversation',
state: 'planning',
pendingFields: [],
lastTraceId: 'trace-mismatch',
updatedAt: new Date('2026-02-25T10:00:00.000Z').toISOString(),
}),
);
const store = new WorkflowCheckpointStore(adapter);
const loaded = await store.load('conversation-3');
expect(loaded).toBeNull();
});
});

View File

@@ -1,106 +0,0 @@
import { describe, expect, it } from 'vitest';
import { AgentTurnStateMachine } from '../../../../src/main/agentic/workflow/turnStateMachine';
describe('AgentTurnStateMachine', () => {
it('transitions to awaiting_input when envelope requests required input', () => {
const stateMachine = new AgentTurnStateMachine();
const next = stateMachine.transition({
previousState: 'planning',
envelope: {
intent: 'ask_input',
needsInput: {
required: true,
fields: [{ key: 'date', label: 'Date', inputType: 'date' }],
},
},
});
expect(next).toBe('awaiting_input');
});
it('transitions to completed when summarize intent has no required input', () => {
const stateMachine = new AgentTurnStateMachine();
const next = stateMachine.transition({
previousState: 'observing',
envelope: {
intent: 'summarize',
needsInput: {
required: false,
fields: [],
},
},
});
expect(next).toBe('completed');
});
it('transitions to executing when intent requests action execution', () => {
const stateMachine = new AgentTurnStateMachine();
const next = stateMachine.transition({
previousState: 'planning',
envelope: {
intent: 'execute_action',
needsInput: {
required: false,
fields: [],
},
},
});
expect(next).toBe('executing');
});
it('transitions to observing when proposing actions', () => {
const stateMachine = new AgentTurnStateMachine();
const next = stateMachine.transition({
previousState: 'planning',
envelope: {
intent: 'propose_action',
needsInput: {
required: false,
fields: [],
},
},
});
expect(next).toBe('observing');
});
it('returns to executing after awaiting input when input is no longer required', () => {
const stateMachine = new AgentTurnStateMachine();
const next = stateMachine.transition({
previousState: 'awaiting_input',
envelope: {
intent: 'analyze',
needsInput: {
required: false,
fields: [],
},
},
});
expect(next).toBe('executing');
});
it('stays in planning for non-terminal analyze intent by default', () => {
const stateMachine = new AgentTurnStateMachine();
const next = stateMachine.transition({
previousState: 'planning',
envelope: {
intent: 'analyze',
needsInput: {
required: false,
fields: [],
},
},
});
expect(next).toBe('planning');
});
});

View File

@@ -9,21 +9,6 @@ const mainWindowMock = {
}, },
}; };
const protocolSnapshot = {
totalTurns: 3,
validEnvelopeTurns: 2,
repairAttempts: 1,
fallbackTurns: 1,
blockedActionCount: 2,
parseValidityRate: 2 / 3,
repairRate: 1 / 3,
fallbackRate: 1 / 3,
};
const telemetryServiceMock = {
getSnapshot: vi.fn(() => protocolSnapshot),
};
const chatEngineInstances: Array<Record<string, any>> = []; const chatEngineInstances: Array<Record<string, any>> = [];
const openCodeManagerInstances: Array<Record<string, any>> = []; const openCodeManagerInstances: Array<Record<string, any>> = [];
@@ -50,10 +35,6 @@ vi.mock('../../src/main/engine/MediaEngine', () => ({
getMediaEngine: vi.fn(() => ({})), getMediaEngine: vi.fn(() => ({})),
})); }));
vi.mock('../../src/main/agentic/observability/protocolTelemetry', () => ({
getProtocolTelemetryService: vi.fn(() => telemetryServiceMock),
}));
vi.mock('../../src/main/engine/ChatEngine', () => ({ vi.mock('../../src/main/engine/ChatEngine', () => ({
ChatEngine: class { ChatEngine: class {
constructor() { constructor() {
@@ -85,18 +66,6 @@ vi.mock('../../src/main/engine/OpenCodeManager', () => ({
return { return {
success: true, success: true,
message: 'assistant reply', message: 'assistant reply',
envelope: {
protocolVersion: '2.0',
assistantText: 'assistant reply',
intent: 'summarize',
needsInput: { required: false, fields: [] },
actions: [],
confidence: 0.9,
traceId: 'trace-1',
},
protocolVersion: '2.0',
traceId: 'trace-1',
warnings: [],
}; };
}), }),
abortMessage: vi.fn(async () => ({ success: true })), abortMessage: vi.fn(async () => ({ success: true })),
@@ -116,7 +85,6 @@ describe('chatHandlers', () => {
webContentsSend.mockReset(); webContentsSend.mockReset();
chatEngineInstances.length = 0; chatEngineInstances.length = 0;
openCodeManagerInstances.length = 0; openCodeManagerInstances.length = 0;
telemetryServiceMock.getSnapshot.mockClear();
vi.resetModules(); vi.resetModules();
}); });
@@ -125,19 +93,6 @@ describe('chatHandlers', () => {
await mod.cleanupChatHandlers(); await mod.cleanupChatHandlers();
}); });
it('returns protocol health snapshot from telemetry service', async () => {
const mod = await import('../../src/main/ipc/chatHandlers');
mod.registerChatHandlers();
const handler = registeredHandlers.get('chat:getProtocolHealth');
expect(handler).toBeDefined();
const result = await handler!();
expect(telemetryServiceMock.getSnapshot).toHaveBeenCalledTimes(1);
expect(result).toEqual(protocolSnapshot);
});
it('streams sendMessage callbacks through main window events', async () => { it('streams sendMessage callbacks through main window events', async () => {
const mod = await import('../../src/main/ipc/chatHandlers'); const mod = await import('../../src/main/ipc/chatHandlers');
mod.initializeChatHandlers(() => mainWindowMock as never); mod.initializeChatHandlers(() => mainWindowMock as never);
@@ -154,7 +109,6 @@ describe('chatHandlers', () => {
); );
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.envelope?.protocolVersion).toBe('2.0');
const manager = openCodeManagerInstances[0]; const manager = openCodeManagerInstances[0];
expect(manager.setApiKey).toHaveBeenCalledWith('stored-key'); expect(manager.setApiKey).toHaveBeenCalledWith('stored-key');

View File

@@ -15,7 +15,6 @@ describe('AssistantSidebar wiring', () => {
validateApiKey: vi.fn(), validateApiKey: vi.fn(),
setApiKey: vi.fn(), setApiKey: vi.fn(),
getApiKey: vi.fn(), getApiKey: vi.fn(),
getProtocolHealth: vi.fn(),
getAvailableModels: vi.fn(), getAvailableModels: vi.fn(),
setDefaultModel: vi.fn(), setDefaultModel: vi.fn(),
getSystemPrompt: vi.fn(), getSystemPrompt: vi.fn(),
@@ -37,6 +36,8 @@ describe('AssistantSidebar wiring', () => {
onToolCall, onToolCall,
onToolResult, onToolResult,
onTitleUpdated, onTitleUpdated,
onA2UIMessage: vi.fn(() => vi.fn()),
dispatchA2UIAction: vi.fn(),
} as never; } as never;
}); });

View File

@@ -55,18 +55,7 @@ describe('Editor dashboard timeline', () => {
]); ]);
(window as any).electronAPI.posts.getTagsWithCounts = vi.fn().mockResolvedValue([]); (window as any).electronAPI.posts.getTagsWithCounts = vi.fn().mockResolvedValue([]);
(window as any).electronAPI.posts.getCategoriesWithCounts = vi.fn().mockResolvedValue([]); (window as any).electronAPI.posts.getCategoriesWithCounts = vi.fn().mockResolvedValue([]);
(window as any).electronAPI.chat = { (window as any).electronAPI.chat = {};
getProtocolHealth: vi.fn().mockResolvedValue({
totalTurns: 10,
validEnvelopeTurns: 9,
repairAttempts: 1,
fallbackTurns: 0,
blockedActionCount: 2,
parseValidityRate: 0.9,
repairRate: 0.1,
fallbackRate: 0,
}),
};
(window as any).electronAPI.tags = { (window as any).electronAPI.tags = {
getAll: vi.fn().mockResolvedValue([]), getAll: vi.fn().mockResolvedValue([]),
}; };
@@ -94,17 +83,4 @@ describe('Editor dashboard timeline', () => {
expect(screen.getByText('2024')).toBeInTheDocument(); expect(screen.getByText('2024')).toBeInTheDocument();
}); });
it('renders protocol telemetry stats in dashboard', async () => {
render(<Editor />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText('90%')).toBeInTheDocument();
expect(screen.getByText('2 blocked actions')).toBeInTheDocument();
});
}); });

View File

@@ -1,240 +0,0 @@
import { describe, expect, it } from 'vitest';
import { extractAssistantPanelSpec, extractAssistantResponseContent } from '../../../src/renderer/navigation/assistantPanelSpec';
describe('assistantPanelSpec', () => {
it('extracts valid spec from fenced json block', () => {
const raw = [
'Here is the analysis summary.',
'```json',
'{"specVersion":"1","elements":[{"type":"metric","label":"Drafts","value":"12"}]}',
'```',
].join('\n');
const result = extractAssistantPanelSpec(raw);
expect(result).not.toBeNull();
expect(result?.specVersion).toBe('1');
expect(result?.elements).toHaveLength(1);
expect(result?.elements[0]).toEqual({ type: 'metric', label: 'Drafts', value: '12' });
});
it('returns null for invalid schema payload', () => {
const raw = '{"specVersion":"1","elements":[{"type":"table","columns":[]}]}';
const result = extractAssistantPanelSpec(raw);
expect(result).toBeNull();
});
it('ignores yaml payloads to keep the protocol JSON-only', () => {
const raw = [
'Here is your chart.',
'```yaml',
'specVersion: "1"',
'elements:',
' - type: chart',
' chartType: bar',
' title: Posts by Month',
' series:',
' - label: Jan',
' value: 10',
' - label: Feb',
' value: 20',
'```',
].join('\n');
const result = extractAssistantPanelSpec(raw);
expect(result).toBeNull();
});
it('extracts text plus ui payload from mixed assistant response', () => {
const raw = [
'I found two weak months. Please confirm how to proceed.',
'```json',
'{"specVersion":"1","elements":[{"type":"chart","chartType":"bar","title":"Posts by Month","series":[{"label":"Jan","value":10},{"label":"Feb","value":20}]}]}',
'```',
].join('\n\n');
const result = extractAssistantResponseContent(raw);
expect(result.displayText).toContain('I found two weak months');
expect(result.panelSpec).not.toBeNull();
expect(result.panelSpec?.elements[0]).toMatchObject({ type: 'chart', chartType: 'bar' });
});
it('normalizes tab-channel envelope payloads into canonical panel spec', () => {
const raw = JSON.stringify({
type: 'tab',
title: 'Posts mit Tag spielen',
id: 'spielen-tag-analysis',
content: {
type: 'tabs',
tabs: [
{
id: 'yearly-chart',
title: 'Jahresübersicht',
content: {
type: 'chart',
chartType: 'bar',
data: {
labels: ['2011', '2013'],
datasets: [{ data: [2, 8] }],
},
},
},
],
},
});
const result = extractAssistantPanelSpec(raw);
expect(result).not.toBeNull();
expect(result?.specVersion).toBe('1');
expect(result?.elements[0]).toMatchObject({ type: 'tabs' });
});
it('normalizes chartjs-like chart payloads to series format', () => {
const raw = JSON.stringify({
specVersion: '1',
elements: [
{
type: 'chart',
chartType: 'bar',
data: {
labels: ['Jan', 'Feb', 'Mar'],
datasets: [{ data: [23, 10, 14] }],
},
},
],
});
const result = extractAssistantPanelSpec(raw);
expect(result).not.toBeNull();
expect(result?.elements[0]).toMatchObject({
type: 'chart',
chartType: 'bar',
series: [
{ label: 'Jan', value: 23 },
{ label: 'Feb', value: 10 },
{ label: 'Mar', value: 14 },
],
});
});
it('parses extended widgets including chart, form, datePicker, card, image, input and tabs', () => {
const raw = JSON.stringify({
specVersion: '1',
elements: [
{
type: 'chart',
chartType: 'bar',
title: 'Posts by Month',
series: [
{ label: 'Jan', value: 10 },
{ label: 'Feb', value: 20 },
],
},
{
type: 'input',
key: 'query',
label: 'Search Query',
inputType: 'text',
placeholder: 'Find post',
},
{
type: 'datePicker',
key: 'publishDate',
label: 'Publish Date',
},
{
type: 'form',
formId: 'meta-form',
title: 'Update Metadata',
submitLabel: 'Apply',
action: 'updatePostMetadata',
fields: [
{ key: 'title', label: 'Title', inputType: 'text' },
{ key: 'isDraft', label: 'Draft', inputType: 'checkbox' },
],
},
{
type: 'card',
title: 'Suggestion',
body: 'Consider adding tags.',
actions: [
{ label: 'Open Tags', action: 'switchView', payload: { view: 'tags' } },
],
},
{
type: 'image',
src: 'https://example.com/image.png',
alt: 'Preview',
caption: 'Generated preview',
},
{
type: 'tabs',
tabs: [
{
id: 'summary',
label: 'Summary',
elements: [{ type: 'text', text: 'Summary text' }],
},
{
id: 'details',
label: 'Details',
elements: [{ type: 'metric', label: 'Count', value: '42' }],
},
],
},
],
});
const result = extractAssistantPanelSpec(raw);
expect(result).not.toBeNull();
expect(result?.elements).toHaveLength(7);
});
it('parses canonical protocol envelope JSON and extracts assistant text plus ui spec', () => {
const raw = JSON.stringify({
protocolVersion: '2.0',
assistantText: 'Here is your chart.',
ui: {
specVersion: '1',
elements: [
{
type: 'chart',
chartType: 'bar',
data: {
labels: ['aside', 'article'],
datasets: [{ data: [181, 53] }],
},
},
{
type: 'text',
content: 'Breakdown details',
},
],
},
intent: 'summarize',
needsInput: { required: false, fields: [] },
actions: [],
confidence: 0.9,
traceId: 'trace-1',
});
const result = extractAssistantResponseContent(raw);
expect(result.displayText).toBe('Here is your chart.');
expect(result.panelSpec).not.toBeNull();
expect(result.panelSpec?.elements[0]).toMatchObject({
type: 'chart',
series: [
{ label: 'aside', value: 181 },
{ label: 'article', value: 53 },
],
});
expect(result.panelSpec?.elements[1]).toEqual({
type: 'text',
text: 'Breakdown details',
});
});
});

View File

@@ -1,8 +1,7 @@
import React from 'react'; import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render } from '@testing-library/react'; import { render } from '@testing-library/react';
import { AssistantSidebar } from '../../../src/renderer/components/AssistantSidebar/AssistantSidebar'; import { AssistantSidebar } from '../../../src/renderer/components/AssistantSidebar/AssistantSidebar';
import { AssistantPanelControls } from '../../../src/renderer/components/AssistantPanelControls';
import { useAppStore } from '../../../src/renderer/store'; import { useAppStore } from '../../../src/renderer/store';
describe('assistant sidebar guard rails', () => { describe('assistant sidebar guard rails', () => {
@@ -14,7 +13,6 @@ describe('assistant sidebar guard rails', () => {
validateApiKey: vi.fn(), validateApiKey: vi.fn(),
setApiKey: vi.fn(), setApiKey: vi.fn(),
getApiKey: vi.fn(), getApiKey: vi.fn(),
getProtocolHealth: vi.fn(),
getAvailableModels: vi.fn(), getAvailableModels: vi.fn(),
setDefaultModel: vi.fn(), setDefaultModel: vi.fn(),
getSystemPrompt: vi.fn(), getSystemPrompt: vi.fn(),
@@ -36,6 +34,8 @@ describe('assistant sidebar guard rails', () => {
onToolCall: vi.fn(() => vi.fn()), onToolCall: vi.fn(() => vi.fn()),
onToolResult: vi.fn(() => vi.fn()), onToolResult: vi.fn(() => vi.fn()),
onTitleUpdated: vi.fn(() => vi.fn()), onTitleUpdated: vi.fn(() => vi.fn()),
onA2UIMessage: vi.fn(() => vi.fn()),
dispatchA2UIAction: vi.fn(),
} as never; } as never;
}); });
@@ -44,60 +44,4 @@ describe('assistant sidebar guard rails', () => {
expect(useAppStore.getState().tabs.some((tab) => tab.type === 'chat')).toBe(false); expect(useAppStore.getState().tabs.some((tab) => tab.type === 'chat')).toBe(false);
}); });
it('renders rich assistant panel widget branches at runtime', () => {
const onAction = vi.fn();
const { container } = render(
React.createElement(AssistantPanelControls, {
elements: [
{ type: 'chart', chartType: 'bar', title: 'Trend', series: [{ label: 'Jan', value: 10 }] },
{
type: 'form',
formId: 'f1',
submitLabel: 'Submit',
action: 'submitNeedsInput',
fields: [{ key: 'name', label: 'Name', inputType: 'text', required: true }],
},
{ type: 'datePicker', key: 'date', label: 'Date', submitLabel: 'Pick', action: 'submitNeedsInput' },
{ type: 'card', title: 'Card', body: 'Body', actions: [{ label: 'Open', action: 'openSettings' }] },
{ type: 'image', src: 'https://example.com/a.png', caption: 'Image', action: 'openSettings' },
{
type: 'tabs',
tabs: [{ id: 'tab-1', label: 'Tab 1', elements: [{ type: 'text', text: 'Inside tab' }] }],
},
{ type: 'input', key: 'query', label: 'Query', inputType: 'text', submitLabel: 'Run', action: 'openSettings' },
],
onAction,
}),
);
expect(container.querySelector('.assistant-panel-chart')).not.toBeNull();
expect(container.querySelector('.assistant-panel-form')).not.toBeNull();
expect(container.querySelector('.assistant-panel-card')).not.toBeNull();
expect(container.querySelector('.assistant-panel-image')).not.toBeNull();
expect(container.querySelector('.assistant-panel-tabs')).not.toBeNull();
});
it('enforces action confirmation policy before dispatching assistant actions', () => {
const onAction = vi.fn();
const confirmMock = vi.fn().mockReturnValue(true);
Object.defineProperty(window, 'confirm', {
value: confirmMock,
configurable: true,
});
const { getByText } = render(
React.createElement(AssistantPanelControls, {
elements: [{ type: 'action', label: 'Open Settings', action: 'openSettings' }],
actionPolicies: { openSettings: 'confirm' },
onAction,
}),
);
fireEvent.click(getByText('Open Settings'));
expect(confirmMock).toHaveBeenCalledTimes(1);
expect(onAction).toHaveBeenCalledWith('openSettings', undefined);
});
}); });

View File

@@ -15,7 +15,6 @@ describe('chat surface mode usage guards', () => {
validateApiKey: vi.fn(), validateApiKey: vi.fn(),
setApiKey: vi.fn(), setApiKey: vi.fn(),
getApiKey: vi.fn(), getApiKey: vi.fn(),
getProtocolHealth: vi.fn(),
getAvailableModels: vi.fn().mockResolvedValue({ getAvailableModels: vi.fn().mockResolvedValue({
success: true, success: true,
models: [{ id: 'gpt-5', name: 'GPT-5' }], models: [{ id: 'gpt-5', name: 'GPT-5' }],
@@ -46,6 +45,8 @@ describe('chat surface mode usage guards', () => {
onToolCall: vi.fn(() => vi.fn()), onToolCall: vi.fn(() => vi.fn()),
onToolResult: vi.fn(() => vi.fn()), onToolResult: vi.fn(() => vi.fn()),
onTitleUpdated: vi.fn(() => vi.fn()), onTitleUpdated: vi.fn(() => vi.fn()),
onA2UIMessage: vi.fn(() => vi.fn()),
dispatchA2UIAction: vi.fn(),
} as never; } as never;
}); });

View File

@@ -18,7 +18,6 @@ describe('chat surface shared usage guards', () => {
validateApiKey: vi.fn(), validateApiKey: vi.fn(),
setApiKey: vi.fn(), setApiKey: vi.fn(),
getApiKey: vi.fn(), getApiKey: vi.fn(),
getProtocolHealth: vi.fn(),
getAvailableModels: vi.fn().mockResolvedValue({ getAvailableModels: vi.fn().mockResolvedValue({
success: true, success: true,
models: [{ id: 'gpt-5', name: 'GPT-5' }], models: [{ id: 'gpt-5', name: 'GPT-5' }],
@@ -49,6 +48,8 @@ describe('chat surface shared usage guards', () => {
onToolCall: vi.fn(() => vi.fn()), onToolCall: vi.fn(() => vi.fn()),
onToolResult: vi.fn(() => vi.fn()), onToolResult: vi.fn(() => vi.fn()),
onTitleUpdated: vi.fn(() => vi.fn()), onTitleUpdated: vi.fn(() => vi.fn()),
onA2UIMessage: vi.fn(() => vi.fn()),
dispatchA2UIAction: vi.fn(),
} as never; } as never;
}); });

View File

@@ -1,56 +0,0 @@
import { describe, expect, it } from 'vitest';
import { buildActionPoliciesFromEnvelope } from '../../../src/renderer/navigation/protocolActionPolicies';
describe('buildActionPoliciesFromEnvelope', () => {
it('preserves server-provided action policies', () => {
const result = buildActionPoliciesFromEnvelope({
actions: [
{
id: 'a1',
action: 'openSettings',
policy: 'confirm',
requiresConfirmation: true,
},
],
needsInput: {
required: false,
fields: [],
},
});
expect(result).toEqual({
openSettings: 'confirm',
});
});
it('adds confirm policy for submitNeedsInput when clarification is required', () => {
const result = buildActionPoliciesFromEnvelope({
actions: [],
needsInput: {
required: true,
fields: [{ key: 'date', label: 'Date', inputType: 'date' }],
},
});
expect(result.submitNeedsInput).toBe('confirm');
});
it('does not override explicit server policy for submitNeedsInput', () => {
const result = buildActionPoliciesFromEnvelope({
actions: [
{
id: 'a1',
action: 'submitNeedsInput',
policy: 'danger',
requiresConfirmation: true,
},
],
needsInput: {
required: true,
fields: [{ key: 'title', label: 'Title', inputType: 'text' }],
},
});
expect(result.submitNeedsInput).toBe('danger');
});
});

View File

@@ -1,30 +0,0 @@
import { describe, expect, it } from 'vitest';
import { toClarificationElements } from '../../../src/renderer/navigation/protocolNeedsInput';
describe('protocolNeedsInput', () => {
it('builds a clarification form element when required fields are provided', () => {
const elements = toClarificationElements({
required: true,
fields: [
{ key: 'date', label: 'Date', inputType: 'date', required: true },
{ key: 'category', label: 'Category', inputType: 'select', options: [{ label: 'A', value: 'a' }] },
],
});
expect(elements).toHaveLength(1);
expect(elements[0]).toMatchObject({
type: 'form',
formId: 'agui-needs-input',
action: 'submitNeedsInput',
});
});
it('returns empty elements when input is not required', () => {
const elements = toClarificationElements({
required: false,
fields: [],
});
expect(elements).toEqual([]);
});
});

View File

@@ -25,7 +25,6 @@ describe('pythonApiContractV1', () => {
'app.getSystemLanguage', 'app.getSystemLanguage',
'chat.getConversations', 'chat.getConversations',
'chat.sendMessage', 'chat.sendMessage',
'chat.getProtocolHealth',
])); ]));
}); });
@@ -44,7 +43,7 @@ describe('pythonApiContractV1', () => {
}); });
}); });
it('documents chat.sendMessage protocol envelope return contract and metadata input', () => { it('documents chat.sendMessage return contract and metadata input', () => {
expect(getPythonApiMethodContract('chat.sendMessage')).toEqual({ expect(getPythonApiMethodContract('chat.sendMessage')).toEqual({
method: 'chat.sendMessage', method: 'chat.sendMessage',
description: 'Send message to chat conversation.', description: 'Send message to chat conversation.',
@@ -65,7 +64,7 @@ describe('pythonApiContractV1', () => {
required: false, required: false,
}, },
], ],
returns: "{ success: boolean; message?: string; envelope?: ProtocolResponseEnvelope; protocolVersion?: '2.0'; traceId?: string; warnings?: string[]; error?: string }", returns: '{ success: boolean; message?: string; error?: string }',
}); });
}); });
@@ -81,8 +80,6 @@ describe('pythonApiContractV1', () => {
expect.objectContaining({ name: 'PostData' }), expect.objectContaining({ name: 'PostData' }),
expect.objectContaining({ name: 'MediaData' }), expect.objectContaining({ name: 'MediaData' }),
expect.objectContaining({ name: 'ProjectData' }), expect.objectContaining({ name: 'ProjectData' }),
expect.objectContaining({ name: 'ProtocolResponseEnvelope' }),
expect.objectContaining({ name: 'ProtocolTelemetrySnapshot' }),
])); ]));
}); });
}); });

View File

@@ -29,16 +29,12 @@ describe('invokePythonApiMethodV1', () => {
const getProjectMetadata = vi.fn().mockResolvedValue({ name: 'My Project' }); const getProjectMetadata = vi.fn().mockResolvedValue({ name: 'My Project' });
const getAllProjects = vi.fn().mockResolvedValue([{ id: 'prj-1', name: 'Main' }]); const getAllProjects = vi.fn().mockResolvedValue([{ id: 'prj-1', name: 'Main' }]);
const getAllPosts = vi.fn().mockResolvedValue({ items: [], hasMore: false, total: 0 }); const getAllPosts = vi.fn().mockResolvedValue({ items: [], hasMore: false, total: 0 });
const getProtocolHealth = vi.fn().mockResolvedValue({ totalTurns: 1, parseValidityRate: 1 });
vi.stubGlobal('window', { vi.stubGlobal('window', {
electronAPI: { electronAPI: {
projects: { projects: {
getAll: getAllProjects, getAll: getAllProjects,
}, },
chat: {
getProtocolHealth,
},
posts: { posts: {
search: searchPosts, search: searchPosts,
getAll: getAllPosts, getAll: getAllPosts,
@@ -53,12 +49,10 @@ describe('invokePythonApiMethodV1', () => {
await expect(invokePythonApiMethodV1('posts.getAll', { options: { limit: 10, offset: 5 } })).resolves.toEqual({ items: [], hasMore: false, total: 0 }); await expect(invokePythonApiMethodV1('posts.getAll', { options: { limit: 10, offset: 5 } })).resolves.toEqual({ items: [], hasMore: false, total: 0 });
await expect(invokePythonApiMethodV1('posts.search', { query: 'hit' })).resolves.toEqual([{ id: 'p1', title: 'Hit' }]); await expect(invokePythonApiMethodV1('posts.search', { query: 'hit' })).resolves.toEqual([{ id: 'p1', title: 'Hit' }]);
await expect(invokePythonApiMethodV1('meta.getProjectMetadata', {})).resolves.toEqual({ name: 'My Project' }); await expect(invokePythonApiMethodV1('meta.getProjectMetadata', {})).resolves.toEqual({ name: 'My Project' });
await expect(invokePythonApiMethodV1('chat.getProtocolHealth', {})).resolves.toEqual({ totalTurns: 1, parseValidityRate: 1 });
expect(getAllProjects).toHaveBeenCalledWith(); expect(getAllProjects).toHaveBeenCalledWith();
expect(getAllPosts).toHaveBeenCalledWith({ limit: 10, offset: 5 }); expect(getAllPosts).toHaveBeenCalledWith({ limit: 10, offset: 5 });
expect(searchPosts).toHaveBeenCalledWith('hit'); expect(searchPosts).toHaveBeenCalledWith('hit');
expect(getProjectMetadata).toHaveBeenCalledWith(); expect(getProjectMetadata).toHaveBeenCalledWith();
expect(getProtocolHealth).toHaveBeenCalledWith();
}); });
it('rejects unknown methods and malformed args', async () => { it('rejects unknown methods and malformed args', async () => {
@@ -72,9 +66,6 @@ describe('invokePythonApiMethodV1', () => {
projects: { projects: {
getAll: vi.fn(), getAll: vi.fn(),
}, },
chat: {
getProtocolHealth: vi.fn(),
},
meta: { meta: {
getProjectMetadata: vi.fn(), getProjectMetadata: vi.fn(),
}, },