@things-factory/ai-assistant 10.1.27 → 10.1.29

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 (210) hide show
  1. package/client/components/assistant-chat.ts +701 -63
  2. package/client/components/assistant-mode.test.ts +105 -55
  3. package/client/components/assistant-mode.ts +144 -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 +54 -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 +37 -1
  14. package/client/utils/assistant-session-transport.ts +13 -5
  15. package/dist-client/components/assistant-chat.d.ts +140 -13
  16. package/dist-client/components/assistant-chat.js +659 -71
  17. package/dist-client/components/assistant-chat.js.map +1 -1
  18. package/dist-client/components/assistant-mode.d.ts +102 -68
  19. package/dist-client/components/assistant-mode.js +79 -19
  20. package/dist-client/components/assistant-mode.js.map +1 -1
  21. package/dist-client/components/assistant-mode.test.js +90 -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 +49 -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 +20 -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 +13 -5
  52. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  53. package/dist-server/door-declaration-report.d.ts +14 -0
  54. package/dist-server/door-declaration-report.js +54 -0
  55. package/dist-server/door-declaration-report.js.map +1 -0
  56. package/dist-server/service/assistant-capability-resolver.d.ts +17 -0
  57. package/dist-server/service/assistant-capability-resolver.js +88 -0
  58. package/dist-server/service/assistant-capability-resolver.js.map +1 -0
  59. package/dist-server/service/assistant-chat-resolver.d.ts +6 -6
  60. package/dist-server/service/assistant-chat-resolver.js +185 -44
  61. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  62. package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +33 -0
  63. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +148 -0
  64. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -0
  65. package/dist-server/service/assistant-mode/index.d.ts +9 -0
  66. package/dist-server/service/assistant-mode/index.js +19 -0
  67. package/dist-server/service/assistant-mode/index.js.map +1 -0
  68. package/dist-server/service/assistant-mode/registry.d.ts +18 -0
  69. package/dist-server/service/assistant-mode/registry.js +136 -0
  70. package/dist-server/service/assistant-mode/registry.js.map +1 -0
  71. package/dist-server/service/assistant-mode/resolve.d.ts +43 -0
  72. package/dist-server/service/assistant-mode/resolve.js +65 -0
  73. package/dist-server/service/assistant-mode/resolve.js.map +1 -0
  74. package/dist-server/service/assistant-mode/types.d.ts +120 -0
  75. package/dist-server/service/assistant-mode/types.js +12 -0
  76. package/dist-server/service/assistant-mode/types.js.map +1 -0
  77. package/dist-server/service/chat-message/carried-turns.d.ts +48 -0
  78. package/dist-server/service/chat-message/carried-turns.js +92 -0
  79. package/dist-server/service/chat-message/carried-turns.js.map +1 -0
  80. package/dist-server/service/chat-message/chat-message-subscription.d.ts +1 -1
  81. package/dist-server/service/chat-message/chat-message-subscription.js +24 -6
  82. package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -1
  83. package/dist-server/service/chat-message/chat-message.d.ts +32 -0
  84. package/dist-server/service/chat-message/chat-message.js +17 -0
  85. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  86. package/dist-server/service/chat-message/door-access.d.ts +27 -0
  87. package/dist-server/service/chat-message/door-access.js +34 -0
  88. package/dist-server/service/chat-message/door-access.js.map +1 -0
  89. package/dist-server/service/chat-message/fold-session-history.d.ts +30 -0
  90. package/dist-server/service/chat-message/fold-session-history.js +133 -0
  91. package/dist-server/service/chat-message/fold-session-history.js.map +1 -0
  92. package/dist-server/service/chat-message/llm-history.d.ts +7 -0
  93. package/dist-server/service/chat-message/llm-history.js +23 -11
  94. package/dist-server/service/chat-message/llm-history.js.map +1 -1
  95. package/dist-server/service/chat-message/message-doors.d.ts +29 -0
  96. package/dist-server/service/chat-message/message-doors.js +107 -0
  97. package/dist-server/service/chat-message/message-doors.js.map +1 -0
  98. package/dist-server/service/chat-message/proposal-outcome.d.ts +18 -0
  99. package/dist-server/service/chat-message/proposal-outcome.js +31 -0
  100. package/dist-server/service/chat-message/proposal-outcome.js.map +1 -0
  101. package/dist-server/service/chat-message/reader-gate.d.ts +3 -0
  102. package/dist-server/service/chat-message/reader-gate.js +39 -0
  103. package/dist-server/service/chat-message/reader-gate.js.map +1 -0
  104. package/dist-server/service/chat-message/room-message.d.ts +4 -0
  105. package/dist-server/service/chat-message/room-message.js +25 -0
  106. package/dist-server/service/chat-message/room-message.js.map +1 -0
  107. package/dist-server/service/chat-session/chat-session.d.ts +36 -0
  108. package/dist-server/service/chat-session/chat-session.js +27 -2
  109. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  110. package/dist-server/service/chat-session/session-anchor.d.ts +10 -1
  111. package/dist-server/service/chat-session/session-anchor.js +12 -4
  112. package/dist-server/service/chat-session/session-anchor.js.map +1 -1
  113. package/dist-server/service/chat-session/session-inbox.d.ts +6 -1
  114. package/dist-server/service/chat-session/session-inbox.js +3 -2
  115. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  116. package/dist-server/service/chat-session/session-kind.d.ts +14 -0
  117. package/dist-server/service/chat-session/session-kind.js +30 -0
  118. package/dist-server/service/chat-session/session-kind.js.map +1 -0
  119. package/dist-server/service/chat-session/session-visibility.d.ts +15 -0
  120. package/dist-server/service/chat-session/session-visibility.js +35 -0
  121. package/dist-server/service/chat-session/session-visibility.js.map +1 -0
  122. package/dist-server/service/chat-session/station-session-guard.d.ts +1 -5
  123. package/dist-server/service/chat-session/station-session-guard.js +7 -2
  124. package/dist-server/service/chat-session/station-session-guard.js.map +1 -1
  125. package/dist-server/service/chat-session-participant/mention-invite.d.ts +29 -0
  126. package/dist-server/service/chat-session-participant/mention-invite.js +103 -0
  127. package/dist-server/service/chat-session-participant/mention-invite.js.map +1 -0
  128. package/dist-server/service/chat-session-resolver.d.ts +17 -0
  129. package/dist-server/service/chat-session-resolver.js +271 -15
  130. package/dist-server/service/chat-session-resolver.js.map +1 -1
  131. package/dist-server/service/grounding-sources.d.ts +14 -0
  132. package/dist-server/service/grounding-sources.js +26 -0
  133. package/dist-server/service/grounding-sources.js.map +1 -0
  134. package/dist-server/service/index.d.ts +9 -1
  135. package/dist-server/service/index.js +21 -0
  136. package/dist-server/service/index.js.map +1 -1
  137. package/dist-server/service/request-translator.d.ts +2 -0
  138. package/dist-server/service/request-translator.js +20 -0
  139. package/dist-server/service/request-translator.js.map +1 -0
  140. package/dist-server/service/testing/conversation-script.d.ts +123 -0
  141. package/dist-server/service/testing/conversation-script.js +202 -0
  142. package/dist-server/service/testing/conversation-script.js.map +1 -0
  143. package/dist-server/service/testing/mode-conformance.d.ts +50 -0
  144. package/dist-server/service/testing/mode-conformance.js +103 -0
  145. package/dist-server/service/testing/mode-conformance.js.map +1 -0
  146. package/dist-server/tool-registry-boot-report.d.ts +3 -0
  147. package/dist-server/tool-registry-boot-report.js +32 -2
  148. package/dist-server/tool-registry-boot-report.js.map +1 -1
  149. package/dist-server/tsconfig.tsbuildinfo +1 -1
  150. package/package.json +5 -5
  151. package/server/door-declaration-report.test.ts +53 -0
  152. package/server/door-declaration-report.ts +61 -0
  153. package/server/explicit-graphql-types.test.ts +56 -0
  154. package/server/service/assistant-capability-resolver.ts +66 -0
  155. package/server/service/assistant-chat-resolver.ts +205 -42
  156. package/server/service/assistant-mode/assistant-mode-resolver.ts +108 -0
  157. package/server/service/assistant-mode/index.ts +16 -0
  158. package/server/service/assistant-mode/registry.test.ts +105 -0
  159. package/server/service/assistant-mode/registry.ts +147 -0
  160. package/server/service/assistant-mode/resolve.test.ts +120 -0
  161. package/server/service/assistant-mode/resolve.ts +105 -0
  162. package/server/service/assistant-mode/subject-defaults.test.ts +86 -0
  163. package/server/service/assistant-mode/types.ts +121 -0
  164. package/server/service/chat-message/carried-turns.test.ts +80 -0
  165. package/server/service/chat-message/carried-turns.ts +112 -0
  166. package/server/service/chat-message/chat-message-subscription.ts +20 -4
  167. package/server/service/chat-message/chat-message.ts +44 -0
  168. package/server/service/chat-message/door-access.test.ts +72 -0
  169. package/server/service/chat-message/door-access.ts +65 -0
  170. package/server/service/chat-message/fold-history-window.test.ts +184 -0
  171. package/server/service/chat-message/fold-session-history.test.ts +98 -0
  172. package/server/service/chat-message/fold-session-history.ts +115 -0
  173. package/server/service/chat-message/llm-history.test.ts +58 -7
  174. package/server/service/chat-message/llm-history.ts +30 -11
  175. package/server/service/chat-message/message-doors.test.ts +87 -0
  176. package/server/service/chat-message/message-doors.ts +117 -0
  177. package/server/service/chat-message/proposal-outcome.test.ts +48 -0
  178. package/server/service/chat-message/proposal-outcome.ts +42 -0
  179. package/server/service/chat-message/reader-gate.ts +44 -0
  180. package/server/service/chat-message/room-message.test.ts +21 -0
  181. package/server/service/chat-message/room-message.ts +22 -0
  182. package/server/service/chat-session/chat-session.ts +46 -1
  183. package/server/service/chat-session/session-anchor.ts +12 -3
  184. package/server/service/chat-session/session-inbox.test.ts +18 -0
  185. package/server/service/chat-session/session-inbox.ts +11 -2
  186. package/server/service/chat-session/session-kind.test.ts +24 -0
  187. package/server/service/chat-session/session-kind.ts +32 -0
  188. package/server/service/chat-session/session-visibility.test.ts +52 -0
  189. package/server/service/chat-session/session-visibility.ts +34 -0
  190. package/server/service/chat-session/station-session-guard.ts +10 -2
  191. package/server/service/chat-session-participant/mention-invite.test.ts +125 -0
  192. package/server/service/chat-session-participant/mention-invite.ts +121 -0
  193. package/server/service/chat-session-resolver.ts +276 -15
  194. package/server/service/grounding-sources.test.ts +55 -0
  195. package/server/service/grounding-sources.ts +46 -0
  196. package/server/service/index.ts +21 -0
  197. package/server/service/request-translator.test.ts +45 -0
  198. package/server/service/request-translator.ts +33 -0
  199. package/server/service/station-session-guard.test.ts +12 -5
  200. package/server/service/testing/conversation-scenario.test.ts +163 -0
  201. package/server/service/testing/conversation-script.test.ts +131 -0
  202. package/server/service/testing/conversation-script.ts +288 -0
  203. package/server/service/testing/mode-conformance.ts +122 -0
  204. package/server/tool-registry-boot-report.ts +31 -2
  205. package/test/assistant-chat-call-sites.test.ts +82 -0
  206. package/translations/en.json +46 -3
  207. package/translations/ja.json +46 -3
  208. package/translations/ko.json +46 -3
  209. package/translations/ms.json +46 -3
  210. package/translations/zh.json +46 -3
@@ -0,0 +1,184 @@
1
+ /*
2
+ * ★ 요약 접기는 **필요한 만큼만 읽는다** — 그러면서 앞부분을 잃지 않는다.
3
+ *
4
+ * ── 무엇을 겪었나 (2026-08-20) ──────────────────────────────────────────────
5
+ * `_foldHistorySummary` 는 **메시지를 보낼 때마다** 그 세션의 대화를 처음부터 끝까지 읽었다
6
+ * (`creator` 조인까지). 정작 쓰는 것은 두 조각뿐이다: 마지막 `maxTurns`개와, 요약 워터마크
7
+ * 뒤부터의 미반영분. 대화가 2,000개면 매 메시지마다 2,000행을 읽어 수십 행을 쓴 셈이다.
8
+ *
9
+ * 같은 종류의 결함을 트윈 쪽에서 먼저 잡았다(`timeRange` — min/max 두 수를 구하려고 저널 27만
10
+ * 행을 4초마다 읽었다). 사람 리뷰가 그것을 못 잡은 이유는 **코드가 맞았기 때문**이다. 여기도 같다:
11
+ * 답은 맞고 비용만 틀렸다. 그래서 이 시험은 답과 함께 **어디까지 읽어야 하는가**를 못 박는다.
12
+ *
13
+ * ── 지키는 것 ───────────────────────────────────────────────────────────────
14
+ * ① 창으로 읽어 왔으면 밀려난 것이 곧 미반영분이다 — 워터마크로 또 자르지 않는다.
15
+ * ② 전체를 읽어 왔으면 워터마크로 자른다 — 이미 요약에 담은 것을 두 번 담지 않는다.
16
+ * ③ 두 경로가 **같은 답**을 낸다. 읽는 범위를 바꾼 것이 요약 내용을 바꾸면 안 된다.
17
+ * ④ 워터마크 행이 사라졌으면 전부 미반영으로 본다 — 빠뜨리는 쪽보다 다시 요약하는 쪽이 안전하다.
18
+ *
19
+ * ── 왜 여기로 왔나 (2026-09-18) ────────────────────────────────────────────
20
+ * 요약 접기를 `board-ai` 에서 이 꾸러미로 옮기면서(`4257583d1b`) 이 시험만 board-ai 에 남았다. 읽는
21
+ * 파일이 사라졌으니 board-ai 의 `npm test` 가 그날부터 실패했다 — 옮긴 사람이 시험을 두고 온 것이다.
22
+ * 여기 오면서 `dist-server` 를 거치지 않고 소스를 바로 읽는다.
23
+ */
24
+
25
+ import { foldHistorySummary } from './fold-history'
26
+ import { pendingForSummary, splitByCap } from './history-summary'
27
+
28
+ const MAX_TURNS = 5
29
+ const THRESHOLD = 3
30
+
31
+ /** 메시지 n개 — id 는 `m1`.. 로 두어 어디까지 담겼는지 눈으로 읽을 수 있게 한다. */
32
+ const rows = (n: number, from = 1) =>
33
+ Array.from({ length: n }, (_, i) => ({
34
+ id: `m${from + i}`,
35
+ role: (from + i) % 2 ? 'user' : 'assistant',
36
+ content: `말 ${from + i}`
37
+ }))
38
+
39
+ /** 요약기를 가로채 **무엇이 요약 대상이 됐는지** 본다. 이것이 이 시험의 핵심 관측점이다. */
40
+ function capturing() {
41
+ const seen: string[] = []
42
+ return {
43
+ seen,
44
+ summarize: async (content: string) => {
45
+ seen.push(content)
46
+ return '요약'
47
+ }
48
+ }
49
+ }
50
+
51
+ it('창으로 읽어 왔으면 밀려난 것이 곧 미반영분이다 — 워터마크로 또 자르지 않는다', async () => {
52
+ /* m1..m9 가 창(요약 뒤부터)이다. 마지막 5개는 원문 유지, 앞 4개가 새로 밀려났다. */
53
+ const cap = capturing()
54
+ const r = await foldHistorySummary({
55
+ rows: rows(9),
56
+ rowsStartAfterSummary: true,
57
+ stored: { text: '앞선 요약', upToMessageId: 'm0-요약에-담긴-것' },
58
+ maxTurns: MAX_TURNS,
59
+ threshold: THRESHOLD,
60
+ summarize: cap.summarize
61
+ })
62
+
63
+ expect(r.patch).toBeTruthy() // '4개가 밀려났고 임계치 3 이상이므로 접는다'
64
+ expect(r.patch!.summaryUpToMessageId).toBe('m4') // '밀려난 마지막까지 담았다고 기록한다'
65
+ expect(cap.seen[0]).toMatch(/말 1/) // '창의 첫 메시지가 요약에 들어간다'
66
+ expect(cap.seen[0]).toMatch(/말 4/)
67
+ expect(cap.seen[0]).not.toMatch(/말 5/) // '유지분은 원문으로 남으므로 요약에 넣지 않는다'
68
+ expect(cap.seen[0]).toMatch(/앞선 요약/) // '앞선 요약에 이어 붙인다 — 앞부분을 잃지 않는다'
69
+ })
70
+
71
+ it('전체를 읽어 왔으면 워터마크로 자른다 — 이미 담은 것을 두 번 담지 않는다', async () => {
72
+ /* m1..m14 전체. m5 까지 요약에 담겼다 → 미반영분은 m6..m9(마지막 5개는 유지분). */
73
+ const cap = capturing()
74
+ const r = await foldHistorySummary({
75
+ rows: rows(14),
76
+ stored: { text: '앞선 요약', upToMessageId: 'm5' },
77
+ maxTurns: MAX_TURNS,
78
+ threshold: THRESHOLD,
79
+ summarize: cap.summarize
80
+ })
81
+
82
+ expect(r.patch).toBeTruthy()
83
+ expect(r.patch!.summaryUpToMessageId).toBe('m9')
84
+ /* m5 는 이미 요약에 담겼다 — 두 번 담지 않는다. */
85
+ expect(cap.seen[0]).not.toMatch(/말 5(?!\d)/)
86
+ expect(cap.seen[0]).toMatch(/말 6/)
87
+ expect(cap.seen[0]).toMatch(/말 9/)
88
+ })
89
+
90
+ it('읽는 범위를 바꿔도 요약 대상은 같다 — 성능을 바꾼 것이 내용을 바꾸지 않는다', async () => {
91
+ /*
92
+ * 같은 상황을 두 방식으로 읽는다. 전체는 m1..m14 이고 m5 까지 요약됨. 창은 그 뒤인 m6..m14.
93
+ * 둘의 요약 대상이 다르면, 이 최적화가 사실을 옮긴 것이다.
94
+ */
95
+ const whole = capturing()
96
+ const windowed = capturing()
97
+
98
+ const a = await foldHistorySummary({
99
+ rows: rows(14),
100
+ stored: { text: '앞선 요약', upToMessageId: 'm5' },
101
+ maxTurns: MAX_TURNS,
102
+ threshold: THRESHOLD,
103
+ summarize: whole.summarize
104
+ })
105
+ const b = await foldHistorySummary({
106
+ rows: rows(9, 6),
107
+ rowsStartAfterSummary: true,
108
+ stored: { text: '앞선 요약', upToMessageId: 'm5' },
109
+ maxTurns: MAX_TURNS,
110
+ threshold: THRESHOLD,
111
+ summarize: windowed.summarize
112
+ })
113
+
114
+ expect(whole.seen[0]).toBe(windowed.seen[0]) // '요약에 들어가는 글이 글자까지 같다'
115
+ expect(a.patch).toEqual(b.patch) // '기록하는 워터마크도 같다'
116
+ })
117
+
118
+ it('워터마크 행이 사라졌으면 전부 미반영으로 본다 — 빠뜨리지 않는다', () => {
119
+ /* 삭제·편집으로 그 id 가 이력에 없을 수 있다. 그때 덮은 범위를 알 수 없으므로 전부 다시 본다. */
120
+ const { dropped } = splitByCap(rows(14), MAX_TURNS)
121
+ const pending = pendingForSummary(dropped, { text: '앞선 요약', upToMessageId: '지워진-id' })
122
+
123
+ expect(pending.length).toBe(dropped.length) // '전부 미반영'
124
+ expect(pending[0].id).toBe('m1')
125
+ })
126
+
127
+ it('창이 유지분보다 짧으면 접지 않는다 — 새로 밀려난 것이 없다', async () => {
128
+ const cap = capturing()
129
+ const r = await foldHistorySummary({
130
+ rows: rows(MAX_TURNS),
131
+ rowsStartAfterSummary: true,
132
+ stored: { text: '앞선 요약', upToMessageId: 'm0' },
133
+ maxTurns: MAX_TURNS,
134
+ threshold: THRESHOLD,
135
+ summarize: cap.summarize
136
+ })
137
+
138
+ expect(r.patch).toBe(null)
139
+ expect(r.skipped).toBe('nothing-dropped')
140
+ expect(cap.seen.length).toBe(0) // 'LLM 을 부르지 않는다'
141
+ })
142
+
143
+ it('밀려난 것이 임계치 미만이면 접지 않는다 — 매 턴 요약하지 않는다', async () => {
144
+ const cap = capturing()
145
+ const r = await foldHistorySummary({
146
+ rows: rows(MAX_TURNS + THRESHOLD - 1),
147
+ rowsStartAfterSummary: true,
148
+ stored: { text: '앞선 요약', upToMessageId: 'm0' },
149
+ maxTurns: MAX_TURNS,
150
+ threshold: THRESHOLD,
151
+ summarize: cap.summarize
152
+ })
153
+
154
+ expect(r.skipped).toBe('below-threshold')
155
+ expect(cap.seen.length).toBe(0)
156
+ })
157
+
158
+ /*
159
+ * ★ 위 ① 을 **가르는** 자리.
160
+ *
161
+ * 앞의 「창으로 읽어 왔으면…」 시험은 워터마크 id 가 창에 없는 값이라(`m0-요약에-담긴-것`), 두 갈래가
162
+ * 같은 답을 낸다 — 워터마크가 안 보이면 전부 미반영으로 보는 규칙 ④ 때문이다. 그래서 창 갈래를 통째로
163
+ * 지워도 그 시험은 초록이었다(2026-09-18, 옮겨 온 뒤 결함을 심어 보고 알았다).
164
+ *
165
+ * 둘이 갈리는 것은 **워터마크 id 가 창 안에 보일 때**다. 그때 맞는 답은 창 쪽이다 — 창은 이미 요약
166
+ * 뒤부터이므로, 그 안에 같은 id 가 보여도 그것은 아직 요약에 안 담긴 말이다.
167
+ */
168
+ it('★ 워터마크 id 가 창 안에 보여도 창은 워터마크로 자르지 않는다 — 여기서 두 갈래가 갈린다', async () => {
169
+ const cap = capturing()
170
+
171
+ const r = await foldHistorySummary({
172
+ rows: rows(9),
173
+ rowsStartAfterSummary: true,
174
+ stored: { text: '앞선 요약', upToMessageId: 'm2' },
175
+ maxTurns: MAX_TURNS,
176
+ threshold: THRESHOLD,
177
+ summarize: cap.summarize
178
+ })
179
+
180
+ expect(r.patch).toBeTruthy()
181
+ /* 창 갈래: m1..m4 가 전부 미반영이다. 전체 갈래였다면 m3..m4 만 담고 m1 · m2 를 잃는다. */
182
+ expect(cap.seen[0]).toMatch(/말 1/)
183
+ expect(cap.seen[0]).toMatch(/말 2/)
184
+ })
@@ -0,0 +1,98 @@
1
+ /*
2
+ * What this guards: a long conversation on the neutral door gets its earlier turns summarised.
3
+ *
4
+ * Every piece existed — the cap, the "N left out" notice, `ChatSession.lastSummary`, and the pure
5
+ * rules for when to summarise again. Only the board product called them, so twin, plant and figure
6
+ * conversations announced the omission every turn and never folded anything. A twin deliberation
7
+ * runs for days, and the first hour is exactly what falls off the front.
8
+ */
9
+ import { foldSessionHistory, rowsAfterWatermark } from './fold-session-history'
10
+
11
+ const rows = (count: number, from = 1) =>
12
+ Array.from({ length: count }, (_, i) => ({
13
+ id: `m${from + i}`,
14
+ role: i % 2 ? 'assistant' : 'user',
15
+ content: `말 ${from + i}`,
16
+ createdAt: new Date(2026, 8, 18, 9, 0, from + i),
17
+ creator: { id: 'u1', name: 'alice' }
18
+ }))
19
+
20
+ function repos(session: any, messages: any[]) {
21
+ const updates: any[] = []
22
+ return {
23
+ updates,
24
+ repos: {
25
+ sessions: {
26
+ findOneBy: async () => session,
27
+ update: async (id: string, patch: any) => updates.push({ id, patch })
28
+ },
29
+ messages: {
30
+ findOne: async ({ where }: any) => messages.find(m => m.id === where.id),
31
+ find: async () => messages
32
+ }
33
+ }
34
+ }
35
+ }
36
+
37
+ it('★ a conversation past the cap is summarised, and the summary is stored', async () => {
38
+ const summarize = jest.fn().mockResolvedValue('앞부분 요약')
39
+ const { repos: r, updates } = repos({ id: 's1' }, rows(60))
40
+
41
+ await foldSessionHistory({
42
+ sessionId: 's1',
43
+ domainId: 'dom-1',
44
+ client: { chat: summarize } as any,
45
+ maxTurns: 40,
46
+ repos: r
47
+ })
48
+
49
+ expect(summarize).toHaveBeenCalled()
50
+ expect(updates).toHaveLength(1)
51
+ expect(updates[0].patch.lastSummary).toBe('앞부분 요약')
52
+ expect(updates[0].patch.summaryUpToMessageId).toBeDefined()
53
+ })
54
+
55
+ it('★ a short conversation is left alone — nothing has fallen off yet', async () => {
56
+ const summarize = jest.fn()
57
+ const { repos: r, updates } = repos({ id: 's1' }, rows(5))
58
+
59
+ await foldSessionHistory({ sessionId: 's1', domainId: 'dom-1', client: { chat: summarize } as any, maxTurns: 40, repos: r })
60
+
61
+ expect(summarize).not.toHaveBeenCalled()
62
+ expect(updates).toEqual([])
63
+ })
64
+
65
+ it('★ a failed summary does not throw — the next turn says "left out" instead', async () => {
66
+ const summarize = jest.fn().mockRejectedValue(new Error('provider down'))
67
+ const { repos: r, updates } = repos({ id: 's1' }, rows(60))
68
+
69
+ await expect(
70
+ foldSessionHistory({ sessionId: 's1', domainId: 'dom-1', client: { chat: summarize } as any, maxTurns: 40, repos: r })
71
+ ).resolves.toBeUndefined()
72
+ expect(updates).toEqual([])
73
+ })
74
+
75
+ it('a session that is not this tenant\'s is not touched', async () => {
76
+ const summarize = jest.fn()
77
+ const { repos: r, updates } = repos(undefined, rows(60))
78
+
79
+ await foldSessionHistory({ sessionId: 's1', domainId: 'dom-1', client: { chat: summarize } as any, maxTurns: 40, repos: r })
80
+
81
+ expect(summarize).not.toHaveBeenCalled()
82
+ expect(updates).toEqual([])
83
+ })
84
+
85
+ describe('rowsAfterWatermark', () => {
86
+ it('reads the whole conversation when nothing is summarised yet', () => {
87
+ expect(rowsAfterWatermark('s1', undefined)).toEqual({ session: { id: 's1' } })
88
+ })
89
+
90
+ it('★ continues within one timestamp by id — messages in the same millisecond are not skipped', () => {
91
+ const at = new Date(2026, 8, 18, 9, 0, 1)
92
+
93
+ const where = rowsAfterWatermark('s1', { id: 'm7', createdAt: at })
94
+
95
+ expect(Array.isArray(where)).toBe(true)
96
+ expect(where[1]).toMatchObject({ session: { id: 's1' }, createdAt: at })
97
+ })
98
+ })
@@ -0,0 +1,115 @@
1
+ /*
2
+ * Folding a long conversation's earlier turns into a summary — for every surface, not only boards.
3
+ *
4
+ * ── The gap this closes (2026-09-18) ───────────────────────────────────────────
5
+ * The pieces were all here: a cap on how many turns reach the model, a notice when turns are left
6
+ * out, `ChatSession.lastSummary` to carry what was dropped, and pure rules for when to re-summarise
7
+ * (`history-summary`, `fold-history`). Only the board product ever called them. The neutral door —
8
+ * the one the twin, plant and figure conversations run through — never did, so those sessions kept
9
+ * announcing "the first N messages were left out" and never summarised them.
10
+ *
11
+ * That is worst exactly where it was designed to help: a twin deliberation runs for days, and what
12
+ * the room agreed on in the first hour is what falls off the front.
13
+ *
14
+ * ── What is here and what is not ───────────────────────────────────────────────
15
+ * The rules stay pure and tested elsewhere. This is the reading and the writing: find what the
16
+ * stored summary does not cover yet, ask the model for a new one, store it. It is called after the
17
+ * reply has gone out, and it never throws — a conversation must not fail because a summary did not
18
+ * get written, and the next turn says "N left out" honestly if it did not.
19
+ */
20
+ import { MoreThan } from 'typeorm'
21
+
22
+ import type { AIClient } from '@things-factory/ai-client-base'
23
+
24
+ import { foldHistorySummary } from './fold-history.js'
25
+ import { summaryInstruction } from './history-summary.js'
26
+
27
+ /*
28
+ * 엔티티는 **늦게 가져온다.** 위에서 import 하면 이 파일을 읽는 순간 TypeGraphQL 데코레이터가 돌고,
29
+ * 데코레이터 메타데이터가 없는 자리(시험 러너)에서 모듈 적재가 깨진다. 규칙 자체는 데이터베이스가
30
+ * 없어도 검사할 수 있어야 한다.
31
+ */
32
+ async function defaultRepos() {
33
+ const { getRepository } = await import('@things-factory/shell')
34
+ const { ChatSession } = await import('../chat-session/chat-session.js')
35
+ const { ChatMessage } = await import('./chat-message.js')
36
+ return { sessions: getRepository(ChatSession as any), messages: getRepository(ChatMessage as any) }
37
+ }
38
+
39
+ /** How many newly-dropped turns are worth another summarisation call. */
40
+ export const DEFAULT_SUMMARY_THRESHOLD = 10
41
+
42
+ export interface FoldSessionHistoryInput {
43
+ sessionId: string
44
+ domainId: string
45
+ /** The same client that answered — a summary is written by whatever this deployment has. */
46
+ client: Pick<AIClient, 'chat'>
47
+ /** The cap the door uses when it builds the history, so both sides agree on what fell off. */
48
+ maxTurns: number
49
+ threshold?: number
50
+ locale?: string
51
+ /** Test seam: the two repositories this reads and writes. */
52
+ repos?: { sessions: any; messages: any }
53
+ }
54
+
55
+ /**
56
+ * Where the rows after the summary's watermark are.
57
+ *
58
+ * Kept separate so the window can be read without a database: the array is an OR, and the second
59
+ * branch continues within one timestamp by id — the same axis the ordering uses, or a burst of
60
+ * messages in the same millisecond would be summarised twice or not at all.
61
+ */
62
+ export function rowsAfterWatermark(sessionId: string, mark: { id?: string; createdAt?: Date } | undefined): any {
63
+ const session = { id: sessionId } as any
64
+ if (!mark?.createdAt) return { session }
65
+ return [
66
+ { session, createdAt: MoreThan(mark.createdAt) },
67
+ { session, createdAt: mark.createdAt, id: MoreThan(mark.id) }
68
+ ]
69
+ }
70
+
71
+ export async function foldSessionHistory(input: FoldSessionHistoryInput): Promise<void> {
72
+ try {
73
+ const { sessions, messages } = input.repos ?? (await defaultRepos())
74
+ const session = await sessions.findOneBy({ id: input.sessionId, domain: { id: input.domainId } as any })
75
+ if (!session) return
76
+
77
+ const stored = { text: (session as any).lastSummary, upToMessageId: (session as any).summaryUpToMessageId }
78
+ const order = { createdAt: 'ASC', id: 'ASC' } as any
79
+ const relations = { creator: true } as any
80
+
81
+ /* 요약이 덮은 지점을 알면 그 뒤만 읽는다 — 매 턴 전체 대화를 읽지 않는다. */
82
+ let mark: { id?: string; createdAt?: Date } | undefined
83
+ if (stored.text && stored.upToMessageId) {
84
+ mark = await messages.findOne({ where: { session: { id: input.sessionId } as any, id: stored.upToMessageId } })
85
+ }
86
+ /* 워터마크 행이 사라졌으면 덮은 범위를 모른다 — 전부 다시 본다. */
87
+ const startsAfterSummary = Boolean(stored.text && mark?.createdAt)
88
+ const rows = await messages.find({ where: rowsAfterWatermark(input.sessionId, startsAfterSummary ? mark : undefined), relations, order })
89
+
90
+ const { patch } = await foldHistorySummary({
91
+ rows: rows.map((row: any) => ({
92
+ id: row.id,
93
+ role: row.role,
94
+ content: row.content ?? '',
95
+ senderId: row.creator?.id,
96
+ senderName: row.creator?.name || row.creator?.username
97
+ })),
98
+ rowsStartAfterSummary: startsAfterSummary,
99
+ stored,
100
+ maxTurns: input.maxTurns,
101
+ threshold: input.threshold ?? DEFAULT_SUMMARY_THRESHOLD,
102
+ summarize: content =>
103
+ input.client.chat!([{ role: 'user', content }], {
104
+ systemPrompt: summaryInstruction(input.locale),
105
+ maxTokens: 700,
106
+ /* 요약은 창작이 아니다 — 흔들림을 줄인다. */
107
+ temperature: 0
108
+ })
109
+ })
110
+
111
+ if (patch) await sessions.update(input.sessionId, patch as any)
112
+ } catch {
113
+ /* 요약 실패는 대화를 막지 않는다 — 다음 턴이 「N 개 생략」으로 정직하게 알린다. */
114
+ }
115
+ }
@@ -48,7 +48,7 @@ describe('발신자 표기', () => {
48
48
 
49
49
  it('이름이 없으면 빈 대괄호를 남기지 않고 참여자로 표기한다', () => {
50
50
  const out = buildLlmHistory([u('1', 'a', 'U1', '김철수'), u('2', 'b', 'U2', undefined)])
51
- expect(out[1].content).toBe('[참여자] b')
51
+ expect(out[1].content).toBe('[participant] b')
52
52
  })
53
53
 
54
54
  it('assistant 발언에는 표기하지 않는다 — role 로 이미 구별된다', () => {
@@ -75,7 +75,7 @@ describe('발신자 표기', () => {
75
75
  describe('system 기록', () => {
76
76
  it('시스템 기록은 표기를 붙인 user 메시지로 전달된다 — 사장되지 않는다', () => {
77
77
  const out = buildLlmHistory([sys('1', 'User directly edited the board: 3 op(s)')])
78
- expect(out).toEqual([{ role: 'user', content: '[시스템] User directly edited the board: 3 op(s)' }])
78
+ expect(out).toEqual([{ role: 'user', content: '[system] User directly edited the board: 3 op(s)' }])
79
79
  })
80
80
 
81
81
  it('시스템 기록은 발신자 수 계산에 넣지 않는다 — 사람 한 명이면 표기 없음 유지', () => {
@@ -145,8 +145,9 @@ describe('이력 상한 — 협의가 길어질 때', () => {
145
145
 
146
146
  it('생략은 **반드시 알린다** — 조용히 버리면 모델이 남은 첫 줄을 대화의 시작으로 오해한다', () => {
147
147
  const out = buildLlmHistory(rows(10), { maxTurns: 4 })
148
- expect(out[0].content).toContain('6개')
149
- expect(out[0].content).toContain('생략')
148
+ /* The notice is read by the model, so it is English. */
149
+ expect(out[0].content).toContain('6 message(s)')
150
+ expect(out[0].content).toContain('left out')
150
151
  })
151
152
 
152
153
  it('상한에 걸리지 않으면 알림을 붙이지 않는다 — 없는 생략을 말하지 않는다', () => {
@@ -172,18 +173,18 @@ describe('이력 요약 접기 — 프롬프트 조립', () => {
172
173
  summary: { text: '앞에서 정한 것: 지게차 1대 추가', upToMessageId: 'm5' }
173
174
  })
174
175
  expect(out[0].content).toContain('지게차 1대 추가')
175
- expect(out[0].content).not.toContain('생략되었습니다.')
176
+ expect(out[0].content).not.toContain('left out')
176
177
  })
177
178
 
178
179
  it('요약임을 밝힌다 — 모델이 원문처럼 인용하지 않게', () => {
179
180
  const out = buildLlmHistory(rows(10), { maxTurns: 4, summary: { text: '요약본', upToMessageId: 'm5' } })
180
- expect(out[0].content).toContain('원문 아님')
181
+ expect(out[0].content).toContain('not the original')
181
182
  })
182
183
 
183
184
  it('요약이 밀려난 구간을 다 덮지 못하면 남은 개수를 밝힌다 — 최신이 아님을 숨기지 않는다', () => {
184
185
  /* 밀려난 것은 m0..m5(6개), 요약은 m3 까지 → m4·m5 두 개가 미반영. */
185
186
  const out = buildLlmHistory(rows(10), { maxTurns: 4, summary: { text: '요약본', upToMessageId: 'm3' } })
186
- expect(out[0].content).toContain('2개')
187
+ expect(out[0].content).toContain('2 message(s) after it are not in the summary yet')
187
188
  })
188
189
 
189
190
  it('상한에 걸리지 않으면 요약을 싣지 않는다 — 필요 없는 문맥을 넣지 않는다', () => {
@@ -192,3 +193,53 @@ describe('이력 요약 접기 — 프롬프트 조립', () => {
192
193
  expect(out[0].content).toBe('말 0')
193
194
  })
194
195
  })
196
+
197
+ /*
198
+ * 누구에게 한 말인가 — ADR-0070 A15 결정 ②.
199
+ *
200
+ * 여럿이 있는 방에서는 옆 사람에게 한 말과 어시스턴트를 부른 말이 같은 모양으로 저장된다. 대상을
201
+ * 적지 않으면 모델은 그 둘을 가르지 못하고, 방에 한 말을 자기에게 온 지시로 읽는다.
202
+ */
203
+ describe('대상 표기', () => {
204
+ const rows = [
205
+ { id: 'm1', role: 'user', content: '3 라인이 멈춘 것 같아요', senderId: 'u1', senderName: 'alice', addressee: 'room' },
206
+ { id: 'm2', role: 'user', content: '아까부터 그랬어', senderId: 'u2', senderName: 'bob', addressee: 'room' },
207
+ { id: 'm3', role: 'user', content: '왜 멈췄는지 확인해줘', senderId: 'u1', senderName: 'alice', addressee: 'assistant' }
208
+ ]
209
+
210
+ it('★ 사람의 말마다 발신자와 대상을 함께 적는다', () => {
211
+ const out = buildLlmHistory(rows as any)
212
+
213
+ expect(out.map(m => m.content)).toEqual([
214
+ '[alice → room] 3 라인이 멈춘 것 같아요',
215
+ '[bob → room] 아까부터 그랬어',
216
+ '[alice → assistant] 왜 멈췄는지 확인해줘'
217
+ ])
218
+ })
219
+
220
+ it('★ 혼자 하는 대화에서도 적는다 — 한 모양이어야 모델이 빈 자리에서 추론하지 않는다', () => {
221
+ const out = buildLlmHistory([rows[2]] as any)
222
+
223
+ expect(out[0].content).toBe('[alice → assistant] 왜 멈췄는지 확인해줘')
224
+ })
225
+
226
+ it('★ 대상을 모르는 옛 행은 대상을 지어내지 않는다', () => {
227
+ const out = buildLlmHistory([
228
+ { id: 'm1', role: 'user', content: '예전 말', senderId: 'u1', senderName: 'alice' },
229
+ { id: 'm2', role: 'user', content: '예전 말 둘', senderId: 'u2', senderName: 'bob' }
230
+ ] as any)
231
+
232
+ expect(out.map(m => m.content)).toEqual(['[alice] 예전 말', '[bob] 예전 말 둘'])
233
+ })
234
+
235
+ it('어시스턴트의 답과 시스템 행은 대상을 적지 않는다', () => {
236
+ const out = buildLlmHistory([
237
+ { id: 'm1', role: 'user', content: '물었다', senderId: 'u1', senderName: 'alice', addressee: 'assistant' },
238
+ { id: 'm2', role: 'assistant', content: '답했다' },
239
+ { id: 'm3', role: 'system', content: 'proposal k: refused (no-target)' }
240
+ ] as any)
241
+
242
+ expect(out[1]).toEqual({ role: 'assistant', content: '답했다' })
243
+ expect(out[2].content).toBe('[system] proposal k: refused (no-target)')
244
+ })
245
+ })
@@ -47,6 +47,13 @@ export interface HistoryRow {
47
47
  senderId?: string
48
48
  /** 프롬프트에 노출할 표시 이름. 이메일 같은 개인정보는 넣지 않는다. */
49
49
  senderName?: string
50
+ /**
51
+ * 누구에게 한 말인가 — `'assistant'` · `'room'`(ADR-0070 A15). 문이 저장한 값 그대로.
52
+ *
53
+ * 없는 행(이 칸이 생기기 전에 저장된 것)은 **모른다.** 모르는 것을 `room` 으로 적으면 그것이
54
+ * 지어낸 사실이다 — 그때는 대상 없이 발신자만 적는다.
55
+ */
56
+ addressee?: string
50
57
  }
51
58
 
52
59
  export interface BuildLlmHistoryOptions {
@@ -76,12 +83,17 @@ export interface BuildLlmHistoryOptions {
76
83
  summary?: StoredSummary
77
84
  }
78
85
 
79
- /** 사람이 아닌 기록(캔버스 직접 편집 등)의 표기. */
80
- const SYSTEM_LABEL = '시스템'
81
- /** 이름을 모르는 참여자의 표기 — 빈 대괄호를 남기지 않는다. */
82
- const UNKNOWN_LABEL = '참여자'
83
- /** 상한으로 잘라낸 앞부분을 알리는 표기 — 모델이 남은 첫 줄을 대화의 시작으로 오해하지 않게. */
84
- const OMITTED_LABEL = '시스템'
86
+ /*
87
+ * These labels and notices are read by the model, so they are English. The reply the model writes is in the
88
+ * language the person used; that is the model's doing, not this file's. They were Korean, which made every
89
+ * conversation in this house speak Korean to the model whatever the reader's language was.
90
+ */
91
+ /** Marks a record no person wrote (a direct canvas edit, for one). */
92
+ const SYSTEM_LABEL = 'system'
93
+ /** Marks a participant whose name we do not have — better than empty brackets. */
94
+ const UNKNOWN_LABEL = 'participant'
95
+ /** Marks the notice about messages the turn limit dropped, so the model does not read the first kept line as the start. */
96
+ const OMITTED_LABEL = 'system'
85
97
 
86
98
  /**
87
99
  * 저장된 이력 → LLM 대화.
@@ -108,18 +120,16 @@ export function buildLlmHistory(rows: HistoryRow[], options: BuildLlmHistoryOpti
108
120
  /* 요약이 있으면 그것을 싣는다. **요약임을 밝힌다** — 모델이 원문처럼 인용하지 않게.
109
121
  * 요약이 밀려난 구간을 다 덮지 못하면 남은 개수도 함께 알린다(최신이 아님을 숨기지 않는다). */
110
122
  const uncovered = pendingForSummary(dropped, options.summary).length
111
- const tail = uncovered
112
- ? ` 그 뒤 ${uncovered}개 메시지는 요약에 아직 반영되지 않았습니다.`
113
- : ''
123
+ const tail = uncovered ? ` ${uncovered} message(s) after it are not in the summary yet.` : ''
114
124
  out.push({
115
125
  role: 'user',
116
- content: `[${OMITTED_LABEL}] 이 대화의 앞부분 ${dropped.length}개 메시지는 길이 제한으로 아래 요약으로 대체되었습니다(원문 아님 — 요약에 없는 사실은 사용자에게 확인하세요).${tail}\n${summaryText}`
126
+ content: `[${OMITTED_LABEL}] The first ${dropped.length} message(s) of this conversation were replaced by the summary below because of the length limit. It is a summary, not the original — ask the person about anything it does not say.${tail}\n${summaryText}`
117
127
  })
118
128
  } else {
119
129
  /* 요약이 아직 없으면 생략 사실만 알린다 — 개수만 밝히고 내용은 지어내지 않는다. */
120
130
  out.push({
121
131
  role: 'user',
122
- content: `[${OMITTED_LABEL}] 이 대화의 앞부분 ${omitted}개 메시지는 길이 제한으로 생략되었습니다. 필요하면 사용자에게 다시 확인하세요.`
132
+ content: `[${OMITTED_LABEL}] The first ${omitted} message(s) of this conversation were left out because of the length limit. Ask the person again if you need them.`
123
133
  })
124
134
  }
125
135
  }
@@ -134,6 +144,15 @@ export function buildLlmHistory(rows: HistoryRow[], options: BuildLlmHistoryOpti
134
144
  out.push({ role: 'user', content: `[${SYSTEM_LABEL}] ${content}` })
135
145
  continue
136
146
  }
147
+ /*
148
+ * 사람의 말은 **누구에게 한 말인지까지** 적는다(ADR-0070 A15 결정 ②). 대상이 있을 때만 적으면
149
+ * 모델은 빈 자리에서 추론한다 — 옆 사람이 방에 한 말을 자기에게 온 지시로 읽는 것이 그 결과다.
150
+ */
151
+ const addressee = (row.addressee ?? '').trim()
152
+ if (addressee) {
153
+ out.push({ role: 'user', content: `[${speaker(row)} → ${addressee}] ${content}` })
154
+ continue
155
+ }
137
156
  out.push({ role: 'user', content: label ? `[${speaker(row)}] ${content}` : content })
138
157
  }
139
158
  return out
@@ -0,0 +1,87 @@
1
+ /*
2
+ * ADR-0073 판정 1 — 답이 지난 문을 읽는 사람이 지나는가.
3
+ *
4
+ * 여기서 무는 것 넷: 문을 합치는가 · 모르는 도구가 가려지는 쪽으로 넘어지는가 · 한 요청에 한 번만
5
+ * 묻는가 · 가린 행에서 값 실린 칸이 함께 지워지는가.
6
+ */
7
+ import { doorGate, doorsForAnswer, passesRow, redactByDoors } from './message-doors'
8
+
9
+ const notice = (doors: string[]) => `needs ${doors.join(' + ')}`
10
+
11
+ describe('message doors', () => {
12
+ it('★ 여러 도구를 부른 답은 그 문을 모두 지난 것으로 적힌다', () => {
13
+ const doorsOf = (name: string) =>
14
+ ({ getPlantOverview: ['Query.jobOrders', 'Query.productionReports'], findJobOrders: ['Query.jobOrders'] } as any)[name]
15
+
16
+ expect(doorsForAnswer(['getPlantOverview', 'findJobOrders'], doorsOf)).toEqual([
17
+ 'Query.jobOrders',
18
+ 'Query.productionReports'
19
+ ])
20
+ })
21
+
22
+ it('사람의 말과 문 없는 도구는 빈 목록이다 — 누구나 읽는다', () => {
23
+ expect(doorsForAnswer([], () => undefined)).toEqual([])
24
+ expect(doorsForAnswer(['getOpenScene'], () => [])).toEqual([])
25
+ })
26
+
27
+ it('★ 등록기가 모르는 도구는 아무도 못 지나는 자리로 적힌다', async () => {
28
+ const doors = doorsForAnswer(['whoIsThis'], () => undefined)
29
+ expect(doors).toEqual(['unresolved:whoIsThis'])
30
+
31
+ /* 통과 검사에 무엇을 넣어도 열리지 않는다. */
32
+ expect(await passesRow(doors, doorGate(async () => true))).toBe(false)
33
+ })
34
+
35
+ it('★ 같은 문은 한 요청에 한 번만 묻는다', async () => {
36
+ const asked: string[] = []
37
+ const gate = doorGate(async door => {
38
+ asked.push(door)
39
+ return true
40
+ })
41
+
42
+ const rows = Array.from({ length: 50 }, () => ({ doors: ['Query.jobOrders', 'Query.productionReports'] }))
43
+ await redactByDoors(rows, gate, notice)
44
+
45
+ expect(asked).toEqual(['Query.jobOrders', 'Query.productionReports'])
46
+ })
47
+
48
+ it('★ 문 하나라도 못 지나면 가린다 — 전부 지나야 한다(AND)', async () => {
49
+ const gate = doorGate(async door => door === 'Query.jobOrders')
50
+
51
+ const [row] = await redactByDoors(
52
+ [{ doors: ['Query.jobOrders', 'Query.productionReports'], content: '오늘 양품 1,204' }],
53
+ gate,
54
+ notice
55
+ )
56
+
57
+ expect(row.content).toBe('needs Query.jobOrders + Query.productionReports')
58
+ expect((row as any).redacted).toBe(true)
59
+ })
60
+
61
+ it('★ 가린 행에서는 값이 실린 칸도 함께 지워진다 — 본문만 지우면 그리로 샌다', async () => {
62
+ const [row] = await redactByDoors(
63
+ [
64
+ {
65
+ doors: ['Query.jobOrders'],
66
+ content: '오늘 양품 1,204',
67
+ extra: { rows: [{ good: 1204 }] },
68
+ proposals: [{ key: 'a', args: { jobOrder: 'JO-1' } }],
69
+ patchEntryId: 'patch-1'
70
+ }
71
+ ],
72
+ doorGate(async () => false),
73
+ notice
74
+ )
75
+
76
+ expect(row.extra).toBeNull()
77
+ expect(row.proposals).toBeNull()
78
+ expect(row.patchEntryId).toBeNull()
79
+ })
80
+
81
+ it('지나는 사람에게는 행이 손대지 않은 채로 간다', async () => {
82
+ const rows = [{ doors: ['Query.jobOrders'], content: '오늘 양품 1,204', extra: { good: 1204 } }]
83
+ const [row] = await redactByDoors(rows, doorGate(async () => true), notice)
84
+
85
+ expect(row).toBe(rows[0])
86
+ })
87
+ })