wip: first run of implementation

This commit is contained in:
2026-02-25 20:29:01 +01:00
parent 2e203fa3a9
commit 20ea499a6f
40 changed files with 2170 additions and 22 deletions

View 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']));
});
});

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

View 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');
});
});

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

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

View 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');
});
});

View 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');
});
});

View File

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

View 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([]);
});
});

View File

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

View File

@@ -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(),
},