wip: complete rework first round

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

View File

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

View File

@@ -1,8 +1,7 @@
import React from 'react';
import { 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);
});
});

View File

@@ -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;
});

View File

@@ -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;
});

View File

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

View File

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