@things-factory/ai-assistant 10.1.26 → 10.1.28

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 (194) hide show
  1. package/client/components/assistant-chat.ts +653 -59
  2. package/client/components/assistant-mode.test.ts +86 -55
  3. package/client/components/assistant-mode.ts +124 -82
  4. package/client/components/chat-echo-dedup.test.ts +23 -0
  5. package/client/components/chat-echo-dedup.ts +29 -3
  6. package/client/components/chat-input-builder.test.ts +20 -20
  7. package/client/components/chat-input-builder.ts +16 -14
  8. package/client/components/chat-proposal-survival.test.ts +73 -0
  9. package/client/components/mention-popup.ts +31 -8
  10. package/client/components/panel-aria.test.ts +34 -0
  11. package/client/components/proposal-outcome-line.ts +33 -0
  12. package/client/utils/assistant-session-controller.test.ts +75 -0
  13. package/client/utils/assistant-session-controller.ts +23 -0
  14. package/client/utils/assistant-session-transport.ts +11 -3
  15. package/dist-client/components/assistant-chat.d.ts +128 -13
  16. package/dist-client/components/assistant-chat.js +613 -67
  17. package/dist-client/components/assistant-chat.js.map +1 -1
  18. package/dist-client/components/assistant-mode.d.ts +80 -68
  19. package/dist-client/components/assistant-mode.js +63 -19
  20. package/dist-client/components/assistant-mode.js.map +1 -1
  21. package/dist-client/components/assistant-mode.test.js +74 -53
  22. package/dist-client/components/assistant-mode.test.js.map +1 -1
  23. package/dist-client/components/chat-echo-dedup.d.ts +2 -0
  24. package/dist-client/components/chat-echo-dedup.js +27 -4
  25. package/dist-client/components/chat-echo-dedup.js.map +1 -1
  26. package/dist-client/components/chat-echo-dedup.test.js +20 -0
  27. package/dist-client/components/chat-echo-dedup.test.js.map +1 -1
  28. package/dist-client/components/chat-input-builder.d.ts +12 -7
  29. package/dist-client/components/chat-input-builder.js +8 -10
  30. package/dist-client/components/chat-input-builder.js.map +1 -1
  31. package/dist-client/components/chat-input-builder.test.js +17 -18
  32. package/dist-client/components/chat-input-builder.test.js.map +1 -1
  33. package/dist-client/components/chat-proposal-survival.test.d.ts +1 -0
  34. package/dist-client/components/chat-proposal-survival.test.js +61 -0
  35. package/dist-client/components/chat-proposal-survival.test.js.map +1 -0
  36. package/dist-client/components/mention-popup.js +28 -8
  37. package/dist-client/components/mention-popup.js.map +1 -1
  38. package/dist-client/components/panel-aria.test.d.ts +1 -0
  39. package/dist-client/components/panel-aria.test.js +31 -0
  40. package/dist-client/components/panel-aria.test.js.map +1 -0
  41. package/dist-client/components/proposal-outcome-line.d.ts +9 -0
  42. package/dist-client/components/proposal-outcome-line.js +15 -0
  43. package/dist-client/components/proposal-outcome-line.js.map +1 -0
  44. package/dist-client/tsconfig.tsbuildinfo +1 -1
  45. package/dist-client/utils/assistant-session-controller.d.ts +10 -0
  46. package/dist-client/utils/assistant-session-controller.js +16 -0
  47. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  48. package/dist-client/utils/assistant-session-controller.test.d.ts +1 -0
  49. package/dist-client/utils/assistant-session-controller.test.js +56 -0
  50. package/dist-client/utils/assistant-session-controller.test.js.map +1 -0
  51. package/dist-client/utils/assistant-session-transport.js +11 -3
  52. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  53. package/dist-server/service/assistant-capability-resolver.d.ts +17 -0
  54. package/dist-server/service/assistant-capability-resolver.js +88 -0
  55. package/dist-server/service/assistant-capability-resolver.js.map +1 -0
  56. package/dist-server/service/assistant-chat-resolver.d.ts +6 -6
  57. package/dist-server/service/assistant-chat-resolver.js +166 -44
  58. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  59. package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +25 -0
  60. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +140 -0
  61. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -0
  62. package/dist-server/service/assistant-mode/index.d.ts +9 -0
  63. package/dist-server/service/assistant-mode/index.js +19 -0
  64. package/dist-server/service/assistant-mode/index.js.map +1 -0
  65. package/dist-server/service/assistant-mode/registry.d.ts +18 -0
  66. package/dist-server/service/assistant-mode/registry.js +136 -0
  67. package/dist-server/service/assistant-mode/registry.js.map +1 -0
  68. package/dist-server/service/assistant-mode/resolve.d.ts +33 -0
  69. package/dist-server/service/assistant-mode/resolve.js +60 -0
  70. package/dist-server/service/assistant-mode/resolve.js.map +1 -0
  71. package/dist-server/service/assistant-mode/types.d.ts +99 -0
  72. package/dist-server/service/assistant-mode/types.js +12 -0
  73. package/dist-server/service/assistant-mode/types.js.map +1 -0
  74. package/dist-server/service/chat-message/carried-turns.d.ts +48 -0
  75. package/dist-server/service/chat-message/carried-turns.js +80 -0
  76. package/dist-server/service/chat-message/carried-turns.js.map +1 -0
  77. package/dist-server/service/chat-message/chat-message-subscription.d.ts +1 -1
  78. package/dist-server/service/chat-message/chat-message-subscription.js +24 -6
  79. package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -1
  80. package/dist-server/service/chat-message/chat-message.d.ts +32 -0
  81. package/dist-server/service/chat-message/chat-message.js +17 -0
  82. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  83. package/dist-server/service/chat-message/door-access.d.ts +15 -0
  84. package/dist-server/service/chat-message/door-access.js +26 -0
  85. package/dist-server/service/chat-message/door-access.js.map +1 -0
  86. package/dist-server/service/chat-message/fold-session-history.d.ts +30 -0
  87. package/dist-server/service/chat-message/fold-session-history.js +133 -0
  88. package/dist-server/service/chat-message/fold-session-history.js.map +1 -0
  89. package/dist-server/service/chat-message/llm-history.d.ts +7 -0
  90. package/dist-server/service/chat-message/llm-history.js +23 -11
  91. package/dist-server/service/chat-message/llm-history.js.map +1 -1
  92. package/dist-server/service/chat-message/message-doors.d.ts +29 -0
  93. package/dist-server/service/chat-message/message-doors.js +107 -0
  94. package/dist-server/service/chat-message/message-doors.js.map +1 -0
  95. package/dist-server/service/chat-message/proposal-outcome.d.ts +18 -0
  96. package/dist-server/service/chat-message/proposal-outcome.js +31 -0
  97. package/dist-server/service/chat-message/proposal-outcome.js.map +1 -0
  98. package/dist-server/service/chat-message/reader-gate.d.ts +3 -0
  99. package/dist-server/service/chat-message/reader-gate.js +37 -0
  100. package/dist-server/service/chat-message/reader-gate.js.map +1 -0
  101. package/dist-server/service/chat-message/room-message.d.ts +4 -0
  102. package/dist-server/service/chat-message/room-message.js +25 -0
  103. package/dist-server/service/chat-message/room-message.js.map +1 -0
  104. package/dist-server/service/chat-session/chat-session.d.ts +24 -0
  105. package/dist-server/service/chat-session/chat-session.js +26 -2
  106. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  107. package/dist-server/service/chat-session/session-inbox.d.ts +4 -1
  108. package/dist-server/service/chat-session/session-inbox.js +3 -2
  109. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  110. package/dist-server/service/chat-session/session-visibility.d.ts +15 -0
  111. package/dist-server/service/chat-session/session-visibility.js +28 -0
  112. package/dist-server/service/chat-session/session-visibility.js.map +1 -0
  113. package/dist-server/service/chat-session/station-session-guard.d.ts +1 -5
  114. package/dist-server/service/chat-session/station-session-guard.js +7 -2
  115. package/dist-server/service/chat-session/station-session-guard.js.map +1 -1
  116. package/dist-server/service/chat-session-participant/mention-invite.d.ts +29 -0
  117. package/dist-server/service/chat-session-participant/mention-invite.js +103 -0
  118. package/dist-server/service/chat-session-participant/mention-invite.js.map +1 -0
  119. package/dist-server/service/chat-session-resolver.d.ts +15 -0
  120. package/dist-server/service/chat-session-resolver.js +267 -15
  121. package/dist-server/service/chat-session-resolver.js.map +1 -1
  122. package/dist-server/service/grounding-sources.d.ts +14 -0
  123. package/dist-server/service/grounding-sources.js +26 -0
  124. package/dist-server/service/grounding-sources.js.map +1 -0
  125. package/dist-server/service/index.d.ts +8 -1
  126. package/dist-server/service/index.js +19 -0
  127. package/dist-server/service/index.js.map +1 -1
  128. package/dist-server/service/request-translator.d.ts +2 -0
  129. package/dist-server/service/request-translator.js +20 -0
  130. package/dist-server/service/request-translator.js.map +1 -0
  131. package/dist-server/service/testing/conversation-script.d.ts +123 -0
  132. package/dist-server/service/testing/conversation-script.js +202 -0
  133. package/dist-server/service/testing/conversation-script.js.map +1 -0
  134. package/dist-server/service/testing/mode-conformance.d.ts +40 -0
  135. package/dist-server/service/testing/mode-conformance.js +90 -0
  136. package/dist-server/service/testing/mode-conformance.js.map +1 -0
  137. package/dist-server/tool-registry-boot-report.d.ts +3 -0
  138. package/dist-server/tool-registry-boot-report.js +25 -2
  139. package/dist-server/tool-registry-boot-report.js.map +1 -1
  140. package/dist-server/tsconfig.tsbuildinfo +1 -1
  141. package/package.json +5 -5
  142. package/server/explicit-graphql-types.test.ts +56 -0
  143. package/server/service/assistant-capability-resolver.ts +66 -0
  144. package/server/service/assistant-chat-resolver.ts +184 -42
  145. package/server/service/assistant-mode/assistant-mode-resolver.ts +102 -0
  146. package/server/service/assistant-mode/index.ts +16 -0
  147. package/server/service/assistant-mode/registry.test.ts +105 -0
  148. package/server/service/assistant-mode/registry.ts +147 -0
  149. package/server/service/assistant-mode/resolve.test.ts +78 -0
  150. package/server/service/assistant-mode/resolve.ts +96 -0
  151. package/server/service/assistant-mode/subject-defaults.test.ts +86 -0
  152. package/server/service/assistant-mode/types.ts +103 -0
  153. package/server/service/chat-message/carried-turns.test.ts +69 -0
  154. package/server/service/chat-message/carried-turns.ts +99 -0
  155. package/server/service/chat-message/chat-message-subscription.ts +20 -4
  156. package/server/service/chat-message/chat-message.ts +44 -0
  157. package/server/service/chat-message/door-access.ts +45 -0
  158. package/server/service/chat-message/fold-session-history.test.ts +98 -0
  159. package/server/service/chat-message/fold-session-history.ts +115 -0
  160. package/server/service/chat-message/llm-history.test.ts +58 -7
  161. package/server/service/chat-message/llm-history.ts +30 -11
  162. package/server/service/chat-message/message-doors.test.ts +87 -0
  163. package/server/service/chat-message/message-doors.ts +117 -0
  164. package/server/service/chat-message/proposal-outcome.test.ts +48 -0
  165. package/server/service/chat-message/proposal-outcome.ts +42 -0
  166. package/server/service/chat-message/reader-gate.ts +42 -0
  167. package/server/service/chat-message/room-message.test.ts +21 -0
  168. package/server/service/chat-message/room-message.ts +22 -0
  169. package/server/service/chat-session/chat-session.ts +31 -1
  170. package/server/service/chat-session/session-inbox.test.ts +18 -0
  171. package/server/service/chat-session/session-inbox.ts +9 -2
  172. package/server/service/chat-session/session-visibility.test.ts +36 -0
  173. package/server/service/chat-session/session-visibility.ts +26 -0
  174. package/server/service/chat-session/station-session-guard.ts +10 -2
  175. package/server/service/chat-session-participant/mention-invite.test.ts +125 -0
  176. package/server/service/chat-session-participant/mention-invite.ts +121 -0
  177. package/server/service/chat-session-resolver.ts +272 -15
  178. package/server/service/grounding-sources.test.ts +55 -0
  179. package/server/service/grounding-sources.ts +46 -0
  180. package/server/service/index.ts +19 -0
  181. package/server/service/request-translator.test.ts +45 -0
  182. package/server/service/request-translator.ts +33 -0
  183. package/server/service/station-session-guard.test.ts +12 -5
  184. package/server/service/testing/conversation-scenario.test.ts +163 -0
  185. package/server/service/testing/conversation-script.test.ts +131 -0
  186. package/server/service/testing/conversation-script.ts +288 -0
  187. package/server/service/testing/mode-conformance.ts +107 -0
  188. package/server/tool-registry-boot-report.ts +25 -2
  189. package/test/assistant-chat-call-sites.test.ts +82 -0
  190. package/translations/en.json +44 -2
  191. package/translations/ja.json +44 -2
  192. package/translations/ko.json +44 -2
  193. package/translations/ms.json +44 -2
  194. package/translations/zh.json +44 -2
@@ -18,7 +18,13 @@ export function remoteMessageToLine(m) {
18
18
  toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : undefined,
19
19
  groundingWarnings: Array.isArray(m.groundingWarningsJson) ? m.groundingWarningsJson : undefined,
20
20
  senderEmail: m.creator?.email ?? undefined,
21
- senderName: m.creator?.name ?? undefined
21
+ senderName: m.creator?.name ?? undefined,
22
+ /*
23
+ * 서버가 이 사람에게 값을 안 내준 줄 — ADR-0073 판정 1. `content` 에 온 것은 어시스턴트가 쓴
24
+ * 문장이 아니라 「권한이 필요합니다」 한 줄이다. 그대로 말풍선에 넣으면 어시스턴트가 그렇게 말한
25
+ * 것처럼 읽힌다.
26
+ */
27
+ redacted: m.redacted === true
22
28
  };
23
29
  }
24
30
  /**
@@ -99,12 +105,29 @@ export function receiveRemoteMessage(lines, seen, m) {
99
105
  */
100
106
  export function reconcileSentLine(lines, seen, localId, serverId, patch) {
101
107
  const idx = lines.findIndex(l => l._localId === localId);
102
- if (idx < 0)
103
- return { lines, seen };
108
+ /* 같은 서버 id 를 든 **다른** 줄 — 에코가 흡수되지 못하고 새 줄로 붙은 경우다. */
109
+ const echoed = serverId ? lines.findIndex((l, i) => i !== idx && l.id === serverId) : -1;
110
+ /*
111
+ * 대기 줄이 없어졌다 — 보내는 동안 대화가 지워졌거나 이력으로 갈렸다. 그래도 **응답이 어느 메시지의
112
+ * 것인지는 안다**(`assistantMessageId`). 그 줄에 합친다.
113
+ *
114
+ * 여기서 그냥 돌아가면, 저장되는 값(본문 · 도구 사용)은 에코가 들고 있으니 화면은 멀쩡해 보이고
115
+ * **저장되지 않는 것만 사라진다** — 제안 카드다. 모델은 「카드를 눌러 적용하세요」라고 말하고 화면에는
116
+ * 누를 것이 없다(2026-09-18 트윈에서 실제로 그랬다).
117
+ */
118
+ if (idx < 0) {
119
+ if (echoed < 0)
120
+ return { lines, seen };
121
+ const merged = [...lines];
122
+ merged[echoed] = { ...merged[echoed], ...patch, id: serverId ?? merged[echoed].id };
123
+ return { lines: merged, seen: serverId ? new Set(seen).add(serverId) : seen };
124
+ }
104
125
  const nextLines = [...lines];
105
126
  /* seen 만 보고 지우면, 에코를 이 줄에 **흡수**한 경우(id 가 이미 이 줄에 있다) 자기 줄을 지운다.
106
127
  * 실제 중복은 "다른 줄이 같은 id 를 가진" 상태일 때뿐이다. */
107
- if (serverId && nextLines.some((l, i) => i !== idx && l.id === serverId)) {
128
+ if (echoed >= 0) {
129
+ /* 남는 것은 에코 줄이다. 응답에만 있는 것(제안)을 그 줄에 옮기고 대기 줄을 지운다. */
130
+ nextLines[echoed] = { ...nextLines[echoed], ...patch, id: serverId ?? nextLines[echoed].id };
108
131
  nextLines.splice(idx, 1);
109
132
  return { lines: nextLines, seen };
110
133
  }
@@ -1 +1 @@
1
- {"version":3,"file":"chat-echo-dedup.js","sourceRoot":"","sources":["../../client/components/chat-echo-dedup.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 그룹챗 메시지 에코 dedup — 순수 로직.\n *\n * 실시간 방송은 자기 발신 메시지도 되돌려준다(에코). 또 pubsub publish 가 mutation 응답보다\n * 먼저 도착할 수 있어(레이스) optimistic 라인과 방송 라인이 겹칠 수 있다. 이 모듈은 그 조정을\n * **id 기반**으로 결정한다 (서버가 멘션 마커로 content 를 enrich 하므로 content 매칭은 불가).\n *\n * 컴포넌트 상태(this.lines / this._seenMessageIds)에서 분리 → 단위테스트 가능.\n * (C2 의 patch-apply-gate.ts 와 동일한 \"순수 게이트\" 컨벤션.)\n */\n\n/** dedup 에 필요한 최소 라인 형태 (board-ai-chat 의 ChatLine 부분집합). */\nexport interface DedupLine {\n id?: string\n _localId?: string\n role: string\n content: string\n senderEmail?: string\n senderName?: string\n [k: string]: any\n}\n\n/** 서버 방송 메시지(필요 필드만). */\nexport interface RemoteMessage {\n id?: string\n role: string\n content: string\n relatedPatchId?: string\n toolUsagesJson?: any\n /** 접지 경고(영속) — 답이 언급했으나 근거에 없던 식별자. 나중에 읽는 사람도 같은 주의를 봐야 한다. */\n groundingWarningsJson?: any\n /** 발신자 — 표시용 name + **자기 여부 판정용 email**(내부 관리키 id 는 쓰지 않는다). */\n creator?: { name?: string; email?: string } | null\n}\n\n/** 방송 메시지 → 라인. */\nexport function remoteMessageToLine(m: RemoteMessage): DedupLine {\n return {\n id: m.id,\n role: m.role,\n content: m.content,\n patchId: m.relatedPatchId ?? undefined,\n toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : undefined,\n groundingWarnings: Array.isArray(m.groundingWarningsJson) ? m.groundingWarningsJson : undefined,\n senderEmail: m.creator?.email ?? undefined,\n senderName: m.creator?.name ?? undefined\n }\n}\n\n/**\n * 원격(방송) 메시지 수신 결과를 계산.\n * - id 없음 / 이미 seen → 변화 없음(null).\n * - **내 발신 에코**이고 아직 서버 id 를 못 받은 optimistic 라인이 있으면 → 그 라인에 id 를 입힌다.\n * - 그 밖 → seen 에 추가 + 라인 append.\n *\n * 왜 '입히기'인가: 방송은 mutation 응답보다 먼저 도착할 수 있다. 예전에는 에코를 새 줄로 붙이고\n * 중복 제거를 mutation 응답 시점(reconcileSentLine)에 했는데, 그 응답은 **LLM 이 끝난 뒤** 오므로\n * 사용자는 대기 시간 내내 자기 질문을 두 줄로 본다. 그래서 붙이지 않고 대기 중인 줄에 흡수시킨다.\n *\n * `_localId` 는 유지한다 — 이후 mutation 응답이 patch(요약·patchId 등)를 그 줄에 병합할 수 있어야 한다.\n */\nexport function receiveRemoteMessage(\n lines: DedupLine[],\n seen: Set<string>,\n m: RemoteMessage | undefined | null\n): { lines: DedupLine[]; seen: Set<string> } | null {\n if (!m || !m.id) return null\n if (seen.has(m.id)) return null\n const nextSeen = new Set(seen)\n nextSeen.add(m.id)\n\n /* 내 발신 에코 흡수 — `_localId` 가 있는 줄은 이 클라이언트가 만든 것뿐이다.\n * 발신자 판정: 양쪽 email 이 있으면 같아야 하고, 방송에 발신자 email 이 없으면(단일 사용자·구형)\n * 내 것으로 본다. 다른 사용자의 메시지가 내 대기 줄을 삼키지 않도록 email 이 다르면 흡수하지 않는다. */\n /* AI 답변 에코 흡수 — 사용자 발화와 **같은 이유**로 붙이지 않고 대기 줄에 흡수한다.\n *\n * 왜 필요한가(실제 사고): 방송은 mutation 응답보다 먼저 도착할 수 있다. 예전에는 assistant 방송을\n * 새 줄로 붙였고, 뒤이어 mutation 응답이 \"같은 서버 id 를 가진 다른 줄이 있다\" 며 **대기 줄을 지웠다**.\n * 그런데 대기 줄에만 있는 것이 있다 — **영속되지 않는 응답 데이터**(조치 제안 카드·접지 경고).\n * 그래서 화면에서 실행 버튼이 사라졌다(제안 도구는 분명히 호출됐는데도).\n *\n * 흡수하면 서버가 보낸 본문(멘션 마커 포함)을 그 줄에 입히면서 대기 표시를 풀 수 있어, 답이 더\n * 빨리 보이는 효과도 있다. */\n if (m.role === 'assistant') {\n const idx = lines.findIndex(l => l.role === 'assistant' && !l.id && !!l._localId)\n if (idx >= 0) {\n const nextLines = [...lines]\n nextLines[idx] = {\n ...nextLines[idx],\n id: m.id,\n /* 본문·영속 데이터는 서버 것으로 갱신하고, 이 줄이 들고 있던 값(제안 등)은 유지한다. */\n content: m.content,\n patchId: m.relatedPatchId ?? nextLines[idx].patchId,\n toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : nextLines[idx].toolUsages,\n groundingWarnings: Array.isArray(m.groundingWarningsJson)\n ? m.groundingWarningsJson\n : nextLines[idx].groundingWarnings,\n pending: false\n }\n return { lines: nextLines, seen: nextSeen }\n }\n }\n\n if (m.role === 'user') {\n const echoEmail = m.creator?.email\n const idx = lines.findIndex(\n l =>\n l.role === 'user' &&\n !l.id &&\n !!l._localId &&\n (!echoEmail || l.senderEmail == null || l.senderEmail === echoEmail)\n )\n if (idx >= 0) {\n const nextLines = [...lines]\n nextLines[idx] = {\n ...nextLines[idx],\n id: m.id,\n senderEmail: echoEmail ?? nextLines[idx].senderEmail,\n senderName: m.creator?.name ?? nextLines[idx].senderName\n }\n return { lines: nextLines, seen: nextSeen }\n }\n }\n\n return { lines: [...lines, remoteMessageToLine(m)], seen: nextSeen }\n}\n\n/**\n * 전송한 optimistic 라인(_localId)을 서버 id 로 reconcile.\n * - **다른 줄**이 이미 그 serverId 를 갖고 있으면(옛 append 경로) optimistic 중복 라인 제거.\n * - 아니면 serverId 부여(+ seen 등록) + patch 병합 + _localId 해제.\n * - 해당 _localId 라인이 없으면 변화 없음.\n * 반환: 새 { lines, seen } (불변).\n */\nexport function reconcileSentLine(\n lines: DedupLine[],\n seen: Set<string>,\n localId: string,\n serverId: string | undefined,\n patch: Partial<DedupLine>\n): { lines: DedupLine[]; seen: Set<string> } {\n const idx = lines.findIndex(l => l._localId === localId)\n if (idx < 0) return { lines, seen }\n\n const nextLines = [...lines]\n /* seen 만 보고 지우면, 에코를 이 줄에 **흡수**한 경우(id 가 이미 이 줄에 있다) 자기 줄을 지운다.\n * 실제 중복은 \"다른 줄이 같은 id 를 가진\" 상태일 때뿐이다. */\n if (serverId && nextLines.some((l, i) => i !== idx && l.id === serverId)) {\n nextLines.splice(idx, 1)\n return { lines: nextLines, seen }\n }\n const nextSeen = serverId ? new Set(seen).add(serverId) : seen\n nextLines[idx] = { ...nextLines[idx], ...patch, id: serverId ?? nextLines[idx].id, _localId: undefined }\n return { lines: nextLines, seen: nextSeen }\n}\n"]}
1
+ {"version":3,"file":"chat-echo-dedup.js","sourceRoot":"","sources":["../../client/components/chat-echo-dedup.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 그룹챗 메시지 에코 dedup — 순수 로직.\n *\n * 실시간 방송은 자기 발신 메시지도 되돌려준다(에코). 또 pubsub publish 가 mutation 응답보다\n * 먼저 도착할 수 있어(레이스) optimistic 라인과 방송 라인이 겹칠 수 있다. 이 모듈은 그 조정을\n * **id 기반**으로 결정한다 (서버가 멘션 마커로 content 를 enrich 하므로 content 매칭은 불가).\n *\n * 컴포넌트 상태(this.lines / this._seenMessageIds)에서 분리 → 단위테스트 가능.\n * (C2 의 patch-apply-gate.ts 와 동일한 \"순수 게이트\" 컨벤션.)\n */\n\n/** dedup 에 필요한 최소 라인 형태 (board-ai-chat 의 ChatLine 부분집합). */\nexport interface DedupLine {\n id?: string\n _localId?: string\n role: string\n content: string\n senderEmail?: string\n senderName?: string\n [k: string]: any\n}\n\n/** 서버 방송 메시지(필요 필드만). */\nexport interface RemoteMessage {\n id?: string\n role: string\n content: string\n relatedPatchId?: string\n toolUsagesJson?: any\n /** 접지 경고(영속) — 답이 언급했으나 근거에 없던 식별자. 나중에 읽는 사람도 같은 주의를 봐야 한다. */\n groundingWarningsJson?: any\n /** 발신자 — 표시용 name + **자기 여부 판정용 email**(내부 관리키 id 는 쓰지 않는다). */\n creator?: { name?: string; email?: string } | null\n /** 이 사람이 이 답의 문을 못 지나 본문이 한 줄로 바뀌어 왔나(ADR-0073 판정 1). */\n redacted?: boolean\n}\n\n/** 방송 메시지 → 라인. */\nexport function remoteMessageToLine(m: RemoteMessage): DedupLine {\n return {\n id: m.id,\n role: m.role,\n content: m.content,\n patchId: m.relatedPatchId ?? undefined,\n toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : undefined,\n groundingWarnings: Array.isArray(m.groundingWarningsJson) ? m.groundingWarningsJson : undefined,\n senderEmail: m.creator?.email ?? undefined,\n senderName: m.creator?.name ?? undefined,\n /*\n * 서버가 이 사람에게 값을 안 내준 줄 — ADR-0073 판정 1. `content` 에 온 것은 어시스턴트가 쓴\n * 문장이 아니라 「권한이 필요합니다」 한 줄이다. 그대로 말풍선에 넣으면 어시스턴트가 그렇게 말한\n * 것처럼 읽힌다.\n */\n redacted: m.redacted === true\n }\n}\n\n/**\n * 원격(방송) 메시지 수신 결과를 계산.\n * - id 없음 / 이미 seen → 변화 없음(null).\n * - **내 발신 에코**이고 아직 서버 id 를 못 받은 optimistic 라인이 있으면 → 그 라인에 id 를 입힌다.\n * - 그 밖 → seen 에 추가 + 라인 append.\n *\n * 왜 '입히기'인가: 방송은 mutation 응답보다 먼저 도착할 수 있다. 예전에는 에코를 새 줄로 붙이고\n * 중복 제거를 mutation 응답 시점(reconcileSentLine)에 했는데, 그 응답은 **LLM 이 끝난 뒤** 오므로\n * 사용자는 대기 시간 내내 자기 질문을 두 줄로 본다. 그래서 붙이지 않고 대기 중인 줄에 흡수시킨다.\n *\n * `_localId` 는 유지한다 — 이후 mutation 응답이 patch(요약·patchId 등)를 그 줄에 병합할 수 있어야 한다.\n */\nexport function receiveRemoteMessage(\n lines: DedupLine[],\n seen: Set<string>,\n m: RemoteMessage | undefined | null\n): { lines: DedupLine[]; seen: Set<string> } | null {\n if (!m || !m.id) return null\n if (seen.has(m.id)) return null\n const nextSeen = new Set(seen)\n nextSeen.add(m.id)\n\n /* 내 발신 에코 흡수 — `_localId` 가 있는 줄은 이 클라이언트가 만든 것뿐이다.\n * 발신자 판정: 양쪽 email 이 있으면 같아야 하고, 방송에 발신자 email 이 없으면(단일 사용자·구형)\n * 내 것으로 본다. 다른 사용자의 메시지가 내 대기 줄을 삼키지 않도록 email 이 다르면 흡수하지 않는다. */\n /* AI 답변 에코 흡수 — 사용자 발화와 **같은 이유**로 붙이지 않고 대기 줄에 흡수한다.\n *\n * 왜 필요한가(실제 사고): 방송은 mutation 응답보다 먼저 도착할 수 있다. 예전에는 assistant 방송을\n * 새 줄로 붙였고, 뒤이어 mutation 응답이 \"같은 서버 id 를 가진 다른 줄이 있다\" 며 **대기 줄을 지웠다**.\n * 그런데 대기 줄에만 있는 것이 있다 — **영속되지 않는 응답 데이터**(조치 제안 카드·접지 경고).\n * 그래서 화면에서 실행 버튼이 사라졌다(제안 도구는 분명히 호출됐는데도).\n *\n * 흡수하면 서버가 보낸 본문(멘션 마커 포함)을 그 줄에 입히면서 대기 표시를 풀 수 있어, 답이 더\n * 빨리 보이는 효과도 있다. */\n if (m.role === 'assistant') {\n const idx = lines.findIndex(l => l.role === 'assistant' && !l.id && !!l._localId)\n if (idx >= 0) {\n const nextLines = [...lines]\n nextLines[idx] = {\n ...nextLines[idx],\n id: m.id,\n /* 본문·영속 데이터는 서버 것으로 갱신하고, 이 줄이 들고 있던 값(제안 등)은 유지한다. */\n content: m.content,\n patchId: m.relatedPatchId ?? nextLines[idx].patchId,\n toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : nextLines[idx].toolUsages,\n groundingWarnings: Array.isArray(m.groundingWarningsJson)\n ? m.groundingWarningsJson\n : nextLines[idx].groundingWarnings,\n pending: false\n }\n return { lines: nextLines, seen: nextSeen }\n }\n }\n\n if (m.role === 'user') {\n const echoEmail = m.creator?.email\n const idx = lines.findIndex(\n l =>\n l.role === 'user' &&\n !l.id &&\n !!l._localId &&\n (!echoEmail || l.senderEmail == null || l.senderEmail === echoEmail)\n )\n if (idx >= 0) {\n const nextLines = [...lines]\n nextLines[idx] = {\n ...nextLines[idx],\n id: m.id,\n senderEmail: echoEmail ?? nextLines[idx].senderEmail,\n senderName: m.creator?.name ?? nextLines[idx].senderName\n }\n return { lines: nextLines, seen: nextSeen }\n }\n }\n\n return { lines: [...lines, remoteMessageToLine(m)], seen: nextSeen }\n}\n\n/**\n * 전송한 optimistic 라인(_localId)을 서버 id 로 reconcile.\n * - **다른 줄**이 이미 그 serverId 를 갖고 있으면(옛 append 경로) optimistic 중복 라인 제거.\n * - 아니면 serverId 부여(+ seen 등록) + patch 병합 + _localId 해제.\n * - 해당 _localId 라인이 없으면 변화 없음.\n * 반환: 새 { lines, seen } (불변).\n */\nexport function reconcileSentLine(\n lines: DedupLine[],\n seen: Set<string>,\n localId: string,\n serverId: string | undefined,\n patch: Partial<DedupLine>\n): { lines: DedupLine[]; seen: Set<string> } {\n const idx = lines.findIndex(l => l._localId === localId)\n /* 같은 서버 id 를 든 **다른** 줄 — 에코가 흡수되지 못하고 새 줄로 붙은 경우다. */\n const echoed = serverId ? lines.findIndex((l, i) => i !== idx && l.id === serverId) : -1\n\n /*\n * 대기 줄이 없어졌다 — 보내는 동안 대화가 지워졌거나 이력으로 갈렸다. 그래도 **응답이 어느 메시지의\n * 것인지는 안다**(`assistantMessageId`). 그 줄에 합친다.\n *\n * 여기서 그냥 돌아가면, 저장되는 값(본문 · 도구 사용)은 에코가 들고 있으니 화면은 멀쩡해 보이고\n * **저장되지 않는 것만 사라진다** — 제안 카드다. 모델은 「카드를 눌러 적용하세요」라고 말하고 화면에는\n * 누를 것이 없다(2026-09-18 트윈에서 실제로 그랬다).\n */\n if (idx < 0) {\n if (echoed < 0) return { lines, seen }\n const merged = [...lines]\n merged[echoed] = { ...merged[echoed], ...patch, id: serverId ?? merged[echoed].id }\n return { lines: merged, seen: serverId ? new Set(seen).add(serverId) : seen }\n }\n\n const nextLines = [...lines]\n /* seen 만 보고 지우면, 에코를 이 줄에 **흡수**한 경우(id 가 이미 이 줄에 있다) 자기 줄을 지운다.\n * 실제 중복은 \"다른 줄이 같은 id 를 가진\" 상태일 때뿐이다. */\n if (echoed >= 0) {\n /* 남는 것은 에코 줄이다. 응답에만 있는 것(제안)을 그 줄에 옮기고 대기 줄을 지운다. */\n nextLines[echoed] = { ...nextLines[echoed], ...patch, id: serverId ?? nextLines[echoed].id }\n nextLines.splice(idx, 1)\n return { lines: nextLines, seen }\n }\n const nextSeen = serverId ? new Set(seen).add(serverId) : seen\n nextLines[idx] = { ...nextLines[idx], ...patch, id: serverId ?? nextLines[idx].id, _localId: undefined }\n return { lines: nextLines, seen: nextSeen }\n}\n"]}
@@ -200,4 +200,24 @@ describe('AI 답변 에코 흡수 — 영속되지 않는 응답 데이터를
200
200
  expect(r.lines[1].id).toBe('a9');
201
201
  });
202
202
  });
203
+ /*
204
+ * ADR-0073 판정 1 — 가려진 줄이 화면까지 가려진 채로 오는가.
205
+ *
206
+ * 서버가 본문을 한 줄로 바꿔 보내도, 클라이언트가 그 사실을 안 들고 오면 말풍선은 그것을 어시스턴트가
207
+ * 쓴 문장으로 그린다. 그러면 「이 답은 … 권한이 필요합니다」가 어시스턴트의 말이 된다.
208
+ */
209
+ describe('redacted rows reach the line', () => {
210
+ it('★ 서버가 가렸다고 하면 줄도 가려진 줄이다', () => {
211
+ const line = remoteMessageToLine({
212
+ id: 'm1',
213
+ role: 'assistant',
214
+ content: 'this answer needs Query.jobOrders permission',
215
+ redacted: true
216
+ });
217
+ expect(line.redacted).toBe(true);
218
+ });
219
+ it('보통 답은 가려지지 않은 줄이다 — 없는 값을 참으로 읽지 않는다', () => {
220
+ expect(remoteMessageToLine({ id: 'm2', role: 'assistant', content: '오늘 양품 1,204' }).redacted).toBe(false);
221
+ });
222
+ });
203
223
  //# sourceMappingURL=chat-echo-dedup.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"chat-echo-dedup.test.js","sourceRoot":"","sources":["../../client/components/chat-echo-dedup.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { receiveRemoteMessage, reconcileSentLine, remoteMessageToLine, type DedupLine } from './chat-echo-dedup'\n\nconst msg = (id: string, role = 'user', content = 'hi') => ({ id, role, content })\n\ndescribe('remoteMessageToLine', () => {\n it('필드 매핑 + relatedPatchId/toolUsagesJson 변환', () => {\n const line = remoteMessageToLine({\n id: 'm1',\n role: 'assistant',\n content: 'done',\n relatedPatchId: 'p1',\n toolUsagesJson: [{ name: 'x' }]\n })\n expect(line).toMatchObject({ id: 'm1', role: 'assistant', content: 'done', patchId: 'p1' })\n expect(line.toolUsages).toEqual([{ name: 'x' }])\n })\n\n it('toolUsagesJson 이 배열 아니면 undefined', () => {\n expect(remoteMessageToLine({ id: 'm', role: 'user', content: 'x', toolUsagesJson: null }).toolUsages).toBeUndefined()\n })\n\n it('creator → senderEmail/senderName (그룹챗 발신자 표시·자기 판정)', () => {\n const line = remoteMessageToLine({ id: 'm', role: 'user', content: 'x', creator: { name: 'Kim', email: 'u9@example.com' } })\n expect(line.senderEmail).toBe('u9@example.com')\n expect(line.senderName).toBe('Kim')\n })\n\n it('creator 없으면 sender 필드 undefined', () => {\n const line = remoteMessageToLine({ id: 'm', role: 'user', content: 'x', creator: null })\n expect(line.senderEmail).toBeUndefined()\n expect(line.senderName).toBeUndefined()\n })\n})\n\ndescribe('receiveRemoteMessage — 방송 수신/에코 차단', () => {\n it('신규 id → append + seen 등록', () => {\n const res = receiveRemoteMessage([], new Set(), msg('m1'))!\n expect(res).not.toBeNull()\n expect(res.lines).toHaveLength(1)\n expect(res.lines[0].id).toBe('m1')\n expect(res.seen.has('m1')).toBe(true)\n })\n\n it('이미 seen 인 id → null (에코·중복 차단)', () => {\n expect(receiveRemoteMessage([], new Set(['m1']), msg('m1'))).toBeNull()\n })\n\n it('id 없는 메시지 → null', () => {\n expect(receiveRemoteMessage([], new Set(), { role: 'user', content: 'x' } as any)).toBeNull()\n expect(receiveRemoteMessage([], new Set(), null)).toBeNull()\n })\n\n it('불변 — 원본 배열/셋 미변경', () => {\n const lines: DedupLine[] = []\n const seen = new Set<string>()\n receiveRemoteMessage(lines, seen, msg('m1'))\n expect(lines).toHaveLength(0)\n expect(seen.size).toBe(0)\n })\n\n it('다른 참여자 메시지 두 개 순차 append', () => {\n let lines: DedupLine[] = []\n let seen = new Set<string>()\n ;({ lines, seen } = receiveRemoteMessage(lines, seen, msg('a'))!)\n ;({ lines, seen } = receiveRemoteMessage(lines, seen, msg('b'))!)\n expect(lines.map(l => l.id)).toEqual(['a', 'b'])\n })\n})\n\ndescribe('reconcileSentLine — optimistic 라인 조정', () => {\n const optimistic = (): DedupLine[] => [{ _localId: 'L1', role: 'user', content: 'typed' }]\n\n it('정상(에코 전): serverId 부여 + seen 등록 + _localId 해제', () => {\n const res = reconcileSentLine(optimistic(), new Set(), 'L1', 'srv1', {})\n expect(res.lines).toHaveLength(1)\n expect(res.lines[0].id).toBe('srv1')\n expect(res.lines[0]._localId).toBeUndefined()\n expect(res.seen.has('srv1')).toBe(true)\n })\n\n it('레이스(에코 먼저): serverId 가 이미 seen → optimistic 중복 제거', () => {\n // 에코가 먼저 도착해 같은 메시지가 이미 append + seen 됨\n const lines: DedupLine[] = [\n { id: 'srv1', role: 'user', content: 'typed#enriched' }, // 방송 에코로 들어온 것\n { _localId: 'L1', role: 'user', content: 'typed' } // optimistic 중복\n ]\n const res = reconcileSentLine(lines, new Set(['srv1']), 'L1', 'srv1', {})\n expect(res.lines).toHaveLength(1)\n expect(res.lines[0].id).toBe('srv1')\n expect(res.lines.some(l => l._localId === 'L1')).toBe(false)\n })\n\n it('patch 병합 (AI 라인: pending → 실제 응답)', () => {\n const lines: DedupLine[] = [{ _localId: 'A1', role: 'assistant', content: '', pending: true }]\n const res = reconcileSentLine(lines, new Set(), 'A1', 'srv2', {\n content: 'reply',\n pending: false,\n patchId: 'p9'\n })\n expect(res.lines[0]).toMatchObject({ id: 'srv2', content: 'reply', pending: false, patchId: 'p9' })\n })\n\n it('serverId 없음(ad-hoc 세션): 라인 유지 + _localId 만 해제, seen 불변', () => {\n const seen = new Set<string>()\n const res = reconcileSentLine(optimistic(), seen, 'L1', undefined, {})\n expect(res.lines[0]._localId).toBeUndefined()\n expect(res.lines[0].id).toBeUndefined()\n expect(res.seen.size).toBe(0)\n })\n\n it('해당 _localId 없으면 변화 없음', () => {\n const lines = optimistic()\n const res = reconcileSentLine(lines, new Set(), 'NOPE', 'srv1', {})\n expect(res.lines).toBe(lines)\n })\n})\n\ndescribe('내 발신 에코 흡수 (대기 중 중복 렌더 방지)', () => {\n /* 방송은 mutation 응답보다 먼저 올 수 있고, 그 응답은 LLM 이 끝난 뒤에 온다.\n * 붙였다 나중에 지우면 사용자는 **대기 시간 내내** 자기 질문을 두 줄로 본다. */\n const pending = (): DedupLine[] => [\n { _localId: 'L1', role: 'user', content: '질문', senderEmail: 'me@x.com' },\n { _localId: 'L2', role: 'assistant', content: '', pending: true }\n ]\n\n it('내 에코는 대기 중인 내 줄에 id 를 입힌다 — 줄 수가 늘지 않는다', () => {\n const r = receiveRemoteMessage(pending(), new Set(), {\n id: 'S1',\n role: 'user',\n content: '질문',\n creator: { name: 'Me', email: 'me@x.com' }\n })!\n expect(r.lines).toHaveLength(2) // append 되지 않음\n expect(r.lines[0].id).toBe('S1')\n expect(r.lines[0]._localId).toBe('L1') // 이후 patch 병합을 위해 유지\n expect(r.seen.has('S1')).toBe(true)\n })\n\n it('발신자 email 이 없는 방송(단일 사용자·구형)도 내 줄에 흡수된다', () => {\n const r = receiveRemoteMessage(pending(), new Set(), { id: 'S1', role: 'user', content: '질문' })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[0].id).toBe('S1')\n })\n\n it('다른 사용자 메시지는 흡수하지 않고 append 한다 — 내 대기 줄을 삼키면 안 된다', () => {\n const r = receiveRemoteMessage(pending(), new Set(), {\n id: 'S9',\n role: 'user',\n content: '남의 말',\n creator: { name: 'Other', email: 'other@x.com' }\n })!\n expect(r.lines).toHaveLength(3)\n expect(r.lines[0].id).toBeUndefined() // 내 대기 줄은 그대로\n expect(r.lines[2]).toMatchObject({ id: 'S9', senderEmail: 'other@x.com' })\n })\n\n /* 이 테스트는 예전에 \"assistant 방송은 흡수하지 않고 붙인다\" 를 규칙으로 굳혀 두고 있었다.\n * 그것이 실제 결함이었다 — 붙이면 뒤이은 mutation 응답이 대기 줄을 지우고, 그 줄에만 있던\n * **영속되지 않는 응답 데이터**(조치 제안 카드·접지 경고)가 함께 사라진다. 규칙을 바로잡는다. */\n it('assistant 방송도 대기 중인 AI 줄에 흡수한다 — 붙이면 제안 카드가 사라진다', () => {\n const lines = [\n { _localId: 'L1', role: 'user', content: '질문', senderEmail: 'me@x.com' },\n { _localId: 'L2', role: 'assistant', content: '', pending: true }\n ]\n const r = receiveRemoteMessage(lines as any, new Set(), { id: 'S2', role: 'assistant', content: '답' })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[1]).toMatchObject({ id: 'S2', content: '답', pending: false })\n })\n\n it('대기 중인 AI 줄이 없으면 붙인다 — 다른 참여자가 부른 답변', () => {\n const settled = [{ id: 'S0', role: 'user', content: '질문' }]\n const r = receiveRemoteMessage(settled as any, new Set(), { id: 'S2', role: 'assistant', content: '답' })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[1].id).toBe('S2')\n })\n\n it('흡수한 뒤 mutation 응답 reconcile 은 그 줄을 지우지 않고 patch 를 병합한다', () => {\n const echo = receiveRemoteMessage(pending(), new Set(), {\n id: 'S1',\n role: 'user',\n content: '질문',\n creator: { email: 'me@x.com' }\n })!\n const after = reconcileSentLine(echo.lines, echo.seen, 'L1', 'S1', { content: '질문(보강)' })\n expect(after.lines).toHaveLength(2) // 자기 줄을 지우지 않음\n expect(after.lines[0]).toMatchObject({ id: 'S1', content: '질문(보강)' })\n expect(after.lines[0]._localId).toBeUndefined()\n })\n})\n\ndescribe('AI 답변 에코 흡수 — 영속되지 않는 응답 데이터를 잃지 않기', () => {\n /* 실제 사고: 방송이 mutation 응답보다 먼저 와서 assistant 줄이 새로 붙고, 뒤이어 응답이 대기 줄을\n * 지웠다. 대기 줄에만 있던 **조치 제안 카드**가 그때 사라졌다(도구는 분명히 호출됐는데도). */\n const pendingAi = () => [\n { id: 'u1', role: 'user', content: '지게차 한 대 추가해줘' },\n { _localId: 'L2', role: 'assistant', content: '', pending: true, proposals: [{ command: 'resource.add' }] }\n ]\n\n it('대기 중인 AI 줄에 흡수한다 — 새 줄로 붙이지 않는다', () => {\n const r = receiveRemoteMessage(pendingAi() as any, new Set(), {\n id: 'a1',\n role: 'assistant',\n content: '제안해 두었습니다.'\n })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[1].id).toBe('a1')\n expect(r.lines[1].content).toBe('제안해 두었습니다.')\n })\n\n it('제안 카드를 유지한다 — 영속되지 않는 값이라 잃으면 화면에서 버튼이 사라진다', () => {\n const r = receiveRemoteMessage(pendingAi() as any, new Set(), {\n id: 'a1',\n role: 'assistant',\n content: '제안해 두었습니다.'\n })!\n expect(r.lines[1].proposals).toEqual([{ command: 'resource.add' }])\n })\n\n it('대기 표시를 푼다 — 방송이 먼저 오면 답이 더 빨리 보인다', () => {\n const r = receiveRemoteMessage(pendingAi() as any, new Set(), { id: 'a1', role: 'assistant', content: '답' })!\n expect(r.lines[1].pending).toBe(false)\n })\n\n it('대기 줄이 없으면(다른 참여자가 부른 AI 답변) 새 줄로 붙인다', () => {\n const lines = [{ id: 'u1', role: 'user', content: '질문' }]\n const r = receiveRemoteMessage(lines as any, new Set(), { id: 'a9', role: 'assistant', content: '답' })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[1].id).toBe('a9')\n })\n})\n"]}
1
+ {"version":3,"file":"chat-echo-dedup.test.js","sourceRoot":"","sources":["../../client/components/chat-echo-dedup.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { receiveRemoteMessage, reconcileSentLine, remoteMessageToLine, type DedupLine } from './chat-echo-dedup'\n\nconst msg = (id: string, role = 'user', content = 'hi') => ({ id, role, content })\n\ndescribe('remoteMessageToLine', () => {\n it('필드 매핑 + relatedPatchId/toolUsagesJson 변환', () => {\n const line = remoteMessageToLine({\n id: 'm1',\n role: 'assistant',\n content: 'done',\n relatedPatchId: 'p1',\n toolUsagesJson: [{ name: 'x' }]\n })\n expect(line).toMatchObject({ id: 'm1', role: 'assistant', content: 'done', patchId: 'p1' })\n expect(line.toolUsages).toEqual([{ name: 'x' }])\n })\n\n it('toolUsagesJson 이 배열 아니면 undefined', () => {\n expect(remoteMessageToLine({ id: 'm', role: 'user', content: 'x', toolUsagesJson: null }).toolUsages).toBeUndefined()\n })\n\n it('creator → senderEmail/senderName (그룹챗 발신자 표시·자기 판정)', () => {\n const line = remoteMessageToLine({ id: 'm', role: 'user', content: 'x', creator: { name: 'Kim', email: 'u9@example.com' } })\n expect(line.senderEmail).toBe('u9@example.com')\n expect(line.senderName).toBe('Kim')\n })\n\n it('creator 없으면 sender 필드 undefined', () => {\n const line = remoteMessageToLine({ id: 'm', role: 'user', content: 'x', creator: null })\n expect(line.senderEmail).toBeUndefined()\n expect(line.senderName).toBeUndefined()\n })\n})\n\ndescribe('receiveRemoteMessage — 방송 수신/에코 차단', () => {\n it('신규 id → append + seen 등록', () => {\n const res = receiveRemoteMessage([], new Set(), msg('m1'))!\n expect(res).not.toBeNull()\n expect(res.lines).toHaveLength(1)\n expect(res.lines[0].id).toBe('m1')\n expect(res.seen.has('m1')).toBe(true)\n })\n\n it('이미 seen 인 id → null (에코·중복 차단)', () => {\n expect(receiveRemoteMessage([], new Set(['m1']), msg('m1'))).toBeNull()\n })\n\n it('id 없는 메시지 → null', () => {\n expect(receiveRemoteMessage([], new Set(), { role: 'user', content: 'x' } as any)).toBeNull()\n expect(receiveRemoteMessage([], new Set(), null)).toBeNull()\n })\n\n it('불변 — 원본 배열/셋 미변경', () => {\n const lines: DedupLine[] = []\n const seen = new Set<string>()\n receiveRemoteMessage(lines, seen, msg('m1'))\n expect(lines).toHaveLength(0)\n expect(seen.size).toBe(0)\n })\n\n it('다른 참여자 메시지 두 개 순차 append', () => {\n let lines: DedupLine[] = []\n let seen = new Set<string>()\n ;({ lines, seen } = receiveRemoteMessage(lines, seen, msg('a'))!)\n ;({ lines, seen } = receiveRemoteMessage(lines, seen, msg('b'))!)\n expect(lines.map(l => l.id)).toEqual(['a', 'b'])\n })\n})\n\ndescribe('reconcileSentLine — optimistic 라인 조정', () => {\n const optimistic = (): DedupLine[] => [{ _localId: 'L1', role: 'user', content: 'typed' }]\n\n it('정상(에코 전): serverId 부여 + seen 등록 + _localId 해제', () => {\n const res = reconcileSentLine(optimistic(), new Set(), 'L1', 'srv1', {})\n expect(res.lines).toHaveLength(1)\n expect(res.lines[0].id).toBe('srv1')\n expect(res.lines[0]._localId).toBeUndefined()\n expect(res.seen.has('srv1')).toBe(true)\n })\n\n it('레이스(에코 먼저): serverId 가 이미 seen → optimistic 중복 제거', () => {\n // 에코가 먼저 도착해 같은 메시지가 이미 append + seen 됨\n const lines: DedupLine[] = [\n { id: 'srv1', role: 'user', content: 'typed#enriched' }, // 방송 에코로 들어온 것\n { _localId: 'L1', role: 'user', content: 'typed' } // optimistic 중복\n ]\n const res = reconcileSentLine(lines, new Set(['srv1']), 'L1', 'srv1', {})\n expect(res.lines).toHaveLength(1)\n expect(res.lines[0].id).toBe('srv1')\n expect(res.lines.some(l => l._localId === 'L1')).toBe(false)\n })\n\n it('patch 병합 (AI 라인: pending → 실제 응답)', () => {\n const lines: DedupLine[] = [{ _localId: 'A1', role: 'assistant', content: '', pending: true }]\n const res = reconcileSentLine(lines, new Set(), 'A1', 'srv2', {\n content: 'reply',\n pending: false,\n patchId: 'p9'\n })\n expect(res.lines[0]).toMatchObject({ id: 'srv2', content: 'reply', pending: false, patchId: 'p9' })\n })\n\n it('serverId 없음(ad-hoc 세션): 라인 유지 + _localId 만 해제, seen 불변', () => {\n const seen = new Set<string>()\n const res = reconcileSentLine(optimistic(), seen, 'L1', undefined, {})\n expect(res.lines[0]._localId).toBeUndefined()\n expect(res.lines[0].id).toBeUndefined()\n expect(res.seen.size).toBe(0)\n })\n\n it('해당 _localId 없으면 변화 없음', () => {\n const lines = optimistic()\n const res = reconcileSentLine(lines, new Set(), 'NOPE', 'srv1', {})\n expect(res.lines).toBe(lines)\n })\n})\n\ndescribe('내 발신 에코 흡수 (대기 중 중복 렌더 방지)', () => {\n /* 방송은 mutation 응답보다 먼저 올 수 있고, 그 응답은 LLM 이 끝난 뒤에 온다.\n * 붙였다 나중에 지우면 사용자는 **대기 시간 내내** 자기 질문을 두 줄로 본다. */\n const pending = (): DedupLine[] => [\n { _localId: 'L1', role: 'user', content: '질문', senderEmail: 'me@x.com' },\n { _localId: 'L2', role: 'assistant', content: '', pending: true }\n ]\n\n it('내 에코는 대기 중인 내 줄에 id 를 입힌다 — 줄 수가 늘지 않는다', () => {\n const r = receiveRemoteMessage(pending(), new Set(), {\n id: 'S1',\n role: 'user',\n content: '질문',\n creator: { name: 'Me', email: 'me@x.com' }\n })!\n expect(r.lines).toHaveLength(2) // append 되지 않음\n expect(r.lines[0].id).toBe('S1')\n expect(r.lines[0]._localId).toBe('L1') // 이후 patch 병합을 위해 유지\n expect(r.seen.has('S1')).toBe(true)\n })\n\n it('발신자 email 이 없는 방송(단일 사용자·구형)도 내 줄에 흡수된다', () => {\n const r = receiveRemoteMessage(pending(), new Set(), { id: 'S1', role: 'user', content: '질문' })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[0].id).toBe('S1')\n })\n\n it('다른 사용자 메시지는 흡수하지 않고 append 한다 — 내 대기 줄을 삼키면 안 된다', () => {\n const r = receiveRemoteMessage(pending(), new Set(), {\n id: 'S9',\n role: 'user',\n content: '남의 말',\n creator: { name: 'Other', email: 'other@x.com' }\n })!\n expect(r.lines).toHaveLength(3)\n expect(r.lines[0].id).toBeUndefined() // 내 대기 줄은 그대로\n expect(r.lines[2]).toMatchObject({ id: 'S9', senderEmail: 'other@x.com' })\n })\n\n /* 이 테스트는 예전에 \"assistant 방송은 흡수하지 않고 붙인다\" 를 규칙으로 굳혀 두고 있었다.\n * 그것이 실제 결함이었다 — 붙이면 뒤이은 mutation 응답이 대기 줄을 지우고, 그 줄에만 있던\n * **영속되지 않는 응답 데이터**(조치 제안 카드·접지 경고)가 함께 사라진다. 규칙을 바로잡는다. */\n it('assistant 방송도 대기 중인 AI 줄에 흡수한다 — 붙이면 제안 카드가 사라진다', () => {\n const lines = [\n { _localId: 'L1', role: 'user', content: '질문', senderEmail: 'me@x.com' },\n { _localId: 'L2', role: 'assistant', content: '', pending: true }\n ]\n const r = receiveRemoteMessage(lines as any, new Set(), { id: 'S2', role: 'assistant', content: '답' })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[1]).toMatchObject({ id: 'S2', content: '답', pending: false })\n })\n\n it('대기 중인 AI 줄이 없으면 붙인다 — 다른 참여자가 부른 답변', () => {\n const settled = [{ id: 'S0', role: 'user', content: '질문' }]\n const r = receiveRemoteMessage(settled as any, new Set(), { id: 'S2', role: 'assistant', content: '답' })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[1].id).toBe('S2')\n })\n\n it('흡수한 뒤 mutation 응답 reconcile 은 그 줄을 지우지 않고 patch 를 병합한다', () => {\n const echo = receiveRemoteMessage(pending(), new Set(), {\n id: 'S1',\n role: 'user',\n content: '질문',\n creator: { email: 'me@x.com' }\n })!\n const after = reconcileSentLine(echo.lines, echo.seen, 'L1', 'S1', { content: '질문(보강)' })\n expect(after.lines).toHaveLength(2) // 자기 줄을 지우지 않음\n expect(after.lines[0]).toMatchObject({ id: 'S1', content: '질문(보강)' })\n expect(after.lines[0]._localId).toBeUndefined()\n })\n})\n\ndescribe('AI 답변 에코 흡수 — 영속되지 않는 응답 데이터를 잃지 않기', () => {\n /* 실제 사고: 방송이 mutation 응답보다 먼저 와서 assistant 줄이 새로 붙고, 뒤이어 응답이 대기 줄을\n * 지웠다. 대기 줄에만 있던 **조치 제안 카드**가 그때 사라졌다(도구는 분명히 호출됐는데도). */\n const pendingAi = () => [\n { id: 'u1', role: 'user', content: '지게차 한 대 추가해줘' },\n { _localId: 'L2', role: 'assistant', content: '', pending: true, proposals: [{ command: 'resource.add' }] }\n ]\n\n it('대기 중인 AI 줄에 흡수한다 — 새 줄로 붙이지 않는다', () => {\n const r = receiveRemoteMessage(pendingAi() as any, new Set(), {\n id: 'a1',\n role: 'assistant',\n content: '제안해 두었습니다.'\n })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[1].id).toBe('a1')\n expect(r.lines[1].content).toBe('제안해 두었습니다.')\n })\n\n it('제안 카드를 유지한다 — 영속되지 않는 값이라 잃으면 화면에서 버튼이 사라진다', () => {\n const r = receiveRemoteMessage(pendingAi() as any, new Set(), {\n id: 'a1',\n role: 'assistant',\n content: '제안해 두었습니다.'\n })!\n expect(r.lines[1].proposals).toEqual([{ command: 'resource.add' }])\n })\n\n it('대기 표시를 푼다 — 방송이 먼저 오면 답이 더 빨리 보인다', () => {\n const r = receiveRemoteMessage(pendingAi() as any, new Set(), { id: 'a1', role: 'assistant', content: '답' })!\n expect(r.lines[1].pending).toBe(false)\n })\n\n it('대기 줄이 없으면(다른 참여자가 부른 AI 답변) 새 줄로 붙인다', () => {\n const lines = [{ id: 'u1', role: 'user', content: '질문' }]\n const r = receiveRemoteMessage(lines as any, new Set(), { id: 'a9', role: 'assistant', content: '답' })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[1].id).toBe('a9')\n })\n})\n\n/*\n * ADR-0073 판정 1 — 가려진 줄이 화면까지 가려진 채로 오는가.\n *\n * 서버가 본문을 한 줄로 바꿔 보내도, 클라이언트가 그 사실을 안 들고 오면 말풍선은 그것을 어시스턴트가\n * 쓴 문장으로 그린다. 그러면 「이 답은 … 권한이 필요합니다」가 어시스턴트의 말이 된다.\n */\ndescribe('redacted rows reach the line', () => {\n it('★ 서버가 가렸다고 하면 줄도 가려진 줄이다', () => {\n const line = remoteMessageToLine({\n id: 'm1',\n role: 'assistant',\n content: 'this answer needs Query.jobOrders permission',\n redacted: true\n } as any)\n\n expect(line.redacted).toBe(true)\n })\n\n it('보통 답은 가려지지 않은 줄이다 — 없는 값을 참으로 읽지 않는다', () => {\n expect(remoteMessageToLine({ id: 'm2', role: 'assistant', content: '오늘 양품 1,204' } as any).redacted).toBe(false)\n })\n})\n"]}
@@ -67,18 +67,23 @@ export interface AssistantChatInputArgs {
67
67
  role: string;
68
68
  content: string;
69
69
  }>;
70
- /** Who the assistant is on this surface. The host owns this prose; the server appends tool rules. */
71
- systemPrompt: string;
70
+ /**
71
+ * Which conversation this is and what it is about. The persona, the tool categories, the model
72
+ * and the limits belong to the mode the app server registered (ADR-0061 decision 7) — a caller
73
+ * that could choose its own tool categories was a caller choosing its own authority.
74
+ */
75
+ mode: {
76
+ kind: string;
77
+ target?: string;
78
+ };
72
79
  truncateAfterMessageId?: string;
73
80
  hostContext?: any;
74
- toolCategories?: string[];
75
- requireGroundingTools?: boolean;
76
81
  }
77
82
  /**
78
83
  * Build the neutral input.
79
84
  *
80
- * Refuses an empty system prompt instead of sending one. A conversation with no persona still
81
- * answers it just answers as nobody, on a surface that never said what it is — and that reads
82
- * as a bad model rather than a missing argument.
85
+ * Refuses a conversation with no mode instead of sending one. A turn with no mode has no persona
86
+ * and no tools, and an answer given as nobody reads like a bad model rather than a missing
87
+ * argument the server refuses it too, and this says so where the surface is.
83
88
  */
84
89
  export declare function buildAssistantChatInput(args: AssistantChatInputArgs): Record<string, any>;
@@ -32,23 +32,21 @@ export function buildChatMutationInput(args) {
32
32
  /**
33
33
  * Build the neutral input.
34
34
  *
35
- * Refuses an empty system prompt instead of sending one. A conversation with no persona still
36
- * answers it just answers as nobody, on a surface that never said what it is — and that reads
37
- * as a bad model rather than a missing argument.
35
+ * Refuses a conversation with no mode instead of sending one. A turn with no mode has no persona
36
+ * and no tools, and an answer given as nobody reads like a bad model rather than a missing
37
+ * argument the server refuses it too, and this says so where the surface is.
38
38
  */
39
39
  export function buildAssistantChatInput(args) {
40
- if (!args.systemPrompt?.trim()) {
41
- throw new Error('[ox-assistant-chat] systemPrompt is required when the chat talks to assistantChat');
40
+ if (!args.mode?.kind?.trim()) {
41
+ throw new Error('[ox-assistant-chat] mode.kind is required when the chat talks to assistantChat — the app server ' +
42
+ 'registers the mode and the screen names it (ADR-0061 decision 7)');
42
43
  }
43
44
  return {
44
45
  sessionId: args.sessionId ?? null,
45
46
  messages: args.history,
46
- systemPrompt: args.systemPrompt,
47
+ mode: { kind: args.mode.kind, ...(args.mode.target ? { target: args.mode.target } : {}) },
47
48
  ...(args.truncateAfterMessageId ? { truncateAfterMessageId: args.truncateAfterMessageId } : {}),
48
- ...(args.hostContext ? { hostContext: args.hostContext } : {}),
49
- /* Absent means every registered category; an empty array means none. Both are meant, so pass it through. */
50
- ...(args.toolCategories ? { toolCategories: args.toolCategories } : {}),
51
- ...(args.requireGroundingTools ? { requireGroundingTools: true } : {})
49
+ ...(args.hostContext ? { hostContext: args.hostContext } : {})
52
50
  };
53
51
  }
54
52
  //# sourceMappingURL=chat-input-builder.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"chat-input-builder.js","sourceRoot":"","sources":["../../client/components/chat-input-builder.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAuCH;;;;;GAKG;AACH,MAAM,UAAU,sBAAsB,CAAC,IAA2B;IAChE,OAAO;QACL,SAAS,EAAE,IAAI,CAAC,SAAS,IAAI,IAAI;QACjC,QAAQ,EAAE,IAAI,CAAC,OAAO;QACtB,YAAY,EAAE,IAAI,CAAC,SAAS,IAAI,IAAI;QACpC,MAAM,EAAE,IAAI,CAAC,MAAM;QACnB,GAAG,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,EAAE,sBAAsB,EAAE,IAAI,CAAC,sBAAsB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC/F,GAAG,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC9D,qDAAqD;QACrD,GAAG,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC,CAAC,EAAE,cAAc,EAAE,IAAI,CAAC,cAAc,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACvE,GAAG,CAAC,IAAI,CAAC,UAAU,KAAK,KAAK,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3D,GAAG,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC,CAAC,EAAE,qBAAqB,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACtE,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,gBAAgB,EAAE,IAAI,CAAC,gBAAgB;QACvC,cAAc,EAAE,IAAI,CAAC,cAAc;QACnC,GAAG,CAAC,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,CAAC;KAC9E,CAAA;AACH,CAAC;AAqBD;;;;;;GAMG;AACH,MAAM,UAAU,uBAAuB,CAAC,IAA4B;IAClE,IAAI,CAAC,IAAI,CAAC,YAAY,EAAE,IAAI,EAAE,EAAE,CAAC;QAC/B,MAAM,IAAI,KAAK,CAAC,mFAAmF,CAAC,CAAA;IACtG,CAAC;IAED,OAAO;QACL,SAAS,EAAE,IAAI,CAAC,SAAS,IAAI,IAAI;QACjC,QAAQ,EAAE,IAAI,CAAC,OAAO;QACtB,YAAY,EAAE,IAAI,CAAC,YAAY;QAC/B,GAAG,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,EAAE,sBAAsB,EAAE,IAAI,CAAC,sBAAsB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC/F,GAAG,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC9D,4GAA4G;QAC5G,GAAG,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC,CAAC,EAAE,cAAc,EAAE,IAAI,CAAC,cAAc,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACvE,GAAG,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC,CAAC,EAAE,qBAAqB,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACvE,CAAA;AACH,CAAC","sourcesContent":["/**\n * boardAIChat GraphQL mutation 의 input 객체 빌더.\n *\n * board-ai-chat.ts (Lit / Material Web 의존) 와 분리 — pure 함수만 두면 jest 의 node\n * 환경에서도 import 가능. 회귀 테스트 (selectedRefids 누락 등) 가 단위 테스트로 잠긴다.\n */\n\nexport interface ChatMutationInputArgs {\n sessionId?: string\n history: Array<{ role: string; content: string }>\n liveBoard: any\n scopes?: string[]\n /** 편집으로 접은 지점(마지막으로 남긴 서버 메시지 id) — 서버가 그 뒤 이력을 에이전트 문맥에서 뺀다. */\n truncateAfterMessageId?: string\n /** 이 대화면이 무엇을 보고 있는지(예: { spaceId }) — 도구가 대상을 알 수 있게. 없으면 모델이 식별자를 되묻는다. */\n hostContext?: any\n /**\n * 이 대화면이 쓸 수 있는 외부 도구 카테고리(registerToolCategory 이름).\n * 미지정 = 등록된 전부(현행 동작). 지정하면 정의와 **실행** 모두 그 범위로 제한된다.\n */\n toolCategories?: string[]\n /** 코어 보드 편집 도구(addComponent·스타일링·씬 조작) 사용 여부. 기본 true. */\n boardTools?: boolean\n /**\n * 첫 턴에 도구 호출을 강제할지 — 라이브 상태를 다루는 대화면에서 켠다.\n * 근거 없이 상태를 단언하는 답(도구를 아예 부르지 않은 답)을 원천에서 막는다.\n */\n requireGroundingTools?: boolean\n knownTypes?: string[]\n categories?: string[]\n componentSchemas?: any\n /**\n * 선택된 컴포넌트의 `refid` 배열 — things-scene universal numeric handle.\n * id (데이터 바인딩 이름) 와는 다른 개념.\n */\n selectedRefids: number[]\n /**\n * popup 에서 명시 선택한 #mention 매핑 — {token, refid}.\n * 사용자 textarea 에는 #token 만 보이고, 정확한 refid 는 이 별도 채널로 server 에 전달.\n * server 가 mention map 으로 LLM 에 정확한 refid 정보 첨부 (사용자 노출 0).\n */\n mentions?: Array<{ token: string; refid: number }>\n}\n\n/**\n * GraphQL mutation 의 input 객체를 빌드.\n *\n * 핵심 보호 — `selectedRefids` 가 항상 포함되어야 한다. 누락이 사용자에게 보고된\n * \"AI 가 선택된 컴포넌트를 모른다\" 의 root cause 였다.\n */\nexport function buildChatMutationInput(args: ChatMutationInputArgs): Record<string, any> {\n return {\n sessionId: args.sessionId ?? null,\n messages: args.history,\n currentBoard: args.liveBoard ?? null,\n scopes: args.scopes,\n ...(args.truncateAfterMessageId ? { truncateAfterMessageId: args.truncateAfterMessageId } : {}),\n ...(args.hostContext ? { hostContext: args.hostContext } : {}),\n /* 미지정은 서버 기본(전부 허용)에 맡긴다 — null 을 보내 굳이 덮어쓰지 않는다. */\n ...(args.toolCategories ? { toolCategories: args.toolCategories } : {}),\n ...(args.boardTools === false ? { boardTools: false } : {}),\n ...(args.requireGroundingTools ? { requireGroundingTools: true } : {}),\n knownTypes: args.knownTypes,\n categories: args.categories,\n componentSchemas: args.componentSchemas,\n selectedRefids: args.selectedRefids,\n ...(args.mentions && args.mentions.length > 0 && { mentions: args.mentions })\n }\n}\n\n/**\n * Input for `assistantChat` — the door for a surface that has no document of its own.\n *\n * Deliberately a separate builder rather than a flag on the one above. The two mutations take\n * different inputs, and folding them together would mean a board field reaching a surface that\n * has no board, or a required field going missing with nothing to notice it. The one thing they\n * share is the conversation, and that is passed the same way.\n */\nexport interface AssistantChatInputArgs {\n sessionId?: string\n history: Array<{ role: string; content: string }>\n /** Who the assistant is on this surface. The host owns this prose; the server appends tool rules. */\n systemPrompt: string\n truncateAfterMessageId?: string\n hostContext?: any\n toolCategories?: string[]\n requireGroundingTools?: boolean\n}\n\n/**\n * Build the neutral input.\n *\n * Refuses an empty system prompt instead of sending one. A conversation with no persona still\n * answers it just answers as nobody, on a surface that never said what it isand that reads\n * as a bad model rather than a missing argument.\n */\nexport function buildAssistantChatInput(args: AssistantChatInputArgs): Record<string, any> {\n if (!args.systemPrompt?.trim()) {\n throw new Error('[ox-assistant-chat] systemPrompt is required when the chat talks to assistantChat')\n }\n\n return {\n sessionId: args.sessionId ?? null,\n messages: args.history,\n systemPrompt: args.systemPrompt,\n ...(args.truncateAfterMessageId ? { truncateAfterMessageId: args.truncateAfterMessageId } : {}),\n ...(args.hostContext ? { hostContext: args.hostContext } : {}),\n /* Absent means every registered category; an empty array means none. Both are meant, so pass it through. */\n ...(args.toolCategories ? { toolCategories: args.toolCategories } : {}),\n ...(args.requireGroundingTools ? { requireGroundingTools: true } : {})\n }\n}\n"]}
1
+ {"version":3,"file":"chat-input-builder.js","sourceRoot":"","sources":["../../client/components/chat-input-builder.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAuCH;;;;;GAKG;AACH,MAAM,UAAU,sBAAsB,CAAC,IAA2B;IAChE,OAAO;QACL,SAAS,EAAE,IAAI,CAAC,SAAS,IAAI,IAAI;QACjC,QAAQ,EAAE,IAAI,CAAC,OAAO;QACtB,YAAY,EAAE,IAAI,CAAC,SAAS,IAAI,IAAI;QACpC,MAAM,EAAE,IAAI,CAAC,MAAM;QACnB,GAAG,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,EAAE,sBAAsB,EAAE,IAAI,CAAC,sBAAsB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC/F,GAAG,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC9D,qDAAqD;QACrD,GAAG,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC,CAAC,EAAE,cAAc,EAAE,IAAI,CAAC,cAAc,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACvE,GAAG,CAAC,IAAI,CAAC,UAAU,KAAK,KAAK,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3D,GAAG,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC,CAAC,EAAE,qBAAqB,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACtE,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,gBAAgB,EAAE,IAAI,CAAC,gBAAgB;QACvC,cAAc,EAAE,IAAI,CAAC,cAAc;QACnC,GAAG,CAAC,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,CAAC;KAC9E,CAAA;AACH,CAAC;AAuBD;;;;;;GAMG;AACH,MAAM,UAAU,uBAAuB,CAAC,IAA4B;IAClE,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,CAAC;QAC7B,MAAM,IAAI,KAAK,CACb,kGAAkG;YAChG,kEAAkE,CACrE,CAAA;IACH,CAAC;IAED,OAAO;QACL,SAAS,EAAE,IAAI,CAAC,SAAS,IAAI,IAAI;QACjC,QAAQ,EAAE,IAAI,CAAC,OAAO;QACtB,IAAI,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE;QACzF,GAAG,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,EAAE,sBAAsB,EAAE,IAAI,CAAC,sBAAsB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC/F,GAAG,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC/D,CAAA;AACH,CAAC","sourcesContent":["/**\n * boardAIChat GraphQL mutation 의 input 객체 빌더.\n *\n * board-ai-chat.ts (Lit / Material Web 의존) 와 분리 — pure 함수만 두면 jest 의 node\n * 환경에서도 import 가능. 회귀 테스트 (selectedRefids 누락 등) 가 단위 테스트로 잠긴다.\n */\n\nexport interface ChatMutationInputArgs {\n sessionId?: string\n history: Array<{ role: string; content: string }>\n liveBoard: any\n scopes?: string[]\n /** 편집으로 접은 지점(마지막으로 남긴 서버 메시지 id) — 서버가 그 뒤 이력을 에이전트 문맥에서 뺀다. */\n truncateAfterMessageId?: string\n /** 이 대화면이 무엇을 보고 있는지(예: { spaceId }) — 도구가 대상을 알 수 있게. 없으면 모델이 식별자를 되묻는다. */\n hostContext?: any\n /**\n * 이 대화면이 쓸 수 있는 외부 도구 카테고리(registerToolCategory 이름).\n * 미지정 = 등록된 전부(현행 동작). 지정하면 정의와 **실행** 모두 그 범위로 제한된다.\n */\n toolCategories?: string[]\n /** 코어 보드 편집 도구(addComponent·스타일링·씬 조작) 사용 여부. 기본 true. */\n boardTools?: boolean\n /**\n * 첫 턴에 도구 호출을 강제할지 — 라이브 상태를 다루는 대화면에서 켠다.\n * 근거 없이 상태를 단언하는 답(도구를 아예 부르지 않은 답)을 원천에서 막는다.\n */\n requireGroundingTools?: boolean\n knownTypes?: string[]\n categories?: string[]\n componentSchemas?: any\n /**\n * 선택된 컴포넌트의 `refid` 배열 — things-scene universal numeric handle.\n * id (데이터 바인딩 이름) 와는 다른 개념.\n */\n selectedRefids: number[]\n /**\n * popup 에서 명시 선택한 #mention 매핑 — {token, refid}.\n * 사용자 textarea 에는 #token 만 보이고, 정확한 refid 는 이 별도 채널로 server 에 전달.\n * server 가 mention map 으로 LLM 에 정확한 refid 정보 첨부 (사용자 노출 0).\n */\n mentions?: Array<{ token: string; refid: number }>\n}\n\n/**\n * GraphQL mutation 의 input 객체를 빌드.\n *\n * 핵심 보호 — `selectedRefids` 가 항상 포함되어야 한다. 누락이 사용자에게 보고된\n * \"AI 가 선택된 컴포넌트를 모른다\" 의 root cause 였다.\n */\nexport function buildChatMutationInput(args: ChatMutationInputArgs): Record<string, any> {\n return {\n sessionId: args.sessionId ?? null,\n messages: args.history,\n currentBoard: args.liveBoard ?? null,\n scopes: args.scopes,\n ...(args.truncateAfterMessageId ? { truncateAfterMessageId: args.truncateAfterMessageId } : {}),\n ...(args.hostContext ? { hostContext: args.hostContext } : {}),\n /* 미지정은 서버 기본(전부 허용)에 맡긴다 — null 을 보내 굳이 덮어쓰지 않는다. */\n ...(args.toolCategories ? { toolCategories: args.toolCategories } : {}),\n ...(args.boardTools === false ? { boardTools: false } : {}),\n ...(args.requireGroundingTools ? { requireGroundingTools: true } : {}),\n knownTypes: args.knownTypes,\n categories: args.categories,\n componentSchemas: args.componentSchemas,\n selectedRefids: args.selectedRefids,\n ...(args.mentions && args.mentions.length > 0 && { mentions: args.mentions })\n }\n}\n\n/**\n * Input for `assistantChat` — the door for a surface that has no document of its own.\n *\n * Deliberately a separate builder rather than a flag on the one above. The two mutations take\n * different inputs, and folding them together would mean a board field reaching a surface that\n * has no board, or a required field going missing with nothing to notice it. The one thing they\n * share is the conversation, and that is passed the same way.\n */\nexport interface AssistantChatInputArgs {\n sessionId?: string\n history: Array<{ role: string; content: string }>\n /**\n * Which conversation this is and what it is about. The persona, the tool categories, the model\n * and the limits belong to the mode the app server registered (ADR-0061 decision 7) — a caller\n * that could choose its own tool categories was a caller choosing its own authority.\n */\n mode: { kind: string; target?: string }\n truncateAfterMessageId?: string\n hostContext?: any\n}\n\n/**\n * Build the neutral input.\n *\n * Refuses a conversation with no mode instead of sending one. A turn with no mode has no persona\n * and no tools, and an answer given as nobody reads like a bad model rather than a missing\n * argument the server refuses it too, and this says so where the surface is.\n */\nexport function buildAssistantChatInput(args: AssistantChatInputArgs): Record<string, any> {\n if (!args.mode?.kind?.trim()) {\n throw new Error(\n '[ox-assistant-chat] mode.kind is required when the chat talks to assistantChat — the app server ' +\n 'registers the mode and the screen names it (ADR-0061 decision 7)'\n )\n }\n\n return {\n sessionId: args.sessionId ?? null,\n messages: args.history,\n mode: { kind: args.mode.kind, ...(args.mode.target ? { target: args.mode.target } : {}) },\n ...(args.truncateAfterMessageId ? { truncateAfterMessageId: args.truncateAfterMessageId } : {}),\n ...(args.hostContext ? { hostContext: args.hostContext } : {})\n }\n}\n"]}
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { buildAssistantChatInput, buildChatMutationInput } from './chat-input-builder';
14
14
  const history = [{ role: 'user', content: 'what figures do we have' }];
15
- const args = (over = {}) => ({ history, systemPrompt: 'you help someone draw a figure', ...over });
15
+ const args = (over = {}) => ({ history, mode: { kind: 'figure.modeller', target: 'fig-1' }, ...over });
16
16
  describe('the neutral door carries no document', () => {
17
17
  it('★ no board field reaches assistantChat', () => {
18
18
  const input = buildAssistantChatInput(args({ sessionId: 's-1' }));
@@ -20,34 +20,33 @@ describe('the neutral door carries no document', () => {
20
20
  expect(input).not.toHaveProperty(field);
21
21
  }
22
22
  });
23
- it('carries the conversation, the session and the persona', () => {
23
+ it('carries the conversation, the session and which conversation this is', () => {
24
24
  const input = buildAssistantChatInput(args({ sessionId: 's-1' }));
25
25
  expect(input.sessionId).toBe('s-1');
26
26
  expect(input.messages).toBe(history);
27
- expect(input.systemPrompt).toBe('you help someone draw a figure');
27
+ expect(input.mode).toEqual({ kind: 'figure.modeller', target: 'fig-1' });
28
+ });
29
+ it('★ the persona, the tools and the limits are not on the wire — they are the server\'s', () => {
30
+ const input = buildAssistantChatInput(args({ sessionId: 's-1' }));
31
+ for (const field of ['systemPrompt', 'toolCategories', 'model', 'temperature', 'maxTokens', 'requireGroundingTools']) {
32
+ expect(input).not.toHaveProperty(field);
33
+ }
28
34
  });
29
35
  it('a one-off question has no session — null, not absent, so the server reads it as ad-hoc', () => {
30
36
  expect(buildAssistantChatInput(args()).sessionId).toBeNull();
31
37
  });
32
38
  });
33
- describe('the system prompt is required', () => {
34
- it('★ refuses an empty prompt instead of sending one', () => {
35
- expect(() => buildAssistantChatInput(args({ systemPrompt: '' }))).toThrow(/systemPrompt is required/);
36
- expect(() => buildAssistantChatInput(args({ systemPrompt: ' ' }))).toThrow(/systemPrompt is required/);
37
- expect(() => buildAssistantChatInput(args({ systemPrompt: undefined }))).toThrow(/systemPrompt is required/);
39
+ describe('the mode is required', () => {
40
+ it('★ refuses a turn with no mode instead of sending one', () => {
41
+ expect(() => buildAssistantChatInput(args({ mode: { kind: '' } }))).toThrow(/mode.kind is required/);
42
+ expect(() => buildAssistantChatInput(args({ mode: { kind: ' ' } }))).toThrow(/mode.kind is required/);
43
+ expect(() => buildAssistantChatInput(args({ mode: undefined }))).toThrow(/mode.kind is required/);
44
+ });
45
+ it('a conversation about nothing in particular carries no target', () => {
46
+ expect(buildAssistantChatInput(args({ mode: { kind: 'twin.none' } })).mode).toEqual({ kind: 'twin.none' });
38
47
  });
39
48
  });
40
49
  describe('optional fields are sent only when they mean something', () => {
41
- it('an unset tool category list is left out — the server default is every registered category', () => {
42
- expect(buildAssistantChatInput(args())).not.toHaveProperty('toolCategories');
43
- });
44
- it('★ an empty tool category list is sent — it means none, which is a different answer', () => {
45
- expect(buildAssistantChatInput(args({ toolCategories: [] })).toolCategories).toEqual([]);
46
- });
47
- it('grounding is sent only when required — false is the server default', () => {
48
- expect(buildAssistantChatInput(args())).not.toHaveProperty('requireGroundingTools');
49
- expect(buildAssistantChatInput(args({ requireGroundingTools: true })).requireGroundingTools).toBe(true);
50
- });
51
50
  it('host context and truncation travel when given', () => {
52
51
  const input = buildAssistantChatInput(args({ hostContext: { figureId: 'fig-1' }, truncateAfterMessageId: 'm-9' }));
53
52
  expect(input.hostContext).toEqual({ figureId: 'fig-1' });
@@ -1 +1 @@
1
- {"version":3,"file":"chat-input-builder.test.js","sourceRoot":"","sources":["../../client/components/chat-input-builder.test.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * What this guards: **the two doors take different inputs, and neither one leaks into the other.**\n *\n * The chat component talks to one of two server operations. `boardAIChat` is board authoring and\n * carries the board model; `assistantChat` is for a surface with no document of its own. Sending\n * a board field to the neutral door, or dropping a required one, produces a GraphQL error the\n * user sees as \"the assistant is broken\".\n *\n * The required system prompt is the one that would be silent. A conversation with no persona\n * still answers — as nobody, on a surface that never said what it is — and that reads as a poor\n * model rather than a missing argument.\n */\nimport { buildAssistantChatInput, buildChatMutationInput } from './chat-input-builder'\n\nconst history = [{ role: 'user', content: 'what figures do we have' }]\n\nconst args = (over: any = {}) => ({ history, systemPrompt: 'you help someone draw a figure', ...over })\n\ndescribe('the neutral door carries no document', () => {\n it('★ no board field reaches assistantChat', () => {\n const input = buildAssistantChatInput(args({ sessionId: 's-1' }))\n\n for (const field of ['currentBoard', 'selectedRefids', 'boardTools', 'knownTypes', 'componentSchemas', 'scopes', 'mentions']) {\n expect(input).not.toHaveProperty(field)\n }\n })\n\n it('carries the conversation, the session and the persona', () => {\n const input = buildAssistantChatInput(args({ sessionId: 's-1' }))\n\n expect(input.sessionId).toBe('s-1')\n expect(input.messages).toBe(history)\n expect(input.systemPrompt).toBe('you help someone draw a figure')\n })\n\n it('a one-off question has no session — null, not absent, so the server reads it as ad-hoc', () => {\n expect(buildAssistantChatInput(args()).sessionId).toBeNull()\n })\n})\n\ndescribe('the system prompt is required', () => {\n it('★ refuses an empty prompt instead of sending one', () => {\n expect(() => buildAssistantChatInput(args({ systemPrompt: '' }))).toThrow(/systemPrompt is required/)\n expect(() => buildAssistantChatInput(args({ systemPrompt: ' ' }))).toThrow(/systemPrompt is required/)\n expect(() => buildAssistantChatInput(args({ systemPrompt: undefined }))).toThrow(/systemPrompt is required/)\n })\n})\n\ndescribe('optional fields are sent only when they mean something', () => {\n it('an unset tool category list is left out — the server default is every registered category', () => {\n expect(buildAssistantChatInput(args())).not.toHaveProperty('toolCategories')\n })\n\n it('★ an empty tool category list is sent — it means none, which is a different answer', () => {\n expect(buildAssistantChatInput(args({ toolCategories: [] })).toolCategories).toEqual([])\n })\n\n it('grounding is sent only when required false is the server default', () => {\n expect(buildAssistantChatInput(args())).not.toHaveProperty('requireGroundingTools')\n expect(buildAssistantChatInput(args({ requireGroundingTools: true })).requireGroundingTools).toBe(true)\n })\n\n it('host context and truncation travel when given', () => {\n const input = buildAssistantChatInput(args({ hostContext: { figureId: 'fig-1' }, truncateAfterMessageId: 'm-9' }))\n\n expect(input.hostContext).toEqual({ figureId: 'fig-1' })\n expect(input.truncateAfterMessageId).toBe('m-9')\n })\n})\n\ndescribe('the board door is unchanged', () => {\n it('★ still carries selectedRefids — a missing one was why the AI did not know what was selected', () => {\n const input = buildChatMutationInput({ history, liveBoard: null, selectedRefids: [3, 7] } as any)\n expect(input.selectedRefids).toEqual([3, 7])\n })\n\n it('does not take a system prompt — the board conversation has its persona on the server', () => {\n const input = buildChatMutationInput({ history, liveBoard: null, selectedRefids: [] } as any)\n expect(input).not.toHaveProperty('systemPrompt')\n })\n})\n"]}
1
+ {"version":3,"file":"chat-input-builder.test.js","sourceRoot":"","sources":["../../client/components/chat-input-builder.test.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * What this guards: **the two doors take different inputs, and neither one leaks into the other.**\n *\n * The chat component talks to one of two server operations. `boardAIChat` is board authoring and\n * carries the board model; `assistantChat` is for a surface with no document of its own. Sending\n * a board field to the neutral door, or dropping a required one, produces a GraphQL error the\n * user sees as \"the assistant is broken\".\n *\n * The required system prompt is the one that would be silent. A conversation with no persona\n * still answers — as nobody, on a surface that never said what it is — and that reads as a poor\n * model rather than a missing argument.\n */\nimport { buildAssistantChatInput, buildChatMutationInput } from './chat-input-builder'\n\nconst history = [{ role: 'user', content: 'what figures do we have' }]\n\nconst args = (over: any = {}) => ({ history, mode: { kind: 'figure.modeller', target: 'fig-1' }, ...over })\n\ndescribe('the neutral door carries no document', () => {\n it('★ no board field reaches assistantChat', () => {\n const input = buildAssistantChatInput(args({ sessionId: 's-1' }))\n\n for (const field of ['currentBoard', 'selectedRefids', 'boardTools', 'knownTypes', 'componentSchemas', 'scopes', 'mentions']) {\n expect(input).not.toHaveProperty(field)\n }\n })\n\n it('carries the conversation, the session and which conversation this is', () => {\n const input = buildAssistantChatInput(args({ sessionId: 's-1' }))\n\n expect(input.sessionId).toBe('s-1')\n expect(input.messages).toBe(history)\n expect(input.mode).toEqual({ kind: 'figure.modeller', target: 'fig-1' })\n })\n\n it('★ the persona, the tools and the limits are not on the wire — they are the server\\'s', () => {\n const input = buildAssistantChatInput(args({ sessionId: 's-1' }))\n\n for (const field of ['systemPrompt', 'toolCategories', 'model', 'temperature', 'maxTokens', 'requireGroundingTools']) {\n expect(input).not.toHaveProperty(field)\n }\n })\n\n it('a one-off question has no session — null, not absent, so the server reads it as ad-hoc', () => {\n expect(buildAssistantChatInput(args()).sessionId).toBeNull()\n })\n})\n\ndescribe('the mode is required', () => {\n it('★ refuses a turn with no mode instead of sending one', () => {\n expect(() => buildAssistantChatInput(args({ mode: { kind: '' } }))).toThrow(/mode.kind is required/)\n expect(() => buildAssistantChatInput(args({ mode: { kind: ' ' } }))).toThrow(/mode.kind is required/)\n expect(() => buildAssistantChatInput(args({ mode: undefined }))).toThrow(/mode.kind is required/)\n })\n\n it('a conversation about nothing in particular carries no target', () => {\n expect(buildAssistantChatInput(args({ mode: { kind: 'twin.none' } })).mode).toEqual({ kind: 'twin.none' })\n })\n})\n\ndescribe('optional fields are sent only when they mean something', () => {\n\n it('host context and truncation travel when given', () => {\n const input = buildAssistantChatInput(args({ hostContext: { figureId: 'fig-1' }, truncateAfterMessageId: 'm-9' }))\n\n expect(input.hostContext).toEqual({ figureId: 'fig-1' })\n expect(input.truncateAfterMessageId).toBe('m-9')\n })\n})\n\ndescribe('the board door is unchanged', () => {\n it('★ still carries selectedRefids — a missing one was why the AI did not know what was selected', () => {\n const input = buildChatMutationInput({ history, liveBoard: null, selectedRefids: [3, 7] } as any)\n expect(input.selectedRefids).toEqual([3, 7])\n })\n\n it('does not take a system prompt — the board conversation has its persona on the server', () => {\n const input = buildChatMutationInput({ history, liveBoard: null, selectedRefids: [] } as any)\n expect(input).not.toHaveProperty('systemPrompt')\n })\n})\n"]}
@@ -0,0 +1,61 @@
1
+ /*
2
+ * What this guards: the one thing a turn carries that is never stored — its proposals — survives
3
+ * the race between the broadcast echo and the mutation's own answer.
4
+ *
5
+ * A proposal card exists only in the mutation result. The reply, the tool usages and the grounding
6
+ * warnings are all in the database, so any path that rebuilds a line from the broadcast looks
7
+ * right on screen and silently drops the one thing the person can act on. That shipped once
8
+ * before (the comment in chat-echo-dedup remembers it), and on 2026-09-18 the twin saw it again:
9
+ * `proposals` came back with one item and the line on screen had no `proposals` field at all.
10
+ */
11
+ import { receiveRemoteMessage, reconcileSentLine } from './chat-echo-dedup';
12
+ const pending = (localId) => ({ role: 'assistant', content: '', pending: true, _localId: localId });
13
+ const echo = (id) => ({ id, role: 'assistant', content: 'placed at the centre', toolUsagesJson: [{ name: 'proposeSceneEdit' }] });
14
+ const answer = { proposals: [{ tool: 'proposeSceneEdit', proposed: true, kind: 'scene-edit' }], toolUsages: [{ name: 'proposeSceneEdit' }], content: 'placed at the centre', pending: false };
15
+ it('★ echo first, then the answer — the card survives', () => {
16
+ const absorbed = receiveRemoteMessage([pending('local-1-a')], new Set(), echo('server-1'));
17
+ const settled = reconcileSentLine(absorbed.lines, absorbed.seen, 'local-1-a', 'server-1', answer);
18
+ expect(settled.lines).toHaveLength(1);
19
+ expect(settled.lines[0].proposals).toHaveLength(1);
20
+ });
21
+ it('★ the answer first, then the echo — the card survives', () => {
22
+ const settled = reconcileSentLine([pending('local-1-a')], new Set(), 'local-1-a', 'server-1', answer);
23
+ const after = receiveRemoteMessage(settled.lines, settled.seen, echo('server-1'));
24
+ /* The echo is already known; it must not append a second line, and it must not replace this one. */
25
+ const lines = after ? after.lines : settled.lines;
26
+ expect(lines).toHaveLength(1);
27
+ expect(lines[0].proposals).toHaveLength(1);
28
+ });
29
+ it('★ the pending line is gone when the echo arrives — the answer still finds its message', () => {
30
+ /*
31
+ * This is the shape the twin saw on 2026-09-18: a line carrying exactly what the database has,
32
+ * no `proposals` field at all, while the response had one. It happens when the pending line is
33
+ * no longer in `lines` by the time the echo arrives, and the echo is appended as a new line.
34
+ *
35
+ * The answer knows which stored message it is (`assistantMessageId`), so it merges there. What
36
+ * it must never do is return with the card nowhere: the model tells the person to press a button
37
+ * that does not exist.
38
+ */
39
+ const appended = receiveRemoteMessage([], new Set(), echo('server-1'));
40
+ expect(appended.lines[0].proposals).toBeUndefined();
41
+ const settled = reconcileSentLine(appended.lines, appended.seen, 'local-1-a', 'server-1', answer);
42
+ expect(settled.lines).toHaveLength(1);
43
+ expect(settled.lines[0].proposals).toHaveLength(1);
44
+ expect(settled.lines[0].id).toBe('server-1');
45
+ });
46
+ it('★ the echo was appended beside a pending line — the card lands on the message that stays', () => {
47
+ /*
48
+ * The duplicate branch used to drop the local line and the answer with it. Everything the
49
+ * database holds was already on the echo line, so the screen looked complete and only the card
50
+ * was missing — the exact defect the absorption path exists to prevent.
51
+ */
52
+ const withBoth = { lines: [pending('local-1-a'), { id: 'server-1', role: 'assistant', content: 'placed at the centre' }], seen: new Set(['server-1']) };
53
+ const settled = reconcileSentLine(withBoth.lines, withBoth.seen, 'local-1-a', 'server-1', answer);
54
+ expect(settled.lines).toHaveLength(1);
55
+ expect(settled.lines[0].proposals).toHaveLength(1);
56
+ });
57
+ it('a turn with nothing to merge into is left alone — no line is invented', () => {
58
+ const settled = reconcileSentLine([], new Set(), 'local-1-a', 'server-9', answer);
59
+ expect(settled.lines).toEqual([]);
60
+ });
61
+ //# sourceMappingURL=chat-proposal-survival.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chat-proposal-survival.test.js","sourceRoot":"","sources":["../../client/components/chat-proposal-survival.test.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * What this guards: the one thing a turn carries that is never stored — its proposals — survives\n * the race between the broadcast echo and the mutation's own answer.\n *\n * A proposal card exists only in the mutation result. The reply, the tool usages and the grounding\n * warnings are all in the database, so any path that rebuilds a line from the broadcast looks\n * right on screen and silently drops the one thing the person can act on. That shipped once\n * before (the comment in chat-echo-dedup remembers it), and on 2026-09-18 the twin saw it again:\n * `proposals` came back with one item and the line on screen had no `proposals` field at all.\n */\nimport { receiveRemoteMessage, reconcileSentLine } from './chat-echo-dedup'\n\nconst pending = (localId: string) => ({ role: 'assistant', content: '', pending: true, _localId: localId }) as any\nconst echo = (id: string) => ({ id, role: 'assistant', content: 'placed at the centre', toolUsagesJson: [{ name: 'proposeSceneEdit' }] }) as any\nconst answer = { proposals: [{ tool: 'proposeSceneEdit', proposed: true, kind: 'scene-edit' }], toolUsages: [{ name: 'proposeSceneEdit' }], content: 'placed at the centre', pending: false }\n\nit('★ echo first, then the answer — the card survives', () => {\n const absorbed = receiveRemoteMessage([pending('local-1-a')], new Set(), echo('server-1'))!\n const settled = reconcileSentLine(absorbed.lines, absorbed.seen, 'local-1-a', 'server-1', answer as any)\n\n expect(settled.lines).toHaveLength(1)\n expect((settled.lines[0] as any).proposals).toHaveLength(1)\n})\n\nit('★ the answer first, then the echo — the card survives', () => {\n const settled = reconcileSentLine([pending('local-1-a')], new Set(), 'local-1-a', 'server-1', answer as any)\n const after = receiveRemoteMessage(settled.lines, settled.seen, echo('server-1'))\n\n /* The echo is already known; it must not append a second line, and it must not replace this one. */\n const lines = after ? after.lines : settled.lines\n expect(lines).toHaveLength(1)\n expect((lines[0] as any).proposals).toHaveLength(1)\n})\n\nit('★ the pending line is gone when the echo arrives — the answer still finds its message', () => {\n /*\n * This is the shape the twin saw on 2026-09-18: a line carrying exactly what the database has,\n * no `proposals` field at all, while the response had one. It happens when the pending line is\n * no longer in `lines` by the time the echo arrives, and the echo is appended as a new line.\n *\n * The answer knows which stored message it is (`assistantMessageId`), so it merges there. What\n * it must never do is return with the card nowhere: the model tells the person to press a button\n * that does not exist.\n */\n const appended = receiveRemoteMessage([], new Set(), echo('server-1'))!\n expect((appended.lines[0] as any).proposals).toBeUndefined()\n\n const settled = reconcileSentLine(appended.lines, appended.seen, 'local-1-a', 'server-1', answer as any)\n\n expect(settled.lines).toHaveLength(1)\n expect((settled.lines[0] as any).proposals).toHaveLength(1)\n expect((settled.lines[0] as any).id).toBe('server-1')\n})\n\nit('★ the echo was appended beside a pending line — the card lands on the message that stays', () => {\n /*\n * The duplicate branch used to drop the local line and the answer with it. Everything the\n * database holds was already on the echo line, so the screen looked complete and only the card\n * was missing — the exact defect the absorption path exists to prevent.\n */\n const withBoth = { lines: [pending('local-1-a'), { id: 'server-1', role: 'assistant', content: 'placed at the centre' }], seen: new Set(['server-1']) }\n\n const settled = reconcileSentLine(withBoth.lines as any, withBoth.seen, 'local-1-a', 'server-1', answer as any)\n\n expect(settled.lines).toHaveLength(1)\n expect((settled.lines[0] as any).proposals).toHaveLength(1)\n})\n\nit('a turn with nothing to merge into is left alone — no line is invented', () => {\n const settled = reconcileSentLine([], new Set(), 'local-1-a', 'server-9', answer as any)\n\n expect(settled.lines).toEqual([])\n})\n"]}