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