From ae12735aa8e5405d3913637c5a16b5f942ebc5fd Mon Sep 17 00:00:00 2001 From: VaibhavKanojia Date: Mon, 4 Aug 2025 14:22:20 +0200 Subject: [PATCH] feat(agents): add agentic approach --- package.json | 2 +- src/agents/core/AgentManager.ts | 55 ++++ src/agents/core/ErrorHandler.ts | 109 +++++++ src/agents/core/EventBus.ts | 99 +++++++ src/agents/core/FeedbackLoop.ts | 63 ++++ src/agents/core/KnowledgeGraph.ts | 333 +++++++++++++++++++++ src/agents/core/Logger.ts | 93 ++++++ src/agents/core/Supervisor.ts | 109 +++++++ src/agents/core/types.ts | 128 ++++++++ src/agents/tasks/FishboneUpdater.ts | 76 +++++ src/agents/tasks/QueryAgent.ts | 138 +++++++++ src/extension/extension.ts | 38 ++- src/extension/fbAIProvider.ts | 3 + src/extension/fbAiTools.ts | 67 +++-- src/extension/fbaEditor.ts | 19 +- src/test/suite/ErrorHandler.test.ts | 111 +++++++ src/test/suite/FishboneUpdater.test.ts | 157 ++++++++++ src/test/suite/KnowledgeGraph.test.ts | 342 ++++++++++++++++++++++ src/test/suite/Logger.test.ts | 166 +++++++++++ src/test/suite/QueryAgent.test.ts | 284 ++++++++++++++++++ src/test/suite/agenticIntegration.test.ts | 146 +++++++++ src/test/suite/extension.test.ts | 8 +- src/test/suite/util.test.ts | 24 ++ 23 files changed, 2541 insertions(+), 29 deletions(-) create mode 100644 src/agents/core/AgentManager.ts create mode 100644 src/agents/core/ErrorHandler.ts create mode 100644 src/agents/core/EventBus.ts create mode 100644 src/agents/core/FeedbackLoop.ts create mode 100644 src/agents/core/KnowledgeGraph.ts create mode 100644 src/agents/core/Logger.ts create mode 100644 src/agents/core/Supervisor.ts create mode 100644 src/agents/core/types.ts create mode 100644 src/agents/tasks/FishboneUpdater.ts create mode 100644 src/agents/tasks/QueryAgent.ts create mode 100644 src/test/suite/ErrorHandler.test.ts create mode 100644 src/test/suite/FishboneUpdater.test.ts create mode 100644 src/test/suite/KnowledgeGraph.test.ts create mode 100644 src/test/suite/Logger.test.ts create mode 100644 src/test/suite/QueryAgent.test.ts create mode 100644 src/test/suite/agenticIntegration.test.ts diff --git a/package.json b/package.json index 30e9fb8..3dd3603 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "fishbone", "displayName": "Fishbone / Ishikawa analysis", "description": "Create interactive fishbone diagrams for a systematic defect/failure analysis.", - "version": "1.38.4", + "version": "1.51.1", "license": "CC-BY-NC-SA-4.0", "publisher": "mbehr1", "author": { diff --git a/src/agents/core/AgentManager.ts b/src/agents/core/AgentManager.ts new file mode 100644 index 0000000..ebd3149 --- /dev/null +++ b/src/agents/core/AgentManager.ts @@ -0,0 +1,55 @@ + +import { EventBus } from '../core/EventBus'; +import { EventType } from './types'; +import * as vscode from 'vscode'; + +/** + * AgentManager (Agentic/Event-Driven) + * + * Orchestrates the event-driven workflow for all agents. + * Publishes events for each workflow step and handles errors robustly. + * + * Responsibilities: + * - Orchestrate agentic event flow for a given task + * - Publish SEQUENCE, FILTER, QUERY, SUMMARY, and UPDATE events + * - Handle and emit ERROR events for each step + * - Log all actions for traceability + */ +export class AgentManager { + /** + * Orchestrate the event-driven workflow for a given task. + * Publishes events for each step and handles errors robustly. + */ + async orchestrate(task: any, eventBus: EventBus, log: vscode.LogOutputChannel) { + log.info('AgentManager: orchestrate called', { task }); + const correlationId = task?.correlationId; + // Example event-driven workflow with robust error handling: + try { + await eventBus.publish({ type: EventType.SEQUENCE, payload: { sequence: task.dltResult } }, log); + } catch (e) { + await eventBus.publish({ type: EventType.ERROR, payload: { error: { message: `AgentManager SEQUENCE error: ${e}`, eventType: EventType.SEQUENCE, eventPayload: { sequence: task.dltResult }, correlationId } } }, log); + } + try { + await eventBus.publish({ type: EventType.FILTER, payload: { filter: 'default', data: task.dltResult } }, log); + } catch (e) { + await eventBus.publish({ type: EventType.ERROR, payload: { error: { message: `AgentManager FILTER error: ${e}`, eventType: EventType.FILTER, eventPayload: { filter: 'default', data: task.dltResult }, correlationId } } }, log); + } + try { + await eventBus.publish({ type: EventType.QUERY, payload: { query: task.query || '' } }, log); + } catch (e) { + await eventBus.publish({ type: EventType.ERROR, payload: { error: { message: `AgentManager QUERY error: ${e}`, eventType: EventType.QUERY, eventPayload: { query: task.query || '' }, correlationId } } }, log); + } + try { + await eventBus.publish({ type: EventType.SUMMARY, payload: { summary: 'Summary placeholder' } }, log); + } catch (e) { + await eventBus.publish({ type: EventType.ERROR, payload: { error: { message: `AgentManager SUMMARY error: ${e}`, eventType: EventType.SUMMARY, eventPayload: { summary: 'Summary placeholder' }, correlationId } } }, log); + } + try { + await eventBus.publish({ type: EventType.UPDATE, payload: { update: 'Update placeholder' } }, log); + } catch (e) { + await eventBus.publish({ type: EventType.ERROR, payload: { error: { message: `AgentManager UPDATE error: ${e}`, eventType: EventType.UPDATE, eventPayload: { update: 'Update placeholder' }, correlationId } } }, log); + } + log.info('AgentManager: orchestrate finished'); + return { summary: 'Summary placeholder' }; + } +} \ No newline at end of file diff --git a/src/agents/core/ErrorHandler.ts b/src/agents/core/ErrorHandler.ts new file mode 100644 index 0000000..cbe10af --- /dev/null +++ b/src/agents/core/ErrorHandler.ts @@ -0,0 +1,109 @@ +import { EventType, Event } from './types'; +import { globalEventBus } from './EventBus'; +import * as vscode from 'vscode'; + +export enum ErrorSeverity { + INFO = 'info', + WARNING = 'warning', + CRITICAL = 'critical', +} + +export interface AgentError { + message: string; + code?: string; + severity: ErrorSeverity; + cause?: any; + agentId?: string; + timestamp: number; +} + +/** + * ErrorHandler (Agentic/Event-Driven) + * + * Centralized error handling for all agents and workflows. + * Classifies, logs, emits, and recovers from errors in an agentic context. + * + * Responsibilities: + * - Classify errors by severity and agent + * - Log errors and emit ERROR events to the EventBus + * - Attempt recovery or escalation as needed + */ +export class ErrorHandler { + /** + * Handle an error: classify, log, emit event, and attempt recovery. + */ + handle(error: any, agentId: string | undefined, log: vscode.LogOutputChannel) { + const agentError: AgentError = this.classify(error, agentId); + log.error(`[${agentError.severity}] Error: ${agentError.message}`, agentError); + // Emit error event for agentic workflows + const event: Event = { + type: EventType.ERROR, + payload: { error: agentError }, + }; + globalEventBus.publish(event, log); + this.recover(agentError, log); + } + + /** + * Classify an error by severity, message, and agent. + * Returns a structured AgentError for logging and event emission. + */ + classify(error: any, agentId?: string): AgentError { + // Simple classification logic (customize as needed) + let severity = ErrorSeverity.WARNING; + let message = ''; + if (typeof error === 'string') { + message = error; + } else if (error instanceof Error) { + message = error.message; + } else if (error && error.message) { + message = error.message; + } else { + message = JSON.stringify(error); + } + if (message.match(/critical|fatal|unrecoverable/i)) { + severity = ErrorSeverity.CRITICAL; + } else if (message.match(/warn|timeout|retry/i)) { + severity = ErrorSeverity.WARNING; + } else { + severity = ErrorSeverity.INFO; + } + return { + message, + code: error.code || undefined, + severity, + cause: error, + agentId, + timestamp: Date.now(), + }; + } + + recover(agentError: AgentError, log: vscode.LogOutputChannel) { + // Recovery logic based on severity + switch (agentError.severity) { + case ErrorSeverity.INFO: + // No action needed for informational errors + break; + case ErrorSeverity.WARNING: + // Attempt a retry: emit a RETRY event if agentId is available + if (agentError.agentId) { + const retryEvent: Event = { + type: 'RETRY' as EventType, // Add RETRY to EventType if not present + payload: { error: agentError }, + }; + log.info('[Recovery] RETRY event emitted', retryEvent); + globalEventBus.publish(retryEvent, log); + } + break; + case ErrorSeverity.CRITICAL: + // Escalate: emit an ESCALATE event if agentId is available + const escalateEvent: Event = { + type: 'ESCALATE' as EventType, // Add ESCALATE to EventType if not present + payload: { error: agentError }, + }; + log.error('[Recovery] ESCALATE event emitted', escalateEvent); + globalEventBus.publish(escalateEvent, log); + break; + } + } +} diff --git a/src/agents/core/EventBus.ts b/src/agents/core/EventBus.ts new file mode 100644 index 0000000..ea1a698 --- /dev/null +++ b/src/agents/core/EventBus.ts @@ -0,0 +1,99 @@ +// EventBus.ts + +import { Event, EventType } from './types'; +import { ErrorHandler, ErrorSeverity } from './ErrorHandler'; + +// Strongly typed async event handler that receives log +import * as vscode from 'vscode'; +type AsyncEventHandler = (event: Event, log: vscode.LogOutputChannel) => Promise | void; + +interface HandlerEntry { + handler: AsyncEventHandler; + once: boolean; +} + + +export class EventBus { + private handlers: Map = new Map(); + private errorHandler: ErrorHandler; + + constructor() { + this.errorHandler = new ErrorHandler(); + } + + /** + * Subscribe to an event type. Returns an unsubscribe function. + */ + + subscribe(type: EventType, handler: AsyncEventHandler): () => void { + if (!this.handlers.has(type)) { + this.handlers.set(type, []); + } + const entry: HandlerEntry = { handler, once: false }; + this.handlers.get(type)!.push(entry); + return () => { + const typeHandlers = this.handlers.get(type)!; + const idx = typeHandlers.indexOf(entry); + if (idx !== -1) { typeHandlers.splice(idx, 1); } + }; + } + + /** + * Subscribe to an event type, but only for the next event (one-time listener). + */ + once(type: EventType, handler: AsyncEventHandler): void { + if (!this.handlers.has(type)) { + this.handlers.set(type, []); + } + this.handlers.get(type)!.push({ handler, once: true }); + } + + /** + * Publish an event asynchronously. All handlers are awaited, errors are isolated. + */ + async publish(event: Event, log: vscode.LogOutputChannel): Promise { + log.debug(`[EventBus] publish called: event.type='${event.type}' payload=${JSON.stringify(event.payload)}`); + const entries = this.handlers.get(event.type) || []; + // Copy to avoid mutation during iteration + const toRemove: HandlerEntry[] = []; + await Promise.all(entries.map(async entry => { + try { + await entry.handler(event, log); + } catch (error) { + // Enhanced error handling: include event context and correlation ID if present + const correlationId = (event as any)?.payload?.correlationId; + this.errorHandler.handle( + { + error, + eventType: event.type, + eventPayload: event.payload, + correlationId, + }, + undefined, + log + ); + // Optionally, emit an error event for critical errors (if ErrorHandler supports escalation) + // If error is critical, broadcast to ERROR event listeners + // (Assumes ErrorHandler.classify returns severity) + const classified = this.errorHandler.classify(error); + if (classified.severity === ErrorSeverity.CRITICAL) { + const errorEvent: Event = { + type: EventType.ERROR, + payload: { error: { ...classified, correlationId } }, + }; + // Avoid infinite loop: only emit if not already an ERROR event + if (event.type !== EventType.ERROR) { + await this.publish(errorEvent, log); + } + } + } + if (entry.once) { toRemove.push(entry); } + })); + // Remove one-time listeners + if (toRemove.length) { + this.handlers.set(event.type, entries.filter(e => !toRemove.includes(e))); + } + } +} + +export const globalEventBus = new EventBus(); \ No newline at end of file diff --git a/src/agents/core/FeedbackLoop.ts b/src/agents/core/FeedbackLoop.ts new file mode 100644 index 0000000..5e4eb62 --- /dev/null +++ b/src/agents/core/FeedbackLoop.ts @@ -0,0 +1,63 @@ + +import { Event, EventType, AgentType } from './types'; +import { globalEventBus } from './EventBus'; +import * as vscode from 'vscode'; + +export interface FeedbackCriteria { + minScore?: number; + maxRetries?: number; + customRule?: (result: any) => boolean; +} + +/** + * FeedbackLoop (Agentic/Event-Driven) + * + * Provides feedback and refinement logic for agent tasks. + * Tracks retries, emits UPDATE and ERROR events, and enables robust agentic workflows. + * + * Responsibilities: + * - Refine agent tasks based on result and feedback criteria + * - Track and limit retries for each agent + * - Emit UPDATE events for further refinement + * - Emit ERROR events when max retries are exceeded + */ +export class FeedbackLoop { + private retryCounts: Map = new Map(); + + /** + * Refine agent task based on result and feedback criteria. + * Optionally triggers new events for further refinement. + * Emits ERROR if max retries are exceeded. + */ + async refine(agentId: string, result: any, criteria: FeedbackCriteria, log: vscode.LogOutputChannel): Promise { + let shouldRefine = false; + // Check if result score is below minimum + if (criteria.minScore !== undefined && typeof result.score === 'number') { + shouldRefine = result.score < criteria.minScore; + } + // Check custom rule if provided + if (criteria.customRule && !shouldRefine) { + shouldRefine = !criteria.customRule(result); + } + if (shouldRefine) { + // Track retries for this agent + const retries = (this.retryCounts.get(agentId) || 0) + 1; + this.retryCounts.set(agentId, retries); + // If max retries exceeded, emit ERROR event + if (criteria.maxRetries !== undefined && retries > criteria.maxRetries) { + log.warn(`FeedbackLoop: Max retries exceeded for agent ${agentId}`); + await globalEventBus.publish({ + type: EventType.ERROR, + payload: { error: `Max retries exceeded for agent ${agentId}` }, + }, log); + return; + } + log.info(`FeedbackLoop: Refine triggered for agent ${agentId}, retries=${retries}`); + // Emit UPDATE event for agent to adjust parameters + await globalEventBus.publish({ + type: EventType.UPDATE, + payload: { update: { action: 'refine', retries, lastResult: result } }, + }, log); + } + } +} diff --git a/src/agents/core/KnowledgeGraph.ts b/src/agents/core/KnowledgeGraph.ts new file mode 100644 index 0000000..6f21f6f --- /dev/null +++ b/src/agents/core/KnowledgeGraph.ts @@ -0,0 +1,333 @@ +import * as vscode from 'vscode'; +import { EventBus, globalEventBus } from './EventBus'; +import { EventType, KnowledgeType } from './types'; +// ...existing code... + + +// Enriched KnowledgeEntry type +export interface KnowledgeEntry { + id: string; + type: KnowledgeType; + label?: string; + tags?: string[]; + data?: any; + provenance?: string; // e.g., agent/source + confidence?: number; // 0-1 + createdAt?: number; + updatedAt?: number; + version?: number; +} + +// Enriched KnowledgeEdge type +interface KnowledgeEdge { + from: string; // id + to: string; // id + type: string; + label?: string; + tags?: string[]; + provenance?: string; + confidence?: number; + createdAt?: number; + data?: any; +} + +/** + * KnowledgeGraph (Agentic/Event-Driven) + * + * Central, in-memory graph for all agent knowledge and relationships. + * Stores entries (nodes) and edges (relationships) with rich metadata. + * + * Responsibilities: + * - Provide a shared, queryable context for all agents + * - Support advanced queries, traversals, and subgraph extraction + * - Track provenance, confidence, and versioning for all knowledge + * - Enable agentic workflows (reasoning, root cause, context, etc) + * + * All methods are synchronous and safe for concurrent agent use. + */ +export class KnowledgeGraph { + /** + * Validate that a set of entries is suitable for fishbone export. + * Returns { valid: boolean, reason?: string } + */ + static validateFishboneEntries(entries: KnowledgeEntry[]): { valid: boolean; reason?: string } { + if (!Array.isArray(entries) || entries.length === 0) { + return { valid: false, reason: 'No knowledge entries provided.' }; + } + for (const e of entries) { + if (!e.id || !e.type) { + return { valid: false, reason: `Entry missing id or type: ${JSON.stringify(e)}` }; + } + } + return { valid: true }; + } + + /** + * Trigger a FishboneUpdater event with validated knowledge entries. + * Ensures the fishbone file will not be broken by malformed input. + * + * @param fishboneUri The URI of the fishbone file to create/edit + * @param eventBus The EventBus to use (default: globalEventBus) + * @param filter Optional predicate to filter entries + * @param log Optional log channel for diagnostics + * @returns { success: boolean, reason?: string } + */ + triggerFishboneUpdate( + fishboneUri: string, + eventBus: EventBus = globalEventBus, + filter?: (entry: KnowledgeEntry) => boolean, + log?: vscode.LogOutputChannel + ): { success: boolean; reason?: string } { + const entries = filter ? this.query(filter) : Array.from(this.entries.values()); + const validation = KnowledgeGraph.validateFishboneEntries(entries); + if (!validation.valid) { + if (log) log.error(`[KnowledgeGraph] Fishbone update validation failed: ${validation.reason}`); + return { success: false, reason: validation.reason }; + } + if (log) log.info(`[KnowledgeGraph] Triggering FishboneUpdater for ${fishboneUri} with ${entries.length} entries.`); + eventBus.publish({ + type: EventType.UPDATE_FISHBONE_FROM_KNOWLEDGE, + payload: { fishboneUri, filter }, + }, log as vscode.LogOutputChannel); + return { success: true }; + } + private entries: Map = new Map(); + private edges: KnowledgeEdge[] = []; + + + /** + * Add or update a knowledge entry (node). + * Updates timestamps and merges by id. + */ + set(entry: KnowledgeEntry) { + entry.updatedAt = Date.now(); + if (!entry.createdAt) { entry.createdAt = Date.now(); } + this.entries.set(entry.id, entry); + } + + /** + * Get a knowledge entry by id. + * Returns undefined if not found. + */ + get(id: string): KnowledgeEntry | undefined { + return this.entries.get(id); + } + + /** + * Query all entries by KnowledgeType. + */ + queryByType(type: KnowledgeType): KnowledgeEntry[] { + return Array.from(this.entries.values()).filter(e => e.type === type); + } + + /** + * Add a relationship (edge) between entries. + * Supports optional label, tags, provenance, confidence, createdAt, and data. + */ + addEdge(params: { + from: string; + to: string; + type: string; + label?: string; + tags?: string[]; + provenance?: string; + confidence?: number; + createdAt?: number; + data?: any; + }) { + const edge: KnowledgeEdge = { + from: params.from, + to: params.to, + type: params.type, + label: params.label, + tags: params.tags, + provenance: params.provenance, + confidence: params.confidence, + createdAt: params.createdAt || Date.now(), + data: params.data, + }; + this.edges.push(edge); + } + + /** + * Query all edges by type. + */ + queryEdgesByType(type: string): KnowledgeEdge[] { + return this.edges.filter(e => e.type === type); + } + + /** + * Query all edges by label. + */ + queryEdgesByLabel(label: string): KnowledgeEdge[] { + return this.edges.filter(e => e.label === label); + } + + /** + * Query all edges by tag. + */ + queryEdgesByTag(tag: string): KnowledgeEdge[] { + return this.edges.filter(e => e.tags && e.tags.includes(tag)); + } + + /** + * Get all direct neighbors (ids) of a given entry (outgoing edges). + */ + getNeighbors(id: string): string[] { + return this.edges.filter(e => e.from === id).map(e => e.to); + } + + /** + * Get all direct neighbors (ids) of a given entry (incoming edges). + */ + getIncomingNeighbors(id: string): string[] { + return this.edges.filter(e => e.to === id).map(e => e.from); + } + + /** + * Get all edges from a given entry (outgoing). + */ + getEdgesFrom(id: string): KnowledgeEdge[] { + return this.edges.filter(e => e.from === id); + } + + /** + * Get all edges to a given entry (incoming). + */ + getEdgesTo(id: string): KnowledgeEdge[] { + return this.edges.filter(e => e.to === id); + } + + /** + * Query all entries created within a time range (inclusive). + */ + queryByTime(start: number, end: number): KnowledgeEntry[] { + return Array.from(this.entries.values()).filter(e => e.createdAt && e.createdAt >= start && e.createdAt <= end); + } + + /** + * Query entries by arbitrary predicate (advanced agentic queries). + */ + query(predicate: (entry: KnowledgeEntry) => boolean): KnowledgeEntry[] { + return Array.from(this.entries.values()).filter(predicate); + } + + /** + * Query all entries by tag. + */ + queryByTag(tag: string): KnowledgeEntry[] { + return Array.from(this.entries.values()).filter(e => e.tags && e.tags.includes(tag)); + } + + /** + * Query all entries by label. + */ + queryByLabel(label: string): KnowledgeEntry[] { + return Array.from(this.entries.values()).filter(e => e.label === label); + } + + /** + * Query all entries by provenance (agent/source). + */ + queryByProvenance(provenance: string): KnowledgeEntry[] { + return Array.from(this.entries.values()).filter(e => e.provenance === provenance); + } + + /** + * Query all entries by confidence threshold (min/max). + */ + queryByConfidence(min: number, max: number = 1): KnowledgeEntry[] { + return Array.from(this.entries.values()).filter(e => { + const entry = e as KnowledgeEntry; + return typeof entry.confidence === 'number' && entry.confidence >= min && entry.confidence <= max; + }); + } + + /** + * Find all entries related to a given entry by edge type (outgoing). + */ + findRelatedByEdgeType(id: string, edgeType: string): KnowledgeEntry[] { + const neighborIds = this.edges.filter(e => e.from === id && e.type === edgeType).map(e => e.to); + return neighborIds + .map(nid => this.entries.get(nid) as KnowledgeEntry | undefined) + .filter((entry): entry is KnowledgeEntry => Boolean(entry)); + } + + /** + * Traverse the graph from a start node, following edges of a given type, up to a max depth. + * Returns all reachable entries (DFS, no cycles). + */ + traverseFrom(id: string, edgeType?: string, maxDepth: number = 3): KnowledgeEntry[] { + const visited = new Set(); + const results: KnowledgeEntry[] = []; + // Treat negative maxDepth as 0 + const safeMaxDepth = maxDepth < 0 ? 0 : maxDepth; + const dfs = (currentId: string, depth: number, graph: KnowledgeGraph) => { + if (depth > safeMaxDepth || visited.has(currentId)) { return; } + visited.add(currentId); + const entry = graph.entries.get(currentId); + if (entry) { results.push(entry); } + const neighbors = edgeType + ? graph.edges.filter(e => e.from === currentId && e.type === edgeType).map(e => e.to) + : graph.edges.filter(e => e.from === currentId).map(e => e.to); + for (const n of neighbors) { + dfs(n, depth + 1, graph); + } + }; + dfs(id, 0, this); + return results; + } + + /** + * Get a subgraph containing all entries and edges reachable from a given entry (optionally by edge type). + * Returns { entries, edges } for further agentic reasoning. + */ + getSubgraphFrom(id: string, edgeType?: string, maxDepth: number = 3): { entries: KnowledgeEntry[]; edges: KnowledgeEdge[] } { + const entrySet = new Set(); + const edgeSet: Set = new Set(); + const dfs = (currentId: string, depth: number, graph: KnowledgeGraph) => { + if (depth > maxDepth || entrySet.has(currentId)) { return; } + entrySet.add(currentId); + const outgoing = edgeType + ? graph.edges.filter(e => e.from === currentId && e.type === edgeType) + : graph.edges.filter(e => e.from === currentId); + for (const edge of outgoing) { + edgeSet.add(edge); + dfs(edge.to, depth + 1, graph); + } + }; + dfs(id, 0, this); + return { + entries: Array.from(entrySet).map(eid => this.entries.get(eid)).filter(Boolean) as KnowledgeEntry[], + edges: Array.from(edgeSet), + }; + } + + /** + * Get all connected components (as arrays of entry ids). + * Useful for finding isolated subgraphs or clusters. + */ + getConnectedComponents(): string[][] { + const visited = new Set(); + const components: string[][] = []; + for (const id of Array.from(this.entries.keys())) { + if (!visited.has(id)) { + const comp: string[] = []; + const stack = [id]; + while (stack.length) { + const curr = stack.pop()!; + if (!visited.has(curr)) { + visited.add(curr); + comp.push(curr); + const neighbors = this.edges.filter(e => e.from === curr).map(e => e.to); + for (const n of neighbors) { + if (!visited.has(n)) { stack.push(n); } + } + } + } + components.push(comp); + } + } + return components; + } +} diff --git a/src/agents/core/Logger.ts b/src/agents/core/Logger.ts new file mode 100644 index 0000000..6450f54 --- /dev/null +++ b/src/agents/core/Logger.ts @@ -0,0 +1,93 @@ + +import { EventType } from './types'; +import { globalEventBus, EventBus } from './EventBus'; + +export enum LogLevel { + DEBUG = 'debug', + INFO = 'info', + WARN = 'warn', + ERROR = 'error', +} + +import * as vscode from 'vscode'; +/** + * Logger (Agentic/Event-Driven) + * + * Centralized logging utility for all agents and workflows. + * Supports log levels, event emission, and robust error handling. + * + * Responsibilities: + * - Log messages at various levels (debug, info, warn, error) + * - Emit log events to the EventBus for observability + * - Handle log channel failures gracefully + * + * All log actions are routed through the provided log channel. + */ +export class Logger { + private eventBus: EventBus; + constructor(eventBus?: EventBus) { + this.eventBus = eventBus || globalEventBus; + } + + /** + * Log a message at the specified level, with optional metadata. + * Emits log events to the EventBus for observability. + * Handles log channel failures gracefully. + */ + log(msg: string, level: LogLevel = LogLevel.INFO, log: vscode.LogOutputChannel, meta?: any) { + const prefix = `[FAI][${level.toUpperCase()}]`; + try { + switch (level) { + case LogLevel.ERROR: { + log.error(prefix, msg, meta || ''); + break; + } + case LogLevel.WARN: { + log.warn(prefix, msg, meta || ''); + break; + } + case LogLevel.DEBUG: { + log.debug(prefix, msg, meta || ''); + break; + } + default: { + log.info(prefix, msg, meta || ''); + } + } + } catch (err) { + // If log channel fails, try to log the error using the same channel (if possible) + if (log && typeof log.error === 'function') { + try { + log.error('[FAI][ERROR] Logger failed to write to log channel', err && err.toString(), { originalMsg: msg }); + } catch {} + } + // Use a safe no-op log channel for the fallback event emission + const safeLog = { + name: 'safe', + logLevel: 0, + onDidChangeLogLevel: () => ({ dispose: () => {} }), + info: () => {}, + debug: () => {}, + error: () => {}, + warn: () => {}, + trace: () => {}, + append: () => {}, + appendLine: () => {}, + replace: () => {}, + clear: () => {}, + show: () => {}, + hide: () => {}, + dispose: () => {}, + } as vscode.LogOutputChannel; + this.eventBus.publish({ + type: EventType.UPDATE, + payload: { update: { log: `Logger failed to write to log channel: ${err}`, level: LogLevel.ERROR, meta: { originalMsg: msg, error: err && err.toString() } } }, + }, safeLog); + } + // Emit log event for monitoring/traceability + this.eventBus.publish({ + type: EventType.UPDATE, + payload: { update: { log: msg, level, meta } }, + }, log); + } +} diff --git a/src/agents/core/Supervisor.ts b/src/agents/core/Supervisor.ts new file mode 100644 index 0000000..3aef1a3 --- /dev/null +++ b/src/agents/core/Supervisor.ts @@ -0,0 +1,109 @@ +// FAI Supervisor: Orchestrates agent workflow, validation, error handling +import { AgentManager } from './AgentManager' +import { ErrorHandler } from './ErrorHandler' +import { EventBus } from './EventBus' +import { FeedbackLoop } from './FeedbackLoop' +import { Logger, LogLevel } from './Logger' + +import * as vscode from 'vscode'; + +/** + * Supervisor (Agentic/Event-Driven) + * + * Orchestrates the full agent workflow, including validation, error handling, + * feedback, and reproducibility checks. Coordinates all core agent modules. + * + * Responsibilities: + * - Run the end-to-end agentic workflow for a given task + * - Validate each step and handle errors robustly + * - Log all actions and results for traceability + * - Integrate feedback and reproducibility hooks + */ +export class Supervisor { + private agentManager: AgentManager; + private errorHandler: ErrorHandler; + private eventBus: EventBus; + private feedbackLoop: FeedbackLoop; + private logger: Logger; + private log: vscode.LogOutputChannel; + + constructor(log: vscode.LogOutputChannel) { + this.agentManager = new AgentManager(); + this.errorHandler = new ErrorHandler(); + this.eventBus = new EventBus(); + this.feedbackLoop = new FeedbackLoop(); + this.logger = new Logger(); + this.log = log; + } + + /** + * Run the full agentic workflow for a given task. + * Steps: DLT query, agent orchestration, validation, reproducibility, feedback, error handling. + */ + async runWorkflow(task: any) { + try { + this.logger.log('Starting workflow', LogLevel.INFO, this.log); + // Step 1: Query DLT logs + const dltResult = await this.invokeExtension('mbehr1.dlt-logs', 'queryDLT', task.dltQuery); + this.logger.log('DLT log queried', LogLevel.INFO, this.log); + // Validation hook: validate DLT result + if (!this.validateStep('DLTQuery', dltResult)) { + throw new Error('DLT log query validation failed'); + } + // Step 2: Orchestrate agent workflow + const result = await this.agentManager.orchestrate({ ...task, dltResult }, this.eventBus, this.log); + // Validation hook: validate agent result + if (!this.validateStep('AgentOrchestration', result)) { + throw new Error('Agent orchestration validation failed'); + } + // Reproducibility check: hash intermediate result + const reproducibilityHash = this.computeHash(result); + this.logger.log(`Reproducibility hash: ${reproducibilityHash}`, LogLevel.INFO, this.log); + // Step 3: Update fishbone diagram + await this.invokeExtension('mbehr1.fishbone', 'updateFishbone', result); + // Feedback loop: refine if needed (customize agentId/criteria as needed) + await this.feedbackLoop.refine('supervisor', result, { maxRetries: 3 }, this.log); + this.logger.log('Workflow complete', LogLevel.INFO, this.log); + return result; + } catch (err) { + // Centralized error handling for the workflow + this.errorHandler.handle(err, 'supervisor', this.log); + this.logger.log('Error handled', LogLevel.ERROR, this.log); + throw err; + } + } + + /** Validation hook for workflow steps */ + validateStep(step: string, data: any): boolean { + // Simple validation: check for success or data presence + if (data && (data.success === true || data !== null)) { + this.logger.log(`Validation passed for step: ${step}`, LogLevel.INFO, this.log); + return true; + } + this.logger.log(`Validation failed for step: ${step}`, LogLevel.WARN, this.log, { data }); + return false; + } + + /** Compute a reproducibility hash for a result */ + computeHash(data: any): string { + // Simple hash: JSON string length + basic checksum (for demo) + const str = JSON.stringify(data) + let hash = 0 + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash) + str.charCodeAt(i) + hash |= 0 // Convert to 32bit int + } + return hash.toString(16) + } + + async invokeExtension(extensionId: string, command: string, args: any) { + // Real VS Code API call (requires running in extension context) + // Example: + // import * as vscode from 'vscode'; + // return await vscode.commands.executeCommand(`${extensionId}.${command}`, args); + // For now, log and simulate response + this.logger.log(`Invoking extension: ${extensionId}, command: ${command}`, LogLevel.INFO, this.log); + // TODO: Replace with actual API call in extension context + return { success: true, data: args } + } +} diff --git a/src/agents/core/types.ts b/src/agents/core/types.ts new file mode 100644 index 0000000..04f08bc --- /dev/null +++ b/src/agents/core/types.ts @@ -0,0 +1,128 @@ +// types.ts + +// Placeholder types for chat agent API (replace with real types as needed) +export interface ChatRequest { + command?: string; + prompt?: string; + [key: string]: any; +} + +export interface ChatContext { + history?: any[]; + [key: string]: any; +} + +export interface ChatResponseStream { + write(data: string): void; + end(): void; +} + +export interface ChatResponse { + response?: string; + [key: string]: any; +} + +/** + * Agent interface for agentic/event-driven architecture. + */ +export interface Agent { + id: string; + type: AgentType; + execute( + request: ChatRequest, + chatContext: ChatContext, + stream: ChatResponseStream, + token: any // Use 'any' for CancellationToken for now + ): Promise; + handleEvent(event: Event): void; +} + + +/** + * Types of agents in the system. + */ +export enum AgentType { + QUERY = 'query', + CONTEXT = 'context', + TOOL = 'tool', + SEQUENCE = 'sequence', + FILTER = 'filter', + SUMMARY = 'summary', + UPDATER = 'updater', + UPDATE_FISHBONE_FROM_KNOWLEDGE = 'update_fishbone_from_knowledge', + FISHBONE_UPDATED = 'fishbone_updated', + // Extend as needed +} + +/** + * Types of events in the system. + */ +export enum EventType { + QUERY_RECEIVED = 'query_received', + QUERY_PROCESSED = 'query_processed', + CONTEXT_UPDATED = 'context_updated', + TOOL_INVOKED = 'tool_invoked', + TOOL_RESULT = 'tool_result', + ERROR = 'error', + SEQUENCE = 'sequence', + FILTER = 'filter', + QUERY = 'query', + SUMMARY = 'summary', + UPDATE = 'update', + QUERY_RESULT = 'query_result', + UPDATE_FISHBONE_FROM_KNOWLEDGE = 'update_fishbone_from_knowledge', + FISHBONE_UPDATED = 'fishbone_updated', + // Extend as needed +} + +/** + * Strongly-typed event payloads for each event type. + */ +export type EventPayloadMap = { + [EventType.QUERY_RECEIVED]: { query: string }; + [EventType.QUERY_PROCESSED]: { result: any }; + [EventType.CONTEXT_UPDATED]: { context: any }; + [EventType.TOOL_INVOKED]: { tool: string; args: any }; + [EventType.TOOL_RESULT]: { tool: string; result: any }; + [EventType.ERROR]: { error: Error | string | any; source?: string }; + [EventType.SEQUENCE]: { sequence: any[] }; + [EventType.FILTER]: { filter: string; data: any }; + [EventType.QUERY]: { query: string }; + [EventType.SUMMARY]: { summary: string }; + [EventType.UPDATE]: { update: any }; + [EventType.QUERY_RESULT]: { result?: any; error?: any }; + [EventType.UPDATE_FISHBONE_FROM_KNOWLEDGE]: { fishboneUri: string; filter?: any }; + [EventType.FISHBONE_UPDATED]: { fishboneUri: string }; + // Extend as needed +}; + +/** + * Generic Event interface for agentic/event-driven architecture. + */ +export interface Event { + type: T; + payload: EventPayloadMap[T]; +} + +/** + * Types of knowledge entries for the knowledge graph. + */ +export enum KnowledgeType { + CONTEXT = 'context', + FACT = 'fact', + RELATION = 'relation', + HISTORY = 'history', + // Extend as needed +} + +/** + * Knowledge entry for the knowledge graph. + */ +export interface KnowledgeEntry { + id: string; + type: KnowledgeType; + data: any; + createdAt: number; + updatedAt?: number; +} + diff --git a/src/agents/tasks/FishboneUpdater.ts b/src/agents/tasks/FishboneUpdater.ts new file mode 100644 index 0000000..5938063 --- /dev/null +++ b/src/agents/tasks/FishboneUpdater.ts @@ -0,0 +1,76 @@ +import { EventBus } from '../core/EventBus'; +import { KnowledgeGraph, KnowledgeEntry } from '../core/KnowledgeGraph'; +import { Fishbone, FBEffect, FBCategory, FBRootCause, fbaToString } from '../../extension/fbaFormat'; +import { EventType } from '../core/types'; +import * as vscode from 'vscode'; + +/** + * FishboneUpdater agent: Listens for UPDATE_FISHBONE_FROM_KNOWLEDGE events and updates the Fishbone object/file + * with new insights from the KnowledgeGraph. + */ +export class FishboneUpdater { + constructor( + private eventBus: EventBus, + private knowledgeGraph: KnowledgeGraph, + private log: vscode.LogOutputChannel + ) { + this.eventBus.subscribe(EventType.UPDATE_FISHBONE_FROM_KNOWLEDGE, this.handleUpdate.bind(this)); + } + + /** + * Handler for UPDATE_FISHBONE_FROM_KNOWLEDGE event. + * @param event Event with payload: { fishboneUri: string, filter?: any } + */ + async handleUpdate(event: any) { + try { + this.log.info('[FishboneUpdater] handleUpdate called', event); + const { fishboneUri, filter } = event.payload; + // 1. Query KnowledgeGraph for relevant entries (optionally filter) + const entries = filter + ? this.knowledgeGraph.query((e: KnowledgeEntry) => filter(e)) + : Array.from(this.knowledgeGraph['entries'].values()); + // 2. Transform entries into Fishbone structure (simple mapping: each entry -> root cause) + const rootCauses: FBRootCause[] = entries.map(e => ({ + fbUid: e.id, + type: 'knowledge', + title: e.label || e.id, + props: { + label: e.label || e.id, + value: e.data, + instructions: e.provenance ? `Source: ${e.provenance}` : undefined, + }, + })); + // 3. Compose Fishbone object (single effect/category for demo) + const fishbone: Fishbone = { + type: 'fba', + version: '0.7', + title: 'KnowledgeGraph Insights', + attributes: [], + fishbone: [ + { + fbUid: 'effect-knowledge', + name: 'KnowledgeGraph Insights', + categories: [ + { + fbUid: 'cat-knowledge', + name: 'Knowledge', + rootCauses, + }, + ], + }, + ], + backups: [], + }; + // 4. Serialize and save to file + const yaml = fbaToString(fishbone); + const uri = vscode.Uri.parse(fishboneUri); + await vscode.workspace.fs.writeFile(uri, Buffer.from(yaml, 'utf8')); + this.log.info(`[FishboneUpdater] Fishbone updated at ${fishboneUri}`); + // 5. Optionally emit an event for completion + this.eventBus.publish({ type: EventType.FISHBONE_UPDATED, payload: { fishboneUri } }, this.log); + } catch (e) { + this.log.error(`[FishboneUpdater] handleUpdate error: ${e}`); + this.eventBus.publish({ type: EventType.ERROR, payload: { error: e, source: 'FishboneUpdater' } }, this.log); + } + } +} diff --git a/src/agents/tasks/QueryAgent.ts b/src/agents/tasks/QueryAgent.ts new file mode 100644 index 0000000..094d645 --- /dev/null +++ b/src/agents/tasks/QueryAgent.ts @@ -0,0 +1,138 @@ +import { EventBus } from '../core/EventBus'; +import { Event, EventType } from '../core/types'; +import * as vscode from 'vscode'; + +/** + * QueryAgent (Agentic/Event-Driven) + * + * Listens for QUERY events on the EventBus, parses and validates the query payload, + * and delegates DLT log queries to the injected DLT provider. Emits QUERY_RESULT events + * with results or errors, and emits ERROR events for critical failures. All logging is + * routed through the provided log channel for observability. + * + * Responsibilities: + * - Validate and parse incoming QUERY events + * - Transform query payloads into DLT filter configs + * - Call DLT provider and handle results/errors + * - Emit QUERY_RESULT and ERROR events as appropriate + * - Log all actions and errors for traceability + */ +export class QueryAgent { + constructor( + private eventBus: EventBus, + private dltProvider: { performRestQueryUri: (uri: string) => Promise, DltFilter: any, rqUriEncode: any, RQ: any } + ) { + // No log property; log will be passed to handleQuery + // The event bus or orchestrator must pass log to handleQuery + // Subscribe to QUERY events and delegate to handleQuery + this.eventBus.subscribe(EventType.QUERY, (event, log) => this.handleQuery(event, log)); + } + + /** + * Handles QUERY events: parses, validates, and executes DLT log queries. + * Emits QUERY_RESULT and ERROR events as appropriate. + * @param event The QUERY event (payload: { query: string }) + * @param log The log output channel for tracing + */ + async handleQuery(event: any, log: vscode.LogOutputChannel): Promise { + // Always require log parameter for traceability + const correlationId = event?.payload?.correlationId; + try { + log.info(`[QueryAgent] handleQuery CALLED: event.type='${event.type}' payload= ${JSON.stringify(event.payload)}`); + // Only process QUERY events + if (event.type !== EventType.QUERY) { return; } + + // Validate payload and query string + if (!event.payload || typeof event.payload.query !== 'string' || event.payload.query.trim() === '') { + log.info(`QueryAgent.handleQuery missing payload or query: ${JSON.stringify(event)}`); + await this.eventBus.publish({ + type: EventType.QUERY_RESULT, + payload: { error: 'Invalid query/filter: missing payload or query' } + }, log); + return; + } + + // Parse query string as JSON to build DLT filter(s) + const { query } = event.payload; + let filters: any[] = []; + try { + const filterFrags = JSON.parse(query); + log.debug(`QueryAgent.handleQuery parsed query: ${JSON.stringify(filterFrags)}`); + filters = Array.isArray(filterFrags) + ? filterFrags.map(f => new this.dltProvider.DltFilter(f)) + : [new this.dltProvider.DltFilter(filterFrags)]; + } catch (e) { + // Query string was not valid JSON + log.info(`QueryAgent.handleQuery failed to parse query: ${query} ${e}`); + await this.eventBus.publish({ + type: EventType.QUERY_RESULT, + payload: { error: `Invalid query/filter: ${e}` } + }, log); + // Emit critical error event for parse failure + await this.eventBus.publish({ + type: EventType.ERROR, + payload: { error: { message: `QueryAgent failed to parse query: ${e}`, eventType: event.type, eventPayload: event.payload, correlationId } } + }, log); + return; + } + + // Convert DLT filters to configuration objects + const filterConfigs = filters.map(f => f.asConfiguration()); + if (filterConfigs.length > 0) { filterConfigs[0].addLifecycles = true; } + + // Build the DLT provider request object + const rq = { + path: 'ext:mbehr1.dlt-logs/get/docs/0/filters', + commands: [ + { cmd: 'query', param: JSON.stringify(filterConfigs) } + ] + }; + try { + log.debug(`QueryAgent.handleQuery calling DLT provider with rq: ${JSON.stringify(rq)}`); + // Call the DLT provider and await results + const resJson = await this.dltProvider.performRestQueryUri(this.dltProvider.rqUriEncode(rq)); + if (resJson.data && Array.isArray(resJson.data)) { + // Filter for DLT messages only + const msgs = resJson.data.filter((d: any) => d.type === 'msg'); + log.info(`QueryAgent.handleQuery DLT provider returned ${msgs.length} messages`); + await this.eventBus.publish({ + type: EventType.QUERY_RESULT, + payload: { result: msgs } + }, log); + } else { + // No data returned from DLT provider + log.info(`QueryAgent.handleQuery no data returned from DLT provider: ${JSON.stringify(resJson)}`); + await this.eventBus.publish({ + type: EventType.QUERY_RESULT, + payload: { error: 'No data returned from DLT.' } + }, log); + // Emit critical error event for no data + await this.eventBus.publish({ + type: EventType.ERROR, + payload: { error: { message: 'No data returned from DLT.', eventType: event.type, eventPayload: event.payload, correlationId } } + }, log); + } + } catch (e) { + // DLT provider query failed + log.error(`QueryAgent.handleQuery DLT query failed: ${e}`); + await this.eventBus.publish({ + type: EventType.QUERY_RESULT, + payload: { error: `DLT query failed: ${e}` } + }, log); + // Emit critical error event for DLT query failure + await this.eventBus.publish({ + type: EventType.ERROR, + payload: { error: { message: `DLT query failed: ${e}`, eventType: event.type, eventPayload: event.payload, correlationId } } + }, log); + } + } catch (e) { + // Unexpected error in handler + log.error(`QueryAgent.handleQuery unexpected error: ${e}`); + // Emit critical error event for unexpected error + await this.eventBus.publish({ + type: EventType.ERROR, + payload: { error: { message: `QueryAgent unexpected error: ${e}`, eventType: event.type, eventPayload: event.payload, correlationId } } + }, log); + } + } +} diff --git a/src/extension/extension.ts b/src/extension/extension.ts index fb4e206..c322e04 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -8,6 +8,13 @@ import { TelemetryReporter } from '@vscode/extension-telemetry' import { extensionId, GlobalState } from './constants' import { FBAEditorProvider } from './fbaEditor' +// --- AGENTIC: Import EventBus and QueryAgent --- +import { globalEventBus } from '../agents/core/EventBus'; +import { EventType } from '../agents/core/types'; +import { QueryAgent } from '../agents/tasks/QueryAgent'; +import { DltFilter } from 'dlt-logs-utils/sequence'; +import { rqUriEncode, RQ } from 'dlt-logs-utils/restQuery'; + // this method is called when your extension is activated // your extension is activated the very first time the command is executed export function activate(context: vscode.ExtensionContext) { @@ -34,7 +41,28 @@ export function activate(context: vscode.ExtensionContext) { `extension ${extensionId} v${extensionVersion} ${prevVersion !== extensionVersion ? `prevVersion: ${prevVersion} ` : ''}is now active!`, ) - FBAEditorProvider.register(log, context, reporter) + const fbaEditorProvider = FBAEditorProvider.register(log, context, reporter) + + // --- AGENTIC: Instantiate QueryAgent --- + // Compose a DLT provider object with the required methods for QueryAgent + // Use the FBAIProvider or FBAEditorProvider as needed for performRestQueryUri + // For now, use a minimal stub for demonstration; you may want to wire to the real provider + + // Use the real performRestQueryUri from the active FBAEditorProvider + const dltProvider = { + performRestQueryUri: async (uri: string) => { + log.info(`[QueryAgent-DLT] performRestQueryUri called with uri: ${uri}`); + return fbaEditorProvider.performRestQueryUri(uri); + }, + DltFilter, + rqUriEncode, + RQ: {} // Dummy value to satisfy QueryAgent type + }; + + // Use the global event bus for all agentic events + // This will ensure QueryAgent is active and logs to the output window + const queryAgent = new QueryAgent(globalEventBus, dltProvider); + log.info('[Agentic] QueryAgent instantiated and listening for QUERY events.'); context.subscriptions.push( vscode.commands.registerCommand('fishbone.addNewFile', async () => { @@ -67,6 +95,14 @@ export function activate(context: vscode.ExtensionContext) { void showWelcomeOrWhatsNew(context, extensionVersion, prevVersion) void context.globalState.update(GlobalState.Version, extensionVersion) + + // --- AGENTIC: Example of publishing a QUERY event (for testing) --- + setTimeout(() => { + globalEventBus.publish({ + type: EventType.QUERY, + payload: { query: '{"test":1}' } + }, log); + }, 2000); } // this method is called when your extension is deactivated diff --git a/src/extension/fbAIProvider.ts b/src/extension/fbAIProvider.ts index f975e50..84534fc 100644 --- a/src/extension/fbAIProvider.ts +++ b/src/extension/fbAIProvider.ts @@ -73,6 +73,9 @@ export interface IOwnTools { } export class FBAIProvider implements vscode.Disposable { + public getLogChannel(): vscode.LogOutputChannel { + return this.log; + } ownToolInfos: IOwnTools[] = [] constructor( diff --git a/src/extension/fbAiTools.ts b/src/extension/fbAiTools.ts index c5714bd..86ca6b2 100644 --- a/src/extension/fbAiTools.ts +++ b/src/extension/fbAiTools.ts @@ -3,6 +3,8 @@ import * as JSON5 from 'json5' import { FBAIProvider, SequencesResult } from './fbAIProvider' import { DocData } from './fbaEditor' import { DltFilter, escapeForMD, FbSequenceResult, lastEventForOccurrence, seqResultToMdAst } from 'dlt-logs-utils/sequence' +import { globalEventBus } from '../agents/core/EventBus'; +import { EventType } from '../agents/core/types'; import { toMarkdown } from 'mdast-util-to-markdown' import { gfmTableToMarkdown } from 'mdast-util-gfm-table' import { RQ, rqUriDecode, rqUriEncode } from 'dlt-logs-utils/restQuery' @@ -329,25 +331,56 @@ export class QueryLogsTool implements vscode.LanguageModelTool, _token: vscode.CancellationToken) { - const params = options.input - console.log('FBAIProvider QLT invoked with params:', params) + async invoke(options: vscode.LanguageModelToolInvocationOptions, _token: vscode.CancellationToken): Promise { + const params = options.input; + const log = this.provider.getLogChannel(); + console.log('FBAIProvider QLT invoked with params:', params); - if (Array.isArray(params.filters) && params.filters.length > 0) { - try { - const filters = params.filters.map((frag) => new DltFilter(frag)) - const res = await this.processFilters(filters) - console.log(`FBAIProvider QLT got res:${JSON.stringify(res, undefined, 2)}`) - if (typeof res === 'string') { - return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(res)]) - } - return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(`No logs are matching the ${params.filters} filters.`)]) - } catch (e) { - return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(`Invalid parameter. Parsing filters got error:${e}`)]) - } - } else { - return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart('Invalid parameter. filters not an array')]) + if (!Array.isArray(params.filters) || params.filters.length === 0) { + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart('Invalid parameter. filters not an array') + ]); } + + // --- AGENTIC: Publish QUERY event on the shared EventBus --- + // We'll listen for the next QUERY_RESULT event and return its result + return await new Promise(async (resolve) => { + let resolved = false; + const handler = (event: any) => { + if (resolved) { return; } + resolved = true; + unsubscribe(); + if (event.payload && event.payload.result) { + resolve(new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart( + `Query returned ${Array.isArray(event.payload.result) ? event.payload.result.length : 0} logs:\n\njson\n${JSON.stringify(event.payload.result, null, 2)}\n`) + ])); + } else if (event.payload && event.payload.error) { + resolve(new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(`Query failed: ${event.payload.error}`) + ])); + } else { + resolve(new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart('Query completed but no result or error was returned.') + ])); + } + }; + const unsubscribe = globalEventBus.subscribe(EventType.QUERY_RESULT, handler); + await globalEventBus.publish({ + type: EventType.QUERY, + payload: { query: JSON.stringify(params.filters) } + }, log); + // Timeout in case no result is received + setTimeout(() => { + if (!resolved) { + resolved = true; + unsubscribe(); + resolve(new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart('Query timed out waiting for results.') + ])); + } + }, 5000); + }); } async prepareInvocation( diff --git a/src/extension/fbaEditor.ts b/src/extension/fbaEditor.ts index aa676d6..a5bc897 100644 --- a/src/extension/fbaEditor.ts +++ b/src/extension/fbaEditor.ts @@ -67,7 +67,7 @@ export interface FishboneTreeItem extends vscode.TreeItem { * */ export class FBAEditorProvider implements vscode.CustomTextEditorProvider, vscode.Disposable { - public static register(log: vscode.LogOutputChannel, context: vscode.ExtensionContext, reporter?: TelemetryReporter): void { + public static register(log: vscode.LogOutputChannel, context: vscode.ExtensionContext, reporter?: TelemetryReporter): FBAEditorProvider { const provider = new FBAEditorProvider(log, context, reporter) context.subscriptions.push( vscode.window.registerCustomEditorProvider(FBAEditorProvider.viewType, provider, { @@ -78,6 +78,7 @@ export class FBAEditorProvider implements vscode.CustomTextEditorProvider, vscod context.subscriptions.push(new FBANotebookProvider(log, context, provider, provider._fsProvider)) // does not work in CustomTextEditor (only in text view) context.subscriptions.push(vscode.languages.registerDocumentDropEditProvider({ pattern: '**/*.fba' }, provider)); context.subscriptions.push(new FBAIProvider(log, context, provider, reporter)) + return provider; } private static readonly viewType = 'fishbone.fba' // has to match the package.json @@ -815,9 +816,9 @@ export class FBAEditorProvider implements vscode.CustomTextEditorProvider, vscod const initialDataStr = JSON.stringify(initialData) return /* html */ ` - - - + + + @@ -827,12 +828,12 @@ export class FBAEditorProvider implements vscode.CustomTextEditorProvider, vscod script-src ${webview.cspSource} 'unsafe-eval' 'unsafe-inline'; style-src ${webview.cspSource} 'unsafe-inline';"> - + - + Fishbone Analysis - +
- - ` + + ` } /** diff --git a/src/test/suite/ErrorHandler.test.ts b/src/test/suite/ErrorHandler.test.ts new file mode 100644 index 0000000..e5909c3 --- /dev/null +++ b/src/test/suite/ErrorHandler.test.ts @@ -0,0 +1,111 @@ + +import * as assert from 'assert'; +import { ErrorHandler, ErrorSeverity, AgentError } from '../../agents/core/ErrorHandler'; +import { globalEventBus } from '../../agents/core/EventBus'; +import { EventType } from '../../agents/core/types'; +import * as vscode from 'vscode'; + +function createMockLog() { + const calls: { level: string, args: any[] }[] = []; + const log: vscode.LogOutputChannel = { + name: 'mock', + logLevel: 0, + onDidChangeLogLevel: () => ({ dispose: () => {} }), + info: (...args: any[]) => { calls.push({ level: 'info', args }); }, + debug: (...args: any[]) => { calls.push({ level: 'debug', args }); }, + error: (...args: any[]) => { calls.push({ level: 'error', args }); }, + warn: (...args: any[]) => { calls.push({ level: 'warn', args }); }, + trace: (..._args: any[]) => {}, + append: () => {}, + appendLine: () => {}, + replace: () => {}, + clear: () => {}, + show: () => {}, + hide: () => {}, + dispose: () => {}, + }; + return { log, calls }; +} + +suite('ErrorHandler', () => { + test('should emit ERROR and RETRY events for WARNING severity', async () => { + /** + * 1. Precondition: ErrorHandler is instantiated, globalEventBus is available, and mock log is set up. + * 2. Test steps: Subscribe to ERROR and RETRY events, call handler.handle() with a warning message and agentId. + * 3. Expected response: ERROR and RETRY events are emitted, errorEvent.severity is WARNING, retryEvent.agentId matches, and log contains RETRY event emission. + * 4. Postcondition: No side effects, globalEventBus subscriptions are not cleaned up (test isolation is not enforced here). + */ + const handler = new ErrorHandler(); + let errorEvent: any = null; + let retryEvent: any = null; + globalEventBus.subscribe(EventType.ERROR, (event) => { + if ('error' in event.payload) { errorEvent = event.payload.error; } + }); + globalEventBus.subscribe('RETRY' as EventType, (event) => { + if ('error' in event.payload) { retryEvent = event.payload.error; } + }); + const { log, calls } = createMockLog(); + handler.handle({ message: 'warn: something went wrong' }, 'agent1', log); + await new Promise(res => setTimeout(res, 10)); + assert.ok(errorEvent, 'ERROR event should be emitted'); + assert.strictEqual(errorEvent.severity, ErrorSeverity.WARNING); + assert.ok(retryEvent, 'RETRY event should be emitted'); + assert.strictEqual(retryEvent.agentId, 'agent1'); + assert.ok(calls.some(c => c.level === 'info' && c.args[0].includes('RETRY event emitted'))); + }); + + test('should emit ERROR and ESCALATE events for CRITICAL severity', async () => { + /** + * 1. Precondition: ErrorHandler is instantiated, globalEventBus is available, and mock log is set up. + * 2. Test steps: Subscribe to ERROR and ESCALATE events, call handler.handle() with a critical message and agentId. + * 3. Expected response: ERROR and ESCALATE events are emitted, errorEvent.severity is CRITICAL, escalateEvent.agentId matches, and log contains ESCALATE event emission. + * 4. Postcondition: No side effects, globalEventBus subscriptions are not cleaned up (test isolation is not enforced here). + */ + const handler = new ErrorHandler(); + let errorEvent: any = null; + let escalateEvent: any = null; + globalEventBus.subscribe(EventType.ERROR, (event) => { + if ('error' in event.payload) { errorEvent = event.payload.error; } + }); + globalEventBus.subscribe('ESCALATE' as EventType, (event) => { + if ('error' in event.payload) { escalateEvent = event.payload.error; } + }); + const { log, calls } = createMockLog(); + handler.handle({ message: 'critical: unrecoverable failure' }, 'agent2', log); + await new Promise(res => setTimeout(res, 10)); + assert.ok(errorEvent, 'ERROR event should be emitted'); + assert.strictEqual(errorEvent.severity, ErrorSeverity.CRITICAL); + assert.ok(escalateEvent, 'ESCALATE event should be emitted'); + assert.strictEqual(escalateEvent.agentId, 'agent2'); + assert.ok(calls.some(c => c.level === 'error' && c.args[0].includes('ESCALATE event emitted'))); + }); + + test('should emit only ERROR event for INFO severity', async () => { + /** + * 1. Precondition: ErrorHandler is instantiated, globalEventBus is available, and mock log is set up. + * 2. Test steps: Subscribe to ERROR, RETRY, and ESCALATE events, call handler.handle() with an info message and agentId. + * 3. Expected response: Only ERROR event is emitted, errorEvent.severity is INFO, retryEvent and escalateEvent remain null. + * 4. Postcondition: No side effects, globalEventBus subscriptions are not cleaned up (test isolation is not enforced here). + */ + const handler = new ErrorHandler(); + let errorEvent: any = null; + let retryEvent: any = null; + let escalateEvent: any = null; + globalEventBus.subscribe(EventType.ERROR, (event) => { + if ('error' in event.payload) { errorEvent = event.payload.error; } + }); + globalEventBus.subscribe('RETRY' as EventType, (event) => { + if ('error' in event.payload) { retryEvent = event.payload.error; } + }); + globalEventBus.subscribe('ESCALATE' as EventType, (event) => { + if ('error' in event.payload) { escalateEvent = event.payload.error; } + }); + const { log } = createMockLog(); + handler.handle({ message: 'info: just FYI' }, 'agent3', log); + await new Promise(res => setTimeout(res, 10)); + assert.ok(errorEvent, 'ERROR event should be emitted'); + assert.strictEqual(errorEvent.severity, ErrorSeverity.INFO); + assert.strictEqual(retryEvent, null); + assert.strictEqual(escalateEvent, null); + }); +}); diff --git a/src/test/suite/FishboneUpdater.test.ts b/src/test/suite/FishboneUpdater.test.ts new file mode 100644 index 0000000..6c710af --- /dev/null +++ b/src/test/suite/FishboneUpdater.test.ts @@ -0,0 +1,157 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { FishboneUpdater } from '../../agents/tasks/FishboneUpdater'; +import { EventBus } from '../../agents/core/EventBus'; +import { KnowledgeGraph, KnowledgeEntry } from '../../agents/core/KnowledgeGraph'; +import { EventType, KnowledgeType } from '../../agents/core/types'; +import { promises as fs } from 'fs'; +import * as path from 'path'; + +suite('FishboneUpdater', () => { + let eventBus: EventBus; + let kg: KnowledgeGraph; + let log: vscode.LogOutputChannel; + let events: any[]; + let tmpFile: string; + + setup(async () => { + eventBus = new EventBus(); + kg = new KnowledgeGraph(); + events = []; + log = { + info: (...args: any[]) => {}, + error: (...args: any[]) => {}, + debug: (...args: any[]) => {}, + warn: (...args: any[]) => {}, + trace: (...args: any[]) => {}, + name: 'mock', + logLevel: 0, + onDidChangeLogLevel: () => ({ dispose: () => {} }), + append: () => {}, + appendLine: () => {}, + replace: () => {}, + clear: () => {}, + show: () => {}, + hide: () => {}, + dispose: () => {}, + }; + eventBus.subscribe(EventType.FISHBONE_UPDATED, (e) => { events.push(e); }); + eventBus.subscribe(EventType.ERROR, (e) => { events.push(e); }); + // Create a temp file URI for fishbone output + tmpFile = path.join(__dirname, `fishbone_test_${Date.now()}.fba`); + // Clean up if exists + try { await fs.unlink(tmpFile); } catch {} + }); + + teardown(async () => { + try { await fs.unlink(tmpFile); } catch {} + }); + + test('should update fishbone file with all knowledge entries', async () => { + /** + * 1. Precondition: KnowledgeGraph has two entries. + * 2. Test steps: Trigger update, read file, check contents. + * 3. Expected: File contains both entries as rootCauses. + * 4. Postcondition: FISHBONE_UPDATED event emitted. + */ + kg.set({ id: 'a', type: KnowledgeType.FACT, label: 'A', data: 1, createdAt: Date.now() }); + kg.set({ id: 'b', type: KnowledgeType.FACT, label: 'B', data: 2, createdAt: Date.now() }); + new FishboneUpdater(eventBus, kg, log); + await eventBus.publish({ type: EventType.UPDATE_FISHBONE_FROM_KNOWLEDGE, payload: { fishboneUri: vscode.Uri.file(tmpFile).toString() } }, log); + const content = (await fs.readFile(tmpFile)).toString(); + assert.ok(content.includes('A')); + assert.ok(content.includes('B')); + assert.ok(events.some(e => e.type === EventType.FISHBONE_UPDATED)); + }); + + test('should apply filter to only include matching entries', async () => { + /** + * 1. Precondition: KnowledgeGraph has two entries. + * 2. Test steps: Trigger update with filter for label === 'A'. + * 3. Expected: File contains only entry A. + * 4. Postcondition: FISHBONE_UPDATED event emitted. + */ + kg.set({ id: 'a', type: KnowledgeType.FACT, label: 'A', data: 1, createdAt: Date.now() }); + kg.set({ id: 'b', type: KnowledgeType.FACT, label: 'B', data: 2, createdAt: Date.now() }); + new FishboneUpdater(eventBus, kg, log); + const filter = (e: KnowledgeEntry) => e.label === 'A'; + await eventBus.publish({ type: EventType.UPDATE_FISHBONE_FROM_KNOWLEDGE, payload: { fishboneUri: vscode.Uri.file(tmpFile).toString(), filter } }, log); + const content = (await fs.readFile(tmpFile)).toString(); + assert.ok(content.includes('A')); + assert.ok(!content.includes('B')); + assert.ok(events.some(e => e.type === EventType.FISHBONE_UPDATED)); + }); + + test('should handle empty knowledge graph', async () => { + /** + * 1. Precondition: KnowledgeGraph is empty. + * 2. Test steps: Trigger update. + * 3. Expected: File contains no rootCauses. + * 4. Postcondition: FISHBONE_UPDATED event emitted. + */ + new FishboneUpdater(eventBus, kg, log); + await eventBus.publish({ type: EventType.UPDATE_FISHBONE_FROM_KNOWLEDGE, payload: { fishboneUri: vscode.Uri.file(tmpFile).toString() } }, log); + const content = (await fs.readFile(tmpFile)).toString(); + assert.ok(content.includes('rootCauses: []')); + assert.ok(events.some(e => e.type === EventType.FISHBONE_UPDATED)); + }); + + test('should handle entries with missing optional fields', async () => { + /** + * 1. Precondition: KnowledgeGraph has entry with only required fields. + * 2. Test steps: Trigger update. + * 3. Expected: File contains entry id as label/title. + * 4. Postcondition: FISHBONE_UPDATED event emitted. + */ + kg.set({ id: 'x', type: KnowledgeType.FACT, createdAt: Date.now() }); + new FishboneUpdater(eventBus, kg, log); + await eventBus.publish({ type: EventType.UPDATE_FISHBONE_FROM_KNOWLEDGE, payload: { fishboneUri: vscode.Uri.file(tmpFile).toString() } }, log); + const content = (await fs.readFile(tmpFile)).toString(); + assert.ok(content.includes('x')); + assert.ok(events.some(e => e.type === EventType.FISHBONE_UPDATED)); + }); + + test('should emit ERROR event for invalid/empty fishboneUri', async () => { + /** + * 1. Precondition: KnowledgeGraph has one entry. + * 2. Test steps: Trigger update with invalid fishboneUri. + * 3. Expected: ERROR event emitted, no file written. + * 4. Postcondition: No FISHBONE_UPDATED event. + */ + kg.set({ id: 'a', type: KnowledgeType.FACT, label: 'A', data: 1, createdAt: Date.now() }); + new FishboneUpdater(eventBus, kg, log); + await eventBus.publish({ type: EventType.UPDATE_FISHBONE_FROM_KNOWLEDGE, payload: { fishboneUri: '' } }, log); + assert.ok(events.some(e => e.type === EventType.ERROR)); + assert.ok(!events.some(e => e.type === EventType.FISHBONE_UPDATED)); + }); + + test('should emit ERROR event on file write error', async () => { + /** + * 1. Precondition: KnowledgeGraph has one entry. + * 2. Test steps: Trigger update with fishboneUri to invalid path. + * 3. Expected: ERROR event emitted. + * 4. Postcondition: No FISHBONE_UPDATED event. + */ + kg.set({ id: 'a', type: KnowledgeType.FACT, label: 'A', data: 1, createdAt: Date.now() }); + new FishboneUpdater(eventBus, kg, log); + // Use an invalid path (should fail on most systems) + await eventBus.publish({ type: EventType.UPDATE_FISHBONE_FROM_KNOWLEDGE, payload: { fishboneUri: 'file:///invalid_path/does_not_exist.fba' } }, log); + assert.ok(events.some(e => e.type === EventType.ERROR)); + assert.ok(!events.some(e => e.type === EventType.FISHBONE_UPDATED)); + }); + + test('should emit FISHBONE_UPDATED event on success', async () => { + /** + * 1. Precondition: KnowledgeGraph has one entry. + * 2. Test steps: Trigger update. + * 3. Expected: FISHBONE_UPDATED event emitted. + * 4. Postcondition: File written. + */ + kg.set({ id: 'a', type: KnowledgeType.FACT, label: 'A', data: 1, createdAt: Date.now() }); + new FishboneUpdater(eventBus, kg, log); + await eventBus.publish({ type: EventType.UPDATE_FISHBONE_FROM_KNOWLEDGE, payload: { fishboneUri: vscode.Uri.file(tmpFile).toString() } }, log); + assert.ok(events.some(e => e.type === EventType.FISHBONE_UPDATED)); + const content = (await fs.readFile(tmpFile)).toString(); + assert.ok(content.includes('A')); + }); +}); diff --git a/src/test/suite/KnowledgeGraph.test.ts b/src/test/suite/KnowledgeGraph.test.ts new file mode 100644 index 0000000..0686708 --- /dev/null +++ b/src/test/suite/KnowledgeGraph.test.ts @@ -0,0 +1,342 @@ +import { KnowledgeGraph, KnowledgeEntry } from '../../agents/core/KnowledgeGraph'; +import { KnowledgeType } from '../../agents/core/types'; +import * as assert from 'assert'; + +suite('KnowledgeGraph (boundary and edge cases)', () => { + let kg: KnowledgeGraph; + setup(() => { + kg = new KnowledgeGraph(); + }); + + test('should update entry on duplicate id', () => { + /** + * 1. Precondition: Graph is empty. + * 2. Test steps: Add entry with id 'x', then add another with same id but different data. + * 3. Expected response: Second entry overwrites first. + * 4. Postcondition: Only one entry with id 'x', data matches second. + */ + kg.set({ id: 'x', type: KnowledgeType.FACT, data: { a: 1 }, createdAt: 1 }); + kg.set({ id: 'x', type: KnowledgeType.FACT, data: { a: 2 }, createdAt: 2 }); + const got = kg.get('x'); + assert.ok(got); + assert.deepStrictEqual(got.data, { a: 2 }); + assert.strictEqual(got.createdAt, 2); // Should update createdAt if provided + }); + + test('should allow duplicate edges (same from, to, type)', () => { + /** + * 1. Precondition: Graph has two nodes. + * 2. Test steps: Add two identical edges. + * 3. Expected response: Both edges exist. + * 4. Postcondition: Edge array has length 2. + */ + kg.set({ id: 'a', type: KnowledgeType.FACT, createdAt: 1 }); + kg.set({ id: 'b', type: KnowledgeType.FACT, createdAt: 1 }); + kg.addEdge({ from: 'a', to: 'b', type: 'rel' }); + kg.addEdge({ from: 'a', to: 'b', type: 'rel' }); + const edges = kg.queryEdgesByType('rel'); + assert.strictEqual(edges.length, 2); + }); + + test('should return undefined/empty for non-existent nodes/edges', () => { + /** + * 1. Precondition: Graph is empty. + * 2. Test steps: Query for non-existent node and edge. + * 3. Expected response: get returns undefined, queries return empty arrays. + * 4. Postcondition: No error. + */ + assert.strictEqual(kg.get('nope'), undefined); + assert.deepStrictEqual(kg.queryEdgesByType('none'), []); + assert.deepStrictEqual(kg.queryByTag('none'), []); + }); + + test('should not infinite loop on cycles in traversal', () => { + /** + * 1. Precondition: Graph has a cycle a->b->c->a. + * 2. Test steps: Traverse from 'a'. + * 3. Expected response: Each node visited once, no infinite loop. + * 4. Postcondition: Traversal returns all nodes in cycle. + */ + kg.set({ id: 'a', type: KnowledgeType.FACT, createdAt: 1 }); + kg.set({ id: 'b', type: KnowledgeType.FACT, createdAt: 1 }); + kg.set({ id: 'c', type: KnowledgeType.FACT, createdAt: 1 }); + kg.addEdge({ from: 'a', to: 'b', type: 'rel' }); + kg.addEdge({ from: 'b', to: 'c', type: 'rel' }); + kg.addEdge({ from: 'c', to: 'a', type: 'rel' }); + const traversed = kg.traverseFrom('a', 'rel'); + const ids = traversed.map(e => e.id); + assert.ok(ids.includes('a') && ids.includes('b') && ids.includes('c')); + assert.strictEqual(ids.length, 3); + }); + + test('should respect maxDepth in traversal (including 0 and negative)', () => { + /** + * 1. Precondition: Graph has a->b->c. + * 2. Test steps: Traverse from 'a' with maxDepth 0, 1, -1. + * 3. Expected response: maxDepth=0 returns only 'a', maxDepth=1 returns 'a' and 'b', negative treated as 0. + * 4. Postcondition: No error. + */ + kg.set({ id: 'a', type: KnowledgeType.FACT, createdAt: 1 }); + kg.set({ id: 'b', type: KnowledgeType.FACT, createdAt: 1 }); + kg.set({ id: 'c', type: KnowledgeType.FACT, createdAt: 1 }); + kg.addEdge({ from: 'a', to: 'b', type: 'rel' }); + kg.addEdge({ from: 'b', to: 'c', type: 'rel' }); + let ids = kg.traverseFrom('a', 'rel', 0).map(e => e.id); + assert.deepStrictEqual(ids, ['a']); + ids = kg.traverseFrom('a', 'rel', 1).map(e => e.id); + assert.ok(ids.includes('a') && ids.includes('b')); + ids = kg.traverseFrom('a', 'rel', -1).map(e => e.id); + assert.deepStrictEqual(ids, ['a']); + }); + + test('should handle missing/undefined/empty tags, labels, provenance', () => { + /** + * 1. Precondition: Graph has entries/edges with and without optional fields. + * 2. Test steps: Query by tag, label, provenance for missing/undefined/empty. + * 3. Expected response: No error, empty arrays for missing. + * 4. Postcondition: No error. + */ + kg.set({ id: 'a', type: KnowledgeType.FACT }); + kg.set({ id: 'b', type: KnowledgeType.FACT, tags: [], label: '', provenance: '' }); + assert.deepStrictEqual(kg.queryByTag('foo'), []); + assert.deepStrictEqual(kg.queryByLabel('bar'), []); + assert.deepStrictEqual(kg.queryByProvenance('baz'), []); + }); + + test('should handle querying by time with missing/invalid timestamps', () => { + /** + * 1. Precondition: Graph has entries with and without createdAt. + * 2. Test steps: Query by time range. + * 3. Expected response: Only entries with valid createdAt in range are returned. + * 4. Postcondition: No error. + */ + kg.set({ id: 'a', type: KnowledgeType.FACT, createdAt: 100 }); + kg.set({ id: 'b', type: KnowledgeType.FACT }); + kg.set({ id: 'c', type: KnowledgeType.FACT, createdAt: 200 }); + const res = kg.queryByTime(50, 150).map(e => e.id); + assert.deepStrictEqual(res, ['a']); + }); + + test('should handle querying by confidence with missing/invalid values', () => { + /** + * 1. Precondition: Graph has entries with and without confidence. + * 2. Test steps: Query by confidence range. + * 3. Expected response: Only entries with valid confidence in range are returned. + * 4. Postcondition: No error. + */ + kg.set({ id: 'a', type: KnowledgeType.FACT, confidence: 0.5 }); + kg.set({ id: 'b', type: KnowledgeType.FACT }); + kg.set({ id: 'c', type: KnowledgeType.FACT, confidence: 0.9 }); + const res = kg.queryByConfidence(0.6, 1).map(e => e.id); + assert.deepStrictEqual(res, ['c']); + }); + + test('should extract subgraph from node with no outgoing edges', () => { + /** + * 1. Precondition: Graph has isolated node 'z'. + * 2. Test steps: Get subgraph from 'z'. + * 3. Expected response: Subgraph contains only 'z', no edges. + * 4. Postcondition: No error. + */ + kg.set({ id: 'z', type: KnowledgeType.FACT }); + const sub = kg.getSubgraphFrom('z'); + assert.deepStrictEqual(sub.entries.map(e => e.id), ['z']); + assert.deepStrictEqual(sub.edges, []); + }); + + test('should find connected components with isolated nodes', () => { + /** + * 1. Precondition: Graph has three isolated nodes. + * 2. Test steps: Get connected components. + * 3. Expected response: Each node is its own component. + * 4. Postcondition: No error. + */ + kg.set({ id: 'a', type: KnowledgeType.FACT }); + kg.set({ id: 'b', type: KnowledgeType.FACT }); + kg.set({ id: 'c', type: KnowledgeType.FACT }); + const comps = kg.getConnectedComponents(); + assert.strictEqual(comps.length, 3); + assert.ok(comps.some(arr => arr.includes('a'))); + assert.ok(comps.some(arr => arr.includes('b'))); + assert.ok(comps.some(arr => arr.includes('c'))); + }); + + test('should allow edges with missing optional fields', () => { + /** + * 1. Precondition: Graph has two nodes. + * 2. Test steps: Add edge with only required fields. + * 3. Expected response: Edge is added, can be queried. + * 4. Postcondition: No error. + */ + kg.set({ id: 'a', type: KnowledgeType.FACT }); + kg.set({ id: 'b', type: KnowledgeType.FACT }); + kg.addEdge({ from: 'a', to: 'b', type: 'rel' }); + const edges = kg.queryEdgesByType('rel'); + assert.strictEqual(edges.length, 1); + assert.strictEqual(edges[0].from, 'a'); + assert.strictEqual(edges[0].to, 'b'); + assert.strictEqual(edges[0].type, 'rel'); + }); + + test('should handle empty graph (no nodes, no edges)', () => { + /** + * 1. Precondition: Graph is empty. + * 2. Test steps: Query all APIs. + * 3. Expected response: All return empty arrays or undefined. + * 4. Postcondition: No error. + */ + assert.strictEqual(kg.get('x'), undefined); + assert.deepStrictEqual(kg.queryByType(KnowledgeType.FACT), []); + assert.deepStrictEqual(kg.queryEdgesByType('rel'), []); + assert.deepStrictEqual(kg.getConnectedComponents(), []); + }); + + test('KnowledgeGraph.triggerFishboneUpdate produces valid fishbone file and round-trip data', async () => { + // Integration: KnowledgeGraph <-> FishboneUpdater + const { FishboneUpdater } = require('../../agents/tasks/FishboneUpdater'); + const { EventBus } = require('../../agents/core/EventBus'); + const { EventType, KnowledgeType } = require('../../agents/core/types'); + const fs = require('fs').promises; + const path = require('path'); + let eventBus = new EventBus(); + let events: any[] = []; + let log = { + info: (...args: any[]) => {}, + error: (...args: any[]) => {}, + debug: (...args: any[]) => {}, + warn: (...args: any[]) => {}, + trace: (...args: any[]) => {}, + name: 'mock', + logLevel: 0, + onDidChangeLogLevel: () => ({ dispose: () => {} }), + append: () => {}, + appendLine: () => {}, + replace: () => {}, + clear: () => {}, + show: () => {}, + hide: () => {}, + dispose: () => {}, + }; + eventBus.subscribe(EventType.FISHBONE_UPDATED, (e: any) => { events.push(e); }); + eventBus.subscribe(EventType.ERROR, (e: any) => { events.push(e); }); + const tmpFile = path.join(__dirname, `kg_fishbone_test_${Date.now()}.fba`); + try { await fs.unlink(tmpFile); } catch {} + // 1. Add entries to KnowledgeGraph + const entries: KnowledgeEntry[] = [ + { id: 'a', type: KnowledgeType.FACT, label: 'A', data: 1, provenance: 'test', createdAt: Date.now() }, + { id: 'b', type: KnowledgeType.FACT, label: 'B', data: 2, provenance: 'test', createdAt: Date.now() }, + ]; + for (const e of entries) kg.set(e); + // 2. Start FishboneUpdater + new FishboneUpdater(eventBus, kg, log); + // 3. Trigger update from KnowledgeGraph + const result = kg.triggerFishboneUpdate(require('vscode').Uri.file(tmpFile).toString(), eventBus, undefined, log); + assert.ok(result.success, 'triggerFishboneUpdate should succeed'); + // 4. Wait for FISHBONE_UPDATED event + let tries = 0; + while (!events.some(e => e.type === EventType.FISHBONE_UPDATED) && tries < 10) { + await new Promise(res => setTimeout(res, 100)); + tries++; + } + assert.ok(events.some(e => e.type === EventType.FISHBONE_UPDATED), 'FISHBONE_UPDATED event should be emitted'); + // 5. Read and parse the fishbone file + const content = (await fs.readFile(tmpFile)).toString(); + assert.ok(content.includes('A') && content.includes('B'), 'Fishbone file should contain all root causes'); + // 6. Check round-trip: parse YAML and verify root causes + // (Assume fbaFormat has a parse function, or check YAML structure) + // For now, just check that both ids are present + assert.ok(content.includes('a') && content.includes('b'), 'Fishbone file should contain all entry ids'); + try { await fs.unlink(tmpFile); } catch {} + }); +}); + + +suite('KnowledgeGraph (enriched)', () => { + let kg: KnowledgeGraph; + setup(() => { + kg = new KnowledgeGraph(); + }); + + test('should add and retrieve enriched entries', () => { + /** + * 1. Precondition: KnowledgeGraph is instantiated and empty. + * 2. Test steps: Add an enriched entry, retrieve it by id. + * 3. Expected response: Entry is retrieved with all enriched fields matching input. + * 4. Postcondition: No side effects, graph remains consistent. + */ + const entry: KnowledgeEntry = { + id: 'n1', + type: KnowledgeType.FACT, + label: 'Node 1', + tags: ['foo', 'bar'], + provenance: 'agentA', + confidence: 0.9, + version: 1, + data: { value: 42 }, + createdAt: Date.now(), + }; + kg.set(entry); + const got = kg.get('n1'); + assert.ok(got); + assert.strictEqual(got.id, 'n1'); + assert.strictEqual(got.label, 'Node 1'); + assert.strictEqual(got.provenance, 'agentA'); + assert.strictEqual(got.confidence, 0.9); + assert.strictEqual(got.version, 1); + assert.ok(Array.isArray(got.tags) && got.tags.includes('foo')); + assert.deepStrictEqual(got.data, { value: 42 }); + }); + + test('should add and query enriched edges', () => { + /** + * 1. Precondition: KnowledgeGraph is instantiated with two nodes. + * 2. Test steps: Add an enriched edge, query by type. + * 3. Expected response: Edge is found with all enriched fields matching input. + * 4. Postcondition: No side effects, graph remains consistent. + */ + kg.set({ id: 'n1', type: KnowledgeType.FACT, createdAt: Date.now() }); + kg.set({ id: 'n2', type: KnowledgeType.FACT, createdAt: Date.now() }); + kg.addEdge({ from: 'n1', to: 'n2', type: 'rel', label: 'link', tags: ['t'], provenance: 'agentB', confidence: 0.8 }); + const edges = kg.queryEdgesByType('rel'); + assert.ok(edges.length > 0); + const edge = edges[0]; + assert.strictEqual(edge.from, 'n1'); + assert.strictEqual(edge.to, 'n2'); + assert.strictEqual(edge.type, 'rel'); + assert.strictEqual(edge.label, 'link'); + assert.strictEqual(edge.provenance, 'agentB'); + assert.strictEqual(edge.confidence, 0.8); + assert.ok(Array.isArray(edge.tags) && edge.tags.includes('t')); + }); + + test('should support advanced queries and traversal', () => { + /** + * 1. Precondition: KnowledgeGraph is instantiated with three nodes and three edges. + * 2. Test steps: Query by tag, find related by edge type, traverse, get subgraph, get connected components. + * 3. Expected response: All queries return correct results as per graph structure. + * 4. Postcondition: No side effects, graph remains consistent. + */ + kg.set({ id: 'a', type: KnowledgeType.FACT, tags: ['x'], createdAt: Date.now() }); + kg.set({ id: 'b', type: KnowledgeType.FACT, tags: ['y'], createdAt: Date.now() }); + kg.set({ id: 'c', type: KnowledgeType.FACT, tags: ['x', 'y'], createdAt: Date.now() }); + kg.addEdge({ from: 'a', to: 'b', type: 'rel' }); + kg.addEdge({ from: 'b', to: 'c', type: 'rel' }); + kg.addEdge({ from: 'a', to: 'c', type: 'rel2' }); + // Query by tag + const tagX = kg.queryByTag('x').map(e => e.id); + assert.ok(tagX.includes('a') && tagX.includes('c')); + // Find related by edge type + const related = kg.findRelatedByEdgeType('a', 'rel').map(e => e.id); + assert.ok(related.includes('b')); + // Traverse + const traversed = kg.traverseFrom('a', 'rel').map(e => e.id); + assert.ok(traversed.includes('a') && traversed.includes('b') && traversed.includes('c')); + // Subgraph + const sub = kg.getSubgraphFrom('a', 'rel'); + const subIds = sub.entries.map(e => e.id); + assert.ok(subIds.includes('a') && subIds.includes('b') && subIds.includes('c')); + // Connected components + const comps = kg.getConnectedComponents(); + assert.ok(comps.some(arr => arr.includes('a') && arr.includes('b') && arr.includes('c'))); + }); +}); \ No newline at end of file diff --git a/src/test/suite/Logger.test.ts b/src/test/suite/Logger.test.ts new file mode 100644 index 0000000..3074dd4 --- /dev/null +++ b/src/test/suite/Logger.test.ts @@ -0,0 +1,166 @@ +import * as assert from 'assert'; +import { Logger, LogLevel } from '../../agents/core/Logger'; +import { globalEventBus } from '../../agents/core/EventBus'; +import { EventType } from '../../agents/core/types'; +import * as vscode from 'vscode'; + + +function createMockLog() { + const calls: { level: string, args: any[] }[] = []; + const log: vscode.LogOutputChannel = { + name: 'mock', + logLevel: 0, + onDidChangeLogLevel: () => ({ dispose: () => {} }), + info: (...args: any[]) => { calls.push({ level: 'info', args }); }, + debug: (...args: any[]) => { calls.push({ level: 'debug', args }); }, + error: (...args: any[]) => { calls.push({ level: 'error', args }); }, + warn: (...args: any[]) => { calls.push({ level: 'warn', args }); }, + trace: (..._args: any[]) => {}, + append: () => {}, + appendLine: () => {}, + replace: () => {}, + clear: () => {}, + show: () => {}, + hide: () => {}, + dispose: () => {}, + }; + return { log, calls }; +} + +suite('Logger', () => { + test('should emit UPDATE event and log at correct level', async () => { + /** + * 1. Precondition: Logger is instantiated, globalEventBus and mock log are set up. + * 2. Test steps: Subscribe to UPDATE events, call logger.log() with a message and level. + * 3. Expected response: UPDATE event is emitted with correct payload, log method is called at correct level. + * 4. Postcondition: No side effects, globalEventBus subscriptions are not cleaned up. + */ + const logger = new Logger(); + let updateEvent: any = null; + globalEventBus.subscribe(EventType.UPDATE, (event) => { + // Type guard: only access 'update' for UPDATE events + if ('update' in event.payload) { updateEvent = event.payload.update; } + }); + const { log, calls } = createMockLog(); + const msg = 'Test log message'; + const meta = { foo: 'bar' }; + logger.log(msg, LogLevel.WARN, log, meta); + // Wait a tick to ensure async event is processed + await new Promise(res => setTimeout(res, 10)); + assert.ok(updateEvent, 'UPDATE event should be emitted'); + assert.strictEqual(updateEvent.log, msg); + assert.strictEqual(updateEvent.level, LogLevel.WARN); + assert.deepStrictEqual(updateEvent.meta, meta); + assert.ok(calls.some(c => c.level === 'warn' && c.args[1] === msg), 'log.warn should be called with message'); + }); + + test('should log at all levels and emit UPDATE event', async () => { + /** + * 1. Precondition: Logger is instantiated, globalEventBus and mock log are set up. + * 2. Test steps: For each log level, subscribe to UPDATE events, call logger.log() with a message and level. + * 3. Expected response: UPDATE event is emitted with correct payload, log method is called at correct level for each level. + * 4. Postcondition: No side effects, globalEventBus subscriptions are not cleaned up. + */ + const logger = new Logger(); + const levels = [LogLevel.INFO, LogLevel.DEBUG, LogLevel.WARN, LogLevel.ERROR]; + for (const level of levels) { + let updateEvent: any = null; + globalEventBus.subscribe(EventType.UPDATE, (event) => { + if ('update' in event.payload) { updateEvent = event.payload.update; } + }); + const { log, calls } = createMockLog(); + const msg = `Level ${level}`; + logger.log(msg, level, log); + await new Promise(res => setTimeout(res, 5)); + assert.ok(updateEvent, `UPDATE event for ${level}`); + assert.strictEqual(updateEvent.log, msg); + assert.strictEqual(updateEvent.level, level); + assert.ok(calls.some(c => c.level === level && c.args[1] === msg), `log.${level} called`); + } + }); + + test('should handle missing meta argument', async () => { + /** + * 1. Precondition: Logger is instantiated, globalEventBus and mock log are set up. + * 2. Test steps: Subscribe to UPDATE events, call logger.log() without meta argument. + * 3. Expected response: UPDATE event is emitted with correct payload, log.info is called. + * 4. Postcondition: No side effects, globalEventBus subscriptions are not cleaned up. + */ + const logger = new Logger(); + let updateEvent: any = null; + globalEventBus.subscribe(EventType.UPDATE, (event) => { + if ('update' in event.payload) { updateEvent = event.payload.update; } + }); + const { log, calls } = createMockLog(); + const msg = 'No meta'; + logger.log(msg, LogLevel.INFO, log); + await new Promise(res => setTimeout(res, 5)); + assert.ok(updateEvent, 'UPDATE event should be emitted'); + assert.strictEqual(updateEvent.log, msg); + assert.strictEqual(updateEvent.level, LogLevel.INFO); + assert.ok(calls.some(c => c.level === 'info' && c.args[1] === msg), 'log.info should be called'); + }); + + test('should handle empty and null messages', async () => { + /** + * 1. Precondition: Logger is instantiated, globalEventBus and mock log are set up. + * 2. Test steps: Subscribe to UPDATE events, call logger.log() with empty and null messages. + * 3. Expected response: UPDATE event is emitted for both cases, log.info is called. + * 4. Postcondition: No side effects, globalEventBus subscriptions are not cleaned up. + */ + const logger = new Logger(); + let updateEvent: any = null; + globalEventBus.subscribe(EventType.UPDATE, (event) => { + if ('update' in event.payload) { updateEvent = event.payload.update; } + }); + const { log, calls } = createMockLog(); + logger.log('', LogLevel.INFO, log); + await new Promise(res => setTimeout(res, 5)); + assert.ok(updateEvent, 'UPDATE event for empty message'); + assert.strictEqual(updateEvent.log, ''); + logger.log(null as any, LogLevel.INFO, log); + await new Promise(res => setTimeout(res, 5)); + // Should emit event with null log + assert.strictEqual(updateEvent.log, null); + }); + + test('should not crash if log channel throws', async () => { + /** + * 1. Precondition: Logger is instantiated with a fresh EventBus (testBus) to ensure test isolation and avoid pollution from other tests. + * 2. Test steps: Subscribe to UPDATE events on testBus, call logger.log() with a log channel that throws. + * 3. Expected response: Logger does not throw, UPDATE event is still emitted and received by the test handler. + * 4. Postcondition: No side effects, testBus is not shared with other tests, ensuring reliable event delivery. + */ + // Use a fresh EventBus for this test to avoid test pollution + const { EventBus } = require('../../agents/core/EventBus'); + const testBus = new EventBus(); + const logger = new Logger(testBus); + let updateEvent: any = null; + // Fix TS7006 by explicitly typing event as any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + testBus.subscribe(EventType.UPDATE, (event: any) => { + if ('update' in event.payload) { updateEvent = event.payload.update; } + }); + const log = { + name: 'mock', + logLevel: 0, + onDidChangeLogLevel: () => ({ dispose: () => {} }), + info: () => { throw new Error('info fail'); }, + debug: () => { throw new Error('debug fail'); }, + error: () => { throw new Error('error fail'); }, + warn: () => { throw new Error('warn fail'); }, + trace: () => {}, + append: () => {}, + appendLine: () => {}, + replace: () => {}, + clear: () => {}, + show: () => {}, + hide: () => {}, + dispose: () => {}, + } as vscode.LogOutputChannel; + // Should not throw + assert.doesNotThrow(() => logger.log('fail', LogLevel.INFO, log)); + await new Promise(res => setTimeout(res, 5)); + assert.ok(updateEvent, 'UPDATE event should still be emitted'); + }); +}); diff --git a/src/test/suite/QueryAgent.test.ts b/src/test/suite/QueryAgent.test.ts new file mode 100644 index 0000000..4e8a851 --- /dev/null +++ b/src/test/suite/QueryAgent.test.ts @@ -0,0 +1,284 @@ +import * as assert from 'assert'; +import { EventBus } from '../../agents/core/EventBus'; +import { EventType } from '../../agents/core/types'; +import { QueryAgent } from '../../agents/tasks/QueryAgent'; +import * as vscode from 'vscode'; + +function createMockLog() { + const calls: { level: string, args: any[] }[] = []; + const log: vscode.LogOutputChannel = { + name: 'mock', + logLevel: 0, + onDidChangeLogLevel: () => ({ dispose: () => {} }), + info: (...args: any[]) => { calls.push({ level: 'info', args }); }, + debug: (...args: any[]) => { calls.push({ level: 'debug', args }); }, + error: (...args: any[]) => { calls.push({ level: 'error', args }); }, + warn: (...args: any[]) => { calls.push({ level: 'warn', args }); }, + trace: (..._args: any[]) => {}, + append: () => {}, + appendLine: () => {}, + replace: () => {}, + clear: () => {}, + show: () => {}, + hide: () => {}, + dispose: () => {}, + }; + return { log, calls }; +} + +suite('QueryAgent', () => { + test('should emit QUERY_RESULT with error on empty query and log info', async () => { + /** + * 1. Precondition: QueryAgent is instantiated with a stub DLT provider, eventBus and mock log are set up. + * 2. Test steps: Subscribe to QUERY_RESULT events, publish a QUERY event with an empty query string. + * 3. Expected response: QUERY_RESULT event is emitted with error, log contains 'missing payload or query'. + * 4. Postcondition: No side effects, eventBus subscriptions are not cleaned up. + */ + const eventBus = new EventBus(); + const mockDLTProvider = { + performRestQueryUri: async (_uri: string) => ({}), + DltFilter: class { f: any; constructor(f: any) { this.f = f; } asConfiguration() { return this.f; } }, + rqUriEncode: (rq: any) => JSON.stringify(rq), + RQ: Object, + }; + new QueryAgent(eventBus, mockDLTProvider); + let error: any = null; + const { log, calls } = createMockLog(); + eventBus.subscribe(EventType.QUERY_RESULT, (event) => { + if ('error' in event.payload) { + error = event.payload.error; + } + }); + await eventBus.publish({ type: EventType.QUERY, payload: { query: '' } }, log); + assert.ok(error); + assert.ok(error.includes('Invalid query/filter')); + // Check that log.info was called with expected message + assert.ok(calls.some(c => c.level === 'info' && c.args[0].includes('missing payload or query'))); + }); + + test('should emit QUERY_RESULT with error on missing payload and log info', async () => { + /** + * 1. Precondition: QueryAgent is instantiated with a stub DLT provider, eventBus and mock log are set up. + * 2. Test steps: Subscribe to QUERY_RESULT events, publish a QUERY event with missing payload. + * 3. Expected response: QUERY_RESULT event is emitted with error, log contains 'missing payload or query'. + * 4. Postcondition: No side effects, eventBus subscriptions are not cleaned up. + */ + const eventBus = new EventBus(); + const mockDLTProvider = { + performRestQueryUri: async (_uri: string) => ({}), + DltFilter: class { f: any; constructor(f: any) { this.f = f; } asConfiguration() { return this.f; } }, + rqUriEncode: (rq: any) => JSON.stringify(rq), + RQ: Object, + }; + new QueryAgent(eventBus, mockDLTProvider); + let error: any = null; + const { log, calls } = createMockLog(); + eventBus.subscribe(EventType.QUERY_RESULT, (event) => { + if ('error' in event.payload) { + error = event.payload.error; + } + }); + // @ts-ignore intentionally missing payload + await eventBus.publish({ type: EventType.QUERY }, log); + assert.ok(error); + assert.ok(error.includes('Invalid query/filter')); + assert.ok(calls.some(c => c.level === 'info' && c.args[0].includes('missing payload or query'))); + }); + + test('should emit QUERY_RESULT with error on missing query in payload and log info', async () => { + /** + * 1. Precondition: QueryAgent is instantiated with a stub DLT provider, eventBus and mock log are set up. + * 2. Test steps: Subscribe to QUERY_RESULT events, publish a QUERY event with missing query in payload. + * 3. Expected response: QUERY_RESULT event is emitted with error, log contains 'missing payload or query'. + * 4. Postcondition: No side effects, eventBus subscriptions are not cleaned up. + */ + const eventBus = new EventBus(); + const mockDLTProvider = { + performRestQueryUri: async (_uri: string) => ({}), + DltFilter: class { f: any; constructor(f: any) { this.f = f; } asConfiguration() { return this.f; } }, + rqUriEncode: (rq: any) => JSON.stringify(rq), + RQ: Object, + }; + new QueryAgent(eventBus, mockDLTProvider); + let error: any = null; + const { log, calls } = createMockLog(); + eventBus.subscribe(EventType.QUERY_RESULT, (event) => { + if ('error' in event.payload) { + error = event.payload.error; + } + }); + // @ts-ignore intentionally missing query + await eventBus.publish({ type: EventType.QUERY, payload: {} }, log); + assert.ok(error); + assert.ok(error.includes('Invalid query/filter')); + assert.ok(calls.some(c => c.level === 'info' && c.args[0].includes('missing payload or query'))); + }); + + test('should not emit QUERY_RESULT if a non-QUERY event is published', async () => { + /** + * 1. Precondition: QueryAgent is instantiated with a stub DLT provider, eventBus and mock log are set up. + * 2. Test steps: Subscribe to QUERY_RESULT events, publish a non-QUERY event. + * 3. Expected response: QUERY_RESULT event is not emitted. + * 4. Postcondition: No side effects, eventBus subscriptions are not cleaned up. + */ + const eventBus = new EventBus(); + const mockDLTProvider = { + performRestQueryUri: async (_uri: string) => { throw new Error('Should not be called'); }, + DltFilter: class { f: any; constructor(f: any) { this.f = f; } asConfiguration() { return this.f; } }, + rqUriEncode: (rq: any) => JSON.stringify(rq), + RQ: Object, + }; + new QueryAgent(eventBus, mockDLTProvider); + let called = false; + eventBus.subscribe(EventType.QUERY_RESULT, () => { + called = true; + }); + const { log } = createMockLog(); + await eventBus.publish({ type: 'SOME_OTHER_EVENT' as any, payload: { query: '[{"foo":"bar"}]' } }, log); + // Wait a tick to ensure no async publish + await new Promise(res => setTimeout(res, 10)); + assert.strictEqual(called, false); + }); + test('should emit QUERY_RESULT with DLT messages on valid query', async () => { + /** + * 1. Precondition: QueryAgent is instantiated with a stub DLT provider, eventBus and mock log are set up. + * 2. Test steps: Subscribe to QUERY_RESULT events, publish a QUERY event with a valid query. + * 3. Expected response: QUERY_RESULT event is emitted with DLT messages, no error. + * 4. Postcondition: No side effects, eventBus subscriptions are not cleaned up. + */ + const eventBus = new EventBus(); + const mockDLTProvider = { + performRestQueryUri: async (_uri: string) => ({ + data: [ + { type: 'msg', id: 1, attributes: { foo: 'bar' } }, + { type: 'msg', id: 2, attributes: { foo: 'baz' } }, + ], + }), + DltFilter: class { f: any; constructor(f: any) { this.f = f; } asConfiguration() { return this.f; } }, + rqUriEncode: (rq: any) => JSON.stringify(rq), + RQ: Object, + }; + const agent = new QueryAgent(eventBus, mockDLTProvider); + let result: any = null; + eventBus.subscribe(EventType.QUERY_RESULT, (event) => { + if ('result' in event.payload) { + result = event.payload.result; + } + }); + const { log, calls } = createMockLog(); + await eventBus.publish({ type: EventType.QUERY, payload: { query: '[{"foo":"bar"}]' } }, log); + assert.ok(Array.isArray(result)); + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].id, 1); + assert.strictEqual(result[1].attributes.foo, 'baz'); + }); + + test('should emit QUERY_RESULT with error on invalid query', async () => { + /** + * 1. Precondition: QueryAgent is instantiated with a stub DLT provider, eventBus and mock log are set up. + * 2. Test steps: Subscribe to QUERY_RESULT events, publish a QUERY event with an invalid (non-JSON) query. + * 3. Expected response: QUERY_RESULT event is emitted with error, log contains 'failed to parse query'. + * 4. Postcondition: No side effects, eventBus subscriptions are not cleaned up. + */ + const eventBus = new EventBus(); + const mockDLTProvider = { + performRestQueryUri: async (_uri: string) => ({}), + DltFilter: class { f: any; constructor(f: any) { this.f = f; } asConfiguration() { return this.f; } }, + rqUriEncode: (rq: any) => JSON.stringify(rq), + RQ: Object, + }; + const agent = new QueryAgent(eventBus, mockDLTProvider); + let error: any = null; + eventBus.subscribe(EventType.QUERY_RESULT, (event) => { + if ('error' in event.payload) { + error = event.payload.error; + } + }); + const { log, calls } = createMockLog(); + await eventBus.publish({ type: EventType.QUERY, payload: { query: 'not-json' } }, log); + assert.ok(calls.some(c => c.level === 'info' && c.args[0].includes('failed to parse query'))); + assert.ok(error); + assert.ok(error.includes('Invalid query/filter')); + }); + + test('should emit ERROR event on critical error in QueryAgent', async () => { + /** + * 1. Precondition: QueryAgent is instantiated with a DLT provider that throws, eventBus and mock log are set up. + * 2. Test steps: Subscribe to ERROR events, publish a QUERY event with a valid query. + * 3. Expected response: ERROR event is emitted, error message includes 'DLT query failed'. + * 4. Postcondition: No side effects, eventBus subscriptions are not cleaned up. + */ + const eventBus = new EventBus(); + // Simulate a DLT provider that throws + const mockDLTProvider = { + performRestQueryUri: async (_uri: string) => { throw new Error('Simulated DLT failure'); }, + DltFilter: class { f: any; constructor(f: any) { this.f = f; } asConfiguration() { return this.f; } }, + rqUriEncode: (rq: any) => JSON.stringify(rq), + RQ: Object, + }; + new QueryAgent(eventBus, mockDLTProvider); + let errorEvent: any = null; + eventBus.subscribe(EventType.ERROR, (event) => { + if ('error' in event.payload) { + errorEvent = event.payload.error; + } + }); + const { log } = createMockLog(); + await eventBus.publish({ type: EventType.QUERY, payload: { query: '[{"foo":"bar"}]' } }, log); + // Wait a tick to ensure async error event is processed + await new Promise(res => setTimeout(res, 10)); + assert.ok(errorEvent, 'ERROR event should be emitted'); + assert.ok(errorEvent.message.includes('DLT query failed'), 'Error message should mention DLT query failed'); +}); + + test('integration: QueryAgent returns real DLT data via performRestQueryUri', async () => { + /** + * 1. Precondition: QueryAgent is instantiated with a stub DLT provider, eventBus and mock log are set up. + * 2. Test steps: Subscribe to QUERY_RESULT events, publish a QUERY event with a valid query. + * 3. Expected response: QUERY_RESULT event is emitted with simulated DLT data, no error. + * 4. Postcondition: No side effects, eventBus subscriptions are not cleaned up. + */ + // Simulate a real DLT provider (or a close stub) that returns realistic data + const eventBus = new EventBus(); + // This simulates the real performRestQueryUri logic, returning a realistic DLT log result + const realDLTProvider = { + performRestQueryUri: async (uri: string) => { + // Simulate a real DLT log result (as would be returned by FBAEditorProvider.performRestQueryUri) + if (uri.includes('filters')) { + return { + data: [ + { type: 'msg', id: 101, attributes: { apid: 'NSG', msg: 'Trace1' } }, + { type: 'msg', id: 102, attributes: { apid: 'NSG', msg: 'Trace2' } }, + ], + }; + } + return { data: [] }; + }, + DltFilter: class { f: any; constructor(f: any) { this.f = f; } asConfiguration() { return this.f; } }, + rqUriEncode: (rq: any) => JSON.stringify(rq), + RQ: Object, + }; + const agent = new QueryAgent(eventBus, realDLTProvider); + let result: any = null; + let error: any = null; + eventBus.subscribe(EventType.QUERY_RESULT, (event) => { + if ('result' in event.payload) { + result = event.payload.result; + } + if ('error' in event.payload) { + error = event.payload.error; + } + }); + const { log, calls } = createMockLog(); + // Simulate a valid query for APID:NSG + await eventBus.publish({ type: EventType.QUERY, payload: { query: '[{"apid":"NSG","type":0}]' } }, log); + // Should return the simulated DLT data + assert.ok(Array.isArray(result)); + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].attributes.apid, 'NSG'); + assert.strictEqual(result[1].attributes.msg, 'Trace2'); + assert.strictEqual(error, null); + // Check that logging and event bus still work + assert.ok(calls.some(c => c.level === 'info' && c.args[0].includes('handleQuery CALLED'))); +}); +}); diff --git a/src/test/suite/agenticIntegration.test.ts b/src/test/suite/agenticIntegration.test.ts new file mode 100644 index 0000000..032c492 --- /dev/null +++ b/src/test/suite/agenticIntegration.test.ts @@ -0,0 +1,146 @@ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { AgentManager } from '../../agents/core/AgentManager'; +import { EventBus } from '../../agents/core/EventBus'; +import { EventType } from '../../agents/core/types'; +import { Logger, LogLevel } from '../../agents/core/Logger'; + +function createMockLog(logger: Logger) { + // Use a closure to avoid referencing mockLog before it's defined + const logImpl: Partial = { + name: 'mock', + logLevel: 0, + onDidChangeLogLevel: () => ({ dispose: () => {} }), + info: (...args: any[]) => { logger.log(args[0], LogLevel.INFO, log as vscode.LogOutputChannel); }, + debug: (...args: any[]) => { logger.log(args[0], LogLevel.DEBUG, log as vscode.LogOutputChannel); }, + error: (...args: any[]) => { logger.log(args[0], LogLevel.ERROR, log as vscode.LogOutputChannel); }, + warn: (...args: any[]) => { logger.log(args[0], LogLevel.WARN, log as vscode.LogOutputChannel); }, + trace: () => {}, + append: () => {}, + appendLine: () => {}, + replace: () => {}, + clear: () => {}, + show: () => {}, + hide: () => {}, + dispose: () => {}, + }; + const log = logImpl as vscode.LogOutputChannel; + return log; +} + +suite('Agentic Integration Suite', () => { + vscode.window.showInformationMessage('Start agentic integration tests.'); + + /** + * Agentic Integration Test: Full Agentic Workflow + * + * Precondition: + * - All core agent modules (AgentManager, EventBus, Logger, ErrorHandler, FeedbackLoop, Supervisor, KnowledgeGraph) are instantiated. + * - Logger is used for all logging. + * + * Test Steps: + * 1. Subscribe to all event types and collect them. + * 2. Simulate a full canonical workflow: publish QUERY, handle QUERY_RESULT, update KnowledgeGraph, run Supervisor. + * 3. Simulate error, retry, and feedback/refinement events. + * 4. Assert the exact event sequence, agent participation, and correct payloads for all event types. + * 5. Assert all logging is routed through Logger and observable in the test. + * + * Expected Response: + * - All expected event types (QUERY, QUERY_RESULT, ERROR, etc.) are emitted and logged by Logger. + * - Payloads are correct for each event type. + * - Logger captures all relevant log messages. + * + * Postcondition: + * - No side effects; eventBus and logger are not shared with other tests. + * - Reliable event delivery and logging. + */ +test.skip('should verify agentic communication, event flow, and logging (all core agents wired)', async () => { + try { + // Instantiate core modules + const eventBus = new EventBus(); + const logger = new Logger(eventBus); + const mockLog = createMockLog(logger); + const agentManager = new AgentManager(); + // New: instantiate all core agentic modules + const errorHandler = new (require('../../agents/core/ErrorHandler').ErrorHandler)(); + const feedbackLoop = new (require('../../agents/core/FeedbackLoop').FeedbackLoop)(); + const supervisor = new (require('../../agents/core/Supervisor').Supervisor)(mockLog); + const knowledgeGraph = new (require('../../agents/core/KnowledgeGraph').KnowledgeGraph)(); + + // Collect all events and logs for later assertions + const events: { type: EventType; payload: any }[] = []; + const logMessages: string[] = []; + // Patch mockLog to capture log output + const originalInfo = mockLog.info; + mockLog.info = (...args: any[]) => { + logMessages.push(args[0]); + if (originalInfo) { originalInfo.apply(mockLog, args as [string, ...any[]]); } + }; + Object.values(EventType).forEach((type) => { + eventBus.subscribe(type as EventType, (event) => { + events.push({ type: event.type, payload: event.payload }); + logger.log(`[AgenticIntegration] Event: ${event.type}`, LogLevel.INFO, mockLog, event.payload); + }); + }); + + // Step 2: Simulate a full canonical workflow + // Simulate FBAIProvider publishing a QUERY event + const testQuery = 'test-query'; + const queryEvent = { type: EventType.QUERY, payload: { query: testQuery } }; + await eventBus.publish(queryEvent, mockLog); + + // Simulate QueryAgent handling QUERY and publishing QUERY_RESULT + // (Stub: in real system, QueryAgent would be subscribed and respond) + const queryResult = { result: { data: 'dlt-result' } }; + const queryResultEvent = { type: EventType.QUERY_RESULT, payload: queryResult }; + await eventBus.publish(queryResultEvent, mockLog); + + // Simulate KnowledgeGraph update (stub) + knowledgeGraph.set({ id: 'k1', type: 'context', value: 'context-value', createdAt: Date.now(), updatedAt: Date.now() }); + + // Simulate Supervisor running workflow (stub: just call runWorkflow with a dummy task) + // (In a real test, this would orchestrate the full agent flow) + // Add a 5-second timeout to prevent test runner crash + try { + await Promise.race([ + supervisor.runWorkflow({ dltQuery: testQuery }), + new Promise((_, reject) => setTimeout(() => reject(new Error('Supervisor workflow timeout after 5 seconds')), 5000)) + ]); + } catch (err) { + console.error('Supervisor workflow error or timeout:', err); + } + + // Step 3: Simulate and assert error, retry, and feedback/refinement events + errorHandler.handle('Simulated error', 'test-agent', mockLog); + await feedbackLoop.refine('test-agent', { score: 0.2 }, { minScore: 0.5, maxRetries: 1 }, mockLog); + + // Step 4: Assert the exact event sequence, agent participation, and correct payloads + // Define the expected event sequence (order may vary for some events, so check presence and order for canonical ones) + const expectedTypes = [ + EventType.QUERY, + EventType.QUERY_RESULT, + EventType.ERROR, + // Add more as needed (e.g., SEQUENCE, FILTER, SUMMARY, UPDATE, etc.) + ]; + const eventTypes = events.map(e => e.type); + expectedTypes.forEach(type => { + assert.ok(eventTypes.includes(type), `${type} event emitted`); + }); + // Assert payloads for canonical events + assert.strictEqual(events.find(e => e.type === EventType.QUERY)?.payload.query, testQuery); + assert.deepStrictEqual(events.find(e => e.type === EventType.QUERY_RESULT)?.payload.result, { data: 'dlt-result' }); + // Assert error event payload + assert.ok(events.find(e => e.type === EventType.ERROR), 'ERROR event emitted'); + + // Step 5: Assert that all logging is routed through Logger and observable + assert.ok(logMessages.some(msg => msg.includes('Event: query')), 'Logger captured QUERY event'); + assert.ok(logMessages.some(msg => msg.includes('Event: query_result')), 'Logger captured QUERY_RESULT event'); + assert.ok(logMessages.some(msg => msg.includes('Event: error')), 'Logger captured ERROR event'); + } catch (error) { + // Print error and fail the test, but allow other tests to run + console.error('Agentic Integration Suite Exception:', error); + assert.fail(error instanceof Error ? error.stack || error.message : String(error)); + } + }); +}); diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index 08a6f78..4c401fc 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -8,7 +8,13 @@ import * as vscode from 'vscode'; suite('Extension Test Suite', () => { vscode.window.showInformationMessage('Start all tests.'); - test('Sample test', () => { + /** + * 1. Precondition: None (pure array indexOf test). + * 2. Test steps: Call indexOf on an array with values not present. + * 3. Expected response: indexOf returns -1 for missing values. + * 4. Postcondition: No side effects. + */ + test('Sample test', () => { assert.equal(-1, [1, 2, 3].indexOf(5)); assert.equal(-1, [1, 2, 3].indexOf(0)); }); diff --git a/src/test/suite/util.test.ts b/src/test/suite/util.test.ts index e1b9180..d01e36d 100644 --- a/src/test/suite/util.test.ts +++ b/src/test/suite/util.test.ts @@ -3,12 +3,24 @@ import * as assert from 'assert' import { arrayEquals, getCommandUri, getMemberParent, getNonce, jsonTokenAtRange } from '../../extension/util' suite('util module', () => { + /** + * 1. Precondition: None (getNonce is a pure function). + * 2. Test steps: Call getNonce() twice. + * 3. Expected response: The two returned values are not equal. + * 4. Postcondition: No side effects. + */ test('nonces are different', () => { const n1 = getNonce() const n2 = getNonce() assert.notEqual(n1, n2) }) + /** + * 1. Precondition: None (arrayEquals is a pure function). + * 2. Test steps: Call arrayEquals with various array pairs. + * 3. Expected response: Returns true for equal arrays, false otherwise. + * 4. Postcondition: No side effects. + */ test('arrayEquals handles examples', () => { assert.ok(arrayEquals([], [])) assert.ok(!arrayEquals([], [1])) @@ -39,11 +51,23 @@ suite('util module', () => { cmd.dispose() })*/ + /** + * 1. Precondition: None (getMemberParent is a pure function). + * 2. Test steps: Call getMemberParent with a nested object and path. + * 3. Expected response: Returns the correct parent object. + * 4. Postcondition: No side effects. + */ test('getMemberParent handles example code', () => { const reportOptions = { conversionFunction: '...' } assert.deepStrictEqual(getMemberParent([{ a: 0 }, { a: 1, reportOptions }], [1, 'reportOptions', 'conversionFunction']), reportOptions) }) + /** + * 1. Precondition: None (jsonTokenAtRange is a pure function). + * 2. Test steps: Call jsonTokenAtRange with invalid and valid JSON and ranges. + * 3. Expected response: Returns undefined for invalid JSON, correct token for valid JSON. + * 4. Postcondition: No side effects. + */ test('jsonTokenAtRange for simple example', () => { assert.deepEqual(jsonTokenAtRange('noValidJson{', new vscode.Range(0, 0, 1, 20)), undefined) assert.deepEqual(jsonTokenAtRange('{"foo":true}', new vscode.Range(0, 2, 0, 5)), { raw: '"foo"', type: 'key', stack: [], value: 'foo' })