@illuma-ai/agents 1.0.82 → 1.0.84

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.
Files changed (36) hide show
  1. package/README.md +485 -485
  2. package/dist/cjs/common/enum.cjs +2 -0
  3. package/dist/cjs/common/enum.cjs.map +1 -1
  4. package/dist/cjs/graphs/Graph.cjs +4 -0
  5. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  6. package/dist/cjs/tools/CodeExecutor.cjs +26 -32
  7. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  8. package/dist/cjs/tools/ToolNode.cjs +83 -1
  9. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  10. package/dist/cjs/types/graph.cjs.map +1 -1
  11. package/dist/esm/common/enum.mjs +2 -0
  12. package/dist/esm/common/enum.mjs.map +1 -1
  13. package/dist/esm/graphs/Graph.mjs +4 -0
  14. package/dist/esm/graphs/Graph.mjs.map +1 -1
  15. package/dist/esm/tools/CodeExecutor.mjs +26 -32
  16. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  17. package/dist/esm/tools/ToolNode.mjs +85 -3
  18. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  19. package/dist/esm/types/graph.mjs.map +1 -1
  20. package/dist/types/common/enum.d.ts +2 -0
  21. package/dist/types/tools/CodeExecutor.d.ts +6 -6
  22. package/dist/types/tools/ToolNode.d.ts +22 -1
  23. package/dist/types/types/graph.d.ts +8 -1
  24. package/dist/types/types/tools.d.ts +85 -0
  25. package/package.json +6 -2
  26. package/src/agents/__tests__/resolveStructuredOutputMode.test.ts +137 -137
  27. package/src/common/enum.ts +2 -0
  28. package/src/graphs/Graph.ts +5 -0
  29. package/src/graphs/__tests__/structured-output.integration.test.ts +809 -809
  30. package/src/graphs/__tests__/structured-output.test.ts +183 -183
  31. package/src/schemas/schema-preparation.test.ts +500 -500
  32. package/src/tools/CodeExecutor.ts +27 -32
  33. package/src/tools/ToolNode.ts +96 -0
  34. package/src/tools/__tests__/ToolApproval.test.ts +699 -0
  35. package/src/types/graph.ts +8 -1
  36. package/src/types/tools.ts +99 -0
@@ -0,0 +1,699 @@
1
+ /**
2
+ * Tests for Human-in-the-Loop (HITL) tool approval in ToolNode.
3
+ *
4
+ * Tests the approval policy evaluation and the event-driven approval flow.
5
+ * The HITL system dispatches ON_TOOL_APPROVAL_REQUIRED events that the host
6
+ * (Ranger) handles by showing UI and resolving/rejecting the promise.
7
+ */
8
+ import { tool } from '@langchain/core/tools';
9
+ import { z } from 'zod';
10
+ import {
11
+ HumanMessage,
12
+ AIMessage,
13
+ ToolMessage,
14
+ } from '@langchain/core/messages';
15
+ import {
16
+ StateGraph,
17
+ Annotation,
18
+ messagesStateReducer,
19
+ START,
20
+ END,
21
+ } from '@langchain/langgraph';
22
+ import type { BaseMessage } from '@langchain/core/messages';
23
+ import type { RunnableConfig } from '@langchain/core/runnables';
24
+ import type * as t from '@/types';
25
+ import { ToolNode, toolsCondition } from '@/tools/ToolNode';
26
+ import { GraphEvents } from '@/common';
27
+
28
+ // ============================================================================
29
+ // Test Fixtures
30
+ // ============================================================================
31
+
32
+ /** Simple echo tool that returns its input */
33
+ const echoTool = tool(
34
+ async ({ message }: { message: string }) => {
35
+ return `Echo: ${message}`;
36
+ },
37
+ {
38
+ name: 'echo',
39
+ description: 'Echoes the input message',
40
+ schema: z.object({
41
+ message: z.string().describe('The message to echo'),
42
+ }),
43
+ }
44
+ );
45
+
46
+ /** Simulated "send email" tool */
47
+ const sendEmailTool = tool(
48
+ async ({ to, subject }: { to: string; subject: string }) => {
49
+ return JSON.stringify({ status: 'sent', to, subject });
50
+ },
51
+ {
52
+ name: 'send_email',
53
+ description: 'Sends an email',
54
+ schema: z.object({
55
+ to: z.string().describe('Recipient email'),
56
+ subject: z.string().describe('Email subject'),
57
+ }),
58
+ }
59
+ );
60
+
61
+ /** Simulated "search" tool - typically safe, no approval needed */
62
+ const searchTool = tool(
63
+ async ({ query }: { query: string }) => {
64
+ return JSON.stringify({ results: [`Result for: ${query}`] });
65
+ },
66
+ {
67
+ name: 'search',
68
+ description: 'Searches for information',
69
+ schema: z.object({
70
+ query: z.string().describe('Search query'),
71
+ }),
72
+ }
73
+ );
74
+
75
+ /**
76
+ * Helper to build a graph with HITL support for integration testing.
77
+ * Returns the compiled graph and a way to intercept approval events.
78
+ */
79
+ function createTestGraph(
80
+ tools: t.GenericTool[],
81
+ toolApprovalConfig: t.ToolApprovalConfig,
82
+ modelResponses: string[][],
83
+ approvalHandler: (event: t.ToolApprovalEvent) => void
84
+ ) {
85
+ const StateAnnotation = Annotation.Root({
86
+ messages: Annotation<BaseMessage[]>({
87
+ reducer: messagesStateReducer,
88
+ default: () => [],
89
+ }),
90
+ });
91
+
92
+ let responseIndex = 0;
93
+
94
+ /** Fake model node that returns pre-defined tool calls or text */
95
+ const callModel = async (state: { messages: BaseMessage[] }) => {
96
+ const responses = modelResponses[responseIndex] ?? ['Done.'];
97
+ responseIndex++;
98
+
99
+ // Check if the response is a tool call instruction (JSON format)
100
+ if (responses.length === 1 && responses[0].startsWith('{')) {
101
+ const parsed = JSON.parse(responses[0]);
102
+ return {
103
+ messages: [
104
+ new AIMessage({
105
+ content: '',
106
+ tool_calls: [parsed],
107
+ }),
108
+ ],
109
+ };
110
+ }
111
+
112
+ return {
113
+ messages: [new AIMessage({ content: responses.join(' ') })],
114
+ };
115
+ };
116
+
117
+ const toolNode = new ToolNode<typeof StateAnnotation.State>({
118
+ tools,
119
+ toolApprovalConfig,
120
+ });
121
+
122
+ const routeMessage = (state: typeof StateAnnotation.State) => {
123
+ return toolsCondition(state, 'tools');
124
+ };
125
+
126
+ const workflow = new StateGraph(StateAnnotation)
127
+ .addNode('agent', callModel)
128
+ .addNode('tools', toolNode)
129
+ .addEdge(START, 'agent')
130
+ .addConditionalEdges('agent', routeMessage)
131
+ .addEdge('tools', 'agent');
132
+
133
+ const compiled = workflow.compile();
134
+
135
+ // Create a config with custom event handler to intercept approval requests
136
+ const config: Partial<RunnableConfig> & { version: 'v1' | 'v2' } = {
137
+ version: 'v2',
138
+ callbacks: [
139
+ {
140
+ handleCustomEvent: async (
141
+ eventName: string,
142
+ data: unknown
143
+ ): Promise<void> => {
144
+ if (eventName === GraphEvents.ON_TOOL_APPROVAL_REQUIRED) {
145
+ approvalHandler(data as t.ToolApprovalEvent);
146
+ }
147
+ },
148
+ },
149
+ ],
150
+ };
151
+
152
+ return { compiled, config };
153
+ }
154
+
155
+ // ============================================================================
156
+ // Unit Tests: ToolApprovalConfig policy evaluation
157
+ // ============================================================================
158
+
159
+ describe('HITL Tool Approval - Policy Evaluation', () => {
160
+ test('no approval config means no interruption', async () => {
161
+ // ToolNode without toolApprovalConfig should execute tools directly
162
+ const toolNode = new ToolNode({
163
+ tools: [echoTool],
164
+ // No toolApprovalConfig
165
+ });
166
+
167
+ const input = {
168
+ messages: [
169
+ new AIMessage({
170
+ content: '',
171
+ tool_calls: [
172
+ {
173
+ name: 'echo',
174
+ args: { message: 'hello' },
175
+ id: 'call_1',
176
+ type: 'tool_call' as const,
177
+ },
178
+ ],
179
+ }),
180
+ ],
181
+ };
182
+
183
+ // Should execute without interruption
184
+ const result = await toolNode.invoke(input);
185
+ expect(result.messages).toHaveLength(1);
186
+ expect(result.messages[0]).toBeInstanceOf(ToolMessage);
187
+ expect((result.messages[0] as ToolMessage).content).toContain('Echo: hello');
188
+ });
189
+
190
+ test('scheduled execution context bypasses all approvals', async () => {
191
+ // Even with defaultPolicy: 'always', scheduled should not interrupt
192
+ const toolNode = new ToolNode({
193
+ tools: [echoTool],
194
+ toolApprovalConfig: {
195
+ defaultPolicy: 'always',
196
+ executionContext: 'scheduled',
197
+ },
198
+ });
199
+
200
+ const input = {
201
+ messages: [
202
+ new AIMessage({
203
+ content: '',
204
+ tool_calls: [
205
+ {
206
+ name: 'echo',
207
+ args: { message: 'scheduled hello' },
208
+ id: 'call_2',
209
+ type: 'tool_call' as const,
210
+ },
211
+ ],
212
+ }),
213
+ ],
214
+ };
215
+
216
+ // Should execute without interruption because context is 'scheduled'
217
+ const result = await toolNode.invoke(input);
218
+ expect(result.messages).toHaveLength(1);
219
+ expect((result.messages[0] as ToolMessage).content).toContain(
220
+ 'Echo: scheduled hello'
221
+ );
222
+ });
223
+
224
+ test('tool with "never" override skips approval even when default is "always"', async () => {
225
+ const toolNode = new ToolNode({
226
+ tools: [searchTool],
227
+ toolApprovalConfig: {
228
+ defaultPolicy: 'always',
229
+ executionContext: 'interactive',
230
+ overrides: {
231
+ search: 'never',
232
+ },
233
+ },
234
+ });
235
+
236
+ const input = {
237
+ messages: [
238
+ new AIMessage({
239
+ content: '',
240
+ tool_calls: [
241
+ {
242
+ name: 'search',
243
+ args: { query: 'test query' },
244
+ id: 'call_3',
245
+ type: 'tool_call' as const,
246
+ },
247
+ ],
248
+ }),
249
+ ],
250
+ };
251
+
252
+ // Should execute without interruption because override is 'never'
253
+ const result = await toolNode.invoke(input);
254
+ expect(result.messages).toHaveLength(1);
255
+ expect((result.messages[0] as ToolMessage).content).toContain(
256
+ 'Result for: test query'
257
+ );
258
+ });
259
+ });
260
+
261
+ // ============================================================================
262
+ // Integration Tests: Event-driven approval flow
263
+ // ============================================================================
264
+
265
+ describe('HITL Tool Approval - Event-Driven Flow', () => {
266
+ jest.setTimeout(15000);
267
+
268
+ test('interactive mode with "always" policy dispatches approval event and waits', async () => {
269
+ const approvalEvents: t.ToolApprovalEvent[] = [];
270
+
271
+ const { compiled, config } = createTestGraph(
272
+ [echoTool as t.GenericTool],
273
+ {
274
+ defaultPolicy: 'always',
275
+ executionContext: 'interactive',
276
+ },
277
+ [
278
+ // First model response: request tool call
279
+ [
280
+ JSON.stringify({
281
+ name: 'echo',
282
+ args: { message: 'needs approval' },
283
+ id: 'call_int_1',
284
+ type: 'tool_call',
285
+ }),
286
+ ],
287
+ // Second model response (after tool executes): final text
288
+ ['The echo returned the result.'],
289
+ ],
290
+ (event) => {
291
+ // Auto-approve after capturing the event
292
+ approvalEvents.push(event);
293
+ event.resolve({ approved: true });
294
+ }
295
+ );
296
+
297
+ const result = await compiled.invoke(
298
+ { messages: [new HumanMessage('Say hello')] },
299
+ config
300
+ );
301
+
302
+ // Verify the approval event was dispatched
303
+ expect(approvalEvents).toHaveLength(1);
304
+ expect(approvalEvents[0].type).toBe('tool_approval_required');
305
+ expect(approvalEvents[0].toolName).toBe('echo');
306
+ expect(approvalEvents[0].toolArgs).toEqual({ message: 'needs approval' });
307
+
308
+ // Verify the tool executed after approval
309
+ const toolMessages = result.messages.filter(
310
+ (m: BaseMessage) => m._getType() === 'tool'
311
+ );
312
+ expect(toolMessages.length).toBeGreaterThan(0);
313
+ expect(toolMessages[0].content.toString()).toContain('Echo: needs approval');
314
+
315
+ // Verify final model response
316
+ const lastMessage = result.messages[result.messages.length - 1];
317
+ expect(lastMessage.content).toContain('The echo returned the result');
318
+ });
319
+
320
+ test('denial stops tool execution and returns denial message', async () => {
321
+ const { compiled, config } = createTestGraph(
322
+ [sendEmailTool as t.GenericTool],
323
+ {
324
+ defaultPolicy: 'always',
325
+ executionContext: 'interactive',
326
+ },
327
+ [
328
+ // First model response: request send email
329
+ [
330
+ JSON.stringify({
331
+ name: 'send_email',
332
+ args: { to: 'user@example.com', subject: 'Test' },
333
+ id: 'call_deny_1',
334
+ type: 'tool_call',
335
+ }),
336
+ ],
337
+ // Second model response (after denial): acknowledge
338
+ ['I understand, the email was not sent.'],
339
+ ],
340
+ (event) => {
341
+ // Deny the tool call
342
+ event.resolve({ approved: false, feedback: 'Not now' });
343
+ }
344
+ );
345
+
346
+ const result = await compiled.invoke(
347
+ { messages: [new HumanMessage('Send an email')] },
348
+ config
349
+ );
350
+
351
+ // Find the tool message - it should be a denial
352
+ const toolMessages = result.messages.filter(
353
+ (m: BaseMessage) => m._getType() === 'tool'
354
+ );
355
+ expect(toolMessages.length).toBeGreaterThan(0);
356
+
357
+ const denialMsg = toolMessages.find((m: BaseMessage) =>
358
+ m.content.toString().includes('denied')
359
+ );
360
+ expect(denialMsg).toBeDefined();
361
+ expect(denialMsg!.content.toString()).toContain('Not now');
362
+ });
363
+
364
+ test('custom function override evaluates args to determine approval', async () => {
365
+ // Only require approval for emails to external domains
366
+ const approvalEvents: t.ToolApprovalEvent[] = [];
367
+
368
+ const { compiled, config } = createTestGraph(
369
+ [sendEmailTool as t.GenericTool],
370
+ {
371
+ defaultPolicy: 'never',
372
+ executionContext: 'interactive',
373
+ overrides: {
374
+ send_email: (args: Record<string, unknown>) => {
375
+ const to = (args.to as string) ?? '';
376
+ return !to.endsWith('@internal.com');
377
+ },
378
+ },
379
+ },
380
+ [
381
+ // Tool call to internal domain (should NOT require approval)
382
+ [
383
+ JSON.stringify({
384
+ name: 'send_email',
385
+ args: { to: 'colleague@internal.com', subject: 'Internal' },
386
+ id: 'call_func_1',
387
+ type: 'tool_call',
388
+ }),
389
+ ],
390
+ // Final text
391
+ ['Email sent to internal address.'],
392
+ ],
393
+ (event) => {
394
+ approvalEvents.push(event);
395
+ event.resolve({ approved: true });
396
+ }
397
+ );
398
+
399
+ const result = await compiled.invoke(
400
+ { messages: [new HumanMessage('Send internal email')] },
401
+ config
402
+ );
403
+
404
+ // No approval events should have been dispatched (internal domain)
405
+ expect(approvalEvents).toHaveLength(0);
406
+
407
+ // Email should have been sent directly
408
+ const toolMessages = result.messages.filter(
409
+ (m: BaseMessage) => m._getType() === 'tool'
410
+ );
411
+ expect(toolMessages.length).toBeGreaterThan(0);
412
+ expect(toolMessages[0].content.toString()).toContain('colleague@internal.com');
413
+ });
414
+
415
+ test('custom function override triggers approval for external email', async () => {
416
+ const approvalEvents: t.ToolApprovalEvent[] = [];
417
+
418
+ const { compiled, config } = createTestGraph(
419
+ [sendEmailTool as t.GenericTool],
420
+ {
421
+ defaultPolicy: 'never',
422
+ executionContext: 'interactive',
423
+ overrides: {
424
+ send_email: (args: Record<string, unknown>) => {
425
+ const to = (args.to as string) ?? '';
426
+ return !to.endsWith('@internal.com');
427
+ },
428
+ },
429
+ },
430
+ [
431
+ // Tool call to external domain (SHOULD require approval)
432
+ [
433
+ JSON.stringify({
434
+ name: 'send_email',
435
+ args: { to: 'external@gmail.com', subject: 'External' },
436
+ id: 'call_func_2',
437
+ type: 'tool_call',
438
+ }),
439
+ ],
440
+ // After approval
441
+ ['Email sent to external address.'],
442
+ ],
443
+ (event) => {
444
+ approvalEvents.push(event);
445
+ event.resolve({ approved: true });
446
+ }
447
+ );
448
+
449
+ const result = await compiled.invoke(
450
+ { messages: [new HumanMessage('Send external email')] },
451
+ config
452
+ );
453
+
454
+ // Approval event should have been dispatched (external domain)
455
+ expect(approvalEvents).toHaveLength(1);
456
+ expect(approvalEvents[0].toolName).toBe('send_email');
457
+ expect(approvalEvents[0].toolArgs.to).toBe('external@gmail.com');
458
+
459
+ // Email should have been sent after approval
460
+ const lastMessage = result.messages[result.messages.length - 1];
461
+ expect(lastMessage.content).toContain('Email sent to external address');
462
+ });
463
+
464
+ test('modified args are used when human edits the tool call', async () => {
465
+ const { compiled, config } = createTestGraph(
466
+ [sendEmailTool as t.GenericTool],
467
+ {
468
+ defaultPolicy: 'always',
469
+ executionContext: 'interactive',
470
+ },
471
+ [
472
+ // Model requests send email with original args
473
+ [
474
+ JSON.stringify({
475
+ name: 'send_email',
476
+ args: { to: 'wrong@example.com', subject: 'Original Subject' },
477
+ id: 'call_edit_1',
478
+ type: 'tool_call',
479
+ }),
480
+ ],
481
+ // After tool execution with modified args
482
+ ['Email sent with corrected details.'],
483
+ ],
484
+ (event) => {
485
+ // Approve with modified args
486
+ event.resolve({
487
+ approved: true,
488
+ modifiedArgs: {
489
+ to: 'correct@example.com',
490
+ subject: 'Corrected Subject',
491
+ },
492
+ });
493
+ }
494
+ );
495
+
496
+ const result = await compiled.invoke(
497
+ { messages: [new HumanMessage('Send email')] },
498
+ config
499
+ );
500
+
501
+ // Find the tool message to verify modified args were used
502
+ const toolMessages = result.messages.filter(
503
+ (m: BaseMessage) => m._getType() === 'tool'
504
+ );
505
+ const sentMsg = toolMessages.find(
506
+ (m: BaseMessage) =>
507
+ (m as ToolMessage).name === 'send_email' &&
508
+ m.content.toString().includes('correct@example.com')
509
+ );
510
+ expect(sentMsg).toBeDefined();
511
+ expect(sentMsg!.content.toString()).toContain('Corrected Subject');
512
+ });
513
+
514
+ test('scheduled context auto-approves without dispatching events', async () => {
515
+ const approvalEvents: t.ToolApprovalEvent[] = [];
516
+
517
+ const { compiled, config } = createTestGraph(
518
+ [sendEmailTool as t.GenericTool],
519
+ {
520
+ defaultPolicy: 'always',
521
+ executionContext: 'scheduled', // Scheduled = auto-approve
522
+ },
523
+ [
524
+ [
525
+ JSON.stringify({
526
+ name: 'send_email',
527
+ args: { to: 'user@example.com', subject: 'Scheduled Email' },
528
+ id: 'call_sched_1',
529
+ type: 'tool_call',
530
+ }),
531
+ ],
532
+ ['Scheduled email sent.'],
533
+ ],
534
+ (event) => {
535
+ approvalEvents.push(event);
536
+ event.resolve({ approved: true });
537
+ }
538
+ );
539
+
540
+ const result = await compiled.invoke(
541
+ { messages: [new HumanMessage('Send scheduled email')] },
542
+ config
543
+ );
544
+
545
+ // No approval events should be dispatched for scheduled execution
546
+ expect(approvalEvents).toHaveLength(0);
547
+
548
+ // Email should have been sent directly
549
+ const toolMessages = result.messages.filter(
550
+ (m: BaseMessage) => m._getType() === 'tool'
551
+ );
552
+ expect(toolMessages.length).toBeGreaterThan(0);
553
+ expect(toolMessages[0].content.toString()).toContain('user@example.com');
554
+ });
555
+
556
+ test('multiple tool calls each get individual approval', async () => {
557
+ const approvalEvents: t.ToolApprovalEvent[] = [];
558
+
559
+ const toolNode = new ToolNode({
560
+ tools: [echoTool, sendEmailTool] as t.GenericTool[],
561
+ toolApprovalConfig: {
562
+ defaultPolicy: 'always',
563
+ executionContext: 'interactive',
564
+ },
565
+ });
566
+
567
+ const StateAnnotation = Annotation.Root({
568
+ messages: Annotation<BaseMessage[]>({
569
+ reducer: messagesStateReducer,
570
+ default: () => [],
571
+ }),
572
+ });
573
+
574
+ let callCount = 0;
575
+ const callModel = async (_state: { messages: BaseMessage[] }) => {
576
+ callCount++;
577
+ if (callCount === 1) {
578
+ return {
579
+ messages: [
580
+ new AIMessage({
581
+ content: '',
582
+ tool_calls: [
583
+ { name: 'echo', args: { message: 'test' }, id: 'c1', type: 'tool_call' as const },
584
+ { name: 'send_email', args: { to: 'a@b.com', subject: 'Hi' }, id: 'c2', type: 'tool_call' as const },
585
+ ],
586
+ }),
587
+ ],
588
+ };
589
+ }
590
+ return { messages: [new AIMessage({ content: 'Both done.' })] };
591
+ };
592
+
593
+ const routeMessage = (state: typeof StateAnnotation.State) =>
594
+ toolsCondition(state, 'tools');
595
+
596
+ const workflow = new StateGraph(StateAnnotation)
597
+ .addNode('agent', callModel)
598
+ .addNode('tools', toolNode)
599
+ .addEdge(START, 'agent')
600
+ .addConditionalEdges('agent', routeMessage)
601
+ .addEdge('tools', 'agent');
602
+
603
+ const compiled = workflow.compile();
604
+ const config: Partial<RunnableConfig> & { version: 'v1' | 'v2' } = {
605
+ version: 'v2',
606
+ callbacks: [
607
+ {
608
+ handleCustomEvent: async (
609
+ eventName: string,
610
+ data: unknown
611
+ ): Promise<void> => {
612
+ if (eventName === GraphEvents.ON_TOOL_APPROVAL_REQUIRED) {
613
+ const event = data as t.ToolApprovalEvent;
614
+ approvalEvents.push(event);
615
+ event.resolve({ approved: true });
616
+ }
617
+ },
618
+ },
619
+ ],
620
+ };
621
+
622
+ const result = await compiled.invoke(
623
+ { messages: [new HumanMessage('Do both')] },
624
+ config
625
+ );
626
+
627
+ // Both tools should have triggered approval events
628
+ expect(approvalEvents).toHaveLength(2);
629
+ expect(approvalEvents.map((e) => e.toolName).sort()).toEqual(['echo', 'send_email']);
630
+
631
+ // Both tools should have executed
632
+ const toolMessages = result.messages.filter(
633
+ (m: BaseMessage) => m._getType() === 'tool'
634
+ );
635
+ expect(toolMessages).toHaveLength(2);
636
+ });
637
+ });
638
+
639
+ // ============================================================================
640
+ // Type Tests: ToolApprovalConfig shape validation
641
+ // ============================================================================
642
+
643
+ describe('HITL Types', () => {
644
+ test('ToolApprovalConfig accepts valid configurations', () => {
645
+ const config1: t.ToolApprovalConfig = {
646
+ defaultPolicy: 'always',
647
+ executionContext: 'interactive',
648
+ };
649
+ expect(config1.defaultPolicy).toBe('always');
650
+
651
+ const config2: t.ToolApprovalConfig = {
652
+ defaultPolicy: 'never',
653
+ executionContext: 'scheduled',
654
+ };
655
+ expect(config2.executionContext).toBe('scheduled');
656
+
657
+ const config3: t.ToolApprovalConfig = {
658
+ defaultPolicy: 'always',
659
+ executionContext: 'interactive',
660
+ overrides: {
661
+ search: 'never',
662
+ send_email: 'always',
663
+ custom_tool: (args) => (args.dangerous as boolean) === true,
664
+ },
665
+ };
666
+ expect(config3.overrides).toBeDefined();
667
+ expect(config3.overrides!.search).toBe('never');
668
+ });
669
+
670
+ test('ToolApprovalRequest has correct shape', () => {
671
+ const request: t.ToolApprovalRequest = {
672
+ type: 'tool_approval_required',
673
+ toolCallId: 'call_123',
674
+ toolName: 'send_email',
675
+ toolArgs: { to: 'user@example.com' },
676
+ agentId: 'agent-1',
677
+ description: 'Send email to user',
678
+ };
679
+ expect(request.type).toBe('tool_approval_required');
680
+ expect(request.toolName).toBe('send_email');
681
+ });
682
+
683
+ test('ToolApprovalResponse covers all response types', () => {
684
+ const approved: t.ToolApprovalResponse = { approved: true };
685
+ expect(approved.approved).toBe(true);
686
+
687
+ const denied: t.ToolApprovalResponse = {
688
+ approved: false,
689
+ feedback: 'Too risky',
690
+ };
691
+ expect(denied.feedback).toBe('Too risky');
692
+
693
+ const edited: t.ToolApprovalResponse = {
694
+ approved: true,
695
+ modifiedArgs: { to: 'corrected@example.com' },
696
+ };
697
+ expect(edited.modifiedArgs).toBeDefined();
698
+ });
699
+ });