@things-factory/ai-assistant 10.1.28 → 10.1.31

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 (110) hide show
  1. package/client/components/assistant-chat.ts +51 -7
  2. package/client/components/assistant-mode.test.ts +20 -1
  3. package/client/components/assistant-mode.ts +20 -0
  4. package/client/components/panel-aria.test.ts +20 -0
  5. package/client/utils/assistant-session-controller.ts +14 -1
  6. package/client/utils/assistant-session-transport.ts +2 -2
  7. package/dist-client/components/assistant-chat.d.ts +12 -0
  8. package/dist-client/components/assistant-chat.js +48 -6
  9. package/dist-client/components/assistant-chat.js.map +1 -1
  10. package/dist-client/components/assistant-mode.d.ts +22 -0
  11. package/dist-client/components/assistant-mode.js +16 -0
  12. package/dist-client/components/assistant-mode.js.map +1 -1
  13. package/dist-client/components/assistant-mode.test.js +17 -1
  14. package/dist-client/components/assistant-mode.test.js.map +1 -1
  15. package/dist-client/components/panel-aria.test.js +18 -0
  16. package/dist-client/components/panel-aria.test.js.map +1 -1
  17. package/dist-client/tsconfig.tsbuildinfo +1 -1
  18. package/dist-client/utils/assistant-session-controller.d.ts +10 -0
  19. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  20. package/dist-client/utils/assistant-session-transport.js +2 -2
  21. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  22. package/dist-server/door-declaration-report.d.ts +14 -0
  23. package/dist-server/door-declaration-report.js +54 -0
  24. package/dist-server/door-declaration-report.js.map +1 -0
  25. package/dist-server/service/assistant-chat-resolver.js +19 -0
  26. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  27. package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +8 -0
  28. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +8 -0
  29. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
  30. package/dist-server/service/assistant-mode/index.d.ts +1 -1
  31. package/dist-server/service/assistant-mode/index.js +1 -1
  32. package/dist-server/service/assistant-mode/index.js.map +1 -1
  33. package/dist-server/service/assistant-mode/resolve.d.ts +10 -0
  34. package/dist-server/service/assistant-mode/resolve.js +5 -0
  35. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  36. package/dist-server/service/assistant-mode/types.d.ts +21 -0
  37. package/dist-server/service/assistant-mode/types.js.map +1 -1
  38. package/dist-server/service/chat-message/carried-turns.js +14 -2
  39. package/dist-server/service/chat-message/carried-turns.js.map +1 -1
  40. package/dist-server/service/chat-message/door-access.d.ts +19 -7
  41. package/dist-server/service/chat-message/door-access.js +19 -11
  42. package/dist-server/service/chat-message/door-access.js.map +1 -1
  43. package/dist-server/service/chat-message/reader-gate.js +6 -4
  44. package/dist-server/service/chat-message/reader-gate.js.map +1 -1
  45. package/dist-server/service/chat-session/chat-session.d.ts +12 -0
  46. package/dist-server/service/chat-session/chat-session.js +12 -11
  47. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  48. package/dist-server/service/chat-session/session-anchor.d.ts +10 -1
  49. package/dist-server/service/chat-session/session-anchor.js +12 -4
  50. package/dist-server/service/chat-session/session-anchor.js.map +1 -1
  51. package/dist-server/service/chat-session/session-inbox.d.ts +2 -0
  52. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  53. package/dist-server/service/chat-session/session-kind.d.ts +14 -0
  54. package/dist-server/service/chat-session/session-kind.js +30 -0
  55. package/dist-server/service/chat-session/session-kind.js.map +1 -0
  56. package/dist-server/service/chat-session/session-visibility.js +8 -1
  57. package/dist-server/service/chat-session/session-visibility.js.map +1 -1
  58. package/dist-server/service/chat-session-resolver.d.ts +2 -0
  59. package/dist-server/service/chat-session-resolver.js +4 -0
  60. package/dist-server/service/chat-session-resolver.js.map +1 -1
  61. package/dist-server/service/index.d.ts +1 -0
  62. package/dist-server/service/index.js +2 -0
  63. package/dist-server/service/index.js.map +1 -1
  64. package/dist-server/service/testing/conversation-script.d.ts +7 -0
  65. package/dist-server/service/testing/conversation-script.js +1 -0
  66. package/dist-server/service/testing/conversation-script.js.map +1 -1
  67. package/dist-server/service/testing/mode-conformance.d.ts +10 -0
  68. package/dist-server/service/testing/mode-conformance.js +13 -0
  69. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  70. package/dist-server/service/tool-access.js +20 -7
  71. package/dist-server/service/tool-access.js.map +1 -1
  72. package/dist-server/tool-registry-boot-report.js +15 -9
  73. package/dist-server/tool-registry-boot-report.js.map +1 -1
  74. package/dist-server/tsconfig.tsbuildinfo +1 -1
  75. package/package.json +5 -5
  76. package/server/door-declaration-report.test.ts +53 -0
  77. package/server/door-declaration-report.ts +61 -0
  78. package/server/service/assistant-chat-resolver.ts +21 -0
  79. package/server/service/assistant-mode/assistant-mode-resolver.ts +6 -0
  80. package/server/service/assistant-mode/index.ts +1 -1
  81. package/server/service/assistant-mode/resolve.test.ts +42 -0
  82. package/server/service/assistant-mode/resolve.ts +9 -0
  83. package/server/service/assistant-mode/types.ts +18 -0
  84. package/server/service/chat-message/carried-turns.test.ts +13 -2
  85. package/server/service/chat-message/carried-turns.ts +16 -3
  86. package/server/service/chat-message/door-access.test.ts +72 -0
  87. package/server/service/chat-message/door-access.ts +43 -23
  88. package/server/service/chat-message/fold-history-window.test.ts +184 -0
  89. package/server/service/chat-message/reader-gate.ts +6 -4
  90. package/server/service/chat-session/chat-session.ts +15 -0
  91. package/server/service/chat-session/session-anchor.ts +12 -3
  92. package/server/service/chat-session/session-inbox.ts +2 -0
  93. package/server/service/chat-session/session-kind.test.ts +24 -0
  94. package/server/service/chat-session/session-kind.ts +32 -0
  95. package/server/service/chat-session/session-visibility.test.ts +16 -0
  96. package/server/service/chat-session/session-visibility.ts +9 -1
  97. package/server/service/chat-session-resolver.ts +4 -0
  98. package/server/service/index.ts +2 -0
  99. package/server/service/testing/conversation-scenario.test.ts +2 -1
  100. package/server/service/testing/conversation-script.test.ts +4 -2
  101. package/server/service/testing/conversation-script.ts +8 -0
  102. package/server/service/testing/mode-conformance.ts +15 -0
  103. package/server/service/tool-access.test.ts +67 -19
  104. package/server/service/tool-access.ts +24 -15
  105. package/server/tool-registry-boot-report.ts +14 -9
  106. package/translations/en.json +3 -2
  107. package/translations/ja.json +3 -2
  108. package/translations/ko.json +3 -2
  109. package/translations/ms.json +3 -2
  110. package/translations/zh.json +3 -2
@@ -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
+ })
@@ -5,8 +5,7 @@
5
5
  * 세션이 private 인지도 여기서 함께 답한다. 리졸버마다 같은 판정을 다시 적으면 한 곳이 빠진 날
6
6
  * 그 문으로 샌다.
7
7
  */
8
- import { getAllToolSpecs } from '@things-factory/ai-client-base'
9
- import { User } from '@things-factory/auth-base'
8
+ import { checkPermission, privilegeOfField } from '@things-factory/auth-base'
10
9
 
11
10
  import { doorCheck } from './door-access.js'
12
11
  import { doorGate, redactByDoors, type DoorReadableRow } from './message-doors.js'
@@ -17,10 +16,13 @@ export function readerGate(context: any): <T extends DoorReadableRow>(rows: read
17
16
  const { domain, user } = context.state
18
17
  const t = requestTranslator(context)
19
18
 
19
+ const { unsafeIP, prohibitedPrivileges } = context.state
20
+
20
21
  const gate = doorGate(
22
+ /* 문에서 쓰는 판정을 그대로 쓴다 — 같은 인자, 같은 함수(ADR-0072 결정 5). */
21
23
  doorCheck({
22
- specs: getAllToolSpecs(),
23
- hasPrivilege: privilege => User.hasPrivilege(privilege.privilege, privilege.category, domain, user)
24
+ lookup: privilegeOfField,
25
+ permits: fieldGate => checkPermission(fieldGate as any, user, domain, unsafeIP, prohibitedPrivileges)
24
26
  })
25
27
  )
26
28
 
@@ -125,6 +125,21 @@ export class ChatSession {
125
125
  @Field({ nullable: true, description: 'Timestamp of the last message in this session (denormalized for list ordering and unread comparison — avoids per-session aggregation across the supported DB drivers).' })
126
126
  lastMessageAt?: Date
127
127
 
128
+ /**
129
+ * 어느 대화인가 — `twin.board` · `figure.catalog` 같은 모드의 kind (ADR-0072 결정 3).
130
+ *
131
+ * **서버가 적는다.** 클라이언트가 보내는 값이 아니다 — 등록된 모드에서 나온 것만 적힌다(`addressee`
132
+ * 와 같은 결). 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 세션이 자기가 어느 대화인지 알아야
133
+ * 한다. 몰랐을 때 화면에서 이렇게 나왔다: 모드를 바꿔도 같은 세션이 그대로 남아, 「도형 찾기」로
134
+ * 바꾼 뒤 보낸 말이 앞의 고치기 대화에 붙었다(2026-09-18, Figure 레인 :3300).
135
+ *
136
+ * 값이 없는 세션은 이 칸이 서기 전에 난 것이다. 다음 턴에 요청한 kind 로 한 번 찍고, 찍힌 뒤에는
137
+ * 「요청한 모드와 다르면 거절」 하나만 남는다. 마이그레이션은 만들지 않는다.
138
+ */
139
+ @Column({ type: 'varchar', length: 64, nullable: true })
140
+ @Field({ nullable: true, description: "Which conversation this session is — the registered mode kind. Written by the server, never sent by the client." })
141
+ kind?: string
142
+
128
143
  /**
129
144
  * 이 대화를 도메인 안 누구나 읽는가 — ADR-0073 판정 2.
130
145
  *
@@ -101,8 +101,17 @@ export function anchorColumns(
101
101
  * 여기서도 앵커 이름을 검사한다 — 읽기 경로에서 틀린 이름은 오류가 아니라 **0 행**으로 나오고,
102
102
  * 화면은 「대화가 없다」로 그린다. 그 증상으로는 원인을 못 찾는다.
103
103
  */
104
- export function anchorWhere(domainId: string, anchorType: string, anchorId: string): any {
104
+ /**
105
+ * 이 앵커의 세션을 찾는 조건 — `kind` 를 주면 **그 대화의** 세션만 본다(ADR-0072 결정 3).
106
+ *
107
+ * 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 앵커만으로 찾으면 저작 대화를 찾는 자리에서 운영
108
+ * 대화가 잡힌다. figure 에서 그것이 화면으로 나왔다 — 모드를 바꿨는데 같은 세션이 그대로 남아 새 말이
109
+ * 앞 대화에 붙었다(2026-09-18, Figure 레인 :3300).
110
+ *
111
+ * `kind` 를 안 주면 앵커의 대화를 모두 본다 — 목록처럼 대화를 가리지 않고 훑는 자리다.
112
+ */
113
+ export function anchorWhere(domainId: string, anchorType: string, anchorId: string, kind?: string): any {
105
114
  const type = assertAnchorType(anchorType, 'anchorWhere')
106
- if (type === 'board') return { boardId: anchorId, domain: { id: domainId } }
107
- return { anchorType: type, anchorId, domain: { id: domainId } }
115
+ const where = type === 'board' ? { boardId: anchorId, domain: { id: domainId } } : { anchorType: type, anchorId, domain: { id: domainId } }
116
+ return kind ? { ...where, kind } : where
108
117
  }
@@ -19,6 +19,8 @@
19
19
  export interface InboxSessionRow {
20
20
  id?: string
21
21
  name?: string
22
+ /** 어느 대화인가 — 목록이 대화를 가리지 않으므로, 고른 뒤 화면이 따라갈 수 있게 함께 낸다. */
23
+ kind?: string
22
24
  anchorType?: string
23
25
  anchorId?: string
24
26
  boardId?: string
@@ -0,0 +1,24 @@
1
+ /*
2
+ * ADR-0072 결정 3 — 세션이 자기 대화를 알고, 다른 대화의 말을 받지 않는다.
3
+ */
4
+ import { judgeSessionKind } from './session-kind'
5
+
6
+ describe('session kind', () => {
7
+ it('같은 대화면 그대로 간다', () => {
8
+ expect(judgeSessionKind('figure.modeller', 'figure.modeller')).toEqual({ action: 'proceed' })
9
+ })
10
+
11
+ it('★ 칸이 서기 전 세션은 요청한 대화로 한 번 찍는다 — 마이그레이션을 만들지 않는다', () => {
12
+ expect(judgeSessionKind(null, 'figure.catalog')).toEqual({ action: 'stamp', kind: 'figure.catalog' })
13
+ expect(judgeSessionKind('', 'figure.catalog')).toEqual({ action: 'stamp', kind: 'figure.catalog' })
14
+ expect(judgeSessionKind(' ', 'figure.catalog')).toEqual({ action: 'stamp', kind: 'figure.catalog' })
15
+ })
16
+
17
+ it('★ 다른 대화의 세션은 거절한다 — 조용히 섞으면 그 이력이 남의 프롬프트로 들어간다', () => {
18
+ expect(judgeSessionKind('figure.modeller', 'figure.catalog')).toEqual({
19
+ action: 'refuse',
20
+ errorCode: 'session-kind-mismatch',
21
+ params: { sessionKind: 'figure.modeller', requested: 'figure.catalog' }
22
+ })
23
+ })
24
+ })
@@ -0,0 +1,32 @@
1
+ /*
2
+ * 이 세션이 어느 대화인가 — ADR-0072 결정 3.
3
+ *
4
+ * 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 세션이 자기가 어느 대화인지 알아야 한다. 모르면 모드를
5
+ * 바꿔도 같은 세션이 그대로 남고, 새 말이 앞 대화에 붙는다 — 화면에서는 줄이 사라져 새 대화처럼 보이니
6
+ * 보낸 사람은 그것을 알 수 없다. 새로 고쳐야 드러난다(2026-09-18, Figure 레인 :3300).
7
+ *
8
+ * 답은 셋이다.
9
+ * `proceed` 세션의 대화와 요청한 대화가 같다.
10
+ * `stamp` 칸이 서기 전에 난 세션이다 — 요청한 대화로 한 번 찍고 진행한다. 찍힌 뒤에는 아래 하나만
11
+ * 남는다. 마이그레이션은 만들지 않는다.
12
+ * `refuse` 다른 대화의 세션이다. **조용히 섞지 않는다** — 섞으면 그 대화의 이력이 다른 대화의
13
+ * 프롬프트로 들어가고, 읽는 사람은 자기가 무엇에 답하고 있는지 모른다.
14
+ */
15
+
16
+ export type SessionKindVerdict =
17
+ | { action: 'proceed' }
18
+ | { action: 'stamp'; kind: string }
19
+ | { action: 'refuse'; errorCode: 'session-kind-mismatch'; params: { sessionKind: string; requested: string } }
20
+
21
+ export function judgeSessionKind(sessionKind: string | null | undefined, requested: string): SessionKindVerdict {
22
+ const stored = (sessionKind ?? '').trim()
23
+
24
+ if (!stored) return { action: 'stamp', kind: requested }
25
+ if (stored === requested) return { action: 'proceed' }
26
+
27
+ return {
28
+ action: 'refuse',
29
+ errorCode: 'session-kind-mismatch',
30
+ params: { sessionKind: stored, requested }
31
+ }
32
+ }
@@ -34,3 +34,19 @@ describe('session visibility', () => {
34
34
  expect(mayReadSession(undefined, false)).toBe(true)
35
35
  })
36
36
  })
37
+
38
+ /*
39
+ * ADR-0073 결정 2 — 대상 없는 개인 질문은 공개 방으로 태어나지 않는다.
40
+ *
41
+ * `'none'` 은 저장되는 앵커 종류 목록에 든 글자다(대상이 없다는 것도 행에 적어야 하므로). 「값이 있으면
42
+ * 앵커가 있다」로 읽으면 그 글자가 앵커 노릇을 하고, `twin.none` 대화가 도메인 안 누구에게나 열린다.
43
+ */
44
+ describe("'none' is not an anchor", () => {
45
+ it('★ 대상 없는 대화는 비공개로 시작한다 — 앵커 종류 목록에 그 글자가 있어도', () => {
46
+ expect(initialVisibility({ anchorType: 'none' })).toBe('private')
47
+ })
48
+
49
+ it('대상이 있는 대화는 그대로 공개다', () => {
50
+ expect(initialVisibility({ anchorType: 'space' })).toBe('open')
51
+ })
52
+ })
@@ -17,7 +17,15 @@ export const SESSION_VISIBILITIES: readonly SessionVisibility[] = ['open', 'priv
17
17
  */
18
18
  export function initialVisibility(session: { anchorType?: string | null; copyOf?: string | null }): SessionVisibility {
19
19
  if (session.copyOf) return 'private'
20
- return session.anchorType ? 'open' : 'private'
20
+
21
+ /*
22
+ * `'none'` 은 **앵커가 아니다.** 저장되는 앵커 종류 목록에 그 글자가 들어 있어서(`session-anchor.ts`
23
+ * — 대상이 없다는 것도 행에 적어야 하므로), 「값이 있으면 앵커가 있다」로 읽으면 대상 없는 개인 질문이
24
+ * 공개 방으로 태어난다. ADR-0073 결정 2 가 그것을 `private` 로 못박았다 — 「앵커 없는 세션(`twin.none`
25
+ * 같은 개인 질문)」.
26
+ */
27
+ const anchored = !!session.anchorType && session.anchorType !== 'none'
28
+ return anchored ? 'open' : 'private'
21
29
  }
22
30
 
23
31
  /** 이 사람이 이 대화를 읽을 수 있나. open 이면 도메인 안 누구나, private 이면 참여자만. */
@@ -55,6 +55,10 @@ class ChatSessionInboxEntry {
55
55
  @Field({ nullable: true, description: 'User-given session name (tab label).' })
56
56
  name?: string
57
57
 
58
+ /** 어느 대화인가 — 목록에서 고른 것이 지금 열린 대화와 다르면 화면이 그 대화로 따라간다. */
59
+ @Field({ nullable: true, description: 'Which conversation this session is — the registered mode kind.' })
60
+ kind?: string
61
+
58
62
  @Field({ nullable: true, description: "What the session is anchored to: 'board' | 'space' | 'instance' | 'none'." })
59
63
  anchorType?: string
60
64
 
@@ -24,6 +24,8 @@ export * from './grounding-sources.js'
24
24
  export * from './subscribe-auth.js'
25
25
  /* 앵커 순수 로직 — 호스트가 앵커 종류를 참조한다(리졸버는 데코레이터 때문에 무겁다). */
26
26
  export * from './chat-session/session-anchor.js'
27
+ /* Who may read a conversation — one rule, also asked by doors outside this package (board-ai's revertPatch). */
28
+ export * from './chat-session/session-visibility.js'
27
29
  export * from './chat-session/index.js'
28
30
  /* 순수 로직들 — 호스트(board-ai 등)가 같은 규칙을 쓰라고 공개한다. 베끼면 한쪽만 고쳐진다. */
29
31
  export * from './chat-session/session-inbox.js'
@@ -22,10 +22,11 @@ beforeEach(() => {
22
22
  name: 'twin-ops',
23
23
  guidance: 'call summarizeStatus before describing the line',
24
24
  specs: [
25
- { kind: 'read', name: 'summarizeStatus', description: 'read the line', schema: {}, builder: async () => ({ line: 3, stopped: true }) },
25
+ { kind: 'read', name: 'summarizeStatus', doors: ['Query.twinReplay'], description: 'read the line', schema: {}, builder: async () => ({ line: 3, stopped: true }) },
26
26
  {
27
27
  kind: 'read',
28
28
  name: 'proposeAction',
29
+ doors: ['Query.twinReplay'],
29
30
  description: 'propose stopping the line',
30
31
  schema: {},
31
32
  builder: async () => ({ proposed: true, command: 'stop', instanceId: 'LINE-3', label: '3 라인 정지' })
@@ -16,8 +16,8 @@ beforeEach(() => {
16
16
  name: 'twin-ops',
17
17
  guidance: 'call summarizeStatus before describing the line',
18
18
  specs: [
19
- { kind: 'read', name: 'summarizeStatus', description: 'read the line', schema: {}, builder: async () => ({ stopped: true }) },
20
- { kind: 'write', name: 'stopLine', description: 'stop the line', schema: {}, builder: async () => ({ ok: true }) }
19
+ { kind: 'read', name: 'summarizeStatus', doors: ['Query.twinReplay'], description: 'read the line', schema: {}, builder: async () => ({ stopped: true }) },
20
+ { kind: 'write', name: 'stopLine', doors: ['Mutation.twinCommand'], description: 'stop the line', schema: {}, builder: async () => ({ ok: true }) }
21
21
  ]
22
22
  } as any)
23
23
  })
@@ -101,6 +101,8 @@ it('★ a proposal belongs to the turn that asked for it', async () => {
101
101
  {
102
102
  kind: 'read',
103
103
  name: 'proposeSceneEdit',
104
+ /* 저작 도구는 열린 씬만 본다 — 저장소에 닿지 않으므로 문이 없다. */
105
+ doors: [],
104
106
  description: 'propose placing a component',
105
107
  schema: {},
106
108
  builder: async () => ({ proposed: true, kind: 'scene-edit', label: 'place EQ-1', ops: [] })
@@ -131,6 +131,13 @@ export interface ConversationOptions {
131
131
  systemPrompt?: string
132
132
  /** Which registered categories this conversation may use. Omitted allows none. */
133
133
  toolCategories?: string[]
134
+ /**
135
+ * Which doors this script's speaker may pass (ADR-0071 decision 2). A tool is offered when the
136
+ * caller passes every door it names, so a script that says nothing here is written as someone who
137
+ * passes them all — the permission axis belongs to `tool-access`, not to a conversation script.
138
+ * Name the doors to write the other case: a person the tool is not offered to.
139
+ */
140
+ toolAccess?: (door: string) => Promise<boolean>
134
141
  /** Request state the tools receive (domain, user). */
135
142
  state?: any
136
143
  /** What the surface is looking at — reaches tools as `ctx.host`, never the model. */
@@ -263,6 +270,7 @@ export function conversation(options: ConversationOptions = {}) {
263
270
  messages: buildLlmHistory(rows, {}) as any,
264
271
  systemPrompt: options.systemPrompt ?? 'you are a test assistant',
265
272
  toolCategories: options.toolCategories,
273
+ toolAccess: options.toolAccess ?? (async () => true),
266
274
  hostContext: options.hostContext,
267
275
  state: options.state ?? { domain: { id: 'dom-1' }, user: { id: 'u-1' } },
268
276
  requireToolOnFirstTurn: options.requireToolOnFirstTurn
@@ -66,6 +66,10 @@ export async function runRegisteredMode(
66
66
 
67
67
  export interface ModeWording {
68
68
  placeholder?: string
69
+ /** 빈 대화의 제목과 한 줄 — 없으면 공용 대화창이 보드 제품의 글을 낸다. */
70
+ intro?: { title: string; subtitle: string }
71
+ /** 빈 대화의 예시 — 이 대화가 실제로 할 수 있는 말. */
72
+ examples?: Array<{ label: string; items: string[] }>
69
73
  slash: { name: string; description: string; template: string }[]
70
74
  footerNotice?: string
71
75
  stagedNotice?: string
@@ -76,6 +80,8 @@ export function modeWording(kind: string, dictionary: Record<string, string>): M
76
80
  const view = assistantModeView(kind, key => dictionary[key] ?? key)
77
81
  return {
78
82
  placeholder: view.placeholder,
83
+ intro: view.intro,
84
+ examples: view.examples,
79
85
  slash: view.slashTemplates,
80
86
  footerNotice: view.footerNotice,
81
87
  stagedNotice: view.stagedNotice
@@ -91,6 +97,15 @@ export function missingWording(kind: string, dictionary: Record<string, string>)
91
97
  for (const key of [spec.placeholderKey, spec.footerNoticeKey, spec.stagedNoticeKey]) {
92
98
  if (key && !(key in dictionary)) missing.push(key)
93
99
  }
100
+ /* 빈 대화의 말도 사전에 있어야 한다 — 없으면 화면에 키가 그대로 찍힌다. */
101
+ for (const key of [spec.introKeys?.title, spec.introKeys?.subtitle]) {
102
+ if (key && !(key in dictionary)) missing.push(key)
103
+ }
104
+ for (const group of spec.exampleKeys ?? []) {
105
+ for (const key of [group.labelKey, ...group.itemKeys]) {
106
+ if (!(key in dictionary)) missing.push(key)
107
+ }
108
+ }
94
109
  for (const slash of spec.slashTemplates ?? []) {
95
110
  for (const key of [slash.descriptionKey, slash.templateKey]) {
96
111
  if (!(key in dictionary)) missing.push(key)
@@ -1,48 +1,96 @@
1
1
  /*
2
2
  * What this guards: a registered tool is asked the same question its GraphQL door asks.
3
3
  *
4
- * The tool declares `{ category, privilege }`; `@privilege` on the door runs `checkPermission`
4
+ * The tool names the door; AUTH's field table says what that door declares; `checkPermission` runs
5
5
  * with the request's user, domain, address safety and prohibited list. If this passed fewer of
6
6
  * those — say, dropped `unsafeIP` — the tool would be a softer way into the same door.
7
+ *
8
+ * `checkPermission` itself is fixed by auth-base's own tests (`permission-gate.test.ts`), including
9
+ * the two cases below: a domain owner passes a role-less door from a safe address, and from an
10
+ * unsafe one ownership counts for nothing and the role decides. What this file fixes is that the
11
+ * tool gate hands that function everything it needs to answer either way.
7
12
  */
8
13
  const mockCheckPermission = jest.fn()
9
- jest.mock('@things-factory/auth-base', () => ({ checkPermission: (...a: any[]) => mockCheckPermission(...a) }))
14
+ const mockPrivilegeOfField = jest.fn()
15
+ jest.mock('@things-factory/auth-base', () => ({
16
+ checkPermission: (...a: any[]) => mockCheckPermission(...a),
17
+ privilegeOfField: (...a: any[]) => mockPrivilegeOfField(...a)
18
+ }))
10
19
 
11
20
  import { toolAccessFor } from './tool-access'
12
21
 
13
22
  const state = {
14
23
  domain: { id: 'dom-1' },
15
24
  user: { id: 'u-1' },
16
- unsafeIP: true,
25
+ unsafeIP: false,
17
26
  prohibitedPrivileges: [{ category: 'board-import', privilege: 'mutation' }]
18
27
  }
19
28
 
20
- beforeEach(() => mockCheckPermission.mockReset())
29
+ /** What plant's five doors declare — `@privilege(category, privilege, domainOwnerGranted: true)`. */
30
+ const OWNER_GRANTED = { category: 'job-order', privilege: 'query', owner: true }
31
+
32
+ beforeEach(() => {
33
+ mockCheckPermission.mockReset()
34
+ mockPrivilegeOfField.mockReset()
35
+ mockPrivilegeOfField.mockReturnValue({ known: true, gate: OWNER_GRANTED })
36
+ })
21
37
 
22
- it('★ asks checkPermission with the declared privilege and everything the door would pass', async () => {
38
+ it('★ asks checkPermission with the door’s own declaration and everything the door would pass', async () => {
23
39
  mockCheckPermission.mockResolvedValue(true)
24
40
 
25
- const allowed = await toolAccessFor({ state })({ category: 'board-import', privilege: 'mutation', owner: true })
41
+ const allowed = await toolAccessFor({ state })('Query.jobOrders')
26
42
 
27
43
  expect(allowed).toBe(true)
28
- expect(mockCheckPermission).toHaveBeenCalledWith(
29
- { category: 'board-import', privilege: 'mutation', owner: true, super: undefined },
30
- state.user,
31
- state.domain,
32
- true,
33
- state.prohibitedPrivileges
34
- )
44
+ expect(mockPrivilegeOfField).toHaveBeenCalledWith('Query', 'jobOrders')
45
+ expect(mockCheckPermission).toHaveBeenCalledWith(OWNER_GRANTED, state.user, state.domain, false, state.prohibitedPrivileges)
46
+ })
47
+
48
+ /*
49
+ * Plant's five doors are all `domainOwnerGranted: true`, so moving the gate onto the doors opens
50
+ * those five tools to a domain owner holding no privilege row. That is the right answer — the owner
51
+ * already sees the same numbers on the screen — and it is right only because the address travels
52
+ * with it. From an unsafe address ownership counts for nothing and the role decides.
53
+ */
54
+ it('★ the owner-granted door gets the address too, so the answer can differ by where the caller sits', async () => {
55
+ mockCheckPermission.mockResolvedValue(true)
56
+ await toolAccessFor({ state })('Query.jobOrders')
57
+ expect(mockCheckPermission).toHaveBeenLastCalledWith(OWNER_GRANTED, state.user, state.domain, false, state.prohibitedPrivileges)
58
+
59
+ /* The same owner, the same door, an unsafe address: auth is asked again with that fact. */
60
+ mockCheckPermission.mockResolvedValue(false)
61
+ const fromUnsafe = await toolAccessFor({ state: { ...state, unsafeIP: true } })('Query.jobOrders')
62
+
63
+ expect(fromUnsafe).toBe(false)
64
+ expect(mockCheckPermission).toHaveBeenLastCalledWith(OWNER_GRANTED, state.user, state.domain, true, state.prohibitedPrivileges)
35
65
  })
36
66
 
37
67
  it('a refusal is a refusal', async () => {
38
68
  mockCheckPermission.mockResolvedValue(false)
39
- expect(await toolAccessFor({ state })({ category: 'board-import', privilege: 'query' })).toBe(false)
69
+ expect(await toolAccessFor({ state })('Query.jobOrders')).toBe(false)
70
+ })
71
+
72
+ it('★ a door with no declaration of its own opens as far as that door opens — auth is not asked', async () => {
73
+ mockPrivilegeOfField.mockReturnValue({ known: true, gate: null })
74
+
75
+ expect(await toolAccessFor({ state })('Query.twinReplay')).toBe(true)
76
+ expect(mockCheckPermission).not.toHaveBeenCalled()
77
+ })
78
+
79
+ it('★ a door the schema does not have is closed, not open — a typo hides a tool, it does not free it', async () => {
80
+ mockPrivilegeOfField.mockReturnValue({ known: false })
81
+
82
+ expect(await toolAccessFor({ state })('Query.twinRepaly')).toBe(false)
83
+ expect(mockCheckPermission).not.toHaveBeenCalled()
84
+ })
85
+
86
+ it('a door name that is not a root field passes nobody', async () => {
87
+ expect(await toolAccessFor({ state })('jobOrders')).toBe(false)
88
+ expect(mockPrivilegeOfField).not.toHaveBeenCalled()
40
89
  })
41
90
 
42
- it('★ a request with no user or no domain holds no privilege, and auth is not asked', async () => {
43
- const privilege = { category: 'board-import', privilege: 'query' }
44
- expect(await toolAccessFor({ state: { domain: state.domain } })(privilege)).toBe(false)
45
- expect(await toolAccessFor({ state: { user: state.user } })(privilege)).toBe(false)
46
- expect(await toolAccessFor(undefined)(privilege)).toBe(false)
91
+ it('★ a request with no user or no domain passes no door, and auth is not asked', async () => {
92
+ expect(await toolAccessFor({ state: { domain: state.domain } })('Query.jobOrders')).toBe(false)
93
+ expect(await toolAccessFor({ state: { user: state.user } })('Query.jobOrders')).toBe(false)
94
+ expect(await toolAccessFor(undefined)('Query.jobOrders')).toBe(false)
47
95
  expect(mockCheckPermission).not.toHaveBeenCalled()
48
96
  })
@@ -2,26 +2,35 @@
2
2
  * Who may use a registered tool — the request's answer to `ToolAccessCheck`.
3
3
  *
4
4
  * `ai-client-base` decides *which* tools a conversation may use but does not know auth, so the
5
- * conversation doors hand it this. A tool declares the same `{ category, privilege }` its GraphQL
6
- * door uses (A1, 2026-09-17), and this asks exactly what `@privilege` on that door would ask: the
7
- * same `checkPermission`, with the request's user, domain, address safety and prohibited list.
8
- * Asking anything looser here would make the tool a second, softer way into that door.
5
+ * conversation doors hand it this. A tool names the GraphQL doors it reaches (ADR-0071 decision 2);
6
+ * this resolves each one against AUTH's field table (`privilegeOfField`, ADR-0072 decision 5) and
7
+ * asks `checkPermission` exactly what `@privilege` on that field asks — the same user, domain,
8
+ * address safety and prohibited list. Asking anything looser here would make the tool a second,
9
+ * softer way into that door.
10
+ *
11
+ * **The declaration is the door's, and it is read, not copied.** Until 2026-09-18 a tool carried its
12
+ * own `{ category, privilege }` and this asked that copy. A copy drifts from the door it mirrors and
13
+ * the looser of the two wins; the table removed the need for one.
14
+ *
15
+ * The three answers of the table are read exactly as the reader's gate reads them (`door-access.ts`)
16
+ * — an unknown field closes, a field with no declaration opens as far as that field opens.
9
17
  */
10
- import { checkPermission } from '@things-factory/auth-base'
11
- import type { ToolAccessCheck, ToolPrivilege } from '@things-factory/ai-client-base'
18
+ import { checkPermission, privilegeOfField } from '@things-factory/auth-base'
19
+ import type { ToolAccessCheck } from '@things-factory/ai-client-base'
20
+
21
+ import { doorCheck } from './chat-message/door-access.js'
12
22
 
13
23
  export function toolAccessFor(context: { state?: any } | undefined): ToolAccessCheck {
14
24
  const { domain, user, unsafeIP, prohibitedPrivileges } = context?.state ?? {}
15
25
 
16
- return async (privilege: ToolPrivilege) => {
17
- /* No caller, no answer — a request without a user or domain holds no privilege. */
26
+ const passes = doorCheck({
27
+ lookup: privilegeOfField,
28
+ permits: gate => checkPermission(gate as any, user, domain, unsafeIP, prohibitedPrivileges)
29
+ })
30
+
31
+ return async (door: string) => {
32
+ /* No caller, no answer — a request without a user or domain passes no door. */
18
33
  if (!user || !domain) return false
19
- return !!(await checkPermission(
20
- { category: privilege.category, privilege: privilege.privilege, owner: privilege.owner, super: privilege.super },
21
- user,
22
- domain,
23
- unsafeIP,
24
- prohibitedPrivileges
25
- ))
34
+ return await passes(door)
26
35
  }
27
36
  }