@things-factory/board-ai 10.0.18 → 10.1.0

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,859 +0,0 @@
1
- /**
2
- * agentic-loop 단위 테스트 — mock chat + mock dispatch helper 로 isolation.
3
- *
4
- * assistant.ts 의 transitive import (board-import → @aws-sdk → @nodable/entities)
5
- * 를 우회하므로 빠르고 안정적. loop 의 *제어 흐름* 만 검증.
6
- */
7
- import {
8
- runAgenticLoop,
9
- type ToolDispatchHelpers,
10
- type AgenticLoopInput
11
- } from './agentic-loop'
12
- import type { AIToolChatResult, AIToolCall } from '@things-factory/ai-client-base'
13
-
14
- // ── helpers ─────────────────────────────────────────────────────
15
-
16
- const TC = (name: string, args: any = {}, id = 't1'): AIToolCall =>
17
- ({ id, name, arguments: args, type: 'tool_use' } as any)
18
-
19
- const okResult = (text?: string, toolCalls: AIToolCall[] = []): AIToolChatResult => ({
20
- text,
21
- toolCalls,
22
- stopReason: toolCalls.length > 0 ? 'tool_use' : 'end_turn',
23
- usage: undefined as any
24
- })
25
-
26
- const baseDispatch = (overrides: Partial<ToolDispatchHelpers> = {}): ToolDispatchHelpers => ({
27
- isReadTool: () => false,
28
- isWriteTool: () => false,
29
- isActionTool: () => false,
30
- executeReadTool: () => ({ stub: true }),
31
- validateWriteToolCall: () => ({ valid: true }),
32
- toolCallToBoardActionOp: () => null,
33
- summarizeToolResult: v => v,
34
- ...overrides
35
- })
36
-
37
- const mkInput = (
38
- overrides: Partial<AgenticLoopInput> = {},
39
- dispatchOverrides: Partial<ToolDispatchHelpers> = {}
40
- ): AgenticLoopInput => ({
41
- initialConversation: [{ role: 'user', content: 'hi' }],
42
- tools: [],
43
- options: { systemPrompt: 'you are a bot' },
44
- chat: async () => okResult('default reply'),
45
- dispatch: baseDispatch(dispatchOverrides),
46
- currentBoard: undefined,
47
- selectedRefids: [],
48
- ...overrides
49
- })
50
-
51
- // ── Tests ───────────────────────────────────────────────────────
52
-
53
- describe('runAgenticLoop — 단일 turn (no tool calls)', () => {
54
- test('LLM 이 텍스트만 반환 → 즉시 종료', async () => {
55
- const input = mkInput({ chat: async () => okResult('완료') })
56
- const r = await runAgenticLoop(input)
57
- expect(r.lastText).toBe('완료')
58
- expect(r.accumulatedWriteCalls).toEqual([])
59
- expect(r.toolUsages).toEqual([])
60
- expect(r.stopReason).toBe('end_turn')
61
- })
62
-
63
- test('빈 reply + 빈 toolCalls — 그래도 정상 종료', async () => {
64
- const input = mkInput({ chat: async () => okResult() })
65
- const r = await runAgenticLoop(input)
66
- expect(r.lastText).toBeUndefined()
67
- expect(r.accumulatedWriteCalls).toEqual([])
68
- })
69
- })
70
-
71
- describe('runAgenticLoop — read tool 처리', () => {
72
- test('read tool 호출 → executeReadTool 실행 → 결과 회신 후 LLM 추론 계속', async () => {
73
- let callCount = 0
74
- const input = mkInput(
75
- {
76
- chat: async () => {
77
- callCount++
78
- if (callCount === 1) {
79
- return okResult(undefined, [TC('getSelection', {})])
80
- }
81
- return okResult('finished')
82
- }
83
- },
84
- {
85
- isReadTool: name => name === 'getSelection',
86
- executeReadTool: tc => ({ called: tc.name, refids: [1, 2] })
87
- }
88
- )
89
- const r = await runAgenticLoop(input)
90
- expect(callCount).toBe(2)
91
- expect(r.lastText).toBe('finished')
92
- expect(r.toolUsages).toHaveLength(1)
93
- expect(r.toolUsages[0]).toMatchObject({
94
- name: 'getSelection',
95
- kind: 'read',
96
- result: { called: 'getSelection', refids: [1, 2] }
97
- })
98
- })
99
-
100
- test('read tool 결과가 다음 turn 의 conversation 에 포함', async () => {
101
- const conversations: any[] = []
102
- const input = mkInput(
103
- {
104
- chat: async msgs => {
105
- conversations.push(msgs)
106
- if (conversations.length === 1) return okResult(undefined, [TC('getX')])
107
- return okResult('done')
108
- }
109
- },
110
- {
111
- isReadTool: () => true,
112
- executeReadTool: () => ({ data: 'x' })
113
- }
114
- )
115
- await runAgenticLoop(input)
116
- // 두 번째 호출 시 conversation 에 assistant tool_use + user tool_result 가 있어야
117
- expect(conversations).toHaveLength(2)
118
- const second = conversations[1]
119
- expect(second.length).toBeGreaterThan(2) // 최소 user / assistant / user
120
- const lastMsg = second[second.length - 1]
121
- expect(lastMsg.role).toBe('user')
122
- expect(lastMsg.content[0]).toMatchObject({
123
- type: 'tool_result',
124
- toolUseId: 't1'
125
- })
126
- expect(JSON.parse(lastMsg.content[0].content)).toEqual({ data: 'x' })
127
- })
128
-
129
- test('summarizeToolResult 가 result 를 변환', async () => {
130
- let callCount = 0
131
- const input = mkInput(
132
- {
133
- chat: async () => {
134
- callCount++
135
- return callCount === 1
136
- ? okResult(undefined, [TC('listX')])
137
- : okResult('end')
138
- }
139
- },
140
- {
141
- isReadTool: () => true,
142
- executeReadTool: () => ({ items: Array(100).fill('x') }),
143
- summarizeToolResult: v => ({ summary: 'truncated', count: v.items.length })
144
- }
145
- )
146
- const r = await runAgenticLoop(input)
147
- expect(r.toolUsages[0].result).toEqual({ summary: 'truncated', count: 100 })
148
- })
149
- })
150
-
151
- describe('runAgenticLoop — write tool 처리', () => {
152
- test('정상 write — 누적 + queued 결과 회신', async () => {
153
- let callCount = 0
154
- const input = mkInput(
155
- {
156
- chat: async () => {
157
- callCount++
158
- return callCount === 1
159
- ? okResult(undefined, [TC('setFill', { refids: [1] })])
160
- : okResult('변경했습니다')
161
- }
162
- },
163
- {
164
- isWriteTool: name => name === 'setFill',
165
- validateWriteToolCall: () => ({ valid: true })
166
- }
167
- )
168
- const r = await runAgenticLoop(input)
169
- expect(r.accumulatedWriteCalls).toHaveLength(1)
170
- expect(r.accumulatedWriteCalls[0].name).toBe('setFill')
171
- expect(r.toolUsages[0]).toMatchObject({
172
- name: 'setFill',
173
- kind: 'write',
174
- result: { queued: true }
175
- })
176
- })
177
-
178
- test('validation 실패 — 누적 안 됨, error 회신', async () => {
179
- let callCount = 0
180
- const input = mkInput(
181
- {
182
- chat: async () => {
183
- callCount++
184
- return callCount === 1
185
- ? okResult(undefined, [TC('setFill', {})])
186
- : okResult('failed')
187
- }
188
- },
189
- {
190
- isWriteTool: () => true,
191
- validateWriteToolCall: () => ({
192
- valid: false,
193
- errors: [{ msg: 'refids missing' }],
194
- suggestion: 'add refids array'
195
- })
196
- }
197
- )
198
- const r = await runAgenticLoop(input)
199
- expect(r.accumulatedWriteCalls).toEqual([])
200
- expect(r.toolUsages[0].result).toMatchObject({
201
- error: 'Tool args failed validation',
202
- issues: [{ msg: 'refids missing' }],
203
- suggestion: 'add refids array'
204
- })
205
- })
206
-
207
- test('일부는 valid, 일부는 invalid — 부분 누적', async () => {
208
- let callCount = 0
209
- const input = mkInput(
210
- {
211
- chat: async () => {
212
- callCount++
213
- return callCount === 1
214
- ? okResult(undefined, [
215
- TC('setFill', { refids: [1] }, 't1'),
216
- TC('setStroke', {}, 't2')
217
- ])
218
- : okResult('mixed')
219
- }
220
- },
221
- {
222
- isWriteTool: () => true,
223
- validateWriteToolCall: tc =>
224
- tc.name === 'setFill' ? { valid: true } : { valid: false, errors: ['bad'] }
225
- }
226
- )
227
- const r = await runAgenticLoop(input)
228
- expect(r.accumulatedWriteCalls).toHaveLength(1)
229
- expect(r.accumulatedWriteCalls[0].name).toBe('setFill')
230
- expect(r.toolUsages).toHaveLength(2)
231
- })
232
- })
233
-
234
- describe('runAgenticLoop — action tool 처리', () => {
235
- test('action tool — accumulatedActions 에 분리 누적', async () => {
236
- let callCount = 0
237
- const input = mkInput(
238
- {
239
- chat: async () => {
240
- callCount++
241
- return callCount === 1
242
- ? okResult(undefined, [TC('selectComponents', { refids: [7] })])
243
- : okResult('selected')
244
- }
245
- },
246
- {
247
- isActionTool: name => name === 'selectComponents',
248
- toolCallToBoardActionOp: tc => ({
249
- action: 'selectComponents',
250
- refids: tc.arguments.refids
251
- }) as any
252
- }
253
- )
254
- const r = await runAgenticLoop(input)
255
- expect(r.accumulatedActions).toHaveLength(1)
256
- expect(r.accumulatedActions[0]).toMatchObject({
257
- action: 'selectComponents',
258
- refids: [7]
259
- })
260
- expect(r.accumulatedWriteCalls).toEqual([])
261
- })
262
-
263
- test('toolCallToBoardActionOp 가 null 반환 — actions 안 누적', async () => {
264
- let callCount = 0
265
- const input = mkInput(
266
- {
267
- chat: async () => {
268
- callCount++
269
- return callCount === 1
270
- ? okResult(undefined, [TC('badAction', {})])
271
- : okResult('done')
272
- }
273
- },
274
- {
275
- isActionTool: () => true,
276
- toolCallToBoardActionOp: () => null
277
- }
278
- )
279
- const r = await runAgenticLoop(input)
280
- expect(r.accumulatedActions).toEqual([])
281
- // 그래도 toolUsages 에는 기록 (queued)
282
- expect(r.toolUsages).toHaveLength(1)
283
- })
284
- })
285
-
286
- describe('runAgenticLoop — unknown tool 처리', () => {
287
- test('어떤 분류에도 안 맞음 → error 결과', async () => {
288
- let callCount = 0
289
- const input = mkInput({
290
- chat: async () => {
291
- callCount++
292
- return callCount === 1
293
- ? okResult(undefined, [TC('mysteryTool', {})])
294
- : okResult('weird')
295
- }
296
- })
297
- const r = await runAgenticLoop(input)
298
- expect(r.toolUsages[0]).toMatchObject({
299
- name: 'mysteryTool',
300
- kind: 'unknown',
301
- result: { error: 'Unknown tool: mysteryTool' }
302
- })
303
- })
304
- })
305
-
306
- describe('runAgenticLoop — iteration cap', () => {
307
- test('LLM 이 계속 tool 호출 → maxIterations 도달 시 종료', async () => {
308
- let callCount = 0
309
- const input = mkInput(
310
- {
311
- chat: async () => {
312
- callCount++
313
- // 매번 read tool 호출 — 무한 루프 가능 시나리오
314
- return okResult(undefined, [TC('endless')])
315
- },
316
- options: { systemPrompt: 'x', maxIterations: 3 }
317
- },
318
- {
319
- isReadTool: () => true,
320
- executeReadTool: () => ({ ok: true })
321
- }
322
- )
323
- const r = await runAgenticLoop(input)
324
- expect(callCount).toBe(3)
325
- expect(r.toolUsages).toHaveLength(3)
326
- })
327
-
328
- test('기본 maxIterations 8', async () => {
329
- let callCount = 0
330
- const input = mkInput(
331
- {
332
- chat: async () => {
333
- callCount++
334
- return okResult(undefined, [TC('endless')])
335
- }
336
- },
337
- {
338
- isReadTool: () => true,
339
- executeReadTool: () => ({})
340
- }
341
- )
342
- await runAgenticLoop(input)
343
- expect(callCount).toBe(8)
344
- })
345
- })
346
-
347
- describe('runAgenticLoop — providerMeta round-trip (Gemini thoughtSignature 등)', () => {
348
- test('tool_use 메시지에 providerMeta 포함', async () => {
349
- let secondMessages: any[] = []
350
- let callCount = 0
351
- const input = mkInput(
352
- {
353
- chat: async messages => {
354
- callCount++
355
- if (callCount === 1) {
356
- return okResult(undefined, [
357
- {
358
- ...TC('getX'),
359
- providerMeta: { thoughtSignature: 'gem-12345' }
360
- } as any
361
- ])
362
- }
363
- secondMessages = messages
364
- return okResult('end')
365
- }
366
- },
367
- {
368
- isReadTool: () => true,
369
- executeReadTool: () => ({})
370
- }
371
- )
372
- await runAgenticLoop(input)
373
- // assistant turn 의 tool_use content 에 providerMeta 가 살아있어야
374
- const assistantMsg = secondMessages.find(m => m.role === 'assistant')
375
- expect(assistantMsg).toBeDefined()
376
- expect(assistantMsg.content[0]).toMatchObject({
377
- type: 'tool_use',
378
- providerMeta: { thoughtSignature: 'gem-12345' }
379
- })
380
- })
381
- })
382
-
383
- describe('runAgenticLoop — error boundary (provider 예외)', () => {
384
- test('provider 가 throw → abortReason: provider_error + graceful 종료', async () => {
385
- const input = mkInput({
386
- chat: async () => {
387
- throw new Error('upstream timeout')
388
- }
389
- })
390
- const r = await runAgenticLoop(input)
391
- expect(r.abortReason).toEqual({
392
- type: 'provider_error',
393
- message: 'upstream timeout',
394
- iter: 0
395
- })
396
- expect(r.accumulatedWriteCalls).toEqual([])
397
- expect(r.toolUsages).toEqual([])
398
- })
399
-
400
- test('두 번째 iteration 에서 throw — 첫 iter 결과는 보존', async () => {
401
- let callCount = 0
402
- const input = mkInput(
403
- {
404
- chat: async () => {
405
- callCount++
406
- if (callCount === 1) return okResult(undefined, [TC('getX')])
407
- throw new Error('500')
408
- }
409
- },
410
- {
411
- isReadTool: () => true,
412
- executeReadTool: () => ({ data: 'ok' })
413
- }
414
- )
415
- const r = await runAgenticLoop(input)
416
- expect(r.abortReason).toMatchObject({ type: 'provider_error', iter: 1 })
417
- // 첫 iter 의 read tool usage 는 보존
418
- expect(r.toolUsages).toHaveLength(1)
419
- expect(r.toolUsages[0].name).toBe('getX')
420
- })
421
-
422
- test('AbortError 는 별도 분류 (provider_error 아님)', async () => {
423
- const err = new Error('aborted')
424
- ;(err as any).name = 'AbortError'
425
- const input = mkInput({
426
- chat: async () => {
427
- throw err
428
- }
429
- })
430
- const r = await runAgenticLoop(input)
431
- expect(r.abortReason).toEqual({ type: 'aborted', iter: 0 })
432
- })
433
- })
434
-
435
- describe('runAgenticLoop — AbortSignal', () => {
436
- test('이미 abort 된 신호 — 첫 iteration 도 안 돔', async () => {
437
- const ctrl = new AbortController()
438
- ctrl.abort()
439
- let chatCalled = false
440
- const input = mkInput({
441
- options: { systemPrompt: 'x', signal: ctrl.signal },
442
- chat: async () => {
443
- chatCalled = true
444
- return okResult('should-not-reach')
445
- }
446
- })
447
- const r = await runAgenticLoop(input)
448
- expect(chatCalled).toBe(false)
449
- expect(r.abortReason).toEqual({ type: 'aborted', iter: 0 })
450
- })
451
-
452
- test('iteration 중간에 abort — 다음 iter 시작 시 종료', async () => {
453
- const ctrl = new AbortController()
454
- let callCount = 0
455
- const input = mkInput(
456
- {
457
- options: { systemPrompt: 'x', signal: ctrl.signal },
458
- chat: async () => {
459
- callCount++
460
- if (callCount === 1) {
461
- ctrl.abort() // 첫 iter 후 abort
462
- return okResult(undefined, [TC('getX')])
463
- }
464
- return okResult('end')
465
- }
466
- },
467
- {
468
- isReadTool: () => true,
469
- executeReadTool: () => ({})
470
- }
471
- )
472
- const r = await runAgenticLoop(input)
473
- // 두 번째 iter 시작 전 abort 감지 → 1번만 호출됨
474
- expect(callCount).toBe(1)
475
- expect(r.abortReason).toEqual({ type: 'aborted', iter: 1 })
476
- // 첫 iter 결과는 보존
477
- expect(r.toolUsages).toHaveLength(1)
478
- })
479
-
480
- test('signal 이 chat 호출에 전달', async () => {
481
- const ctrl = new AbortController()
482
- let receivedSignal: any
483
- const input = mkInput({
484
- options: { systemPrompt: 'x', signal: ctrl.signal },
485
- chat: async (_msgs, _tools, opts) => {
486
- receivedSignal = opts.signal
487
- return okResult('done')
488
- }
489
- })
490
- await runAgenticLoop(input)
491
- expect(receivedSignal).toBe(ctrl.signal)
492
- })
493
- })
494
-
495
- describe('runAgenticLoop — repeated validation failure 조기 종료', () => {
496
- test('같은 tool 3회 연속 fail — 3번째 fail 후 abort', async () => {
497
- let callCount = 0
498
- const input = mkInput(
499
- {
500
- chat: async () => {
501
- callCount++
502
- // 매번 같은 tool 을 같은 invalid args 로 호출
503
- return okResult(undefined, [TC('setFill', { bad: true })])
504
- }
505
- },
506
- {
507
- isWriteTool: () => true,
508
- validateWriteToolCall: () => ({ valid: false, errors: ['always fails'] })
509
- }
510
- )
511
- const r = await runAgenticLoop(input)
512
- expect(callCount).toBe(3) // 3 회까지 호출 후 abort
513
- expect(r.abortReason).toEqual({
514
- type: 'repeated_validation_failure',
515
- toolName: 'setFill',
516
- consecutive: 3,
517
- iter: 2
518
- })
519
- })
520
-
521
- test('limit 설정 (2) — 2회 연속 fail 시 abort', async () => {
522
- let callCount = 0
523
- const input = mkInput(
524
- {
525
- chat: async () => {
526
- callCount++
527
- return okResult(undefined, [TC('setFill', {})])
528
- },
529
- options: { systemPrompt: 'x', repeatedFailureLimit: 2 }
530
- },
531
- {
532
- isWriteTool: () => true,
533
- validateWriteToolCall: () => ({ valid: false, errors: ['x'] })
534
- }
535
- )
536
- const r = await runAgenticLoop(input)
537
- expect(callCount).toBe(2)
538
- expect((r.abortReason as any)?.consecutive).toBe(2)
539
- })
540
-
541
- test('다른 tool 로 fail 전환 — 카운터 reset', async () => {
542
- let callCount = 0
543
- const input = mkInput(
544
- {
545
- chat: async () => {
546
- callCount++
547
- // 1: setFill fail, 2: setFill fail, 3: setStroke fail (다른 tool)
548
- // 3 에서 reset 되어 limit 도달 안 함, 4 에서 success 종료
549
- if (callCount === 1) return okResult(undefined, [TC('setFill', {})])
550
- if (callCount === 2) return okResult(undefined, [TC('setFill', {})])
551
- if (callCount === 3) return okResult(undefined, [TC('setStroke', {})])
552
- return okResult('end')
553
- }
554
- },
555
- {
556
- isWriteTool: () => true,
557
- validateWriteToolCall: () => ({ valid: false, errors: ['x'] })
558
- }
559
- )
560
- const r = await runAgenticLoop(input)
561
- expect(callCount).toBe(4)
562
- expect(r.abortReason).toBeUndefined()
563
- })
564
-
565
- test('성공 사이에 끼어든 fail — 카운터 reset', async () => {
566
- let callCount = 0
567
- const input = mkInput(
568
- {
569
- chat: async () => {
570
- callCount++
571
- // 1: fail, 2: success (read tool), 3: fail, 4: end
572
- if (callCount === 1) return okResult(undefined, [TC('setFill', {})])
573
- if (callCount === 2) return okResult(undefined, [TC('getX')])
574
- if (callCount === 3) return okResult(undefined, [TC('setFill', {})])
575
- return okResult('end')
576
- }
577
- },
578
- {
579
- isReadTool: name => name === 'getX',
580
- isWriteTool: name => name === 'setFill',
581
- executeReadTool: () => ({}),
582
- validateWriteToolCall: () => ({ valid: false, errors: ['x'] })
583
- }
584
- )
585
- const r = await runAgenticLoop(input)
586
- expect(callCount).toBe(4)
587
- expect(r.abortReason).toBeUndefined()
588
- })
589
-
590
- test('valid succeed → 정상 종료, abortReason undefined', async () => {
591
- let callCount = 0
592
- const input = mkInput(
593
- {
594
- chat: async () => {
595
- callCount++
596
- return callCount === 1
597
- ? okResult(undefined, [TC('setFill', { refids: [1] })])
598
- : okResult('done')
599
- }
600
- },
601
- {
602
- isWriteTool: () => true,
603
- validateWriteToolCall: () => ({ valid: true })
604
- }
605
- )
606
- const r = await runAgenticLoop(input)
607
- expect(r.abortReason).toBeUndefined()
608
- expect(r.accumulatedWriteCalls).toHaveLength(1)
609
- })
610
- })
611
-
612
- describe('runAgenticLoop — max iterations 표면화', () => {
613
- test('max iterations 도달 시 abortReason: max_iterations', async () => {
614
- const input = mkInput(
615
- {
616
- chat: async () => okResult(undefined, [TC('endless')]),
617
- options: { systemPrompt: 'x', maxIterations: 2 }
618
- },
619
- {
620
- isReadTool: () => true,
621
- executeReadTool: () => ({})
622
- }
623
- )
624
- const r = await runAgenticLoop(input)
625
- expect(r.abortReason).toEqual({ type: 'max_iterations', iter: 2 })
626
- })
627
-
628
- test('정상 종료 (toolCalls 비어 break) 시 abortReason undefined', async () => {
629
- const input = mkInput({ chat: async () => okResult('end') })
630
- const r = await runAgenticLoop(input)
631
- expect(r.abortReason).toBeUndefined()
632
- })
633
- })
634
-
635
- describe('runAgenticLoop — signal 이 chat 호출에 전달', () => {
636
- test('signal undefined 시 chat opts.signal 도 undefined', async () => {
637
- let receivedSignal: any = 'unset'
638
- const input = mkInput({
639
- chat: async (_msgs, _tools, opts) => {
640
- receivedSignal = opts.signal
641
- return okResult('done')
642
- }
643
- })
644
- await runAgenticLoop(input)
645
- expect(receivedSignal).toBeUndefined()
646
- })
647
- })
648
-
649
- describe('runAgenticLoop — currentBoard / selectedRefids 전달', () => {
650
- test('executeReadTool 에 board / selection 그대로 전달', async () => {
651
- const board = { width: 100, height: 100, components: [{ refid: 1 }] } as any
652
- let received: any
653
- let callCount = 0
654
- const input = mkInput(
655
- {
656
- chat: async () => {
657
- callCount++
658
- return callCount === 1
659
- ? okResult(undefined, [TC('peek')])
660
- : okResult('done')
661
- },
662
- currentBoard: board,
663
- selectedRefids: [1]
664
- },
665
- {
666
- isReadTool: () => true,
667
- executeReadTool: (tc, b, refids) => {
668
- received = { tcName: tc.name, board: b, refids }
669
- return {}
670
- }
671
- }
672
- )
673
- await runAgenticLoop(input)
674
- expect(received).toEqual({
675
- tcName: 'peek',
676
- board,
677
- refids: [1]
678
- })
679
- })
680
-
681
- test('write tool 의 validation 에 components 전달', async () => {
682
- let callCount = 0
683
- let receivedComponents: any
684
- const input = mkInput(
685
- {
686
- chat: async () => {
687
- callCount++
688
- return callCount === 1
689
- ? okResult(undefined, [TC('setFill', {})])
690
- : okResult('done')
691
- },
692
- currentBoard: { width: 10, height: 10, components: [{ refid: 7 }] } as any
693
- },
694
- {
695
- isWriteTool: () => true,
696
- validateWriteToolCall: (_tc, components) => {
697
- receivedComponents = components
698
- return { valid: true }
699
- }
700
- }
701
- )
702
- await runAgenticLoop(input)
703
- expect(receivedComponents).toEqual([{ refid: 7 }])
704
- })
705
- })
706
-
707
- describe('runAgenticLoop — 접지 근거 수집', () => {
708
- test('도구 결과 원문을 groundedTexts 에 담는다 — 압축본(toolUsages.result)이 아니라 원문이어야 한다', async () => {
709
- let callCount = 0
710
- const input = mkInput(
711
- {
712
- chat: async () => {
713
- callCount++
714
- return callCount === 1 ? okResult(undefined, [TC('getTwinStructure', {})]) : okResult('done')
715
- }
716
- },
717
- {
718
- isReadTool: () => true,
719
- executeReadTool: () => ({ nodes: [{ id: 'dock-1' }, { id: 'dock-2' }] }),
720
- /* 표시용 압축이 식별자를 잘라내도 근거는 온전해야 한다. */
721
- summarizeToolResult: () => ({ nodes: '…(생략)' })
722
- }
723
- )
724
- const r = await runAgenticLoop(input)
725
- expect(r.groundedTexts).toEqual([JSON.stringify({ nodes: [{ id: 'dock-1' }, { id: 'dock-2' }] })])
726
- expect(r.toolUsages[0].result).toEqual({ nodes: '…(생략)' })
727
- })
728
-
729
- test('도구를 부르지 않으면 근거가 비어 있다 — 그 답은 접지될 수 없다', async () => {
730
- const r = await runAgenticLoop(mkInput({ chat: async () => okResult('바쁘게 움직이고 있습니다') }))
731
- expect(r.groundedTexts).toEqual([])
732
- })
733
- })
734
-
735
- describe('runAgenticLoop — 첫 턴 도구 강제(근거 없는 단언 차단)', () => {
736
- /** 각 iteration 의 toolChoice 를 기록하는 mock. */
737
- const recordChoices = (turns: number) => {
738
- const choices: any[] = []
739
- let i = 0
740
- return {
741
- choices,
742
- chat: async (_m: any, _t: any, opts: any) => {
743
- choices.push(opts.toolChoice)
744
- i++
745
- return i < turns ? okResult(undefined, [TC('getSelection', {})]) : okResult('done')
746
- }
747
- }
748
- }
749
-
750
- test('켜면 첫 턴만 required, 두 번째부터 auto — 조회 결과를 받고도 또 부르지 않게', async () => {
751
- const rec = recordChoices(2)
752
- await runAgenticLoop(
753
- mkInput(
754
- { chat: rec.chat, options: { systemPrompt: 'x', requireToolOnFirstTurn: true } },
755
- { isReadTool: () => true, executeReadTool: () => ({ ok: 1 }) }
756
- )
757
- )
758
- expect(rec.choices).toEqual(['required', 'auto'])
759
- })
760
-
761
- test('끄면 기존 그대로 auto — 라이브 상태를 다루지 않는 대화면은 강제하지 않는다', async () => {
762
- const rec = recordChoices(2)
763
- await runAgenticLoop(
764
- mkInput({ chat: rec.chat }, { isReadTool: () => true, executeReadTool: () => ({ ok: 1 }) })
765
- )
766
- expect(rec.choices).toEqual(['auto', 'auto'])
767
- })
768
- })
769
-
770
- describe('runAgenticLoop — 제안 채널(실행은 사용자)', () => {
771
- test('도구 결과에 proposed:true 가 있으면 제안으로 올린다 — 대화면이 실행 버튼을 그릴 근거', async () => {
772
- let i = 0
773
- const input = mkInput(
774
- {
775
- chat: async () => {
776
- i++
777
- return i === 1 ? okResult(undefined, [TC('proposeAction', { command: 'order.hold' })]) : okResult('확인해 주세요')
778
- }
779
- },
780
- {
781
- isReadTool: () => true,
782
- executeReadTool: () => ({
783
- proposed: true,
784
- command: 'order.hold',
785
- args: { orderId: 'o7' },
786
- label: 'o7 보류',
787
- instanceId: 'busan-wms'
788
- })
789
- }
790
- )
791
- const r = await runAgenticLoop(input)
792
- expect(r.proposals).toHaveLength(1)
793
- expect(r.proposals[0]).toMatchObject({ tool: 'proposeAction', command: 'order.hold', instanceId: 'busan-wms' })
794
- })
795
-
796
- test('평범한 조회 결과는 제안이 아니다 — 규약은 proposed 플래그 하나뿐', async () => {
797
- const r = await runAgenticLoop(
798
- mkInput(
799
- {
800
- chat: async () => okResult('상태 정상')
801
- },
802
- { isReadTool: () => true, executeReadTool: () => ({ occupancy: 0 }) }
803
- )
804
- )
805
- expect(r.proposals).toEqual([])
806
- })
807
- })
808
-
809
- describe('runAgenticLoop — 같은 조치는 한 번만 제안된다', () => {
810
- /* 실제 사고: 모델이 같은 조치를 두 번 제안했고(하나는 인자가 비어 있었다) 카드가 둘 떴다.
811
- * 하나를 실행한 뒤에도 나머지가 살아 있어 **같은 명령을 두 번** 보낼 수 있었다. */
812
- test('효과가 같은 제안은 하나로 접는다 — 설명 문구가 달라도 같은 조치다', async () => {
813
- let i = 0
814
- const input = mkInput(
815
- {
816
- chat: async () => {
817
- i++
818
- return i === 1
819
- ? okResult(undefined, [TC('proposeAction', { n: 1 }, 'p1'), TC('proposeAction', { n: 2 }, 'p2')])
820
- : okResult('확인해 주세요')
821
- }
822
- },
823
- {
824
- isReadTool: () => true,
825
- /* 같은 명령·같은 인자(키 순서만 다름), 설명만 다르게 — 같은 조치다. */
826
- executeReadTool: (tc: any) =>
827
- tc.id === 'p1'
828
- ? { proposed: true, command: 'resource.add', instanceId: 'w1', args: { kind: 'fk', count: 3 }, label: 'A', reason: '가' }
829
- : { proposed: true, command: 'resource.add', instanceId: 'w1', args: { count: 3, kind: 'fk' }, label: 'B', reason: '나' }
830
- }
831
- )
832
- const r = await runAgenticLoop(input)
833
- expect(r.proposals).toHaveLength(1)
834
- expect(r.proposals[0].label).toBe('A') // 먼저 온 것을 남긴다
835
- })
836
-
837
- test('효과가 다르면 각각 남는다 — 서로 다른 결정이다', async () => {
838
- let i = 0
839
- const input = mkInput(
840
- {
841
- chat: async () => {
842
- i++
843
- return i === 1
844
- ? okResult(undefined, [TC('proposeAction', {}, 'p1'), TC('proposeAction', {}, 'p2')])
845
- : okResult('확인')
846
- }
847
- },
848
- {
849
- isReadTool: () => true,
850
- executeReadTool: (tc: any) =>
851
- tc.id === 'p1'
852
- ? { proposed: true, command: 'resource.add', instanceId: 'w1', args: { count: 3 } }
853
- : { proposed: true, command: 'resource.add', instanceId: 'w1', args: { count: 5 } }
854
- }
855
- )
856
- const r = await runAgenticLoop(input)
857
- expect(r.proposals).toHaveLength(2)
858
- })
859
- })