wip: next round of implementation, this time tests
This commit is contained in:
@@ -1,7 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ProtocolTelemetryService } from '../../../../src/main/agentic/observability/protocolTelemetry';
|
||||
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();
|
||||
|
||||
@@ -35,4 +48,11 @@ describe('ProtocolTelemetryService', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,4 +114,61 @@ describe('ProtocolResponseBuilder', () => {
|
||||
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,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
93
tests/engine/agentic/protocol/uiSpecParser.test.ts
Normal file
93
tests/engine/agentic/protocol/uiSpecParser.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -71,4 +71,23 @@ describe('agentic protocol validator', () => {
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,4 +36,42 @@ describe('WorkflowCheckpointStore', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,4 +35,72 @@ describe('AgentTurnStateMachine', () => {
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user