mcpgraph-ux 0.1.1 → 0.1.3

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