wip: first run of implementation
This commit is contained in:
32
tests/engine/agentic/capabilities/registry.test.ts
Normal file
32
tests/engine/agentic/capabilities/registry.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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']));
|
||||
});
|
||||
});
|
||||
38
tests/engine/agentic/observability/protocolTelemetry.test.ts
Normal file
38
tests/engine/agentic/observability/protocolTelemetry.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ProtocolTelemetryService } from '../../../../src/main/agentic/observability/protocolTelemetry';
|
||||
|
||||
describe('ProtocolTelemetryService', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
21
tests/engine/agentic/policy/actionPolicy.test.ts
Normal file
21
tests/engine/agentic/policy/actionPolicy.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
117
tests/engine/agentic/protocol/responseBuilder.test.ts
Normal file
117
tests/engine/agentic/protocol/responseBuilder.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
74
tests/engine/agentic/protocol/validator.test.ts
Normal file
74
tests/engine/agentic/protocol/validator.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
39
tests/engine/agentic/workflow/checkpointStore.test.ts
Normal file
39
tests/engine/agentic/workflow/checkpointStore.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
38
tests/engine/agentic/workflow/stateMachine.test.ts
Normal file
38
tests/engine/agentic/workflow/stateMachine.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,18 @@ 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.tags = {
|
||||
getAll: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
@@ -82,4 +94,17 @@ 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();
|
||||
});
|
||||
});
|
||||
30
tests/renderer/navigation/protocolNeedsInput.test.ts
Normal file
30
tests/renderer/navigation/protocolNeedsInput.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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,6 +25,7 @@ describe('pythonApiContractV1', () => {
|
||||
'app.getSystemLanguage',
|
||||
'chat.getConversations',
|
||||
'chat.sendMessage',
|
||||
'chat.getProtocolHealth',
|
||||
]));
|
||||
});
|
||||
|
||||
@@ -45,7 +46,7 @@ describe('pythonApiContractV1', () => {
|
||||
|
||||
it('contains semantic version metadata for compatibility checks', () => {
|
||||
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
|
||||
version: '1.3.0',
|
||||
version: '1.4.0',
|
||||
generatedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
@@ -55,6 +56,7 @@ describe('pythonApiContractV1', () => {
|
||||
expect.objectContaining({ name: 'PostData' }),
|
||||
expect.objectContaining({ name: 'MediaData' }),
|
||||
expect.objectContaining({ name: 'ProjectData' }),
|
||||
expect.objectContaining({ name: 'ProtocolTelemetrySnapshot' }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,12 +29,16 @@ 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,
|
||||
@@ -49,10 +53,12 @@ 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 () => {
|
||||
@@ -66,6 +72,9 @@ describe('invokePythonApiMethodV1', () => {
|
||||
projects: {
|
||||
getAll: vi.fn(),
|
||||
},
|
||||
chat: {
|
||||
getProtocolHealth: vi.fn(),
|
||||
},
|
||||
meta: {
|
||||
getProjectMetadata: vi.fn(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user