wip: complete rework first round
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user