mcpgraph-ux 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,282 +1,793 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect } from 'react';
3
+ import { useState, useEffect, useRef } from 'react';
4
4
  import styles from './ToolTester.module.css';
5
+ import { SSEExecutionStream, generateExecutionId, type ExecutionEvent } from '../lib/executionStream';
6
+ import type { NodeExecutionStatus } from './GraphVisualization';
7
+ import ExecutionHistory, { type NodeExecutionRecord } from './ExecutionHistory';
8
+ import DebugControls, { type ExecutionStatus } from './DebugControls';
9
+ import GraphVisualization from './GraphVisualization';
5
10
 
6
- interface ToolTesterProps {
7
- toolName: string;
11
+ // Type definitions for SSE event data
12
+ interface NodeCompleteEventData {
13
+ nodeId: string;
14
+ nodeType: string;
15
+ executionIndex: number;
16
+ input: unknown;
17
+ output: unknown;
18
+ duration: number;
19
+ timestamp: number;
8
20
  }
9
21
 
10
- interface ToolInfo {
11
- name: string;
12
- description: string;
13
- inputSchema: {
14
- type: string;
15
- properties?: Record<string, any>;
16
- required?: string[];
17
- };
18
- outputSchema?: {
19
- type: string;
20
- properties?: Record<string, any>;
22
+ interface NodeErrorEventData {
23
+ nodeId: string;
24
+ nodeType: string;
25
+ executionIndex: number;
26
+ input: unknown; // mcpGraph 0.1.19+ provides actual context
27
+ error: {
28
+ message: string;
29
+ stack?: string;
30
+ code?: number; // MCP error code (e.g., -32000, -32603)
31
+ data?: unknown; // MCP error data/details
32
+ stderr?: string[]; // For ToolCallMcpError - server stderr output
33
+ result?: unknown; // For ToolCallError - tool call result with error
34
+ errorType: 'mcp' | 'tool' | 'unknown'; // Type of error
21
35
  };
36
+ timestamp: number;
37
+ }
38
+
39
+ interface PauseEventData {
40
+ nodeId: string;
41
+ nodeType: string;
42
+ executionIndex: number;
43
+ context: Record<string, unknown>;
44
+ timestamp: number;
45
+ }
46
+
47
+ interface NodeStartEventData {
48
+ nodeId: string;
49
+ nodeType: string;
50
+ executionIndex: number;
51
+ context: Record<string, unknown>;
52
+ timestamp: number;
22
53
  }
23
54
 
24
- export default function ToolTester({ toolName }: ToolTesterProps) {
25
- const [toolInfo, setToolInfo] = useState<ToolInfo | null>(null);
26
- const [formData, setFormData] = useState<Record<string, any>>({});
27
- const [result, setResult] = useState<any>(null);
55
+ export interface ExecutionTelemetry {
56
+ totalDuration: number;
57
+ nodeDurations: Record<string, number>;
58
+ nodeCounts: Record<string, number>;
59
+ errorCount: number;
60
+ }
61
+
62
+ // Re-export ExecutionStatus for consistency
63
+ export type { ExecutionStatus };
64
+
65
+ interface ToolTesterProps {
66
+ toolName: string;
67
+ graphData: { nodes: Array<{ id: string; type: string; data: Record<string, unknown>; position: { x: number; y: number } }>; edges: Array<{ id: string; source: string; target: string; label?: string }> } | null;
68
+ inputFormRef: React.RefObject<{ submit: (startPaused: boolean) => void }>;
69
+ onFormSubmit: (handler: (formData: Record<string, any>, startPaused: boolean) => void) => void;
70
+ }
71
+
72
+
73
+ export default function ToolTester({
74
+ toolName,
75
+ graphData,
76
+ inputFormRef,
77
+ onFormSubmit,
78
+ }: ToolTesterProps) {
79
+ // Expose form submit handler to parent (so InputForm can call it)
80
+ // This needs to be after handleSubmit is defined, so we'll do it in a useEffect
81
+ const formSubmitHandlerRef = useRef<((formData: Record<string, any>, startPaused: boolean) => void) | null>(null);
28
82
  const [loading, setLoading] = useState(false);
29
- const [error, setError] = useState<string | null>(null);
83
+ const [executionHistory, setExecutionHistory] = useState<NodeExecutionRecord[]>([]);
84
+ const [telemetry, setTelemetry] = useState<ExecutionTelemetry | null>(null);
85
+ const [executionStatus, setExecutionStatus] = useState<ExecutionStatus>('not_started');
86
+ const [currentNodeId, setCurrentNodeId] = useState<string | null>(null);
87
+ const [currentExecutionId, setCurrentExecutionId] = useState<string | null>(null);
88
+ const [executionState, setExecutionState] = useState<Map<string, NodeExecutionStatus>>(new Map());
89
+ const [highlightedNode, setHighlightedNode] = useState<string | null>(null);
90
+ const [breakpoints, setBreakpoints] = useState<Set<string>>(new Set());
91
+ // Use a ref to always get the latest breakpoints when handleSubmit executes
92
+ const breakpointsRef = useRef<Set<string>>(breakpoints);
93
+ const [executionResult, setExecutionResult] = useState<unknown>(null);
30
94
 
31
- useEffect(() => {
32
- // Load tool info
33
- fetch(`/api/tools/${toolName}`)
34
- .then(res => res.json())
35
- .then(data => {
36
- if (data.error) {
37
- setError(data.error);
38
- return;
39
- }
40
- setToolInfo(data.tool);
41
- // Initialize form data with default values
42
- const defaults: Record<string, any> = {};
43
- if (data.tool.inputSchema.properties) {
44
- Object.entries(data.tool.inputSchema.properties).forEach(([key, prop]: [string, any]) => {
45
- if (prop.type === 'string') {
46
- defaults[key] = '';
47
- } else if (prop.type === 'number') {
48
- defaults[key] = 0;
49
- } else if (prop.type === 'boolean') {
50
- defaults[key] = false;
51
- } else if (prop.type === 'array') {
52
- defaults[key] = [];
53
- } else if (prop.type === 'object') {
54
- defaults[key] = {};
95
+
96
+ // Fetch execution history from controller
97
+ const fetchExecutionHistory = async (execId: string) => {
98
+ try {
99
+ // Use history-with-indices to get executionIndex for each record
100
+ const response = await fetch(`/api/execution/history-with-indices?executionId=${encodeURIComponent(execId)}`);
101
+ if (response.ok) {
102
+ const data = await response.json();
103
+ if (data.history && Array.isArray(data.history)) {
104
+ // Update history and fetch input for any records missing it
105
+ setExecutionHistory(data.history);
106
+
107
+ // Fetch input for any records that don't have it yet
108
+ data.history.forEach((record: NodeExecutionRecord & { executionIndex?: number }) => {
109
+ if (record.executionIndex !== undefined && !record.input) {
110
+ fetchNodeInput(execId, record.nodeId, record.executionIndex);
55
111
  }
56
112
  });
57
113
  }
58
- setFormData(defaults);
59
- })
60
- .catch(err => {
61
- setError(err.message);
62
- });
63
- }, [toolName]);
64
-
65
- const handleInputChange = (key: string, value: any) => {
66
- setFormData(prev => ({
67
- ...prev,
68
- [key]: value,
69
- }));
114
+ }
115
+ } catch (err) {
116
+ console.error('Error fetching execution history:', err);
117
+ }
70
118
  };
119
+
120
+ // Fetch input context for a specific node using executionIndex
121
+ const fetchNodeInput = async (execId: string, nodeId: string, executionIndex: number) => {
122
+ try {
123
+ console.log(`[ToolTester] Fetching input for nodeId=${nodeId}, executionIndex=${executionIndex}`);
124
+ const response = await fetch(`/api/execution/context?executionId=${encodeURIComponent(execId)}&nodeId=${encodeURIComponent(nodeId)}&sequenceId=${executionIndex}`);
125
+ if (response.ok) {
126
+ const data = await response.json();
127
+ console.log(`[ToolTester] Got context response for ${nodeId}:`, data);
128
+ if (data.context) {
129
+ // Update the history record with the proper input
130
+ // Match by executionIndex if available, otherwise by nodeId
131
+ setExecutionHistory(prev => {
132
+ const newHistory = prev.map(record => {
133
+ // Match by executionIndex if available (from final history)
134
+ const recordWithIndex: NodeExecutionRecord & { executionIndex?: number } = record;
135
+ if (recordWithIndex.executionIndex === executionIndex) {
136
+ console.log(`[ToolTester] Updating input for record with executionIndex=${executionIndex}`);
137
+ return { ...record, input: data.context };
138
+ }
139
+ // Fallback: match by nodeId if no executionIndex (progressive history)
140
+ if (!recordWithIndex.executionIndex && record.nodeId === nodeId && !record.input) {
141
+ return { ...record, input: data.context };
142
+ }
143
+ return record;
144
+ });
145
+ return newHistory;
146
+ });
147
+ } else {
148
+ console.warn(`[ToolTester] No context returned for ${nodeId} at executionIndex=${executionIndex}`);
149
+ }
150
+ } else {
151
+ console.error(`[ToolTester] Failed to fetch context for ${nodeId}: ${response.status} ${response.statusText}`);
152
+ }
153
+ } catch (err) {
154
+ console.error('Error fetching node input context:', err);
155
+ }
156
+ };
157
+
158
+ // Fetch input context for a node after it completes
159
+ // This gets the executionIndex by fetching the execution history from the API
160
+ const fetchNodeInputAfterComplete = async (execId: string, nodeId: string) => {
161
+ try {
162
+ // Fetch the execution history which should have executionIndex for each record
163
+ // We'll find the most recent record for this nodeId and use its executionIndex
164
+ const response = await fetch(`/api/execution/history-with-indices?executionId=${encodeURIComponent(execId)}`);
165
+ if (response.ok) {
166
+ const data = await response.json();
167
+ if (data.history && Array.isArray(data.history)) {
168
+ // Find the most recent record for this nodeId
169
+ // The history is in execution order, so the last matching record is the most recent
170
+ const records: Array<{ nodeId: string; executionIndex: number }> = Array.isArray(data.history) ? data.history : [];
171
+ let matchingRecord: { nodeId: string; executionIndex: number } | undefined;
172
+
173
+ // Find the last (most recent) record for this nodeId
174
+ for (let i = records.length - 1; i >= 0; i--) {
175
+ if (records[i].nodeId === nodeId) {
176
+ matchingRecord = records[i];
177
+ break;
178
+ }
179
+ }
180
+
181
+ if (matchingRecord) {
182
+ await fetchNodeInput(execId, nodeId, matchingRecord.executionIndex);
183
+ }
184
+ }
185
+ }
186
+ } catch (err) {
187
+ console.error('Error fetching node input after complete:', err);
188
+ }
189
+ };
190
+ const executionStreamRef = useRef<SSEExecutionStream | null>(null);
191
+ const executionStateRef = useRef<Map<string, NodeExecutionStatus>>(new Map());
71
192
 
72
- const handleSubmit = async (e: React.FormEvent) => {
73
- e.preventDefault();
193
+ const handleSubmit = async (formData: Record<string, any>, startPaused: boolean = false) => {
194
+ // Read current breakpoints from ref (always up-to-date)
195
+ const currentBreakpoints = breakpointsRef.current;
196
+
74
197
  setLoading(true);
75
- setError(null);
76
- setResult(null);
198
+ setExecutionResult(null);
199
+ setExecutionHistory([]);
200
+ setTelemetry(null);
201
+ setExecutionStatus('not_started');
202
+ setCurrentNodeId(null);
203
+
204
+ // Reset execution state
205
+ executionStateRef.current.clear();
206
+ setExecutionState(new Map());
207
+
208
+ // Generate execution ID and set up SSE stream
209
+ const executionId = generateExecutionId();
210
+ setCurrentExecutionId(executionId);
211
+ // Don't set status here - wait for first event (pause or nodeStart) to tell us actual state
212
+ const stream = new SSEExecutionStream(executionId);
213
+ executionStreamRef.current = stream;
214
+
215
+ // Track if SSE connection is ready (event-driven)
216
+ let sseReady = false;
217
+ let sseReadyResolve: (() => void) | null = null;
218
+
219
+ // Set up event handler
220
+ stream.connect((event: ExecutionEvent) => {
221
+ console.log(`[ToolTester] Received SSE event: ${event.type}`, event);
222
+ const state = executionStateRef.current;
223
+
224
+ switch (event.type) {
225
+ case 'connected':
226
+ console.log(`[ToolTester] SSE connected for execution: ${executionId}`);
227
+ sseReady = true;
228
+ if (sseReadyResolve) {
229
+ sseReadyResolve();
230
+ sseReadyResolve = null;
231
+ }
232
+ break;
233
+ case 'nodeStart': {
234
+ const nodeStartData = event.data as NodeStartEventData;
235
+ if (!nodeStartData || typeof nodeStartData !== 'object' || !('nodeId' in nodeStartData)) {
236
+ console.warn('[ToolTester] Invalid nodeStart event data:', event.data);
237
+ break;
238
+ }
239
+ const existing = state.get(nodeStartData.nodeId);
240
+ state.set(nodeStartData.nodeId, {
241
+ nodeId: nodeStartData.nodeId,
242
+ state: 'running',
243
+ startTime: existing?.startTime || nodeStartData.timestamp,
244
+ endTime: undefined,
245
+ duration: undefined,
246
+ });
247
+ if (nodeStartData.nodeId) {
248
+ setCurrentNodeId(nodeStartData.nodeId);
249
+ }
250
+
251
+ // Update or create history record for this node with input from context
252
+ // If we already created a pending record from pause event, update it with nodeType
253
+ // Otherwise, create a new running record
254
+ const executionIndex = nodeStartData.executionIndex;
255
+ setExecutionHistory(prev => {
256
+ const existingIndex = prev.findIndex(
257
+ r => r.nodeId === nodeStartData.nodeId && r.executionIndex === executionIndex
258
+ );
259
+
260
+ if (existingIndex >= 0) {
261
+ // Update existing record (created from pause event)
262
+ const updated = [...prev];
263
+ updated[existingIndex] = {
264
+ ...updated[existingIndex],
265
+ nodeType: nodeStartData.nodeType,
266
+ startTime: nodeStartData.timestamp,
267
+ input: nodeStartData.context, // Update with context from nodeStart (more accurate)
268
+ };
269
+ return updated;
270
+ } else {
271
+ // Create new running record
272
+ const newHistory = [...prev, {
273
+ nodeId: nodeStartData.nodeId,
274
+ nodeType: nodeStartData.nodeType,
275
+ startTime: nodeStartData.timestamp,
276
+ endTime: undefined, // Not completed yet
277
+ duration: undefined, // Not completed yet
278
+ input: nodeStartData.context, // Use context as input
279
+ output: undefined, // Not completed yet
280
+ executionIndex,
281
+ }];
282
+ return newHistory;
283
+ }
284
+ });
285
+ break;
286
+ }
287
+ case 'nodeComplete': {
288
+ const eventData = event.data as NodeCompleteEventData;
289
+ if (!eventData || typeof eventData !== 'object' || !('nodeId' in eventData)) {
290
+ console.warn('[ToolTester] Invalid nodeComplete event data:', event.data);
291
+ break;
292
+ }
293
+ const existing = state.get(eventData.nodeId);
294
+ state.set(eventData.nodeId, {
295
+ nodeId: eventData.nodeId,
296
+ state: 'completed',
297
+ startTime: existing?.startTime || eventData.timestamp,
298
+ endTime: eventData.timestamp,
299
+ duration: eventData.duration,
300
+ });
301
+ // Build history record immediately for progressive display
302
+ // Use input from the event if available, otherwise fetch it
303
+ const startTime = existing?.startTime || eventData.timestamp;
304
+ const executionIndex = eventData.executionIndex;
305
+ const inputFromEvent = eventData.input;
306
+
307
+ setExecutionHistory(prev => {
308
+ // Check if we already have a record for this node (created from pause/nodeStart)
309
+ const existingIndex = prev.findIndex(
310
+ r => r.nodeId === eventData.nodeId && r.executionIndex === executionIndex
311
+ );
312
+
313
+ if (existingIndex >= 0) {
314
+ // Update existing record (created from pause/nodeStart)
315
+ const updated = [...prev];
316
+ updated[existingIndex] = {
317
+ ...updated[existingIndex],
318
+ nodeType: eventData.nodeType,
319
+ startTime,
320
+ endTime: eventData.timestamp,
321
+ duration: eventData.duration,
322
+ input: inputFromEvent, // Use input from event
323
+ output: eventData.output,
324
+ executionIndex,
325
+ };
326
+ return updated;
327
+ } else {
328
+ // Create new record
329
+ const newHistory = [...prev, {
330
+ nodeId: eventData.nodeId,
331
+ nodeType: eventData.nodeType,
332
+ startTime,
333
+ endTime: eventData.timestamp,
334
+ duration: eventData.duration,
335
+ input: inputFromEvent, // Use input from event
336
+ output: eventData.output,
337
+ executionIndex,
338
+ }];
339
+ return newHistory;
340
+ }
341
+ });
342
+
343
+ // If input wasn't in the event, fetch it using executionIndex
344
+ if (currentExecutionId && inputFromEvent === undefined) {
345
+ fetchNodeInput(currentExecutionId, eventData.nodeId, executionIndex);
346
+ }
347
+ break;
348
+ }
349
+ case 'nodeError': {
350
+ const eventData = event.data as NodeErrorEventData;
351
+ if (!eventData || typeof eventData !== 'object' || !('nodeId' in eventData)) {
352
+ console.warn('[ToolTester] Invalid nodeError event data:', event.data);
353
+ break;
354
+ }
355
+ const existingError = state.get(eventData.nodeId);
356
+ state.set(eventData.nodeId, {
357
+ nodeId: eventData.nodeId,
358
+ state: 'error',
359
+ startTime: existingError?.startTime || eventData.timestamp,
360
+ endTime: eventData.timestamp,
361
+ error: eventData.error?.message || 'Unknown error',
362
+ });
363
+ // Build history record immediately for progressive display
364
+ // Use input from the event (mcpGraph 0.1.19+ provides actual context)
365
+ const errorStartTime = existingError?.startTime || eventData.timestamp;
366
+ const executionIndex = eventData.executionIndex;
367
+ const inputFromEvent = eventData.input; // Context is now always provided
368
+
369
+ setExecutionHistory(prev => {
370
+ // Check if we already have a record for this node (created from pause/nodeStart)
371
+ const existingIndex = prev.findIndex(
372
+ r => r.nodeId === eventData.nodeId && r.executionIndex === executionIndex
373
+ );
374
+
375
+ if (existingIndex >= 0) {
376
+ // Update existing record (created from pause/nodeStart)
377
+ const updated = [...prev];
378
+ updated[existingIndex] = {
379
+ ...updated[existingIndex],
380
+ nodeType: eventData.nodeType,
381
+ startTime: errorStartTime,
382
+ endTime: eventData.timestamp,
383
+ duration: eventData.timestamp - errorStartTime,
384
+ input: inputFromEvent, // Use input from event
385
+ output: undefined,
386
+ error: {
387
+ message: eventData.error.message,
388
+ stack: eventData.error.stack,
389
+ ...(eventData.error.code !== undefined && { code: eventData.error.code }),
390
+ ...(eventData.error.data !== undefined && { data: eventData.error.data }),
391
+ ...(eventData.error.stderr !== undefined && { stderr: eventData.error.stderr }),
392
+ ...(eventData.error.result !== undefined && { result: eventData.error.result }),
393
+ ...(eventData.error.errorType !== undefined && { errorType: eventData.error.errorType }),
394
+ } as Error & {
395
+ code?: number;
396
+ data?: unknown;
397
+ stderr?: string[];
398
+ result?: unknown;
399
+ errorType?: 'mcp' | 'tool' | 'unknown';
400
+ },
401
+ executionIndex,
402
+ };
403
+ return updated;
404
+ } else {
405
+ // Create new record
406
+ const newHistory = [...prev, {
407
+ nodeId: eventData.nodeId,
408
+ nodeType: eventData.nodeType,
409
+ startTime: errorStartTime,
410
+ endTime: eventData.timestamp,
411
+ duration: eventData.timestamp - errorStartTime,
412
+ input: inputFromEvent, // Use input from event
413
+ output: undefined,
414
+ error: {
415
+ message: eventData.error.message,
416
+ stack: eventData.error.stack,
417
+ ...(eventData.error.code !== undefined && { code: eventData.error.code }),
418
+ ...(eventData.error.data !== undefined && { data: eventData.error.data }),
419
+ ...(eventData.error.stderr !== undefined && { stderr: eventData.error.stderr }),
420
+ ...(eventData.error.result !== undefined && { result: eventData.error.result }),
421
+ ...(eventData.error.errorType !== undefined && { errorType: eventData.error.errorType }),
422
+ } as Error & {
423
+ code?: number;
424
+ data?: unknown;
425
+ stderr?: string[];
426
+ result?: unknown;
427
+ errorType?: 'mcp' | 'tool' | 'unknown';
428
+ },
429
+ executionIndex,
430
+ }];
431
+ return newHistory;
432
+ }
433
+ });
434
+
435
+ // If input wasn't in the event, fetch it using executionIndex
436
+ if (currentExecutionId && inputFromEvent === undefined) {
437
+ fetchNodeInput(currentExecutionId, eventData.nodeId, executionIndex);
438
+ }
439
+ break;
440
+ }
441
+ case 'executionComplete': {
442
+ const completeData = event.data as { result?: unknown; executionHistory?: Array<NodeExecutionRecord & { executionIndex?: number }> };
443
+ if (!completeData || typeof completeData !== 'object') {
444
+ console.warn('[ToolTester] Invalid executionComplete event data:', event.data);
445
+ break;
446
+ }
447
+ console.log(`[ToolTester] Execution complete, result:`, completeData.result);
448
+ setExecutionResult(completeData.result);
449
+ // The execution history from the API should already have input populated
450
+ // since we fetch it before unregistering the controller
451
+ if (Array.isArray(completeData.executionHistory)) {
452
+ setExecutionHistory(completeData.executionHistory);
453
+ }
454
+ if ('telemetry' in completeData && completeData.telemetry) {
455
+ setTelemetry(completeData.telemetry as ExecutionTelemetry);
456
+ }
457
+ setExecutionStatus('finished');
458
+ setCurrentNodeId(null);
459
+ setLoading(false);
460
+ stream.disconnect();
461
+ executionStreamRef.current = null;
462
+ break;
463
+ }
464
+ case 'executionError': {
465
+ if (typeof event.data !== 'object' || !event.data) {
466
+ console.warn('[ToolTester] Invalid executionError event data:', event.data);
467
+ break;
468
+ }
469
+ const errorData = event.data as { error?: string | { message?: string } };
470
+ const fullErrorMessage = typeof errorData.error === 'string'
471
+ ? errorData.error
472
+ : (typeof errorData.error === 'object' && errorData.error?.message) || 'Unknown error';
473
+ // Extract just the first line for the summary (before stderr/details)
474
+ const errorSummary = fullErrorMessage.split('\n')[0].split('Server stderr')[0].trim();
475
+ console.log(`[ToolTester] Execution error:`, fullErrorMessage);
476
+ // Set error as the execution result so it shows in history - just the summary
477
+ setExecutionResult({ error: errorSummary });
478
+ setExecutionStatus('error');
479
+ setCurrentNodeId(null);
480
+ setLoading(false);
481
+ stream.disconnect();
482
+ executionStreamRef.current = null;
483
+ setCurrentExecutionId(null);
484
+ break;
485
+ }
486
+ case 'executionStopped':
487
+ console.log(`[ToolTester] Execution stopped by user`);
488
+ setExecutionStatus('stopped');
489
+ setCurrentNodeId(null);
490
+ setLoading(false);
491
+ stream.disconnect();
492
+ executionStreamRef.current = null;
493
+ setCurrentExecutionId(null);
494
+ break;
495
+ case 'pause': {
496
+ const pauseData = event.data as PauseEventData;
497
+ if (!pauseData || typeof pauseData !== 'object' || !('nodeId' in pauseData)) {
498
+ console.warn('[ToolTester] Invalid pause event data:', event.data);
499
+ break;
500
+ }
501
+ console.log(`[ToolTester] Pause event received for node: ${pauseData.nodeId}`);
502
+ setExecutionStatus('paused');
503
+ if (pauseData.nodeId) {
504
+ setCurrentNodeId(pauseData.nodeId);
505
+ }
506
+
507
+ // Create a pending history record for the node we're paused on
508
+ // This allows the user to see the node's input even though it hasn't completed yet
509
+ const executionIndex = pauseData.executionIndex;
510
+ const existingRecord = executionHistory.find(
511
+ r => r.nodeId === pauseData.nodeId && r.executionIndex === executionIndex
512
+ );
513
+
514
+ if (!existingRecord && pauseData.context) {
515
+ // Extract input from context - the context contains all available data at this point
516
+ // For the node about to execute, we need to determine what its input would be
517
+ // The context is the execution context, which includes outputs from previous nodes
518
+ // For now, we'll use the context as the input (it represents what's available to this node)
519
+ setExecutionHistory(prev => {
520
+ const newHistory = [...prev, {
521
+ nodeId: pauseData.nodeId,
522
+ nodeType: pauseData.nodeType, // Use nodeType from pause event
523
+ startTime: pauseData.timestamp,
524
+ endTime: undefined, // Not completed yet
525
+ duration: undefined, // Not completed yet
526
+ input: pauseData.context, // Use context as input (what's available to this node)
527
+ output: undefined, // Not completed yet
528
+ executionIndex,
529
+ }];
530
+ return newHistory;
531
+ });
532
+ }
533
+
534
+ // Fetch execution history from controller on pause to get any completed nodes
535
+ if (currentExecutionId) {
536
+ fetchExecutionHistory(currentExecutionId);
537
+ }
538
+ break;
539
+ }
540
+ case 'resume':
541
+ // Don't set status here - stateUpdate is the authoritative source
542
+ // The resume event is just informational, stateUpdate will follow with the actual status
543
+ break;
544
+ case 'stateUpdate': {
545
+ const stateData = event.data as { status?: ExecutionStatus; currentNodeId?: string | null };
546
+ console.log(`[ToolTester] stateUpdate event:`, stateData);
547
+ if (stateData.status) {
548
+ console.log(`[ToolTester] Setting execution status to: ${stateData.status}`);
549
+ setExecutionStatus(stateData.status);
550
+ }
551
+ if (stateData.currentNodeId !== undefined) {
552
+ setCurrentNodeId(stateData.currentNodeId);
553
+ } else if (stateData.status === 'running' || stateData.status === 'finished' || stateData.status === 'error' || stateData.status === 'stopped') {
554
+ // Clear currentNodeId when execution is no longer paused
555
+ setCurrentNodeId(null);
556
+ }
557
+ break;
558
+ }
559
+ }
560
+
561
+ // Update execution state
562
+ setExecutionState(new Map(state));
563
+ });
564
+
565
+ // Wait for SSE connection to be ready (event-driven)
566
+ const waitForSSE = new Promise<void>((resolve) => {
567
+ if (sseReady) {
568
+ resolve();
569
+ } else {
570
+ sseReadyResolve = resolve;
571
+ // Safety timeout in case connected event never arrives
572
+ setTimeout(() => {
573
+ if (!sseReady && sseReadyResolve) {
574
+ console.warn(`[ToolTester] SSE connection not ready after 2s, proceeding anyway`);
575
+ sseReadyResolve = null;
576
+ resolve();
577
+ }
578
+ }, 2000);
579
+ }
580
+ });
77
581
 
78
582
  try {
583
+ await waitForSSE;
584
+ console.log(`[ToolTester] Starting execution for tool: ${toolName}, executionId: ${executionId}`);
585
+
586
+ const breakpointsArray = Array.from(currentBreakpoints);
587
+
79
588
  const response = await fetch(`/api/tools/${toolName}`, {
80
589
  method: 'POST',
81
590
  headers: {
82
591
  'Content-Type': 'application/json',
83
592
  },
84
- body: JSON.stringify({ args: formData }),
593
+ body: JSON.stringify({
594
+ args: formData,
595
+ executionId,
596
+ options: {
597
+ enableTelemetry: true,
598
+ breakpoints: breakpointsArray,
599
+ startPaused: startPaused, // mcpGraph 0.1.12+ supports starting paused
600
+ },
601
+ }),
85
602
  });
86
603
 
87
604
  const data = await response.json();
88
605
 
89
606
  if (data.error) {
90
- setError(data.error);
91
- } else {
92
- setResult(data.result);
607
+ // Show error in execution history, not as a banner - just the summary
608
+ const fullError = typeof data.error === 'string'
609
+ ? data.error
610
+ : (typeof data.error === 'object' && data.error !== null && 'message' in data.error)
611
+ ? String((data.error as { message: unknown }).message)
612
+ : 'Unknown error';
613
+ // Extract just the first line for the summary (before stderr/details)
614
+ const errorSummary = fullError.split('\n')[0].split('Server stderr')[0].trim();
615
+ setExecutionResult({ error: errorSummary });
616
+ setExecutionStatus('error');
617
+ setLoading(false);
618
+ stream.disconnect();
619
+ executionStreamRef.current = null;
93
620
  }
621
+ // Result will be set via SSE executionComplete event
94
622
  } catch (err) {
95
- setError(err instanceof Error ? err.message : 'Unknown error');
96
- } finally {
623
+ console.error(`[ToolTester] Error executing tool:`, err);
624
+ // Show error in execution history, not as a banner
625
+ setExecutionResult({ error: err instanceof Error ? err.message : 'Unknown error' });
626
+ setExecutionStatus('error');
97
627
  setLoading(false);
628
+ stream.disconnect();
629
+ executionStreamRef.current = null;
630
+ }
631
+ };
632
+
633
+ // Expose handlers for DebugControls - trigger form submission
634
+ const handleRun = () => {
635
+ if (inputFormRef.current) {
636
+ inputFormRef.current.submit(false);
637
+ } else {
638
+ console.warn('[ToolTester] inputFormRef.current is null, cannot submit form');
639
+ }
640
+ };
641
+ const handleStep = () => {
642
+ if (inputFormRef.current) {
643
+ inputFormRef.current.submit(true);
644
+ } else {
645
+ console.warn('[ToolTester] inputFormRef.current is null, cannot submit form');
646
+ }
647
+ };
648
+
649
+ const handleClear = () => {
650
+ // Reset all execution-related state
651
+ setLoading(false);
652
+ setExecutionResult(null);
653
+ setExecutionHistory([]);
654
+ setTelemetry(null);
655
+ setExecutionStatus('not_started');
656
+ setCurrentNodeId(null);
657
+ setCurrentExecutionId(null);
658
+ setExecutionState(new Map());
659
+ setHighlightedNode(null);
660
+ const emptyBreakpoints = new Set<string>();
661
+ setBreakpoints(emptyBreakpoints);
662
+ // Update ref immediately
663
+ breakpointsRef.current = emptyBreakpoints;
664
+
665
+ // Clear execution state ref
666
+ executionStateRef.current.clear();
667
+
668
+ // Disconnect any active stream
669
+ if (executionStreamRef.current) {
670
+ executionStreamRef.current.disconnect();
671
+ executionStreamRef.current = null;
98
672
  }
99
673
  };
674
+
675
+ // Keep breakpointsRef in sync with breakpoints state
676
+ useEffect(() => {
677
+ breakpointsRef.current = breakpoints;
678
+ }, [breakpoints]);
100
679
 
101
- const renderInputField = (key: string, prop: any) => {
102
- const isRequired = toolInfo?.inputSchema.required?.includes(key);
103
- const value = formData[key] ?? '';
680
+ // Reset execution state when toolName changes (switching to a different tool)
681
+ useEffect(() => {
682
+ // Clear all execution-related state when tool changes
683
+ setLoading(false);
684
+ setExecutionResult(null);
685
+ setExecutionHistory([]);
686
+ setTelemetry(null);
687
+ setExecutionStatus('not_started');
688
+ setCurrentNodeId(null);
689
+ setCurrentExecutionId(null);
690
+ setExecutionState(new Map());
691
+ setHighlightedNode(null);
692
+ const emptyBreakpoints = new Set<string>();
693
+ setBreakpoints(emptyBreakpoints);
694
+ breakpointsRef.current = emptyBreakpoints;
695
+ executionStateRef.current.clear();
696
+
697
+ // Disconnect any active stream
698
+ if (executionStreamRef.current) {
699
+ executionStreamRef.current.disconnect();
700
+ executionStreamRef.current = null;
701
+ }
702
+ }, [toolName]);
104
703
 
105
- switch (prop.type) {
106
- case 'string':
107
- return (
108
- <div key={key} className={styles.field}>
109
- <label className={styles.label}>
110
- {key}
111
- {isRequired && <span className={styles.required}>*</span>}
112
- </label>
113
- <input
114
- type="text"
115
- value={value}
116
- onChange={e => handleInputChange(key, e.target.value)}
117
- className={styles.input}
118
- placeholder={prop.description || `Enter ${key}`}
119
- />
120
- {prop.description && (
121
- <div className={styles.hint}>{prop.description}</div>
122
- )}
123
- </div>
124
- );
125
- case 'number':
126
- return (
127
- <div key={key} className={styles.field}>
128
- <label className={styles.label}>
129
- {key}
130
- {isRequired && <span className={styles.required}>*</span>}
131
- </label>
132
- <input
133
- type="number"
134
- value={value}
135
- onChange={e => handleInputChange(key, parseFloat(e.target.value) || 0)}
136
- className={styles.input}
137
- placeholder={prop.description || `Enter ${key}`}
138
- />
139
- {prop.description && (
140
- <div className={styles.hint}>{prop.description}</div>
141
- )}
142
- </div>
143
- );
144
- case 'boolean':
145
- return (
146
- <div key={key} className={styles.field}>
147
- <label className={styles.checkboxLabel}>
148
- <input
149
- type="checkbox"
150
- checked={value}
151
- onChange={e => handleInputChange(key, e.target.checked)}
152
- className={styles.checkbox}
153
- />
154
- {key}
155
- {isRequired && <span className={styles.required}>*</span>}
156
- </label>
157
- {prop.description && (
158
- <div className={styles.hint}>{prop.description}</div>
159
- )}
160
- </div>
161
- );
162
- case 'array':
163
- return (
164
- <div key={key} className={styles.field}>
165
- <label className={styles.label}>
166
- {key}
167
- {isRequired && <span className={styles.required}>*</span>}
168
- </label>
169
- <textarea
170
- value={Array.isArray(value) ? JSON.stringify(value, null, 2) : '[]'}
171
- onChange={e => {
172
- try {
173
- const parsed = JSON.parse(e.target.value);
174
- handleInputChange(key, parsed);
175
- } catch {
176
- // Invalid JSON, ignore
177
- }
178
- }}
179
- className={styles.textarea}
180
- placeholder={prop.description || `Enter ${key} as JSON array`}
181
- rows={3}
182
- />
183
- {prop.description && (
184
- <div className={styles.hint}>{prop.description}</div>
185
- )}
186
- </div>
187
- );
188
- case 'object':
189
- return (
190
- <div key={key} className={styles.field}>
191
- <label className={styles.label}>
192
- {key}
193
- {isRequired && <span className={styles.required}>*</span>}
194
- </label>
195
- <textarea
196
- value={typeof value === 'object' ? JSON.stringify(value, null, 2) : '{}'}
197
- onChange={e => {
198
- try {
199
- const parsed = JSON.parse(e.target.value);
200
- handleInputChange(key, parsed);
201
- } catch {
202
- // Invalid JSON, ignore
203
- }
204
- }}
205
- className={styles.textarea}
206
- placeholder={prop.description || `Enter ${key} as JSON object`}
207
- rows={5}
208
- />
209
- {prop.description && (
210
- <div className={styles.hint}>{prop.description}</div>
211
- )}
212
- </div>
213
- );
214
- default:
215
- return (
216
- <div key={key} className={styles.field}>
217
- <label className={styles.label}>
218
- {key}
219
- {isRequired && <span className={styles.required}>*</span>}
220
- </label>
221
- <textarea
222
- value={typeof value === 'string' ? value : JSON.stringify(value)}
223
- onChange={e => {
224
- try {
225
- const parsed = JSON.parse(e.target.value);
226
- handleInputChange(key, parsed);
227
- } catch {
228
- handleInputChange(key, e.target.value);
229
- }
230
- }}
231
- className={styles.textarea}
232
- placeholder={prop.description || `Enter ${key}`}
233
- rows={3}
234
- />
235
- {prop.description && (
236
- <div className={styles.hint}>{prop.description}</div>
237
- )}
238
- </div>
239
- );
704
+ // Expose form submit handler to parent (so InputForm can call it)
705
+ // Update when toolName changes to ensure we use the correct tool
706
+ useEffect(() => {
707
+ // Update the ref with the current handleSubmit
708
+ // The handleSubmit function will read breakpoints from breakpointsRef when called
709
+ formSubmitHandlerRef.current = handleSubmit;
710
+
711
+ // Expose the handler to parent
712
+ if (onFormSubmit) {
713
+ onFormSubmit((formData: Record<string, any>, startPaused: boolean) => {
714
+ if (formSubmitHandlerRef.current) {
715
+ formSubmitHandlerRef.current(formData, startPaused);
716
+ } else {
717
+ console.warn('[ToolTester] formSubmitHandlerRef.current is null');
718
+ }
719
+ });
720
+ } else {
721
+ console.warn('[ToolTester] onFormSubmit is not provided');
722
+ }
723
+ // Update handler when toolName changes so it uses the correct tool
724
+ // handleSubmit is recreated on every render but uses toolName from props, so we need to update the ref
725
+ // eslint-disable-next-line react-hooks/exhaustive-deps
726
+ }, [toolName, onFormSubmit]);
727
+
728
+ // Cleanup on unmount
729
+ useEffect(() => {
730
+ return () => {
731
+ if (executionStreamRef.current) {
732
+ executionStreamRef.current.disconnect();
733
+ }
734
+ };
735
+ }, []);
736
+
737
+ const handleToggleBreakpoint = (nodeId: string) => {
738
+ const newBreakpoints = new Set(breakpoints);
739
+ if (newBreakpoints.has(nodeId)) {
740
+ newBreakpoints.delete(nodeId);
741
+ } else {
742
+ newBreakpoints.add(nodeId);
240
743
  }
744
+ setBreakpoints(newBreakpoints);
745
+ // Update ref immediately so handleSubmit always has the latest value
746
+ breakpointsRef.current = newBreakpoints;
241
747
  };
242
748
 
243
- if (!toolInfo) {
244
- return <div className={styles.loading}>Loading tool information...</div>;
245
- }
749
+ const handleNodeClick = (nodeId: string) => {
750
+ setHighlightedNode(nodeId);
751
+ setTimeout(() => setHighlightedNode(null), 2000);
752
+ };
246
753
 
247
754
  return (
248
755
  <div className={styles.container}>
249
- <form onSubmit={handleSubmit} className={styles.form}>
250
- <div className={styles.inputs}>
251
- {toolInfo.inputSchema.properties &&
252
- Object.entries(toolInfo.inputSchema.properties).map(([key, prop]) =>
253
- renderInputField(key, prop)
254
- )}
255
- </div>
256
-
257
- <button
258
- type="submit"
259
- disabled={loading}
260
- className={styles.submitButton}
261
- >
262
- {loading ? 'Testing...' : 'Test Tool'}
263
- </button>
264
- </form>
265
-
266
- {error && (
267
- <div className={styles.error}>
268
- <strong>Error:</strong> {error}
756
+ <div className={styles.debugControlsHeader}>
757
+ <DebugControls
758
+ executionId={currentExecutionId}
759
+ status={executionStatus}
760
+ currentNodeId={currentNodeId}
761
+ onRun={handleRun}
762
+ onStepFromStart={handleStep}
763
+ onClear={handleClear}
764
+ />
765
+ </div>
766
+ <div className={styles.graphHistoryContainer}>
767
+ <div className={styles.graphSection}>
768
+ {graphData && (
769
+ <GraphVisualization
770
+ nodes={graphData.nodes}
771
+ edges={graphData.edges}
772
+ selectedTool={toolName}
773
+ executionState={executionState}
774
+ highlightedNode={highlightedNode}
775
+ currentNodeId={currentNodeId}
776
+ breakpoints={breakpoints}
777
+ onToggleBreakpoint={handleToggleBreakpoint}
778
+ onNodeClick={handleNodeClick}
779
+ />
780
+ )}
269
781
  </div>
270
- )}
271
-
272
- {result && (
273
- <div className={styles.result}>
274
- <h3>Result:</h3>
275
- <pre className={styles.resultContent}>
276
- {JSON.stringify(result, null, 2)}
277
- </pre>
782
+ <div className={styles.historySection}>
783
+ <ExecutionHistory
784
+ history={executionHistory}
785
+ result={executionResult}
786
+ telemetry={telemetry || undefined}
787
+ onNodeClick={handleNodeClick}
788
+ />
278
789
  </div>
279
- )}
790
+ </div>
280
791
  </div>
281
792
  );
282
793
  }