@things-factory/board-ai 10.0.2 → 10.0.4

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 (60) hide show
  1. package/client/components/board-ai-chat.ts +458 -19
  2. package/client/components/chat-echo-dedup.test.ts +59 -3
  3. package/client/components/chat-echo-dedup.ts +29 -0
  4. package/dist-client/client/components/board-ai-chat.d.ts +63 -0
  5. package/dist-client/client/components/board-ai-chat.js +438 -16
  6. package/dist-client/client/components/board-ai-chat.js.map +1 -1
  7. package/dist-client/client/components/chat-echo-dedup.js +28 -0
  8. package/dist-client/client/components/chat-echo-dedup.js.map +1 -1
  9. package/dist-client/client/components/chat-echo-dedup.test.js +53 -3
  10. package/dist-client/client/components/chat-echo-dedup.test.js.map +1 -1
  11. package/dist-client/server/service/agentic-loop.d.ts +15 -0
  12. package/dist-client/server/service/agentic-loop.js +70 -9
  13. package/dist-client/server/service/agentic-loop.js.map +1 -1
  14. package/dist-client/server/service/assistant.js +18 -3
  15. package/dist-client/server/service/assistant.js.map +1 -1
  16. package/dist-client/server/service/types.d.ts +23 -0
  17. package/dist-client/server/service/types.js.map +1 -1
  18. package/dist-client/tsconfig.tsbuildinfo +1 -1
  19. package/dist-server/service/agentic-loop.d.ts +15 -0
  20. package/dist-server/service/agentic-loop.js +71 -9
  21. package/dist-server/service/agentic-loop.js.map +1 -1
  22. package/dist-server/service/assistant.js +17 -2
  23. package/dist-server/service/assistant.js.map +1 -1
  24. package/dist-server/service/board-ai-resolver.d.ts +13 -0
  25. package/dist-server/service/board-ai-resolver.js +99 -1
  26. package/dist-server/service/board-ai-resolver.js.map +1 -1
  27. package/dist-server/service/chat-message/fold-history.d.ts +30 -0
  28. package/dist-server/service/chat-message/fold-history.js +29 -0
  29. package/dist-server/service/chat-message/fold-history.js.map +1 -0
  30. package/dist-server/service/chat-message/history-summary.d.ts +43 -0
  31. package/dist-server/service/chat-message/history-summary.js +77 -0
  32. package/dist-server/service/chat-message/history-summary.js.map +1 -0
  33. package/dist-server/service/chat-message/llm-history.d.ts +19 -0
  34. package/dist-server/service/chat-message/llm-history.js +31 -1
  35. package/dist-server/service/chat-message/llm-history.js.map +1 -1
  36. package/dist-server/service/chat-session/chat-session.d.ts +8 -0
  37. package/dist-server/service/chat-session/chat-session.js +5 -0
  38. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  39. package/dist-server/service/types.d.ts +23 -0
  40. package/dist-server/service/types.js.map +1 -1
  41. package/dist-server/tsconfig.tsbuildinfo +1 -1
  42. package/package.json +6 -6
  43. package/server/service/agentic-loop.test.ts +91 -0
  44. package/server/service/agentic-loop.ts +80 -9
  45. package/server/service/assistant.ts +21 -3
  46. package/server/service/board-ai-resolver.ts +108 -1
  47. package/server/service/chat-message/fold-history.test.ts +98 -0
  48. package/server/service/chat-message/fold-history.ts +60 -0
  49. package/server/service/chat-message/history-summary.test.ts +127 -0
  50. package/server/service/chat-message/history-summary.ts +100 -0
  51. package/server/service/chat-message/llm-history.test.ts +65 -0
  52. package/server/service/chat-message/llm-history.ts +48 -1
  53. package/server/service/chat-session/chat-session.ts +11 -0
  54. package/server/service/dock-contract.test.ts +305 -0
  55. package/server/service/types.ts +23 -0
  56. package/translations/en.json +14 -1
  57. package/translations/ja.json +14 -1
  58. package/translations/ko.json +13 -0
  59. package/translations/ms.json +14 -1
  60. package/translations/zh.json +14 -1
@@ -67,11 +67,27 @@ interface ChatLine {
67
67
  patchId?: string
68
68
  pending?: boolean
69
69
  /** AI 가 응답을 만드는 동안 호출한 도구 trace — fold-able 박스에서 노출. */
70
- toolUsages?: Array<{ name: string; arguments: any; result: any; kind: 'read' | 'write' | 'unknown' }>
70
+ toolUsages?: Array<{
71
+ name: string
72
+ arguments: any
73
+ result: any
74
+ kind: 'read' | 'write' | 'unknown'
75
+ /** 몇 번째 LLM 턴 — 단계로 읽기 위한 값. */
76
+ iter?: number
77
+ /** 이 호출이 어떻게 끝났는가 — 배지. */
78
+ outcome?: 'ok' | 'rejected' | 'queued' | 'proposed' | 'folded' | 'error'
79
+ }>
71
80
  /** UI 상태: tool usage 박스 펼침 여부 (assistant 메시지마다 독립). */
72
81
  toolUsagesOpen?: boolean
73
82
  /** UI 상태: 접힌 시스템 기록을 펼쳤는가(곁줄이 대화를 가리지 않게 기본은 3줄 접기). */
74
83
  systemOpen?: boolean
84
+ /**
85
+ * 조치 제안 — AI 가 **실행하지 않고** 제안한 것들. 사용자가 버튼을 눌러야 실행된다.
86
+ *
87
+ * 영속하지 않는다(이번 응답에만 붙는다): 승인은 **지금** 하는 판단이고, 되돌릴 수 없는 조치를
88
+ * 지난 맥락으로 다시 권하는 것은 위험하다. 다시 필요하면 다시 물어보면 된다.
89
+ */
90
+ proposals?: any[]
75
91
  /**
76
92
  * 접지 경고 — 이 답이 언급했으나 모델이 받은 근거(프롬프트·보드 문맥·이력·도구 결과)에 없던 식별자.
77
93
  * 있으면 그 대상은 만들어 낸 것일 수 있다는 주의를 답 아래에 표시한다(답 자체는 그대로 보여준다).
@@ -92,7 +108,9 @@ const BOARD_AI_CHAT_MUTATION = gql`
92
108
  patchId
93
109
  toolUsages
94
110
  actions
111
+ proposals
95
112
  groundingWarnings
113
+ historyFolded
96
114
  }
97
115
  }
98
116
  `
@@ -319,6 +337,26 @@ export class OxBoardAIChat extends LitElement {
319
337
  @state()
320
338
  private copiedIdx?: number
321
339
 
340
+ /** 원본 JSON 을 펼친 단계(라인:단계) — 목록은 한 줄 요약으로 훑고 필요한 것만 펼친다. */
341
+ @state()
342
+ private openToolSteps = new Set<string>()
343
+
344
+ /** 이미 실행을 요청한 제안 키 — 같은 카드를 두 번 눌러 되돌릴 수 없는 명령이 두 번 나가지 않게. */
345
+ @state()
346
+ private sentProposals = new Set<string>()
347
+
348
+ /**
349
+ * 이 대화가 길어져 앞부분이 프롬프트에서 접혔다는 **사실**(서버 보고). 주제 이탈을 판정한 것이
350
+ * 아니다 — 판정하면 오탐이 생기고, 오탐이 생기면 사용자는 안내 자체를 무시한다.
351
+ * 사실만 알리고 "새 대화로 시작할지" 는 사용자가 정한다.
352
+ */
353
+ @state()
354
+ private historyFolded?: { omitted: number; summarized: boolean }
355
+
356
+ /** 안내를 닫았는가 — 한 번 닫으면 이 세션에서는 다시 띄우지 않는다(잔소리 금지). */
357
+ @state()
358
+ private foldNoticeDismissed = false
359
+
322
360
  @state()
323
361
  private busy = false
324
362
 
@@ -1073,6 +1111,126 @@ export class OxBoardAIChat extends LitElement {
1073
1111
  line-height: 1.45;
1074
1112
  }
1075
1113
 
1114
+ /* ── "대화가 길어졌습니다" 안내 — 조용한 한 줄, 강조하지 않는다 ───── */
1115
+ .fold-notice {
1116
+ display: flex;
1117
+ align-items: center;
1118
+ gap: 6px;
1119
+ margin: 0 0 4px;
1120
+ padding: 5px 8px;
1121
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
1122
+ border-radius: 8px;
1123
+ background: var(--md-sys-color-surface-container-low, #f8fafc);
1124
+ color: var(--md-sys-color-on-surface-variant, #475569);
1125
+ font-size: 10.5px;
1126
+ line-height: 1.45;
1127
+ }
1128
+ .fold-notice md-icon {
1129
+ --md-icon-size: 14px;
1130
+ flex-shrink: 0;
1131
+ }
1132
+ .fold-notice .fn-text {
1133
+ flex: 1;
1134
+ min-width: 0;
1135
+ }
1136
+ /* 새 대화 — 권유이므로 강조색 글자만(채운 버튼은 지시처럼 읽힌다). */
1137
+ .fold-notice .fn-new {
1138
+ flex-shrink: 0;
1139
+ border: 0;
1140
+ background: none;
1141
+ color: var(--md-sys-color-primary, #0f766e);
1142
+ font-family: inherit;
1143
+ font-size: 10.5px;
1144
+ font-weight: 650;
1145
+ cursor: pointer;
1146
+ padding: 2px 4px;
1147
+ border-radius: 5px;
1148
+ white-space: nowrap;
1149
+ }
1150
+ .fold-notice .fn-new:hover {
1151
+ background: var(--md-sys-color-surface-container-high, #e2e8f0);
1152
+ }
1153
+ .fold-notice .fn-close {
1154
+ flex-shrink: 0;
1155
+ border: 0;
1156
+ background: none;
1157
+ color: var(--md-sys-color-outline, #94a3b8);
1158
+ cursor: pointer;
1159
+ padding: 0;
1160
+ display: inline-flex;
1161
+ }
1162
+ .fold-notice .fn-close md-icon {
1163
+ --md-icon-size: 13px;
1164
+ }
1165
+
1166
+ /* ── 조치 제안 — AI 는 제안까지, 실행은 사용자 ───── */
1167
+ .proposals {
1168
+ display: flex;
1169
+ flex-direction: column;
1170
+ gap: 4px;
1171
+ margin-top: 6px;
1172
+ }
1173
+ .proposal {
1174
+ display: flex;
1175
+ align-items: center;
1176
+ gap: 8px;
1177
+ padding: 6px 8px 6px 7px;
1178
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
1179
+ /* 왼쪽 강조선 — 되돌릴 수 없는 행동임을 조용히 구분한다(색만으로 소리치지 않는다). */
1180
+ border-left: 3px solid var(--md-sys-color-primary, #0f766e);
1181
+ border-radius: 8px;
1182
+ background: var(--md-sys-color-surface-container-low, #f8fafc);
1183
+ }
1184
+ .proposal md-icon {
1185
+ --md-icon-size: 16px;
1186
+ color: var(--md-sys-color-primary, #0f766e);
1187
+ flex-shrink: 0;
1188
+ }
1189
+ .proposal .pbody {
1190
+ display: flex;
1191
+ flex-direction: column;
1192
+ gap: 1px;
1193
+ min-width: 0;
1194
+ flex: 1;
1195
+ }
1196
+ .proposal .plabel {
1197
+ font-size: 11.5px;
1198
+ font-weight: 650;
1199
+ color: var(--md-sys-color-on-surface, #0f172a);
1200
+ overflow-wrap: anywhere;
1201
+ }
1202
+ .proposal .preason {
1203
+ font-size: 10.5px;
1204
+ color: var(--md-sys-color-on-surface-variant, #475569);
1205
+ overflow-wrap: anywhere;
1206
+ }
1207
+ /* 실행 버튼 — 이 대화의 유일한 "되돌릴 수 없는" 행동이므로 채운 버튼 하나로 분명하게. */
1208
+ .proposal .run {
1209
+ flex-shrink: 0;
1210
+ padding: 4px 12px;
1211
+ border: 0;
1212
+ border-radius: 999px;
1213
+ background: var(--md-sys-color-primary, #0f766e);
1214
+ color: var(--md-sys-color-on-primary, #ffffff);
1215
+ font-family: inherit;
1216
+ font-size: 11px;
1217
+ font-weight: 650;
1218
+ letter-spacing: 0.01em;
1219
+ cursor: pointer;
1220
+ transition: filter 0.12s;
1221
+ }
1222
+ .proposal .run:hover:not(:disabled) {
1223
+ filter: brightness(1.08);
1224
+ }
1225
+ .proposal .run:disabled {
1226
+ background: var(--md-sys-color-surface-container-high, #e2e8f0);
1227
+ color: var(--md-sys-color-on-surface-variant, #475569);
1228
+ cursor: default;
1229
+ }
1230
+ .proposal.sent {
1231
+ border-left-color: var(--md-sys-color-outline, #94a3b8);
1232
+ }
1233
+
1076
1234
  /* ── 접지 경고 — 근거에 없는 대상을 지목한 답 ───── */
1077
1235
  .grounding-warning {
1078
1236
  display: flex;
@@ -1197,6 +1355,76 @@ export class OxBoardAIChat extends LitElement {
1197
1355
  color: var(--md-sys-color-on-error, #ffffff);
1198
1356
  background: var(--md-sys-color-error, #b91c1c);
1199
1357
  }
1358
+ /* 판단 파이프라인 — 훑어 읽는 목록. 배지로 결과를 구분하고 원본은 눌러서 본다. */
1359
+ .tool-usages.has-problem {
1360
+ border-color: var(--md-sys-color-error, #dc2626);
1361
+ }
1362
+ .tool-usages-counts .tu-flag {
1363
+ font-weight: 650;
1364
+ }
1365
+ .tool-usages-counts .tu-flag.bad {
1366
+ color: var(--md-sys-color-error, #dc2626);
1367
+ }
1368
+ .tu-turn {
1369
+ list-style: none;
1370
+ margin: 4px 0 2px;
1371
+ padding: 0 8px;
1372
+ font-size: 9.5px;
1373
+ font-weight: 650;
1374
+ letter-spacing: 0.04em;
1375
+ color: var(--md-sys-color-outline, #94a3b8);
1376
+ text-transform: uppercase;
1377
+ }
1378
+ .tool-usage-head {
1379
+ cursor: pointer;
1380
+ align-items: center;
1381
+ }
1382
+ .tool-usage-item.outcome-rejected,
1383
+ .tool-usage-item.outcome-error {
1384
+ border-left-color: var(--md-sys-color-error, #dc2626);
1385
+ }
1386
+ .tool-usage-item.outcome-proposed {
1387
+ border-left-color: var(--md-sys-color-primary, #0f766e);
1388
+ }
1389
+ .tool-usage-item.outcome-folded {
1390
+ opacity: 0.7;
1391
+ }
1392
+ .tu-badge {
1393
+ flex-shrink: 0;
1394
+ padding: 0 5px;
1395
+ border-radius: 999px;
1396
+ font-size: 9px;
1397
+ font-weight: 700;
1398
+ line-height: 15px;
1399
+ background: var(--md-sys-color-surface-container-high, #e2e8f0);
1400
+ color: var(--md-sys-color-on-surface-variant, #475569);
1401
+ }
1402
+ .tu-badge.rejected,
1403
+ .tu-badge.error {
1404
+ background: var(--md-sys-color-error-container, #fef2f2);
1405
+ color: var(--md-sys-color-error, #dc2626);
1406
+ }
1407
+ .tu-badge.proposed {
1408
+ background: var(--md-sys-color-primary, #0f766e);
1409
+ color: var(--md-sys-color-on-primary, #fff);
1410
+ }
1411
+ /* 한 줄 요약 — 넘치면 자른다. 목록은 훑는 곳이고 읽는 곳이 아니다. */
1412
+ .tu-line {
1413
+ flex: 1;
1414
+ min-width: 0;
1415
+ overflow: hidden;
1416
+ text-overflow: ellipsis;
1417
+ white-space: nowrap;
1418
+ color: var(--md-sys-color-on-surface-variant, #475569);
1419
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1420
+ font-size: 9.5px;
1421
+ }
1422
+ .tu-more {
1423
+ --md-icon-size: 13px;
1424
+ flex-shrink: 0;
1425
+ color: var(--md-sys-color-outline, #94a3b8);
1426
+ }
1427
+
1200
1428
  .tool-usage-args,
1201
1429
  .tool-usage-result {
1202
1430
  margin: 2px 0 0;
@@ -1740,6 +1968,7 @@ export class OxBoardAIChat extends LitElement {
1740
1968
  ${line.followUp
1741
1969
  ? html`<div class="followup">${unsafeHTML(renderMarkdown(line.followUp.trim()))}</div>`
1742
1970
  : nothing}
1971
+ ${line.proposals && line.proposals.length > 0 ? this.renderProposals(line) : nothing}
1743
1972
  ${line.groundingWarnings && line.groundingWarnings.length > 0
1744
1973
  ? this.renderGroundingWarning(line)
1745
1974
  : nothing}
@@ -1767,6 +1996,7 @@ export class OxBoardAIChat extends LitElement {
1767
1996
  </div>
1768
1997
  `
1769
1998
  : nothing}
1999
+ ${this.renderFoldNotice()}
1770
2000
  <div class="composer ${hasMessages ? '' : 'no-actions'}">
1771
2001
  ${hasMessages
1772
2002
  ? html`
@@ -2398,6 +2628,125 @@ export class OxBoardAIChat extends LitElement {
2398
2628
  `
2399
2629
  }
2400
2630
 
2631
+ /**
2632
+ * "대화가 길어졌습니다" 안내 — **사실만** 알리고 결정은 사용자에게 맡긴다.
2633
+ *
2634
+ * 주제가 바뀌었는지 판정하지 않는다: 판정하면 오탐이 생기고, 오탐이 생기면 사용자는 안내 자체를
2635
+ * 무시한다(접지 경고에서 배운 것과 같다). 근거는 "앞부분이 프롬프트에서 접혔다" 는 객관적 사실뿐이다.
2636
+ *
2637
+ * 왜 알려야 하는가: 한 대화에 주제가 여럿 섞이면 (1) 옛 맥락이 새 답을 오염시키고 (2) 요약이
2638
+ * 뭉개지고 (3) 목록의 대화 이름이 거짓말이 된다. 그걸 아는 사람은 사용자뿐이다.
2639
+ */
2640
+ private renderFoldNotice() {
2641
+ const folded = this.historyFolded
2642
+ if (!folded || this.foldNoticeDismissed) return nothing
2643
+ return html`
2644
+ <div class="fold-notice">
2645
+ <md-icon>unfold_less</md-icon>
2646
+ <span class="fn-text">
2647
+ ${folded.summarized
2648
+ ? i18next.t('board-ai.text.history-folded-summarized', {
2649
+ count: folded.omitted,
2650
+ defaultValue: '대화가 길어져 앞부분 {count}개가 요약으로 접혔습니다. 다른 주제라면 새 대화가 낫습니다.'
2651
+ })
2652
+ : i18next.t('board-ai.text.history-folded', {
2653
+ count: folded.omitted,
2654
+ defaultValue: '대화가 길어져 앞부분 {count}개가 생략되었습니다. 다른 주제라면 새 대화가 낫습니다.'
2655
+ })}
2656
+ </span>
2657
+ <button class="fn-new" @click=${this.startNewFromNotice}>
2658
+ ${i18next.t('board-ai.button.new-chat')}
2659
+ </button>
2660
+ <button class="fn-close" title=${i18next.t('board-ai.button.close')} @click=${() => (this.foldNoticeDismissed = true)}>
2661
+ <md-icon>close</md-icon>
2662
+ </button>
2663
+ </div>
2664
+ `
2665
+ }
2666
+
2667
+ /**
2668
+ * 새 대화로 — **맥락을 옮기지 않는다.** 주제를 바꾸려고 새로 시작하는 것이므로, 옮기면 그 이유가
2669
+ * 사라진다(승격은 이어가려는 것이고 이건 끊으려는 것이다). 세션 생성은 호스트의 몫이다.
2670
+ */
2671
+ private startNewFromNotice = () => {
2672
+ this.foldNoticeDismissed = true
2673
+ this.dispatchEvent(new CustomEvent('board-ai-start-new-session', { bubbles: true, composed: true }))
2674
+ }
2675
+
2676
+ /**
2677
+ * 조치 제안 카드 — AI 는 제안까지, **실행은 사용자**.
2678
+ *
2679
+ * 왜 카드인가: 되돌릴 수 없는 현장 명령을 문장 속 링크로 흘리면 실수로 눌린다. 무엇을·왜 하는지
2680
+ * 한 줄로 보이고 실행이 **분명한 한 번의 행동**이어야 한다.
2681
+ * 실행 결과는 호스트가 **대화에 기록**한다 — 누가 무엇을 실행했는지가 협의 이력에 남아야 한다.
2682
+ */
2683
+ private renderProposals(line: ChatLine) {
2684
+ const items = line.proposals ?? []
2685
+ if (items.length === 0) return nothing
2686
+ return html`
2687
+ <div class="proposals">
2688
+ ${items.map(p => {
2689
+ const key = this.proposalKey(p)
2690
+ const sent = this.sentProposals.has(key)
2691
+ return html`
2692
+ <div class="proposal ${sent ? 'sent' : ''}">
2693
+ <md-icon>bolt</md-icon>
2694
+ <span class="pbody">
2695
+ <span class="plabel">${p.label || p.command}</span>
2696
+ ${p.reason ? html`<span class="preason">${p.reason}</span>` : nothing}
2697
+ </span>
2698
+ <button
2699
+ class="run"
2700
+ ?disabled=${sent}
2701
+ @click=${() => this.executeProposal(p, key)}>
2702
+ ${sent ? i18next.t('board-ai.text.proposal-sent', { defaultValue: '실행 요청됨' })
2703
+ : i18next.t('board-ai.button.run-proposal', { defaultValue: '실행' })}
2704
+ </button>
2705
+ </div>
2706
+ `
2707
+ })}
2708
+ </div>
2709
+ `
2710
+ }
2711
+
2712
+ /**
2713
+ * 제안 식별 키 — 같은 조치를 두 번 실행하지 않도록 **효과**(명령·대상·인자)로만 만든다.
2714
+ *
2715
+ * 인자는 **키 순서를 정렬**해 직렬화한다: 같은 인자를 다른 순서로 받았을 뿐인데 키가 달라지면
2716
+ * 한 카드를 실행한 뒤에도 다른 카드가 살아 있어 **같은 명령이 두 번 나간다**(실제로 그랬다).
2717
+ * 설명 문구(label·reason)는 키에 넣지 않는다 — 문구가 달라도 같은 조치다.
2718
+ */
2719
+ private proposalKey(p: any): string {
2720
+ const stable = (v: any): string => {
2721
+ if (v === null || typeof v !== 'object') return JSON.stringify(v ?? null)
2722
+ if (Array.isArray(v)) return `[${v.map(stable).join(',')}]`
2723
+ return `{${Object.keys(v)
2724
+ .sort()
2725
+ .map(k => `${JSON.stringify(k)}:${stable(v[k])}`)
2726
+ .join(',')}}`
2727
+ }
2728
+ try {
2729
+ return `${p?.command ?? ''}|${p?.instanceId ?? ''}|${stable(p?.args)}`
2730
+ } catch {
2731
+ return `${p?.command ?? ''}|${p?.instanceId ?? ''}`
2732
+ }
2733
+ }
2734
+
2735
+ /**
2736
+ * 실행은 호스트가 한다 — 이 컴포넌트는 도메인(트윈 명령 채널)을 모른다.
2737
+ * 확인 대화·명령 전송·결과 기록은 호스트의 몫이고, 여기서는 중복 클릭만 막는다.
2738
+ */
2739
+ private executeProposal(p: any, key: string) {
2740
+ this.sentProposals = new Set(this.sentProposals).add(key)
2741
+ this.dispatchEvent(
2742
+ new CustomEvent('board-ai-proposal-execute', {
2743
+ detail: { proposal: p, sessionId: this.sessionId },
2744
+ bubbles: true,
2745
+ composed: true
2746
+ })
2747
+ )
2748
+ }
2749
+
2401
2750
  /**
2402
2751
  * 접지 경고 — 답이 언급했지만 **근거에 없던** 대상을 알린다.
2403
2752
  *
@@ -2435,37 +2784,39 @@ export class OxBoardAIChat extends LitElement {
2435
2784
  const readCount = usages.filter(u => u.kind === 'read').length
2436
2785
  const writeCount = usages.filter(u => u.kind === 'write').length
2437
2786
  const toolsLabel = i18next.t('board-ai.text.tools-used', { defaultValue: '도구 사용' })
2787
+ /* 눈에 걸려야 하는 것을 머리줄에 올린다 — 거절·접힘이 있으면 펼치기 전에 보인다.
2788
+ * (이번 사고들이 전부 그것이었다: 인자 빠진 호출이 거절됐고, 중복 제안이 접혔다.) */
2789
+ const problems = usages.filter(u => u.outcome === 'rejected' || u.outcome === 'error').length
2790
+ const folded = usages.filter(u => u.outcome === 'folded').length
2438
2791
  return html`
2439
- <div class="tool-usages ${open ? 'open' : ''}">
2792
+ <div class="tool-usages ${open ? 'open' : ''} ${problems ? 'has-problem' : ''}">
2440
2793
  <button
2441
2794
  class="tool-usages-header"
2442
2795
  aria-expanded=${open}
2443
2796
  @click=${() => this.toggleToolUsages(idx)}>
2444
- <md-icon class="tool-usages-icon">${open ? 'expand_less' : 'build'}</md-icon>
2797
+ <md-icon class="tool-usages-icon">${open ? 'expand_less' : 'account_tree'}</md-icon>
2445
2798
  <span class="tool-usages-summary">
2446
2799
  <strong>${usages.length}</strong> ${toolsLabel}
2447
- <span class="tool-usages-counts">· read ${readCount} · write ${writeCount}</span>
2800
+ <span class="tool-usages-counts">
2801
+ · read ${readCount} · write ${writeCount}${problems
2802
+ ? html` · <span class="tu-flag bad">${i18next.t('board-ai.text.tool-rejected-count', {
2803
+ count: problems,
2804
+ defaultValue: '거절 {count}'
2805
+ })}</span>`
2806
+ : nothing}${folded
2807
+ ? html` · <span class="tu-flag">${i18next.t('board-ai.text.tool-folded-count', {
2808
+ count: folded,
2809
+ defaultValue: '접힘 {count}'
2810
+ })}</span>`
2811
+ : nothing}
2812
+ </span>
2448
2813
  </span>
2449
2814
  <md-icon class="tool-usages-chevron">${open ? 'expand_less' : 'expand_more'}</md-icon>
2450
2815
  </button>
2451
2816
  ${open
2452
2817
  ? html`
2453
2818
  <ol class="tool-usages-list">
2454
- ${usages.map(
2455
- (u, i) => html`
2456
- <li class="tool-usage-item kind-${u.kind}">
2457
- <div class="tool-usage-head">
2458
- <span class="tool-usage-step">${i + 1}.</span>
2459
- <span class="tool-usage-name">${u.name}</span>
2460
- <span class="tool-usage-kind kind-${u.kind}">${u.kind}</span>
2461
- </div>
2462
- ${u.arguments && Object.keys(u.arguments).length > 0
2463
- ? html`<pre class="tool-usage-args">${this.formatJson(u.arguments)}</pre>`
2464
- : nothing}
2465
- <pre class="tool-usage-result">${this.formatJson(u.result)}</pre>
2466
- </li>
2467
- `
2468
- )}
2819
+ ${usages.map((u, i) => this.renderToolStep(idx, u, i, usages[i - 1]))}
2469
2820
  </ol>
2470
2821
  `
2471
2822
  : nothing}
@@ -2482,6 +2833,90 @@ export class OxBoardAIChat extends LitElement {
2482
2833
  this.lines = next
2483
2834
  }
2484
2835
 
2836
+ /**
2837
+ * 판단 과정의 한 단계 — 턴 구분 · 도구 · 배지 · **한 줄 요약**.
2838
+ *
2839
+ * 원본 JSON 을 처음부터 펼쳐 두면 눈이 흐려져 정작 중요한 것(무엇이 거절됐는지, 무엇이 접혔는지)이
2840
+ * 안 보인다. 한 줄로 줄이고 원본은 항목별로 눌러서 본다.
2841
+ */
2842
+ private renderToolStep(lineIdx: number, u: any, i: number, prev: any) {
2843
+ const outcome = u.outcome ?? 'ok'
2844
+ const openKey = `${lineIdx}:${i}`
2845
+ const open = this.openToolSteps.has(openKey)
2846
+ /* 턴이 바뀌면 구분선 — "한 턴 안에서 정정했는지, 다음 턴에서 했는지" 가 진단의 절반이다. */
2847
+ const newTurn = typeof u.iter === 'number' && (!prev || prev.iter !== u.iter)
2848
+ return html`
2849
+ ${newTurn
2850
+ ? html`<li class="tu-turn">
2851
+ ${i18next.t('board-ai.text.tool-turn', { n: (u.iter ?? 0) + 1, defaultValue: '{n}번째 판단' })}
2852
+ </li>`
2853
+ : nothing}
2854
+ <li class="tool-usage-item kind-${u.kind} outcome-${outcome}">
2855
+ <div class="tool-usage-head" @click=${() => this.toggleToolStep(openKey)}>
2856
+ <span class="tool-usage-step">${i + 1}.</span>
2857
+ <span class="tool-usage-name">${u.name}</span>
2858
+ <span class="tu-badge ${outcome}">${this.outcomeLabel(outcome)}</span>
2859
+ <span class="tu-line" title=${this.stepSummary(u)}>${this.stepSummary(u)}</span>
2860
+ <md-icon class="tu-more">${open ? 'expand_less' : 'expand_more'}</md-icon>
2861
+ </div>
2862
+ ${open
2863
+ ? html`
2864
+ ${u.arguments && Object.keys(u.arguments).length > 0
2865
+ ? html`<pre class="tool-usage-args">${this.formatJson(u.arguments)}</pre>`
2866
+ : nothing}
2867
+ <pre class="tool-usage-result">${this.formatJson(u.result)}</pre>
2868
+ `
2869
+ : nothing}
2870
+ </li>
2871
+ `
2872
+ }
2873
+
2874
+ /** 배지 문구 — 코드가 아니라 사람 말로. */
2875
+ private outcomeLabel(outcome: string): string {
2876
+ const map: Record<string, [string, string]> = {
2877
+ ok: ['board-ai.text.outcome-ok', '조회'],
2878
+ rejected: ['board-ai.text.outcome-rejected', '거절'],
2879
+ queued: ['board-ai.text.outcome-queued', '적용 예정'],
2880
+ proposed: ['board-ai.text.outcome-proposed', '제안'],
2881
+ folded: ['board-ai.text.outcome-folded', '중복 접힘'],
2882
+ error: ['board-ai.text.outcome-error', '오류']
2883
+ }
2884
+ const [key, fallback] = map[outcome] ?? map.ok
2885
+ return i18next.t(key, { defaultValue: fallback })
2886
+ }
2887
+
2888
+ /**
2889
+ * 결과 한 줄 — 무엇을 얻었는지/왜 막혔는지만. 목록에서 훑어 읽을 수 있어야 한다.
2890
+ * 거절이면 사유를, 조회면 대표적인 개수를, 제안이면 무엇을 제안했는지.
2891
+ */
2892
+ private stepSummary(u: any): string {
2893
+ const r = u?.result
2894
+ if (!r || typeof r !== 'object') return typeof r === 'string' ? r.slice(0, 80) : ''
2895
+ if (u.outcome === 'rejected' || u.outcome === 'error') {
2896
+ return String(r.errorCode || r.error || r.issues?.[0]?.message || '').slice(0, 80)
2897
+ }
2898
+ if (u.outcome === 'proposed' || u.outcome === 'folded') {
2899
+ return [r.command, r.args && Object.keys(r.args).length ? this.formatJson(r.args) : '']
2900
+ .filter(Boolean)
2901
+ .join(' ')
2902
+ .slice(0, 80)
2903
+ }
2904
+ /* 조회 결과 — 배열 길이가 있는 필드를 골라 "무엇이 몇 개" 로 요약한다(전체 덤프 금지). */
2905
+ const counts = Object.keys(r)
2906
+ .filter(k => Array.isArray(r[k]) && r[k].length > 0)
2907
+ .slice(0, 2)
2908
+ .map(k => `${k} ${r[k].length}`)
2909
+ if (counts.length) return counts.join(' · ')
2910
+ const keys = Object.keys(r).slice(0, 4)
2911
+ return keys.map(k => (typeof r[k] === 'object' ? k : `${k} ${r[k]}`)).join(' · ').slice(0, 80)
2912
+ }
2913
+
2914
+ private toggleToolStep(key: string) {
2915
+ const next = new Set(this.openToolSteps)
2916
+ next.has(key) ? next.delete(key) : next.add(key)
2917
+ this.openToolSteps = next
2918
+ }
2919
+
2485
2920
  private toggleToolUsages(idx: number) {
2486
2921
  const next = [...this.lines]
2487
2922
  const line = next[idx]
@@ -2650,6 +3085,9 @@ export class OxBoardAIChat extends LitElement {
2650
3085
  const out = result.data?.boardAIChat
2651
3086
  if (!out) throw new Error(i18next.t('board-ai.text.empty-response'))
2652
3087
 
3088
+ /* 대화가 길어졌다는 서버 보고 — 닫지 않았다면 안내를 띄운다. */
3089
+ this.historyFolded = out.historyFolded ?? undefined
3090
+
2653
3091
  // 전송 성공 — picks 정리 (다음 메시지부터는 새 picks 만)
2654
3092
  this.pickedMentions.clear()
2655
3093
  this.pickedUserMentions.clear()
@@ -2665,6 +3103,7 @@ export class OxBoardAIChat extends LitElement {
2665
3103
  patchId: out.patchId || undefined,
2666
3104
  toolUsages: Array.isArray(out.toolUsages) ? out.toolUsages : undefined,
2667
3105
  groundingWarnings: Array.isArray(out.groundingWarnings) ? out.groundingWarnings : undefined,
3106
+ proposals: Array.isArray(out.proposals) ? out.proposals : undefined,
2668
3107
  pending: false
2669
3108
  })
2670
3109
 
@@ -154,9 +154,24 @@ describe('내 발신 에코 흡수 (대기 중 중복 렌더 방지)', () => {
154
154
  expect(r.lines[2]).toMatchObject({ id: 'S9', senderEmail: 'other@x.com' })
155
155
  })
156
156
 
157
- it('assistant 방송은 흡수 대상이 아니다(append)', () => {
158
- const r = receiveRemoteMessage(pending(), new Set(), { id: 'S2', role: 'assistant', content: '답' })!
159
- expect(r.lines).toHaveLength(3)
157
+ /* 이 테스트는 예전에 "assistant 방송은 흡수하지 않고 붙인다" 규칙으로 굳혀 두고 있었다.
158
+ * 그것이 실제 결함이었다 붙이면 뒤이은 mutation 응답이 대기 줄을 지우고, 줄에만 있던
159
+ * **영속되지 않는 응답 데이터**(조치 제안 카드·접지 경고)가 함께 사라진다. 규칙을 바로잡는다. */
160
+ it('assistant 방송도 대기 중인 AI 줄에 흡수한다 — 붙이면 제안 카드가 사라진다', () => {
161
+ const lines = [
162
+ { _localId: 'L1', role: 'user', content: '질문', senderEmail: 'me@x.com' },
163
+ { _localId: 'L2', role: 'assistant', content: '', pending: true }
164
+ ]
165
+ const r = receiveRemoteMessage(lines as any, new Set(), { id: 'S2', role: 'assistant', content: '답' })!
166
+ expect(r.lines).toHaveLength(2)
167
+ expect(r.lines[1]).toMatchObject({ id: 'S2', content: '답', pending: false })
168
+ })
169
+
170
+ it('대기 중인 AI 줄이 없으면 붙인다 — 다른 참여자가 부른 답변', () => {
171
+ const settled = [{ id: 'S0', role: 'user', content: '질문' }]
172
+ const r = receiveRemoteMessage(settled as any, new Set(), { id: 'S2', role: 'assistant', content: '답' })!
173
+ expect(r.lines).toHaveLength(2)
174
+ expect(r.lines[1].id).toBe('S2')
160
175
  })
161
176
 
162
177
  it('흡수한 뒤 mutation 응답 reconcile 은 그 줄을 지우지 않고 patch 를 병합한다', () => {
@@ -172,3 +187,44 @@ describe('내 발신 에코 흡수 (대기 중 중복 렌더 방지)', () => {
172
187
  expect(after.lines[0]._localId).toBeUndefined()
173
188
  })
174
189
  })
190
+
191
+ describe('AI 답변 에코 흡수 — 영속되지 않는 응답 데이터를 잃지 않기', () => {
192
+ /* 실제 사고: 방송이 mutation 응답보다 먼저 와서 assistant 줄이 새로 붙고, 뒤이어 응답이 대기 줄을
193
+ * 지웠다. 대기 줄에만 있던 **조치 제안 카드**가 그때 사라졌다(도구는 분명히 호출됐는데도). */
194
+ const pendingAi = () => [
195
+ { id: 'u1', role: 'user', content: '지게차 한 대 추가해줘' },
196
+ { _localId: 'L2', role: 'assistant', content: '', pending: true, proposals: [{ command: 'resource.add' }] }
197
+ ]
198
+
199
+ it('대기 중인 AI 줄에 흡수한다 — 새 줄로 붙이지 않는다', () => {
200
+ const r = receiveRemoteMessage(pendingAi() as any, new Set(), {
201
+ id: 'a1',
202
+ role: 'assistant',
203
+ content: '제안해 두었습니다.'
204
+ })!
205
+ expect(r.lines).toHaveLength(2)
206
+ expect(r.lines[1].id).toBe('a1')
207
+ expect(r.lines[1].content).toBe('제안해 두었습니다.')
208
+ })
209
+
210
+ it('제안 카드를 유지한다 — 영속되지 않는 값이라 잃으면 화면에서 버튼이 사라진다', () => {
211
+ const r = receiveRemoteMessage(pendingAi() as any, new Set(), {
212
+ id: 'a1',
213
+ role: 'assistant',
214
+ content: '제안해 두었습니다.'
215
+ })!
216
+ expect(r.lines[1].proposals).toEqual([{ command: 'resource.add' }])
217
+ })
218
+
219
+ it('대기 표시를 푼다 — 방송이 먼저 오면 답이 더 빨리 보인다', () => {
220
+ const r = receiveRemoteMessage(pendingAi() as any, new Set(), { id: 'a1', role: 'assistant', content: '답' })!
221
+ expect(r.lines[1].pending).toBe(false)
222
+ })
223
+
224
+ it('대기 줄이 없으면(다른 참여자가 부른 AI 답변) 새 줄로 붙인다', () => {
225
+ const lines = [{ id: 'u1', role: 'user', content: '질문' }]
226
+ const r = receiveRemoteMessage(lines as any, new Set(), { id: 'a9', role: 'assistant', content: '답' })!
227
+ expect(r.lines).toHaveLength(2)
228
+ expect(r.lines[1].id).toBe('a9')
229
+ })
230
+ })
@@ -72,6 +72,35 @@ export function receiveRemoteMessage(
72
72
  /* 내 발신 에코 흡수 — `_localId` 가 있는 줄은 이 클라이언트가 만든 것뿐이다.
73
73
  * 발신자 판정: 양쪽 email 이 있으면 같아야 하고, 방송에 발신자 email 이 없으면(단일 사용자·구형)
74
74
  * 내 것으로 본다. 다른 사용자의 메시지가 내 대기 줄을 삼키지 않도록 email 이 다르면 흡수하지 않는다. */
75
+ /* AI 답변 에코 흡수 — 사용자 발화와 **같은 이유**로 붙이지 않고 대기 줄에 흡수한다.
76
+ *
77
+ * 왜 필요한가(실제 사고): 방송은 mutation 응답보다 먼저 도착할 수 있다. 예전에는 assistant 방송을
78
+ * 새 줄로 붙였고, 뒤이어 mutation 응답이 "같은 서버 id 를 가진 다른 줄이 있다" 며 **대기 줄을 지웠다**.
79
+ * 그런데 대기 줄에만 있는 것이 있다 — **영속되지 않는 응답 데이터**(조치 제안 카드·접지 경고).
80
+ * 그래서 화면에서 실행 버튼이 사라졌다(제안 도구는 분명히 호출됐는데도).
81
+ *
82
+ * 흡수하면 서버가 보낸 본문(멘션 마커 포함)을 그 줄에 입히면서 대기 표시를 풀 수 있어, 답이 더
83
+ * 빨리 보이는 효과도 있다. */
84
+ if (m.role === 'assistant') {
85
+ const idx = lines.findIndex(l => l.role === 'assistant' && !l.id && !!l._localId)
86
+ if (idx >= 0) {
87
+ const nextLines = [...lines]
88
+ nextLines[idx] = {
89
+ ...nextLines[idx],
90
+ id: m.id,
91
+ /* 본문·영속 데이터는 서버 것으로 갱신하고, 이 줄이 들고 있던 값(제안 등)은 유지한다. */
92
+ content: m.content,
93
+ patchId: m.relatedPatchId ?? nextLines[idx].patchId,
94
+ toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : nextLines[idx].toolUsages,
95
+ groundingWarnings: Array.isArray(m.groundingWarningsJson)
96
+ ? m.groundingWarningsJson
97
+ : nextLines[idx].groundingWarnings,
98
+ pending: false
99
+ }
100
+ return { lines: nextLines, seen: nextSeen }
101
+ }
102
+ }
103
+
75
104
  if (m.role === 'user') {
76
105
  const echoEmail = m.creator?.email
77
106
  const idx = lines.findIndex(