wip: complete rework first round
This commit is contained in:
@@ -638,11 +638,11 @@ describe('ChatEngine', () => {
|
||||
const result = await chatEngine.getDefaultSystemPrompt();
|
||||
|
||||
expect(result).toContain('Blogging Desktop Server');
|
||||
expect(result).toContain('Available Tools');
|
||||
expect(result).toContain('Agentic UI Contract');
|
||||
expect(result).toContain('specVersion');
|
||||
expect(result).toContain('Available Data Tools');
|
||||
expect(result).toContain('UI Render Tools');
|
||||
expect(result).toContain('render_chart');
|
||||
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 () => {
|
||||
|
||||
@@ -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.',
|
||||
}));
|
||||
});
|
||||
});
|
||||
80
tests/engine/a2ui/catalog.test.ts
Normal file
80
tests/engine/a2ui/catalog.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
263
tests/engine/a2ui/generator.test.ts
Normal file
263
tests/engine/a2ui/generator.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
345
tests/engine/a2ui/surfaceManager.test.ts
Normal file
345
tests/engine/a2ui/surfaceManager.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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']));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 openCodeManagerInstances: Array<Record<string, any>> = [];
|
||||
|
||||
@@ -50,10 +35,6 @@ vi.mock('../../src/main/engine/MediaEngine', () => ({
|
||||
getMediaEngine: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/agentic/observability/protocolTelemetry', () => ({
|
||||
getProtocolTelemetryService: vi.fn(() => telemetryServiceMock),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/ChatEngine', () => ({
|
||||
ChatEngine: class {
|
||||
constructor() {
|
||||
@@ -85,18 +66,6 @@ vi.mock('../../src/main/engine/OpenCodeManager', () => ({
|
||||
return {
|
||||
success: true,
|
||||
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 })),
|
||||
@@ -116,7 +85,6 @@ describe('chatHandlers', () => {
|
||||
webContentsSend.mockReset();
|
||||
chatEngineInstances.length = 0;
|
||||
openCodeManagerInstances.length = 0;
|
||||
telemetryServiceMock.getSnapshot.mockClear();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
@@ -125,19 +93,6 @@ describe('chatHandlers', () => {
|
||||
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 () => {
|
||||
const mod = await import('../../src/main/ipc/chatHandlers');
|
||||
mod.initializeChatHandlers(() => mainWindowMock as never);
|
||||
@@ -154,7 +109,6 @@ describe('chatHandlers', () => {
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.envelope?.protocolVersion).toBe('2.0');
|
||||
|
||||
const manager = openCodeManagerInstances[0];
|
||||
expect(manager.setApiKey).toHaveBeenCalledWith('stored-key');
|
||||
@@ -182,4 +136,4 @@ describe('chatHandlers', () => {
|
||||
result: { name: 'search_posts', result: { ok: true } },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ describe('AssistantSidebar wiring', () => {
|
||||
validateApiKey: vi.fn(),
|
||||
setApiKey: vi.fn(),
|
||||
getApiKey: vi.fn(),
|
||||
getProtocolHealth: vi.fn(),
|
||||
getAvailableModels: vi.fn(),
|
||||
setDefaultModel: vi.fn(),
|
||||
getSystemPrompt: vi.fn(),
|
||||
@@ -37,6 +36,8 @@ describe('AssistantSidebar wiring', () => {
|
||||
onToolCall,
|
||||
onToolResult,
|
||||
onTitleUpdated,
|
||||
onA2UIMessage: vi.fn(() => vi.fn()),
|
||||
dispatchA2UIAction: vi.fn(),
|
||||
} as never;
|
||||
});
|
||||
|
||||
|
||||
@@ -55,18 +55,7 @@ describe('Editor dashboard timeline', () => {
|
||||
]);
|
||||
(window as any).electronAPI.posts.getTagsWithCounts = vi.fn().mockResolvedValue([]);
|
||||
(window as any).electronAPI.posts.getCategoriesWithCounts = vi.fn().mockResolvedValue([]);
|
||||
(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.chat = {};
|
||||
(window as any).electronAPI.tags = {
|
||||
getAll: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
@@ -94,17 +83,4 @@ describe('Editor dashboard timeline', () => {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from 'react';
|
||||
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 { AssistantPanelControls } from '../../../src/renderer/components/AssistantPanelControls';
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
|
||||
describe('assistant sidebar guard rails', () => {
|
||||
@@ -14,7 +13,6 @@ describe('assistant sidebar guard rails', () => {
|
||||
validateApiKey: vi.fn(),
|
||||
setApiKey: vi.fn(),
|
||||
getApiKey: vi.fn(),
|
||||
getProtocolHealth: vi.fn(),
|
||||
getAvailableModels: vi.fn(),
|
||||
setDefaultModel: vi.fn(),
|
||||
getSystemPrompt: vi.fn(),
|
||||
@@ -36,6 +34,8 @@ describe('assistant sidebar guard rails', () => {
|
||||
onToolCall: vi.fn(() => vi.fn()),
|
||||
onToolResult: vi.fn(() => vi.fn()),
|
||||
onTitleUpdated: vi.fn(() => vi.fn()),
|
||||
onA2UIMessage: vi.fn(() => vi.fn()),
|
||||
dispatchA2UIAction: vi.fn(),
|
||||
} as never;
|
||||
});
|
||||
|
||||
@@ -44,60 +44,4 @@ describe('assistant sidebar guard rails', () => {
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ describe('chat surface mode usage guards', () => {
|
||||
validateApiKey: vi.fn(),
|
||||
setApiKey: vi.fn(),
|
||||
getApiKey: vi.fn(),
|
||||
getProtocolHealth: vi.fn(),
|
||||
getAvailableModels: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
models: [{ id: 'gpt-5', name: 'GPT-5' }],
|
||||
@@ -46,6 +45,8 @@ describe('chat surface mode usage guards', () => {
|
||||
onToolCall: vi.fn(() => vi.fn()),
|
||||
onToolResult: vi.fn(() => vi.fn()),
|
||||
onTitleUpdated: vi.fn(() => vi.fn()),
|
||||
onA2UIMessage: vi.fn(() => vi.fn()),
|
||||
dispatchA2UIAction: vi.fn(),
|
||||
} as never;
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ describe('chat surface shared usage guards', () => {
|
||||
validateApiKey: vi.fn(),
|
||||
setApiKey: vi.fn(),
|
||||
getApiKey: vi.fn(),
|
||||
getProtocolHealth: vi.fn(),
|
||||
getAvailableModels: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
models: [{ id: 'gpt-5', name: 'GPT-5' }],
|
||||
@@ -49,6 +48,8 @@ describe('chat surface shared usage guards', () => {
|
||||
onToolCall: vi.fn(() => vi.fn()),
|
||||
onToolResult: vi.fn(() => vi.fn()),
|
||||
onTitleUpdated: vi.fn(() => vi.fn()),
|
||||
onA2UIMessage: vi.fn(() => vi.fn()),
|
||||
dispatchA2UIAction: vi.fn(),
|
||||
} as never;
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,6 @@ describe('pythonApiContractV1', () => {
|
||||
'app.getSystemLanguage',
|
||||
'chat.getConversations',
|
||||
'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({
|
||||
method: 'chat.sendMessage',
|
||||
description: 'Send message to chat conversation.',
|
||||
@@ -65,7 +64,7 @@ describe('pythonApiContractV1', () => {
|
||||
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: 'MediaData' }),
|
||||
expect.objectContaining({ name: 'ProjectData' }),
|
||||
expect.objectContaining({ name: 'ProtocolResponseEnvelope' }),
|
||||
expect.objectContaining({ name: 'ProtocolTelemetrySnapshot' }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,16 +29,12 @@ describe('invokePythonApiMethodV1', () => {
|
||||
const getProjectMetadata = vi.fn().mockResolvedValue({ name: 'My Project' });
|
||||
const getAllProjects = vi.fn().mockResolvedValue([{ id: 'prj-1', name: 'Main' }]);
|
||||
const getAllPosts = vi.fn().mockResolvedValue({ items: [], hasMore: false, total: 0 });
|
||||
const getProtocolHealth = vi.fn().mockResolvedValue({ totalTurns: 1, parseValidityRate: 1 });
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
electronAPI: {
|
||||
projects: {
|
||||
getAll: getAllProjects,
|
||||
},
|
||||
chat: {
|
||||
getProtocolHealth,
|
||||
},
|
||||
posts: {
|
||||
search: searchPosts,
|
||||
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.search', { query: 'hit' })).resolves.toEqual([{ id: 'p1', title: 'Hit' }]);
|
||||
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(getAllPosts).toHaveBeenCalledWith({ limit: 10, offset: 5 });
|
||||
expect(searchPosts).toHaveBeenCalledWith('hit');
|
||||
expect(getProjectMetadata).toHaveBeenCalledWith();
|
||||
expect(getProtocolHealth).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('rejects unknown methods and malformed args', async () => {
|
||||
@@ -72,9 +66,6 @@ describe('invokePythonApiMethodV1', () => {
|
||||
projects: {
|
||||
getAll: vi.fn(),
|
||||
},
|
||||
chat: {
|
||||
getProtocolHealth: vi.fn(),
|
||||
},
|
||||
meta: {
|
||||
getProjectMetadata: vi.fn(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user