@things-factory/board-ai 10.0.0-beta.64

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 (95) hide show
  1. package/client/components/board-ai-chat.test.ts +120 -0
  2. package/client/components/board-ai-chat.ts +1502 -0
  3. package/client/components/chat-input-builder.ts +40 -0
  4. package/client/components/markdown.test.ts +220 -0
  5. package/client/components/markdown.ts +184 -0
  6. package/client/index.ts +11 -0
  7. package/client/tsconfig.json +13 -0
  8. package/client/utils/board-edit-patch.ts +200 -0
  9. package/config/config.development.js +43 -0
  10. package/config/config.production.js +15 -0
  11. package/dist-client/components/board-ai-chat.d.ts +127 -0
  12. package/dist-client/components/board-ai-chat.js +1455 -0
  13. package/dist-client/components/board-ai-chat.js.map +1 -0
  14. package/dist-client/components/board-ai-chat.test.d.ts +1 -0
  15. package/dist-client/components/board-ai-chat.test.js +112 -0
  16. package/dist-client/components/board-ai-chat.test.js.map +1 -0
  17. package/dist-client/components/chat-input-builder.d.ts +30 -0
  18. package/dist-client/components/chat-input-builder.js +25 -0
  19. package/dist-client/components/chat-input-builder.js.map +1 -0
  20. package/dist-client/components/markdown.d.ts +16 -0
  21. package/dist-client/components/markdown.js +167 -0
  22. package/dist-client/components/markdown.js.map +1 -0
  23. package/dist-client/components/markdown.test.d.ts +1 -0
  24. package/dist-client/components/markdown.test.js +187 -0
  25. package/dist-client/components/markdown.test.js.map +1 -0
  26. package/dist-client/index.d.ts +11 -0
  27. package/dist-client/index.js +12 -0
  28. package/dist-client/index.js.map +1 -0
  29. package/dist-client/tsconfig.tsbuildinfo +1 -0
  30. package/dist-client/utils/board-edit-patch.d.ts +73 -0
  31. package/dist-client/utils/board-edit-patch.js +159 -0
  32. package/dist-client/utils/board-edit-patch.js.map +1 -0
  33. package/dist-server/index.d.ts +21 -0
  34. package/dist-server/index.js +25 -0
  35. package/dist-server/index.js.map +1 -0
  36. package/dist-server/service/apply-patch.d.ts +46 -0
  37. package/dist-server/service/apply-patch.js +211 -0
  38. package/dist-server/service/apply-patch.js.map +1 -0
  39. package/dist-server/service/assistant.d.ts +75 -0
  40. package/dist-server/service/assistant.js +1298 -0
  41. package/dist-server/service/assistant.js.map +1 -0
  42. package/dist-server/service/board-ai-resolver.d.ts +40 -0
  43. package/dist-server/service/board-ai-resolver.js +260 -0
  44. package/dist-server/service/board-ai-resolver.js.map +1 -0
  45. package/dist-server/service/chat-message/chat-message.d.ts +24 -0
  46. package/dist-server/service/chat-message/chat-message.js +108 -0
  47. package/dist-server/service/chat-message/chat-message.js.map +1 -0
  48. package/dist-server/service/chat-message/index.d.ts +3 -0
  49. package/dist-server/service/chat-message/index.js +7 -0
  50. package/dist-server/service/chat-message/index.js.map +1 -0
  51. package/dist-server/service/chat-session/chat-session.d.ts +22 -0
  52. package/dist-server/service/chat-session/chat-session.js +109 -0
  53. package/dist-server/service/chat-session/chat-session.js.map +1 -0
  54. package/dist-server/service/chat-session/index.d.ts +3 -0
  55. package/dist-server/service/chat-session/index.js +7 -0
  56. package/dist-server/service/chat-session/index.js.map +1 -0
  57. package/dist-server/service/chat-session-resolver.d.ts +13 -0
  58. package/dist-server/service/chat-session-resolver.js +178 -0
  59. package/dist-server/service/chat-session-resolver.js.map +1 -0
  60. package/dist-server/service/index.d.ts +14 -0
  61. package/dist-server/service/index.js +26 -0
  62. package/dist-server/service/index.js.map +1 -0
  63. package/dist-server/service/patch-entry/index.d.ts +3 -0
  64. package/dist-server/service/patch-entry/index.js +7 -0
  65. package/dist-server/service/patch-entry/index.js.map +1 -0
  66. package/dist-server/service/patch-entry/patch-entry.d.ts +16 -0
  67. package/dist-server/service/patch-entry/patch-entry.js +96 -0
  68. package/dist-server/service/patch-entry/patch-entry.js.map +1 -0
  69. package/dist-server/service/types.d.ts +137 -0
  70. package/dist-server/service/types.js +3 -0
  71. package/dist-server/service/types.js.map +1 -0
  72. package/dist-server/tsconfig.tsbuildinfo +1 -0
  73. package/package.json +47 -0
  74. package/server/index.ts +21 -0
  75. package/server/service/apply-patch.test.ts +640 -0
  76. package/server/service/apply-patch.ts +250 -0
  77. package/server/service/assistant.test.ts +1317 -0
  78. package/server/service/assistant.ts +1431 -0
  79. package/server/service/board-ai-resolver.ts +239 -0
  80. package/server/service/chat-message/chat-message.ts +110 -0
  81. package/server/service/chat-message/index.ts +5 -0
  82. package/server/service/chat-session/chat-session.ts +103 -0
  83. package/server/service/chat-session/index.ts +5 -0
  84. package/server/service/chat-session-resolver.ts +154 -0
  85. package/server/service/index.ts +24 -0
  86. package/server/service/patch-entry/index.ts +5 -0
  87. package/server/service/patch-entry/patch-entry.ts +89 -0
  88. package/server/service/types.ts +138 -0
  89. package/things-factory.config.js +1 -0
  90. package/translations/en.json +39 -0
  91. package/translations/ja.json +39 -0
  92. package/translations/ko.json +40 -0
  93. package/translations/ms.json +39 -0
  94. package/translations/zh.json +39 -0
  95. package/tsconfig.json +9 -0
@@ -0,0 +1,1317 @@
1
+ /**
2
+ * assistant.ts 의 pure helper 회귀 방지.
3
+ *
4
+ * 핵심 보호:
5
+ * - LLM tool call → BoardEditOp 매핑이 잘못된 인자에서도 안전
6
+ * - JSON fallback 응답 정규화: 망가진 ops, 빠진 confidence, 잘못된 타입에서도 적절히 무시
7
+ * - summarizeBoard: 토큰 절감 — 컴포넌트 수 제한, 필요 속성만 노출
8
+ */
9
+ import {
10
+ toolCallToBoardEditOp,
11
+ toolResultToChatResponse,
12
+ normalizeChatResponse,
13
+ isValidOp,
14
+ isReadTool,
15
+ isWriteTool,
16
+ executeReadTool,
17
+ summarizeBoard,
18
+ summarizeToolResult,
19
+ buildBoardEditTools,
20
+ DefaultBoardAIAssistant
21
+ } from './assistant'
22
+
23
+ describe('toolCallToBoardEditOp', () => {
24
+ test('addComponent: 정상 인자', () => {
25
+ const r = toolCallToBoardEditOp({
26
+ id: '1',
27
+ name: 'addComponent',
28
+ arguments: { id: 'a', type: 'rect', left: 0, top: 0 }
29
+ } as any)
30
+ expect(r).toEqual({ op: 'add', component: { id: 'a', type: 'rect', left: 0, top: 0 } })
31
+ })
32
+
33
+ test('addComponent: type 누락 → null', () => {
34
+ const r = toolCallToBoardEditOp({ id: '1', name: 'addComponent', arguments: { id: 'a' } } as any)
35
+ expect(r).toBeNull()
36
+ })
37
+
38
+ test('removeComponentByRefid: 정상', () => {
39
+ const r = toolCallToBoardEditOp({
40
+ id: '1',
41
+ name: 'removeComponentByRefid',
42
+ arguments: { refid: 35 }
43
+ } as any)
44
+ expect(r).toEqual({ op: 'remove', refid: 35 })
45
+ })
46
+
47
+ test('removeComponentByRefid: refid 누락 → null', () => {
48
+ const r = toolCallToBoardEditOp({
49
+ id: '1',
50
+ name: 'removeComponentByRefid',
51
+ arguments: {}
52
+ } as any)
53
+ expect(r).toBeNull()
54
+ })
55
+
56
+ test('removeComponentByRefid: refid 가 number 아니면 null (id 로는 targeting 안 함)', () => {
57
+ const r = toolCallToBoardEditOp({
58
+ id: '1',
59
+ name: 'removeComponentByRefid',
60
+ arguments: { refid: 'AGV-1' }
61
+ } as any)
62
+ expect(r).toBeNull()
63
+ })
64
+
65
+ test('modifyComponentByRefid: 정상', () => {
66
+ const r = toolCallToBoardEditOp({
67
+ id: '1',
68
+ name: 'modifyComponentByRefid',
69
+ arguments: { refid: 35, patch: { left: 100 } }
70
+ } as any)
71
+ expect(r).toEqual({ op: 'modify', refid: 35, patch: { left: 100 } })
72
+ })
73
+
74
+ test('modifyComponentByRefid: patch 가 객체가 아니면 null', () => {
75
+ const r = toolCallToBoardEditOp({
76
+ id: '1',
77
+ name: 'modifyComponentByRefid',
78
+ arguments: { refid: 35, patch: 'string' }
79
+ } as any)
80
+ expect(r).toBeNull()
81
+ })
82
+
83
+ test('modifyComponentByRefid: refid 누락 → null', () => {
84
+ const r = toolCallToBoardEditOp({
85
+ id: '1',
86
+ name: 'modifyComponentByRefid',
87
+ arguments: { patch: { left: 100 } }
88
+ } as any)
89
+ expect(r).toBeNull()
90
+ })
91
+
92
+ test('legacy 이름 (removeComponent / modifyComponent / getComponent) → null', () => {
93
+ // 이름이 refid 기반으로 명시 변경됐다 — legacy 이름은 invalid 로 거부.
94
+ expect(toolCallToBoardEditOp({ id: '1', name: 'removeComponent', arguments: { id: 'a' } } as any)).toBeNull()
95
+ expect(
96
+ toolCallToBoardEditOp({ id: '1', name: 'modifyComponent', arguments: { id: 'a', patch: {} } } as any)
97
+ ).toBeNull()
98
+ })
99
+
100
+ test('modifyBoard: patch 객체 정상 — root 속성 변경', () => {
101
+ const r = toolCallToBoardEditOp({
102
+ id: '1',
103
+ name: 'modifyBoard',
104
+ arguments: { patch: { fillStyle: '#0a1929' } }
105
+ } as any)
106
+ expect(r).toEqual({ op: 'modifyBoard', patch: { fillStyle: '#0a1929' } })
107
+ })
108
+
109
+ test('modifyBoard: patch 누락 → null', () => {
110
+ expect(
111
+ toolCallToBoardEditOp({ id: '1', name: 'modifyBoard', arguments: {} } as any)
112
+ ).toBeNull()
113
+ })
114
+
115
+ test('modifyBoard: patch 가 array 면 null (object 만 허용)', () => {
116
+ expect(
117
+ toolCallToBoardEditOp({
118
+ id: '1',
119
+ name: 'modifyBoard',
120
+ arguments: { patch: [1, 2] }
121
+ } as any)
122
+ ).toBeNull()
123
+ })
124
+
125
+ test('modifyBoard: 다중 root 속성 변경', () => {
126
+ const r = toolCallToBoardEditOp({
127
+ id: '1',
128
+ name: 'modifyBoard',
129
+ arguments: { patch: { fillStyle: '#0a1929', name: 'Production', width: 1920 } }
130
+ } as any)
131
+ expect(r).toEqual({
132
+ op: 'modifyBoard',
133
+ patch: { fillStyle: '#0a1929', name: 'Production', width: 1920 }
134
+ })
135
+ })
136
+
137
+ test('replaceBoard: 정상', () => {
138
+ const r = toolCallToBoardEditOp({
139
+ id: '1',
140
+ name: 'replaceBoard',
141
+ arguments: { width: 1920, height: 1080, components: [{ type: 'rect' }] }
142
+ } as any)
143
+ expect(r?.op).toBe('replace')
144
+ if (r && r.op === 'replace') {
145
+ expect(r.board.width).toBe(1920)
146
+ expect(r.board.components).toHaveLength(1)
147
+ }
148
+ })
149
+
150
+ test('replaceBoard: components 누락 → null', () => {
151
+ const r = toolCallToBoardEditOp({ id: '1', name: 'replaceBoard', arguments: {} } as any)
152
+ expect(r).toBeNull()
153
+ })
154
+
155
+ test('알 수 없는 tool name → null', () => {
156
+ const r = toolCallToBoardEditOp({ id: '1', name: 'unknownTool', arguments: {} } as any)
157
+ expect(r).toBeNull()
158
+ })
159
+
160
+ test('arguments 누락도 안전', () => {
161
+ const r = toolCallToBoardEditOp({ id: '1', name: 'addComponent' } as any)
162
+ expect(r).toBeNull()
163
+ })
164
+ })
165
+
166
+ describe('isValidOp', () => {
167
+ test('add: 컴포넌트와 type 필수', () => {
168
+ expect(isValidOp({ op: 'add', component: { type: 'rect' } })).toBe(true)
169
+ expect(isValidOp({ op: 'add', component: {} })).toBe(false)
170
+ expect(isValidOp({ op: 'add' })).toBe(false)
171
+ })
172
+
173
+ test('remove: refid number 필수', () => {
174
+ expect(isValidOp({ op: 'remove', refid: 35 })).toBe(true)
175
+ expect(isValidOp({ op: 'remove', refid: 'a' })).toBe(false)
176
+ expect(isValidOp({ op: 'remove' })).toBe(false)
177
+ })
178
+
179
+ test('modify: refid 와 patch 객체 필수', () => {
180
+ expect(isValidOp({ op: 'modify', refid: 35, patch: {} })).toBe(true)
181
+ expect(isValidOp({ op: 'modify', refid: 35 })).toBe(false)
182
+ expect(isValidOp({ op: 'modify', refid: 35, patch: 'str' })).toBe(false)
183
+ expect(isValidOp({ op: 'modify', patch: {} })).toBe(false)
184
+ })
185
+
186
+ test('replace: board.components 배열 필수', () => {
187
+ expect(isValidOp({ op: 'replace', board: { components: [] } })).toBe(true)
188
+ expect(isValidOp({ op: 'replace', board: {} })).toBe(false)
189
+ expect(isValidOp({ op: 'replace' })).toBe(false)
190
+ })
191
+
192
+ test('null/undefined/잘못된 입력', () => {
193
+ expect(isValidOp(null)).toBe(false)
194
+ expect(isValidOp(undefined)).toBe(false)
195
+ expect(isValidOp({})).toBe(false)
196
+ expect(isValidOp({ op: 'unknown' })).toBe(false)
197
+ })
198
+ })
199
+
200
+ describe('normalizeChatResponse', () => {
201
+ test('reply 만 있는 경우', () => {
202
+ expect(normalizeChatResponse({ reply: '안녕' })).toEqual({
203
+ reply: '안녕',
204
+ patch: undefined,
205
+ followUp: undefined
206
+ })
207
+ })
208
+
209
+ test('reply 가 string 이 아니면 빈 문자열', () => {
210
+ expect(normalizeChatResponse({ reply: 123 } as any).reply).toBe('')
211
+ })
212
+
213
+ test('정상 patch (refid 기반)', () => {
214
+ const r = normalizeChatResponse({
215
+ reply: 'ok',
216
+ patch: {
217
+ ops: [{ op: 'remove', refid: 1 }],
218
+ summary: '삭제',
219
+ confidence: 0.85
220
+ }
221
+ })
222
+ expect(r.patch).toBeDefined()
223
+ expect(r.patch?.ops).toHaveLength(1)
224
+ expect(r.patch?.confidence).toBe(0.85)
225
+ expect(r.patch?.summary).toBe('삭제')
226
+ })
227
+
228
+ test('잘못된 op 는 필터링 (id 기반은 invalid)', () => {
229
+ const r = normalizeChatResponse({
230
+ reply: 'ok',
231
+ patch: {
232
+ ops: [
233
+ { op: 'remove', refid: 1 }, // valid
234
+ { op: 'remove', id: 'a' }, // invalid — id 로는 targeting 안 함
235
+ { op: 'remove' }, // invalid
236
+ { op: 'modify', refid: 2, patch: { left: 0 } }, // valid
237
+ { op: 'modify', id: 'b', patch: { left: 0 } }, // invalid
238
+ { op: 'unknown' } // invalid
239
+ ],
240
+ summary: '',
241
+ confidence: 1
242
+ }
243
+ })
244
+ expect(r.patch?.ops).toHaveLength(2)
245
+ })
246
+
247
+ test('모든 op 가 invalid 면 patch 없음', () => {
248
+ const r = normalizeChatResponse({
249
+ reply: 'ok',
250
+ patch: {
251
+ ops: [{ op: 'unknown' }, { whatever: 1 }] as any,
252
+ summary: '',
253
+ confidence: 1
254
+ }
255
+ })
256
+ expect(r.patch).toBeUndefined()
257
+ })
258
+
259
+ test('confidence 누락 → 0.5 기본값', () => {
260
+ const r = normalizeChatResponse({
261
+ reply: 'ok',
262
+ patch: { ops: [{ op: 'remove', refid: 1 }], summary: '' } as any
263
+ })
264
+ expect(r.patch?.confidence).toBe(0.5)
265
+ })
266
+
267
+ test('빈 ops → patch 없음', () => {
268
+ const r = normalizeChatResponse({
269
+ reply: 'ok',
270
+ patch: { ops: [], summary: '', confidence: 1 }
271
+ })
272
+ expect(r.patch).toBeUndefined()
273
+ })
274
+
275
+ test('followUp 빈 문자열은 undefined', () => {
276
+ expect(normalizeChatResponse({ reply: 'ok', followUp: '' }).followUp).toBeUndefined()
277
+ })
278
+
279
+ test('followUp string', () => {
280
+ expect(normalizeChatResponse({ reply: 'ok', followUp: '?' }).followUp).toBe('?')
281
+ })
282
+ })
283
+
284
+ describe('toolResultToChatResponse — followUp 중복 노출 회귀 방지', () => {
285
+ // 핵심: reply 가 자연어 질문 ("?" 로 끝남) 형태여도 절대 followUp 에 복제하지 않는다.
286
+ // 복제하면 클라이언트가 .md-body (assistant 메시지) 와 .followup 박스 두 곳에 같은 텍스트를
287
+ // 표시 → 사용자에게는 "동일 응답이 두 번 반복" 으로 보임. 사용자 보고된 실제 버그.
288
+ test('? 로 끝나는 reply 라도 followUp 은 항상 undefined', () => {
289
+ const r = toolResultToChatResponse({
290
+ text: '어떤 색상으로 변경할까요?',
291
+ toolCalls: [],
292
+ stopReason: 'stop'
293
+ } as any)
294
+ expect(r.reply).toBe('어떤 색상으로 변경할까요?')
295
+ expect(r.followUp).toBeUndefined()
296
+ })
297
+
298
+ test('느낌표/마침표로 끝나는 reply 도 followUp 없음', () => {
299
+ expect(
300
+ toolResultToChatResponse({ text: '완료했습니다.', toolCalls: [], stopReason: 'stop' } as any).followUp
301
+ ).toBeUndefined()
302
+ })
303
+
304
+ test('한국어 물음표 (?) 도 동일', () => {
305
+ expect(
306
+ toolResultToChatResponse({ text: '어떤 종류?', toolCalls: [], stopReason: 'stop' } as any).followUp
307
+ ).toBeUndefined()
308
+ })
309
+
310
+ test('tool calls 있을 때 reply fallback', () => {
311
+ const r = toolResultToChatResponse({
312
+ text: '',
313
+ toolCalls: [{ name: 'addComponent', arguments: { type: 'rect' } }],
314
+ stopReason: 'tool_use'
315
+ } as any)
316
+ expect(r.reply).toBe('변경했습니다.')
317
+ expect(r.patch?.ops).toHaveLength(1)
318
+ expect(r.followUp).toBeUndefined()
319
+ })
320
+
321
+ test('tool calls 와 text 동시', () => {
322
+ const r = toolResultToChatResponse({
323
+ text: 'AGV 3대 추가했습니다.',
324
+ toolCalls: [
325
+ { name: 'addComponent', arguments: { type: 'rect', id: 'a' } },
326
+ { name: 'addComponent', arguments: { type: 'rect', id: 'b' } }
327
+ ],
328
+ stopReason: 'tool_use'
329
+ } as any)
330
+ expect(r.reply).toBe('AGV 3대 추가했습니다.')
331
+ expect(r.patch?.ops).toHaveLength(2)
332
+ expect(r.patch?.summary).toBe('AGV 3대 추가했습니다.')
333
+ expect(r.patch?.confidence).toBe(0.9)
334
+ expect(r.followUp).toBeUndefined()
335
+ })
336
+
337
+ test('text 와 toolCalls 모두 비어있으면 빈 reply', () => {
338
+ const r = toolResultToChatResponse({ text: '', toolCalls: [], stopReason: 'stop' } as any)
339
+ expect(r.reply).toBe('')
340
+ expect(r.patch).toBeUndefined()
341
+ expect(r.followUp).toBeUndefined()
342
+ })
343
+
344
+ test('잘못된 tool call 는 필터링되고 patch 생성 안 함', () => {
345
+ const r = toolResultToChatResponse({
346
+ text: 'msg',
347
+ toolCalls: [{ name: 'addComponent', arguments: {} }], // type 누락
348
+ stopReason: 'stop'
349
+ } as any)
350
+ expect(r.patch).toBeUndefined()
351
+ })
352
+ })
353
+
354
+ describe('summarizeBoard', () => {
355
+ test('null 입력 → null', () => {
356
+ expect(summarizeBoard(null)).toBeNull()
357
+ expect(summarizeBoard(undefined)).toBeNull()
358
+ })
359
+
360
+ test('빈 보드 — boardRoot 에 root 속성 포함', () => {
361
+ const r = summarizeBoard({ width: 1000, height: 600, components: [] })
362
+ expect(r.boardRoot.type).toBe('model')
363
+ expect(r.boardRoot.width).toBe(1000)
364
+ expect(r.boardRoot.height).toBe(600)
365
+ expect(r.componentCount).toBe(0)
366
+ expect(r.truncated).toBe(false)
367
+ })
368
+
369
+ test('보드 root 의 fillStyle / name 등 모든 비-internal 속성 노출 (회귀 방지)', () => {
370
+ // 보드는 최상위 부모 — 자체 속성을 갖는다. AI 가 보드 자체를 컴포넌트와 별개의
371
+ // 노드로 인식하도록 root 속성을 평면 whitelist 가 아닌 통째 노출.
372
+ const r = summarizeBoard({
373
+ width: 1920,
374
+ height: 1080,
375
+ fillStyle: '#0a1929',
376
+ name: 'Production Floor',
377
+ description: '심해 컬러 대시보드',
378
+ components: []
379
+ } as any)
380
+ expect(r.boardRoot.type).toBe('model')
381
+ expect(r.boardRoot.fillStyle).toBe('#0a1929')
382
+ expect(r.boardRoot.name).toBe('Production Floor')
383
+ expect(r.boardRoot.description).toBe('심해 컬러 대시보드')
384
+ // boardRoot 는 components 를 포함하지 않음 (children 는 별도)
385
+ expect(r.boardRoot.components).toBeUndefined()
386
+ })
387
+
388
+ test('컴포넌트 요약은 모든 type-specific 속성 통과', () => {
389
+ // 기존 좁은 화이트리스트가 type-specific 키 (text/value/cx/cy/radius/points 등) 를
390
+ // 떨어뜨려서 AI 가 modify 대상을 정확히 판단할 수 없었음. 이제는 \_prefix 가 아닌
391
+ // 모든 키를 통과시켜야 한다.
392
+ const r = summarizeBoard({
393
+ width: 1000,
394
+ height: 600,
395
+ components: [
396
+ {
397
+ id: 'a',
398
+ type: 'rect',
399
+ left: 10,
400
+ top: 20,
401
+ width: 100,
402
+ height: 50,
403
+ rotation: 30,
404
+ fillStyle: '#fff',
405
+ strokeStyle: '#000',
406
+ opacity: 0.8,
407
+ customProp: 'visible to AI'
408
+ } as any
409
+ ]
410
+ })
411
+ const c = r.components[0]
412
+ expect(c.type).toBe('rect')
413
+ expect(c.left).toBe(10)
414
+ expect(c.fillStyle).toBe('#fff')
415
+ expect(c.customProp).toBe('visible to AI')
416
+ })
417
+
418
+ test('type-specific 속성 보존 (회귀 방지)', () => {
419
+ // gauge.value, text.text, circle.cx/cy/radius, polyline.points 등
420
+ // — 이들이 누락되면 AI 가 정확한 modify 를 못 한다.
421
+ const r = summarizeBoard({
422
+ width: 1000,
423
+ height: 600,
424
+ components: [
425
+ { id: 'g', type: 'gauge-circle', value: 42, min: 0, max: 100 } as any,
426
+ { id: 't', type: 'text', text: '온도', fontSize: 14 } as any,
427
+ { id: 'c', type: 'circle', cx: 50, cy: 50, radius: 20 } as any,
428
+ { id: 'p', type: 'polyline', points: [[0, 0], [10, 10]] } as any
429
+ ]
430
+ })
431
+ expect(r.components[0].value).toBe(42)
432
+ expect(r.components[0].min).toBe(0)
433
+ expect(r.components[0].max).toBe(100)
434
+ expect(r.components[1].text).toBe('온도')
435
+ expect(r.components[1].fontSize).toBe(14)
436
+ expect(r.components[2].cx).toBe(50)
437
+ expect(r.components[2].radius).toBe(20)
438
+ expect(r.components[3].points).toEqual([[0, 0], [10, 10]])
439
+ })
440
+
441
+ test('threeD 의 모든 (비-internal) 속성 통과', () => {
442
+ const r = summarizeBoard({
443
+ width: 1000,
444
+ height: 600,
445
+ components: [
446
+ {
447
+ id: 'a',
448
+ type: 'rect',
449
+ threeD: {
450
+ enabled: true,
451
+ material: { color: '#ff0000' },
452
+ geometry: { type: 'box' },
453
+ extrudeDepth: 50
454
+ }
455
+ } as any
456
+ ]
457
+ })
458
+ const c = r.components[0]
459
+ expect(c.threeD.material.color).toBe('#ff0000')
460
+ expect(c.threeD.geometry.type).toBe('box')
461
+ expect(c.threeD.enabled).toBe(true)
462
+ expect(c.threeD.extrudeDepth).toBe(50)
463
+ })
464
+
465
+ test('_underscore prefix 키와 transient 내부 키는 제외', () => {
466
+ const r = summarizeBoard({
467
+ width: 1000,
468
+ height: 600,
469
+ components: [
470
+ {
471
+ id: 'a',
472
+ type: 'rect',
473
+ _state: 'internal',
474
+ _cache: 'blob',
475
+ _layout: { stale: true },
476
+ normalProp: 'visible'
477
+ } as any
478
+ ]
479
+ })
480
+ const c = r.components[0]
481
+ expect(c.normalProp).toBe('visible')
482
+ expect(c._state).toBeUndefined()
483
+ expect(c._cache).toBeUndefined()
484
+ expect(c._layout).toBeUndefined()
485
+ })
486
+
487
+ test('긴 문자열은 truncate', () => {
488
+ const longText = 'x'.repeat(500)
489
+ const r = summarizeBoard({
490
+ width: 1000,
491
+ height: 600,
492
+ components: [{ id: 'a', type: 'text', text: longText } as any]
493
+ })
494
+ const c = r.components[0]
495
+ expect(c.text.length).toBeLessThan(longText.length)
496
+ expect(c.text).toMatch(/…\[\+\d+ chars\]$/)
497
+ })
498
+
499
+ test('큰 배열은 truncate', () => {
500
+ const bigPoints = Array.from({ length: 100 }, (_, i) => [i, i])
501
+ const r = summarizeBoard({
502
+ width: 1000,
503
+ height: 600,
504
+ components: [{ id: 'p', type: 'polyline', points: bigPoints } as any]
505
+ })
506
+ const c = r.components[0]
507
+ expect(c.points.length).toBeLessThan(bigPoints.length + 2)
508
+ // 마지막에 truncation 마커
509
+ expect(c.points[c.points.length - 1]).toMatch(/items\]/)
510
+ })
511
+
512
+ test('80개 초과 컴포넌트는 truncate', () => {
513
+ const components = Array.from({ length: 100 }, (_, i) => ({ id: `c${i}`, type: 'rect' } as any))
514
+ const r = summarizeBoard({ width: 1000, height: 600, components })
515
+ expect(r.componentCount).toBe(100)
516
+ expect(r.components).toHaveLength(80)
517
+ expect(r.truncated).toBe(true)
518
+ })
519
+
520
+ test('rotation/opacity 가 undefined 이면 출력에 없음', () => {
521
+ const r = summarizeBoard({
522
+ width: 1000,
523
+ height: 600,
524
+ components: [{ id: 'a', type: 'rect' } as any]
525
+ })
526
+ expect(r.components[0].rotation).toBeUndefined()
527
+ expect(r.components[0].opacity).toBeUndefined()
528
+ })
529
+ })
530
+
531
+ describe('Tool 분류 (read/write)', () => {
532
+ test('read tool 식별', () => {
533
+ expect(isReadTool('getSelection')).toBe(true)
534
+ expect(isReadTool('getComponentByRefid')).toBe(true)
535
+ expect(isReadTool('findComponents')).toBe(true)
536
+ expect(isReadTool('addComponent')).toBe(false)
537
+ })
538
+
539
+ test('write tool 식별', () => {
540
+ expect(isWriteTool('addComponent')).toBe(true)
541
+ expect(isWriteTool('removeComponentByRefid')).toBe(true)
542
+ expect(isWriteTool('modifyComponentByRefid')).toBe(true)
543
+ expect(isWriteTool('replaceBoard')).toBe(true)
544
+ expect(isWriteTool('getSelection')).toBe(false)
545
+ })
546
+
547
+ test('legacy 이름은 read/write 분류에서 제외 (id 기반 → refid 기반 마이그레이션)', () => {
548
+ expect(isReadTool('getComponent')).toBe(false)
549
+ expect(isWriteTool('removeComponent')).toBe(false)
550
+ expect(isWriteTool('modifyComponent')).toBe(false)
551
+ })
552
+
553
+ test('알 수 없는 이름은 둘 다 false', () => {
554
+ expect(isReadTool('foo')).toBe(false)
555
+ expect(isWriteTool('foo')).toBe(false)
556
+ })
557
+ })
558
+
559
+ describe('buildBoardEditTools — refid 기반 tool 이름 (식별자 정책 회귀 방지)', () => {
560
+ test('9개 tool 모두 정의 (5 write + 4 read 포함 atmosphere guide)', () => {
561
+ const tools = buildBoardEditTools(['rect', 'circle'])
562
+ const names = tools.map(t => t.name).sort()
563
+ expect(names).toEqual([
564
+ 'addComponent',
565
+ 'findComponents',
566
+ 'getAtmosphereGuide',
567
+ 'getComponentByRefid',
568
+ 'getSelection',
569
+ 'modifyBoard',
570
+ 'modifyComponentByRefid',
571
+ 'removeComponentByRefid',
572
+ 'replaceBoard'
573
+ ])
574
+ })
575
+
576
+ test('modifyBoard 는 patch (object) 필수', () => {
577
+ const tools = buildBoardEditTools([])
578
+ const t = tools.find(x => x.name === 'modifyBoard')!
579
+ expect(t.parameters.required).toEqual(['patch'])
580
+ expect(t.parameters.properties.patch.type).toBe('object')
581
+ })
582
+
583
+ test('isWriteTool 가 modifyBoard 인식', () => {
584
+ expect(isWriteTool('modifyBoard')).toBe(true)
585
+ })
586
+
587
+ test('getSelection 은 인자 없음', () => {
588
+ const tools = buildBoardEditTools([])
589
+ const t = tools.find(x => x.name === 'getSelection')!
590
+ expect(t.parameters.properties).toEqual({})
591
+ })
592
+
593
+ test('getComponentByRefid 는 refid (number) 필수', () => {
594
+ const tools = buildBoardEditTools([])
595
+ const t = tools.find(x => x.name === 'getComponentByRefid')!
596
+ expect(t.parameters.required).toEqual(['refid'])
597
+ expect(t.parameters.properties.refid.type).toBe('number')
598
+ })
599
+
600
+ test('removeComponentByRefid 는 refid (number) 필수', () => {
601
+ const tools = buildBoardEditTools([])
602
+ const t = tools.find(x => x.name === 'removeComponentByRefid')!
603
+ expect(t.parameters.required).toEqual(['refid'])
604
+ expect(t.parameters.properties.refid.type).toBe('number')
605
+ })
606
+
607
+ test('modifyComponentByRefid 는 refid + patch 필수', () => {
608
+ const tools = buildBoardEditTools([])
609
+ const t = tools.find(x => x.name === 'modifyComponentByRefid')!
610
+ expect(t.parameters.required).toEqual(['refid', 'patch'])
611
+ expect(t.parameters.properties.refid.type).toBe('number')
612
+ })
613
+
614
+ test('findComponents 는 모든 필터 optional + id (data binding) 필터 포함', () => {
615
+ const tools = buildBoardEditTools([])
616
+ const t = tools.find(x => x.name === 'findComponents')!
617
+ expect(t.parameters.required).toBeUndefined()
618
+ expect(t.parameters.properties).toHaveProperty('type')
619
+ expect(t.parameters.properties).toHaveProperty('namePattern')
620
+ expect(t.parameters.properties).toHaveProperty('region')
621
+ expect(t.parameters.properties).toHaveProperty('id')
622
+ })
623
+ })
624
+
625
+ describe('executeReadTool — getSelection', () => {
626
+ const board = {
627
+ width: 1000,
628
+ height: 600,
629
+ components: [
630
+ { refid: 1, type: 'rect', left: 10, top: 20 },
631
+ { refid: 2, type: 'circle', cx: 50, cy: 50, id: 'motor-1' }, // 데이터 바인딩 id 보유
632
+ { refid: 3, type: 'text', text: 'hello' }
633
+ ]
634
+ } as any
635
+
636
+ test('selectedRefids 비어있으면 안내 메시지 반환', () => {
637
+ const r = executeReadTool({ id: '1', name: 'getSelection', arguments: {} } as any, board, [])
638
+ expect(r.selected).toEqual([])
639
+ expect(r.message).toMatch(/no.*selected/i)
640
+ })
641
+
642
+ test('refid 매칭되는 컴포넌트 전체 반환 (truncate 안 함)', () => {
643
+ const r = executeReadTool({ id: '1', name: 'getSelection', arguments: {} } as any, board, [1, 3])
644
+ expect(r.count).toBe(2)
645
+ expect(r.selected).toHaveLength(2)
646
+ expect(r.selected.find((c: any) => c.refid === 1)).toEqual({
647
+ refid: 1,
648
+ type: 'rect',
649
+ left: 10,
650
+ top: 20
651
+ })
652
+ expect(r.selected.find((c: any) => c.refid === 3)).toEqual({
653
+ refid: 3,
654
+ type: 'text',
655
+ text: 'hello'
656
+ })
657
+ })
658
+
659
+ test('id (데이터 바인딩) 와 refid 가 별개 — selection 은 refid 만 사용', () => {
660
+ // selectedRefids 가 컴포넌트의 id 가 아니라 refid 와 매칭됨을 검증.
661
+ const r = executeReadTool({ id: '1', name: 'getSelection', arguments: {} } as any, board, [2])
662
+ expect(r.count).toBe(1)
663
+ expect(r.selected[0].refid).toBe(2)
664
+ expect(r.selected[0].id).toBe('motor-1')
665
+ })
666
+
667
+ test('존재하지 않는 refid 는 결과에서 빠짐 (에러 아님)', () => {
668
+ const r = executeReadTool({ id: '1', name: 'getSelection', arguments: {} } as any, board, [999, 1])
669
+ expect(r.count).toBe(1)
670
+ expect(r.selected[0].refid).toBe(1)
671
+ })
672
+
673
+ test('currentBoard null 이어도 안전', () => {
674
+ const r = executeReadTool({ id: '1', name: 'getSelection', arguments: {} } as any, null, [1])
675
+ expect(r.selected).toEqual([])
676
+ })
677
+ })
678
+
679
+ describe('executeReadTool — getComponentByRefid', () => {
680
+ const board = {
681
+ width: 1000,
682
+ height: 600,
683
+ components: [{ refid: 35, type: 'rect', left: 10, customProp: 'value', id: 'motor-1' }]
684
+ } as any
685
+
686
+ test('정상 조회 — 모든 필드 반환 (id 포함)', () => {
687
+ const r = executeReadTool(
688
+ { id: '1', name: 'getComponentByRefid', arguments: { refid: 35 } } as any,
689
+ board,
690
+ []
691
+ )
692
+ expect(r.component).toEqual({
693
+ refid: 35,
694
+ type: 'rect',
695
+ left: 10,
696
+ customProp: 'value',
697
+ id: 'motor-1'
698
+ })
699
+ })
700
+
701
+ test('refid 누락 → error', () => {
702
+ const r = executeReadTool(
703
+ { id: '1', name: 'getComponentByRefid', arguments: {} } as any,
704
+ board,
705
+ []
706
+ )
707
+ expect(r.error).toMatch(/refid.*required/i)
708
+ })
709
+
710
+ test('refid 가 number 가 아니면 error', () => {
711
+ const r = executeReadTool(
712
+ { id: '1', name: 'getComponentByRefid', arguments: { refid: 'AGV-1' } } as any,
713
+ board,
714
+ []
715
+ )
716
+ expect(r.error).toMatch(/refid.*required/i)
717
+ })
718
+
719
+ test('없는 refid → not found error', () => {
720
+ const r = executeReadTool(
721
+ { id: '1', name: 'getComponentByRefid', arguments: { refid: 999 } } as any,
722
+ board,
723
+ []
724
+ )
725
+ expect(r.error).toMatch(/not found/i)
726
+ })
727
+ })
728
+
729
+ describe('executeReadTool — findComponents', () => {
730
+ const board = {
731
+ width: 1000,
732
+ height: 600,
733
+ components: [
734
+ { refid: 1, type: 'rect', name: 'AGV-1', id: 'motor-1', left: 10, top: 20 },
735
+ { refid: 2, type: 'rect', name: 'AGV-2', id: 'motor-1', left: 100, top: 200 }, // 같은 데이터 바인딩 id
736
+ { refid: 3, type: 'circle', name: 'Sensor-1', cx: 50, cy: 50 },
737
+ { refid: 4, type: 'text', name: '제어판', left: 500, top: 50 }
738
+ ]
739
+ } as any
740
+
741
+ test('필터 없음 — 모두 반환', () => {
742
+ const r = executeReadTool({ id: '1', name: 'findComponents', arguments: {} } as any, board, [])
743
+ expect(r.totalMatched).toBe(4)
744
+ expect(r.components).toHaveLength(4)
745
+ expect(r.truncated).toBe(false)
746
+ })
747
+
748
+ test('type 필터', () => {
749
+ const r = executeReadTool(
750
+ { id: '1', name: 'findComponents', arguments: { type: 'rect' } } as any,
751
+ board,
752
+ []
753
+ )
754
+ expect(r.totalMatched).toBe(2)
755
+ expect(r.components.map((c: any) => c.refid)).toEqual([1, 2])
756
+ })
757
+
758
+ test('id 필터 — 데이터 바인딩 그룹 (unique 아님, 다중 매치)', () => {
759
+ const r = executeReadTool(
760
+ { id: '1', name: 'findComponents', arguments: { id: 'motor-1' } } as any,
761
+ board,
762
+ []
763
+ )
764
+ expect(r.totalMatched).toBe(2)
765
+ expect(r.components.map((c: any) => c.refid)).toEqual([1, 2])
766
+ })
767
+
768
+ test('namePattern 필터 (case-insensitive substring)', () => {
769
+ const r = executeReadTool(
770
+ { id: '1', name: 'findComponents', arguments: { namePattern: 'agv' } } as any,
771
+ board,
772
+ []
773
+ )
774
+ expect(r.totalMatched).toBe(2)
775
+ expect(r.components.map((c: any) => c.refid)).toEqual([1, 2])
776
+ })
777
+
778
+ test('한글 namePattern', () => {
779
+ const r = executeReadTool(
780
+ { id: '1', name: 'findComponents', arguments: { namePattern: '제어' } } as any,
781
+ board,
782
+ []
783
+ )
784
+ expect(r.totalMatched).toBe(1)
785
+ expect(r.components[0].refid).toBe(4)
786
+ })
787
+
788
+ test('region 필터 — bounding box', () => {
789
+ const r = executeReadTool(
790
+ {
791
+ id: '1',
792
+ name: 'findComponents',
793
+ arguments: { region: { left: 0, top: 0, width: 50, height: 100 } }
794
+ } as any,
795
+ board,
796
+ []
797
+ )
798
+ // refid 1 (10,20) ✓, 3 (cx 50, cy 50) ✓, 2 (100,200) ✗, 4 (500,50) ✗
799
+ expect(r.totalMatched).toBe(2)
800
+ })
801
+
802
+ test('필터 AND 결합 (type + namePattern)', () => {
803
+ const r = executeReadTool(
804
+ {
805
+ id: '1',
806
+ name: 'findComponents',
807
+ arguments: { type: 'rect', namePattern: '2' }
808
+ } as any,
809
+ board,
810
+ []
811
+ )
812
+ expect(r.totalMatched).toBe(1)
813
+ expect(r.components[0].refid).toBe(2)
814
+ })
815
+
816
+ test('필터 AND 결합 (type + id) — id 만으로는 multi 매치, type 으로 좁혀짐', () => {
817
+ const r = executeReadTool(
818
+ {
819
+ id: '1',
820
+ name: 'findComponents',
821
+ arguments: { type: 'rect', id: 'motor-1' }
822
+ } as any,
823
+ board,
824
+ []
825
+ )
826
+ expect(r.totalMatched).toBe(2) // 둘 다 type=rect 라 좁혀지지 않음
827
+ })
828
+
829
+ test('50개 cap', () => {
830
+ const big = {
831
+ width: 1000,
832
+ height: 600,
833
+ components: Array.from({ length: 100 }, (_, i) => ({ refid: i + 1, type: 'rect' }))
834
+ } as any
835
+ const r = executeReadTool(
836
+ { id: '1', name: 'findComponents', arguments: { type: 'rect' } } as any,
837
+ big,
838
+ []
839
+ )
840
+ expect(r.totalMatched).toBe(100)
841
+ expect(r.components).toHaveLength(50)
842
+ expect(r.truncated).toBe(true)
843
+ })
844
+
845
+ test('잘못된 region (필드 누락) 은 무시', () => {
846
+ const r = executeReadTool(
847
+ {
848
+ id: '1',
849
+ name: 'findComponents',
850
+ arguments: { region: { left: 0 } } // incomplete
851
+ } as any,
852
+ board,
853
+ []
854
+ )
855
+ // region 필터 무시 → 전체 반환
856
+ expect(r.totalMatched).toBe(4)
857
+ })
858
+ })
859
+
860
+ describe('executeReadTool — 알 수 없는 read tool', () => {
861
+ test('error 반환', () => {
862
+ const r = executeReadTool({ id: '1', name: 'unknownTool', arguments: {} } as any, null, [])
863
+ expect(r.error).toMatch(/unknown/i)
864
+ })
865
+ })
866
+
867
+ describe('executeReadTool — getAtmosphereGuide (3D 분위기 합성 primer)', () => {
868
+ test('가이드 객체 반환 — overview / parameters / archetypes / modifiers / workflow / pitfalls', () => {
869
+ const r = executeReadTool(
870
+ { id: '1', name: 'getAtmosphereGuide', arguments: {} } as any,
871
+ null,
872
+ []
873
+ )
874
+ expect(r).toHaveProperty('overview')
875
+ expect(r).toHaveProperty('parameters')
876
+ expect(r).toHaveProperty('archetypes')
877
+ expect(r).toHaveProperty('modifiers')
878
+ expect(r).toHaveProperty('workflow')
879
+ expect(r).toHaveProperty('pitfalls')
880
+ })
881
+
882
+ test('parameters 섹션이 핵심 키 망라 (sky / exposure / hemisphere / directional / shadow / floor / camera)', () => {
883
+ const r = executeReadTool(
884
+ { id: '1', name: 'getAtmosphereGuide', arguments: {} } as any,
885
+ null,
886
+ []
887
+ )
888
+ expect(r.parameters).toHaveProperty('sky')
889
+ expect(r.parameters).toHaveProperty('skyColor')
890
+ expect(r.parameters).toHaveProperty('exposure')
891
+ expect(r.parameters).toHaveProperty('hemisphere')
892
+ expect(r.parameters).toHaveProperty('directional')
893
+ expect(r.parameters).toHaveProperty('shadow')
894
+ expect(r.parameters).toHaveProperty('floor')
895
+ expect(r.parameters).toHaveProperty('camera')
896
+ })
897
+
898
+ test('sky preset 카탈로그가 things-scene 의 8 개 모두 포함', () => {
899
+ const r = executeReadTool(
900
+ { id: '1', name: 'getAtmosphereGuide', arguments: {} } as any,
901
+ null,
902
+ []
903
+ )
904
+ const presetNames = Object.keys(r.parameters.sky.presets)
905
+ expect(presetNames.sort()).toEqual([
906
+ 'cloudy',
907
+ 'factory',
908
+ 'home',
909
+ 'office',
910
+ 'rainy',
911
+ 'studio',
912
+ 'sunny',
913
+ 'warehouse'
914
+ ])
915
+ })
916
+
917
+ test('archetype 카테고리 — timeOfDay / indoor / surreal', () => {
918
+ const r = executeReadTool(
919
+ { id: '1', name: 'getAtmosphereGuide', arguments: {} } as any,
920
+ null,
921
+ []
922
+ )
923
+ expect(r.archetypes).toHaveProperty('timeOfDay')
924
+ expect(r.archetypes).toHaveProperty('indoor')
925
+ expect(r.archetypes).toHaveProperty('surreal')
926
+ })
927
+
928
+ test('각 archetype 은 description + patch 보유 (modifyBoard patch 그대로 사용 가능)', () => {
929
+ const r = executeReadTool(
930
+ { id: '1', name: 'getAtmosphereGuide', arguments: {} } as any,
931
+ null,
932
+ []
933
+ )
934
+ for (const cat of ['timeOfDay', 'indoor', 'surreal'] as const) {
935
+ const items = r.archetypes[cat]
936
+ for (const name of Object.keys(items)) {
937
+ const a = items[name]
938
+ expect(a.description).toBeDefined()
939
+ expect(a.patch).toBeDefined()
940
+ // patch 가 sky / exposure 등 root 키를 갖는지
941
+ expect(typeof a.patch.exposure).toBe('number')
942
+ }
943
+ }
944
+ })
945
+
946
+ test('대표 archetype "deepOcean" — 심해 분위기 패치 검증', () => {
947
+ const r = executeReadTool(
948
+ { id: '1', name: 'getAtmosphereGuide', arguments: {} } as any,
949
+ null,
950
+ []
951
+ )
952
+ const deep = r.archetypes.surreal.deepOcean
953
+ expect(deep.patch.sky).toBe('color')
954
+ expect(deep.patch.skyColor).toMatch(/^#[0-9a-fA-F]{6}$/)
955
+ expect(deep.patch.exposure).toBeLessThan(1.0) // dim
956
+ expect(deep.patch.hemiIntensity).toBeLessThan(3)
957
+ expect(deep.patch.dirLightEnabled).toBe(true)
958
+ })
959
+
960
+ test('인자 무시 (인자 없음 — 빈 객체 OK)', () => {
961
+ const r = executeReadTool(
962
+ { id: '1', name: 'getAtmosphereGuide', arguments: { ignored: 'whatever' } } as any,
963
+ null,
964
+ []
965
+ )
966
+ expect(r).toHaveProperty('overview')
967
+ })
968
+
969
+ test('isReadTool 가 getAtmosphereGuide 를 read 로 분류', () => {
970
+ expect(isReadTool('getAtmosphereGuide')).toBe(true)
971
+ expect(isWriteTool('getAtmosphereGuide')).toBe(false)
972
+ })
973
+
974
+ test('getAtmosphereGuide tool 정의의 인자 schema 가 비어 있음 (무인자)', () => {
975
+ const tools = buildBoardEditTools([])
976
+ const t = tools.find(x => x.name === 'getAtmosphereGuide')!
977
+ expect(t.parameters.properties).toEqual({})
978
+ })
979
+ })
980
+
981
+ describe('agentic loop (chatViaTools) — mock provider', () => {
982
+ // Mock AIClient — chatWithTools 호출을 record + 미리 정의된 응답 큐에서 반환.
983
+ function makeMockClient(turns: Array<{ text?: string; toolCalls: any[]; stopReason?: string }>) {
984
+ let turnIdx = 0
985
+ const callLog: any[] = []
986
+ return {
987
+ client: {
988
+ id: 'mock:test',
989
+ chat: async () => '',
990
+ generateJSON: async () => ({}),
991
+ chatWithTools: async (messages: any[], _tools: any[], _options: any) => {
992
+ callLog.push({ messages: JSON.parse(JSON.stringify(messages)) })
993
+ const turn = turns[turnIdx++] ?? { toolCalls: [], stopReason: 'end_turn' }
994
+ return {
995
+ text: turn.text,
996
+ toolCalls: turn.toolCalls,
997
+ stopReason: turn.stopReason ?? (turn.toolCalls.length > 0 ? 'tool_use' : 'end_turn')
998
+ }
999
+ }
1000
+ } as any,
1001
+ callLog,
1002
+ remainingTurns: () => turns.length - turnIdx
1003
+ }
1004
+ }
1005
+
1006
+ test('text-only 응답 — single turn 으로 종료', async () => {
1007
+ const { client, callLog } = makeMockClient([
1008
+ { text: '안녕하세요. 무엇을 도와드릴까요?', toolCalls: [] }
1009
+ ])
1010
+ const a = new DefaultBoardAIAssistant(client)
1011
+ const r = await a.chat([{ role: 'user', content: '안녕' }], undefined)
1012
+ expect(r.reply).toBe('안녕하세요. 무엇을 도와드릴까요?')
1013
+ expect(r.patch).toBeUndefined()
1014
+ expect(callLog).toHaveLength(1)
1015
+ })
1016
+
1017
+ test('write tool 호출 → patch 누적, 후속 turn 종료', async () => {
1018
+ const { client, callLog } = makeMockClient([
1019
+ {
1020
+ toolCalls: [
1021
+ { id: 'c1', name: 'addComponent', arguments: { refid: 9, type: 'rect' } }
1022
+ ]
1023
+ },
1024
+ { text: 'AGV 추가했습니다.', toolCalls: [] }
1025
+ ])
1026
+ const a = new DefaultBoardAIAssistant(client)
1027
+ const r = await a.chat([{ role: 'user', content: 'AGV 추가' }], undefined)
1028
+ expect(r.reply).toBe('AGV 추가했습니다.')
1029
+ expect(r.patch?.ops).toHaveLength(1)
1030
+ expect(r.patch?.ops[0]).toEqual({ op: 'add', component: { refid: 9, type: 'rect' } })
1031
+ expect(callLog).toHaveLength(2)
1032
+ })
1033
+
1034
+ test('read tool 호출 → 서버 실행 결과 회신 → 후속 답변 (refid 기반)', async () => {
1035
+ const board = {
1036
+ width: 1000,
1037
+ height: 600,
1038
+ components: [{ refid: 35, type: 'rect', name: 'AGV-1', left: 10 }]
1039
+ }
1040
+ const { client, callLog } = makeMockClient([
1041
+ {
1042
+ toolCalls: [{ id: 'c1', name: 'getSelection', arguments: {} }]
1043
+ },
1044
+ { text: '선택된 컴포넌트는 AGV-1 (rect, refid=35, left=10) 입니다.', toolCalls: [] }
1045
+ ])
1046
+ const a = new DefaultBoardAIAssistant(client)
1047
+ const r = await a.chat(
1048
+ [{ role: 'user', content: '선택된 게 뭐야?' }],
1049
+ board as any,
1050
+ { selectedRefids: [35] }
1051
+ )
1052
+ expect(r.reply).toContain('AGV-1')
1053
+ expect(r.patch).toBeUndefined()
1054
+ expect(callLog).toHaveLength(2)
1055
+ const secondTurnMessages = callLog[1].messages
1056
+ const lastMsg = secondTurnMessages[secondTurnMessages.length - 1]
1057
+ expect(lastMsg.role).toBe('user')
1058
+ const toolResultPart = (lastMsg.content as any[]).find((p: any) => p.type === 'tool_result')
1059
+ expect(toolResultPart).toBeDefined()
1060
+ expect(toolResultPart.toolUseId).toBe('c1')
1061
+ const parsed = JSON.parse(toolResultPart.content)
1062
+ expect(parsed.selected[0].name).toBe('AGV-1')
1063
+ expect(parsed.selected[0].refid).toBe(35)
1064
+ })
1065
+
1066
+ test('read + write 혼합 — refid 기반 tool 이름', async () => {
1067
+ const board = {
1068
+ width: 1000,
1069
+ height: 600,
1070
+ components: [{ refid: 1, type: 'rect' }]
1071
+ }
1072
+ const { client } = makeMockClient([
1073
+ {
1074
+ toolCalls: [
1075
+ { id: 'c1', name: 'getComponentByRefid', arguments: { refid: 1 } },
1076
+ {
1077
+ id: 'c2',
1078
+ name: 'modifyComponentByRefid',
1079
+ arguments: { refid: 1, patch: { left: 100 } }
1080
+ }
1081
+ ]
1082
+ },
1083
+ { text: '확인 후 수정했습니다.', toolCalls: [] }
1084
+ ])
1085
+ const a = new DefaultBoardAIAssistant(client)
1086
+ const r = await a.chat([{ role: 'user', content: '컴포넌트 left=100' }], board as any)
1087
+ expect(r.patch?.ops).toHaveLength(1) // modify 만 patch 로 (read 는 서버 실행만)
1088
+ expect(r.patch?.ops[0]).toEqual({ op: 'modify', refid: 1, patch: { left: 100 } })
1089
+ })
1090
+
1091
+ test('iteration cap (8회) 초과 시 종료', async () => {
1092
+ const turns = Array.from({ length: 20 }, (_, i) => ({
1093
+ toolCalls: [{ id: `c${i}`, name: 'getSelection', arguments: {} }]
1094
+ }))
1095
+ const { client, callLog } = makeMockClient(turns)
1096
+ const a = new DefaultBoardAIAssistant(client)
1097
+ const r = await a.chat([{ role: 'user', content: '?' }], undefined)
1098
+ // 8회 호출 후 강제 종료 (loop 의 MAX_ITERATIONS)
1099
+ expect(callLog.length).toBeLessThanOrEqual(8)
1100
+ expect(r).toBeDefined()
1101
+ })
1102
+
1103
+ test('selectedRefids 비어 있어도 selection 컨텍스트는 항상 포함', async () => {
1104
+ const { client, callLog } = makeMockClient([{ text: 'ok', toolCalls: [] }])
1105
+ const a = new DefaultBoardAIAssistant(client)
1106
+ await a.chat([{ role: 'user', content: 'q' }], undefined)
1107
+ const firstUser = callLog[0].messages[0]
1108
+ expect(firstUser.role).toBe('user')
1109
+ expect(firstUser.content).toMatch(/No component is currently selected/i)
1110
+ })
1111
+
1112
+ test('selectedRefids 가 있을 때 selection 컨텍스트가 refid 명시', async () => {
1113
+ const { client, callLog } = makeMockClient([{ text: 'ok', toolCalls: [] }])
1114
+ const a = new DefaultBoardAIAssistant(client)
1115
+ await a.chat([{ role: 'user', content: 'q' }], undefined, { selectedRefids: [35, 37] })
1116
+ const firstUser = callLog[0].messages[0]
1117
+ expect(firstUser.content).toMatch(/User-selected component refids/i)
1118
+ expect(firstUser.content).toContain('[35,37]')
1119
+ expect(firstUser.content).toMatch(/getSelection/i)
1120
+ })
1121
+
1122
+ test('system prompt 가 id vs refid 정책을 AI 한테 명시', async () => {
1123
+ const { client, callLog } = makeMockClient([{ text: 'ok', toolCalls: [] }])
1124
+ const a = new DefaultBoardAIAssistant(client)
1125
+ await a.chat([{ role: 'user', content: 'q' }], undefined)
1126
+ const optionsArg = callLog[0].messages
1127
+ // system prompt 는 messages 에 직접 들어가지 않고 옵션으로 전달 — 우리 mock 은
1128
+ // chatWithTools options 인자를 무시하므로 직접 검증 어려움. 대신 firstUser
1129
+ // (board context) 가 refid 단어를 포함하는지로 우회 검증.
1130
+ expect(optionsArg).toBeDefined()
1131
+ })
1132
+
1133
+ test('Gemini thoughtSignature round-trip — provider meta 를 다음 turn 으로 echo (회귀 방지)', async () => {
1134
+ // 시나리오: Gemini 가 functionCall + thoughtSignature 를 emit → 우리 어댑터가 providerMeta 에 캐치
1135
+ // → assistant 가 tool_use part 에 그대로 round-trip → 다음 turn 호출의 conversation 에 보존
1136
+ // 안 하면: "Function call is missing a thought_signature in functionCall parts" 400.
1137
+ const { client, callLog } = makeMockClient([
1138
+ {
1139
+ toolCalls: [
1140
+ {
1141
+ id: 'g:replaceBoard:t1_0',
1142
+ name: 'replaceBoard',
1143
+ arguments: { components: [] },
1144
+ providerMeta: { thoughtSignature: 'SIG_ABC123' }
1145
+ }
1146
+ ]
1147
+ },
1148
+ { text: '교체했습니다.', toolCalls: [] }
1149
+ ])
1150
+ const a = new DefaultBoardAIAssistant(client)
1151
+ await a.chat([{ role: 'user', content: '보드 비워' }], undefined)
1152
+ // 두 번째 turn 의 messages 에서 assistant 메시지의 tool_use part 가 providerMeta 를 보존해야 함
1153
+ expect(callLog).toHaveLength(2)
1154
+ const secondTurnMessages = callLog[1].messages
1155
+ const assistantMsg = secondTurnMessages.find(
1156
+ (m: any) => m.role === 'assistant' && Array.isArray(m.content)
1157
+ )
1158
+ expect(assistantMsg).toBeDefined()
1159
+ const toolUsePart = (assistantMsg.content as any[]).find((p: any) => p.type === 'tool_use')
1160
+ expect(toolUsePart).toBeDefined()
1161
+ expect(toolUsePart.providerMeta).toEqual({ thoughtSignature: 'SIG_ABC123' })
1162
+ })
1163
+
1164
+ test('providerMeta 없는 tool_use 는 echo 시 providerMeta 키 자체가 없음', async () => {
1165
+ const { client, callLog } = makeMockClient([
1166
+ {
1167
+ toolCalls: [{ id: 'c1', name: 'getSelection', arguments: {} }]
1168
+ },
1169
+ { text: 'ok', toolCalls: [] }
1170
+ ])
1171
+ const a = new DefaultBoardAIAssistant(client)
1172
+ await a.chat([{ role: 'user', content: 'q' }], undefined)
1173
+ const secondTurnMessages = callLog[1].messages
1174
+ const assistantMsg = secondTurnMessages.find(
1175
+ (m: any) => m.role === 'assistant' && Array.isArray(m.content)
1176
+ )
1177
+ const toolUsePart = (assistantMsg.content as any[]).find((p: any) => p.type === 'tool_use')
1178
+ expect(toolUsePart.providerMeta).toBeUndefined()
1179
+ })
1180
+
1181
+ test('toolUsages 수집 — text-only 응답에는 toolUsages undefined', async () => {
1182
+ const { client } = makeMockClient([{ text: '안녕하세요', toolCalls: [] }])
1183
+ const a = new DefaultBoardAIAssistant(client)
1184
+ const r = await a.chat([{ role: 'user', content: '안녕' }], undefined)
1185
+ expect(r.toolUsages).toBeUndefined()
1186
+ })
1187
+
1188
+ test('toolUsages 수집 — read tool 호출 시 시간순 trace + 결과 포함', async () => {
1189
+ const board = {
1190
+ width: 1000,
1191
+ height: 600,
1192
+ components: [{ refid: 35, type: 'rect', name: 'AGV-1' }]
1193
+ }
1194
+ const { client } = makeMockClient([
1195
+ {
1196
+ toolCalls: [{ id: 'c1', name: 'getSelection', arguments: {} }]
1197
+ },
1198
+ { text: 'AGV-1 입니다', toolCalls: [] }
1199
+ ])
1200
+ const a = new DefaultBoardAIAssistant(client)
1201
+ const r = await a.chat([{ role: 'user', content: '뭐가 선택?' }], board as any, {
1202
+ selectedRefids: [35]
1203
+ })
1204
+ expect(r.toolUsages).toBeDefined()
1205
+ expect(r.toolUsages).toHaveLength(1)
1206
+ const u = r.toolUsages![0]
1207
+ expect(u.name).toBe('getSelection')
1208
+ expect(u.kind).toBe('read')
1209
+ expect(u.arguments).toEqual({})
1210
+ // result 는 summarized 객체
1211
+ expect(u.result.count).toBe(1)
1212
+ expect(u.result.selected[0].name).toBe('AGV-1')
1213
+ })
1214
+
1215
+ test('toolUsages 수집 — write tool 은 queued 마커', async () => {
1216
+ const { client } = makeMockClient([
1217
+ {
1218
+ toolCalls: [
1219
+ { id: 'c1', name: 'addComponent', arguments: { refid: 9, type: 'rect' } }
1220
+ ]
1221
+ },
1222
+ { text: '추가', toolCalls: [] }
1223
+ ])
1224
+ const a = new DefaultBoardAIAssistant(client)
1225
+ const r = await a.chat([{ role: 'user', content: 'rect' }], undefined)
1226
+ expect(r.toolUsages).toHaveLength(1)
1227
+ const u = r.toolUsages![0]
1228
+ expect(u.name).toBe('addComponent')
1229
+ expect(u.kind).toBe('write')
1230
+ expect(u.result.queued).toBe(true)
1231
+ })
1232
+
1233
+ test('toolUsages 수집 — read + write 혼합 시간순 보존', async () => {
1234
+ const board = { width: 1000, height: 600, components: [{ refid: 1, type: 'rect' }] }
1235
+ const { client } = makeMockClient([
1236
+ {
1237
+ toolCalls: [
1238
+ { id: 'c1', name: 'getComponentByRefid', arguments: { refid: 1 } },
1239
+ { id: 'c2', name: 'modifyComponentByRefid', arguments: { refid: 1, patch: { left: 100 } } }
1240
+ ]
1241
+ },
1242
+ { text: 'done', toolCalls: [] }
1243
+ ])
1244
+ const a = new DefaultBoardAIAssistant(client)
1245
+ const r = await a.chat([{ role: 'user', content: 'do' }], board as any)
1246
+ expect(r.toolUsages).toHaveLength(2)
1247
+ expect(r.toolUsages![0].name).toBe('getComponentByRefid')
1248
+ expect(r.toolUsages![0].kind).toBe('read')
1249
+ expect(r.toolUsages![1].name).toBe('modifyComponentByRefid')
1250
+ expect(r.toolUsages![1].kind).toBe('write')
1251
+ })
1252
+
1253
+ test('toolUsages 수집 — 여러 turn 에 걸친 호출 누적', async () => {
1254
+ const { client } = makeMockClient([
1255
+ { toolCalls: [{ id: 'c1', name: 'getSelection', arguments: {} }] },
1256
+ { toolCalls: [{ id: 'c2', name: 'findComponents', arguments: { type: 'rect' } }] },
1257
+ { text: 'final', toolCalls: [] }
1258
+ ])
1259
+ const a = new DefaultBoardAIAssistant(client)
1260
+ const r = await a.chat([{ role: 'user', content: 'q' }], undefined)
1261
+ expect(r.toolUsages).toHaveLength(2)
1262
+ expect(r.toolUsages!.map(u => u.name)).toEqual(['getSelection', 'findComponents'])
1263
+ })
1264
+
1265
+ test('toolUsages 수집 — alkown tool 은 unknown 으로 분류 + isError', async () => {
1266
+ const { client } = makeMockClient([
1267
+ { toolCalls: [{ id: 'c1', name: 'foo', arguments: {} }] },
1268
+ { text: 'err', toolCalls: [] }
1269
+ ])
1270
+ const a = new DefaultBoardAIAssistant(client)
1271
+ const r = await a.chat([{ role: 'user', content: 'x' }], undefined)
1272
+ expect(r.toolUsages).toHaveLength(1)
1273
+ expect(r.toolUsages![0].kind).toBe('unknown')
1274
+ expect(r.toolUsages![0].result.error).toMatch(/unknown/i)
1275
+ })
1276
+ })
1277
+
1278
+ describe('summarizeToolResult — UI 영속용 결과 압축', () => {
1279
+ test('짧은 string / number / boolean 그대로', () => {
1280
+ expect(summarizeToolResult('hello')).toBe('hello')
1281
+ expect(summarizeToolResult(42)).toBe(42)
1282
+ expect(summarizeToolResult(true)).toBe(true)
1283
+ expect(summarizeToolResult(null)).toBeNull()
1284
+ })
1285
+
1286
+ test('200자 초과 string truncate + 마커', () => {
1287
+ const long = 'x'.repeat(500)
1288
+ const r = summarizeToolResult(long)
1289
+ expect(r).toMatch(/…\[\+\d+ chars\]$/)
1290
+ expect(r.length).toBeLessThan(long.length)
1291
+ })
1292
+
1293
+ test('5개 초과 array truncate + 마커', () => {
1294
+ const big = Array.from({ length: 12 }, (_, i) => i)
1295
+ const r = summarizeToolResult(big)
1296
+ expect(r.length).toBe(6) // 5 + marker
1297
+ expect(r[5]).toMatch(/items\]/)
1298
+ })
1299
+
1300
+ test('object 의 nested 값 재귀 요약', () => {
1301
+ const o = { a: 1, b: { c: 'x'.repeat(500) } }
1302
+ const r = summarizeToolResult(o)
1303
+ expect(r.a).toBe(1)
1304
+ expect(r.b.c).toMatch(/…/)
1305
+ })
1306
+
1307
+ test('depth 3 초과 object 는 키 목록 string 으로 압축', () => {
1308
+ const deep = { l1: { l2: { l3: { l4: { x: 1, y: 2, z: 3 } } } } }
1309
+ const r = summarizeToolResult(deep)
1310
+ // l1 (d0) → l2 (d1) → l3 (d2) 까지 object 유지, l3 의 값은 d3 진입 → string
1311
+ expect(typeof r.l1).toBe('object')
1312
+ expect(typeof r.l1.l2).toBe('object')
1313
+ // l3 자체가 d3 에서 평가되어 키 목록 문자열로 압축됨
1314
+ expect(typeof r.l1.l2.l3).toBe('string')
1315
+ expect(r.l1.l2.l3).toMatch(/^\{l4/)
1316
+ })
1317
+ })