@things-factory/ai-assistant 10.1.207 → 10.1.210

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 (181) hide show
  1. package/client/components/assistant-chat.ts +238 -9
  2. package/client/components/assistant-dock.ts +9 -4
  3. package/client/components/assistant-invite.ts +138 -0
  4. package/client/components/assistant-session-manager.ts +53 -12
  5. package/client/components/proposal-request.test.ts +10 -0
  6. package/client/components/roster-identity.test.ts +2 -12
  7. package/client/components/roster-identity.ts +0 -6
  8. package/client/setup-assistant-dock.ts +1 -0
  9. package/client/utils/assistant-session-actions.ts +3 -1
  10. package/client/utils/assistant-session-admin.ts +9 -2
  11. package/client/utils/assistant-session-controller.ts +1 -1
  12. package/client/utils/assistant-typing.test.ts +33 -0
  13. package/client/utils/assistant-typing.ts +53 -0
  14. package/dist-client/components/assistant-chat.d.ts +28 -0
  15. package/dist-client/components/assistant-chat.js +259 -8
  16. package/dist-client/components/assistant-chat.js.map +1 -1
  17. package/dist-client/components/assistant-dock.js +12 -4
  18. package/dist-client/components/assistant-dock.js.map +1 -1
  19. package/dist-client/components/assistant-invite.d.ts +39 -0
  20. package/dist-client/components/assistant-invite.js +150 -0
  21. package/dist-client/components/assistant-invite.js.map +1 -0
  22. package/dist-client/components/assistant-session-manager.d.ts +5 -1
  23. package/dist-client/components/assistant-session-manager.js +40 -10
  24. package/dist-client/components/assistant-session-manager.js.map +1 -1
  25. package/dist-client/components/proposal-request.test.js +8 -0
  26. package/dist-client/components/proposal-request.test.js.map +1 -1
  27. package/dist-client/components/roster-identity.d.ts +0 -2
  28. package/dist-client/components/roster-identity.js +0 -7
  29. package/dist-client/components/roster-identity.js.map +1 -1
  30. package/dist-client/components/roster-identity.test.js +2 -13
  31. package/dist-client/components/roster-identity.test.js.map +1 -1
  32. package/dist-client/setup-assistant-dock.js +1 -0
  33. package/dist-client/setup-assistant-dock.js.map +1 -1
  34. package/dist-client/tsconfig.tsbuildinfo +1 -1
  35. package/dist-client/utils/assistant-session-actions.d.ts +1 -0
  36. package/dist-client/utils/assistant-session-actions.js +3 -1
  37. package/dist-client/utils/assistant-session-actions.js.map +1 -1
  38. package/dist-client/utils/assistant-session-admin.d.ts +5 -2
  39. package/dist-client/utils/assistant-session-admin.js +3 -0
  40. package/dist-client/utils/assistant-session-admin.js.map +1 -1
  41. package/dist-client/utils/assistant-session-controller.d.ts +1 -1
  42. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  43. package/dist-client/utils/assistant-typing.d.ts +20 -0
  44. package/dist-client/utils/assistant-typing.js +48 -0
  45. package/dist-client/utils/assistant-typing.js.map +1 -0
  46. package/dist-client/utils/assistant-typing.test.d.ts +1 -0
  47. package/dist-client/utils/assistant-typing.test.js +30 -0
  48. package/dist-client/utils/assistant-typing.test.js.map +1 -0
  49. package/dist-server/service/assistant-chat-resolver.js +5 -12
  50. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  51. package/dist-server/service/assistant-mode/index.d.ts +1 -1
  52. package/dist-server/service/assistant-mode/index.js +4 -1
  53. package/dist-server/service/assistant-mode/index.js.map +1 -1
  54. package/dist-server/service/assistant-mode/mode-categories.js +9 -2
  55. package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
  56. package/dist-server/service/assistant-mode/screen-rules.d.ts +19 -0
  57. package/dist-server/service/assistant-mode/screen-rules.js +40 -6
  58. package/dist-server/service/assistant-mode/screen-rules.js.map +1 -1
  59. package/dist-server/service/chat-message/chat-message-room.js +3 -1
  60. package/dist-server/service/chat-message/chat-message-room.js.map +1 -1
  61. package/dist-server/service/chat-message/chat-typing-subscription.d.ts +10 -0
  62. package/dist-server/service/chat-message/chat-typing-subscription.js +68 -0
  63. package/dist-server/service/chat-message/chat-typing-subscription.js.map +1 -0
  64. package/dist-server/service/chat-message/chat-typing.d.ts +28 -0
  65. package/dist-server/service/chat-message/chat-typing.js +40 -0
  66. package/dist-server/service/chat-message/chat-typing.js.map +1 -0
  67. package/dist-server/service/chat-session/attachment-tool-result.d.ts +1 -0
  68. package/dist-server/service/chat-session/attachment-tool-result.js +6 -0
  69. package/dist-server/service/chat-session/attachment-tool-result.js.map +1 -1
  70. package/dist-server/service/chat-session/attachment-tools.js +2 -2
  71. package/dist-server/service/chat-session/attachment-tools.js.map +1 -1
  72. package/dist-server/service/chat-session/chat-session.d.ts +2 -1
  73. package/dist-server/service/chat-session/chat-session.js +1 -1
  74. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  75. package/dist-server/service/chat-session/session-activity-broadcast.d.ts +12 -0
  76. package/dist-server/service/chat-session/session-activity-broadcast.js +15 -0
  77. package/dist-server/service/chat-session/session-activity-broadcast.js.map +1 -1
  78. package/dist-server/service/chat-session/session-activity-publish.d.ts +2 -0
  79. package/dist-server/service/chat-session/session-activity-publish.js +5 -2
  80. package/dist-server/service/chat-session/session-activity-publish.js.map +1 -1
  81. package/dist-server/service/chat-session/session-visibility.d.ts +17 -2
  82. package/dist-server/service/chat-session/session-visibility.js +28 -3
  83. package/dist-server/service/chat-session/session-visibility.js.map +1 -1
  84. package/dist-server/service/chat-session/session-write-gate.d.ts +1 -1
  85. package/dist-server/service/chat-session/session-write-gate.js +1 -1
  86. package/dist-server/service/chat-session/session-write-gate.js.map +1 -1
  87. package/dist-server/service/chat-session-participant/index.d.ts +1 -0
  88. package/dist-server/service/chat-session-participant/index.js +4 -1
  89. package/dist-server/service/chat-session-participant/index.js.map +1 -1
  90. package/dist-server/service/chat-session-participant/invite-notification.d.ts +16 -1
  91. package/dist-server/service/chat-session-participant/invite-notification.js +34 -5
  92. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -1
  93. package/dist-server/service/chat-session-participant/mention-invite.d.ts +30 -4
  94. package/dist-server/service/chat-session-participant/mention-invite.js +78 -31
  95. package/dist-server/service/chat-session-participant/mention-invite.js.map +1 -1
  96. package/dist-server/service/chat-session-participant/read-roster.js +5 -2
  97. package/dist-server/service/chat-session-participant/read-roster.js.map +1 -1
  98. package/dist-server/service/chat-session-resolver.d.ts +27 -0
  99. package/dist-server/service/chat-session-resolver.js +226 -25
  100. package/dist-server/service/chat-session-resolver.js.map +1 -1
  101. package/dist-server/service/data-entry/entry-target.d.ts +19 -0
  102. package/dist-server/service/data-entry/entry-target.js +20 -5
  103. package/dist-server/service/data-entry/entry-target.js.map +1 -1
  104. package/dist-server/service/data-entry/entry-tools.js +2 -2
  105. package/dist-server/service/data-entry/entry-tools.js.map +1 -1
  106. package/dist-server/service/data-entry/entry-update.d.ts +2 -1
  107. package/dist-server/service/data-entry/entry-update.js +5 -3
  108. package/dist-server/service/data-entry/entry-update.js.map +1 -1
  109. package/dist-server/service/data-read/list-read.d.ts +10 -0
  110. package/dist-server/service/data-read/list-read.js +12 -0
  111. package/dist-server/service/data-read/list-read.js.map +1 -1
  112. package/dist-server/service/data-read/read-target.d.ts +7 -0
  113. package/dist-server/service/data-read/read-target.js +2 -2
  114. package/dist-server/service/data-read/read-target.js.map +1 -1
  115. package/dist-server/service/data-read/read-tools.js +1 -1
  116. package/dist-server/service/data-read/read-tools.js.map +1 -1
  117. package/dist-server/service/index.d.ts +4 -2
  118. package/dist-server/service/index.js +9 -1
  119. package/dist-server/service/index.js.map +1 -1
  120. package/dist-server/service/testing/conversation-script.d.ts +1 -1
  121. package/dist-server/service/testing/conversation-script.js.map +1 -1
  122. package/dist-server/service/testing/mode-conformance.js +4 -0
  123. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  124. package/dist-server/tsconfig.tsbuildinfo +1 -1
  125. package/package.json +9 -9
  126. package/server/explicit-graphql-types.test.ts +1 -1
  127. package/server/service/assistant-chat-resolver.ts +7 -12
  128. package/server/service/assistant-mode/index.ts +1 -1
  129. package/server/service/assistant-mode/mode-categories.ts +11 -3
  130. package/server/service/assistant-mode/mode-front.test.ts +53 -1
  131. package/server/service/assistant-mode/resolve.test.ts +2 -1
  132. package/server/service/assistant-mode/screen-rules.test.ts +41 -3
  133. package/server/service/assistant-mode/screen-rules.ts +44 -5
  134. package/server/service/chat-message/chat-message-room.test.ts +7 -2
  135. package/server/service/chat-message/chat-message-room.ts +3 -1
  136. package/server/service/chat-message/chat-typing-subscription.ts +52 -0
  137. package/server/service/chat-message/chat-typing.test.ts +45 -0
  138. package/server/service/chat-message/chat-typing.ts +48 -0
  139. package/server/service/chat-message/fold-history-window.test.ts +1 -1
  140. package/server/service/chat-session/attachment-tool-result.test.ts +9 -1
  141. package/server/service/chat-session/attachment-tool-result.ts +7 -0
  142. package/server/service/chat-session/attachment-tools.ts +3 -3
  143. package/server/service/chat-session/chat-session.ts +3 -2
  144. package/server/service/chat-session/group-room.test.ts +233 -0
  145. package/server/service/chat-session/session-activity-broadcast.ts +20 -0
  146. package/server/service/chat-session/session-activity-publish.ts +6 -1
  147. package/server/service/chat-session/session-activity-receivers.test.ts +26 -0
  148. package/server/service/chat-session/session-visibility.test.ts +32 -1
  149. package/server/service/chat-session/session-visibility.ts +33 -4
  150. package/server/service/chat-session/session-write-gate.test.ts +7 -1
  151. package/server/service/chat-session/session-write-gate.ts +2 -2
  152. package/server/service/chat-session-participant/index.ts +2 -0
  153. package/server/service/chat-session-participant/invite-notification.test.ts +36 -1
  154. package/server/service/chat-session-participant/invite-notification.ts +43 -5
  155. package/server/service/chat-session-participant/mention-invite.test.ts +49 -1
  156. package/server/service/chat-session-participant/mention-invite.ts +82 -28
  157. package/server/service/chat-session-participant/read-roster.test.ts +13 -4
  158. package/server/service/chat-session-participant/read-roster.ts +6 -3
  159. package/server/service/chat-session-resolver.ts +204 -27
  160. package/server/service/data-entry/data-entry-update.test.ts +19 -0
  161. package/server/service/data-entry/data-entry.test.ts +65 -1
  162. package/server/service/data-entry/entry-target.ts +36 -5
  163. package/server/service/data-entry/entry-tools.ts +2 -2
  164. package/server/service/data-entry/entry-update.ts +6 -3
  165. package/server/service/data-read/list-missing.test.ts +49 -0
  166. package/server/service/data-read/list-read.ts +18 -0
  167. package/server/service/data-read/read-target.ts +10 -3
  168. package/server/service/data-read/read-tools.ts +1 -1
  169. package/server/service/data-read/record-keys.test.ts +36 -0
  170. package/server/service/index.ts +6 -1
  171. package/server/service/testing/conversation-script.ts +1 -1
  172. package/server/service/testing/mode-conformance.ts +2 -0
  173. package/test/assistant-dock.test.ts +5 -1
  174. package/test/assistant-session-manager.test.ts +4 -2
  175. package/test/open-doors.test.ts +10 -5
  176. package/translations/en.json +29 -4
  177. package/translations/es.json +29 -4
  178. package/translations/ja.json +29 -4
  179. package/translations/ko.json +30 -5
  180. package/translations/ms.json +29 -4
  181. package/translations/zh.json +29 -4
@@ -5,7 +5,7 @@
5
5
  * Until this moved out of the board product, `@name` in a twin deliberation added nobody at all,
6
6
  * and the person who typed it had every reason to think they had called a colleague in.
7
7
  */
8
- import { extractMentionUserIds, inviteMentionedUsers, newParticipantUserIds } from './mention-invite'
8
+ import { extractMentionUserIds, inviteMentionedUsers, newParticipantUserIds, seatTurn, seatWriter } from './mention-invite'
9
9
 
10
10
  const marker = (id: string) => `@someone{userId:${id}}`
11
11
 
@@ -123,3 +123,51 @@ describe('the pure parts', () => {
123
123
  expect(newParticipantUserIds(['a', 'b', 'me'], ['b'], 'me')).toEqual(['a'])
124
124
  })
125
125
  })
126
+
127
+ /*
128
+ * The writer's seat (2026-10-24): four people wrote in an open conversation and its roster showed two - speaking
129
+ * seated nobody, so the two who walked in had no chip, no presence and no unread badge.
130
+ */
131
+ describe('the person who spoke', () => {
132
+ const room = (rows: any[], visibility = 'open') => {
133
+ const p = participants(rows)
134
+ return { ...p, input: { sessionId: 's1', domainId: 'd1', inviter: { id: 'walker' }, participants: p.repo, visibility } }
135
+ }
136
+
137
+ it('★ someone who walks into an open room and speaks is seated as a member', async () => {
138
+ const { input, saved } = room([owner])
139
+ expect(await seatWriter(input)).toBe(true)
140
+ expect(saved).toEqual([expect.objectContaining({ user: { id: 'walker' }, role: 'member' })])
141
+ })
142
+
143
+ it('★ the turn seats the writer and the people mentioned together', async () => {
144
+ const { input, saved } = room([owner])
145
+ await seatTurn({ ...input, content: `ask ${marker('kim')}` })
146
+ expect(saved.map(row => row.user.id)).toEqual(['walker', 'kim'])
147
+ })
148
+
149
+ it('someone seated already is left as they are', async () => {
150
+ const { input, saved, updated } = room([owner, { id: 'p2', userId: 'walker', role: 'member' }])
151
+ expect(await seatWriter(input)).toBe(false)
152
+ expect(saved).toEqual([])
153
+ expect(updated).toEqual([])
154
+ })
155
+
156
+ it('who hid it or left it comes back on the same row', async () => {
157
+ const hid = room([owner, { id: 'p2', userId: 'walker', role: 'viewer' }])
158
+ await seatWriter(hid.input)
159
+ expect(hid.updated).toEqual([{ id: 'p2', patch: expect.objectContaining({ role: 'member', deletedAt: null }) }])
160
+ const left = room([owner, { id: 'p3', userId: 'walker', role: 'member', deletedAt: new Date() }])
161
+ await seatWriter(left.input)
162
+ expect(left.updated).toEqual([{ id: 'p3', patch: expect.objectContaining({ role: 'member', deletedAt: null }) }])
163
+ })
164
+
165
+ it('★ a group or private room is not seated by speaking - there the seat is the key', async () => {
166
+ for (const visibility of ['group', 'private']) {
167
+ const { input, saved, updated } = room([owner], visibility)
168
+ expect(await seatWriter(input)).toBe(false)
169
+ expect(saved).toEqual([])
170
+ expect(updated).toEqual([])
171
+ }
172
+ })
173
+ })
@@ -19,7 +19,13 @@
19
19
  * What went: the inviter's own role (there is nothing to hand out), the refusal verdicts, and the
20
20
  * notice shown before sending. A private conversation is the one place a mention cannot seat
21
21
  * someone — there the named person is told and stays outside until an owner opens it.
22
+ *
23
+ * ── A group (2026-10-24, ADR-0073 decision 3) ──────────────────────────────────
24
+ * In a group the seat is the key, so who seats is asked again: the owner's mention seats, a member's
25
+ * tells (`maySeat`). The invite door (`inviteToAISession`) seats through the same `seatParticipants`
26
+ * under the same rule - two ways in, one seat.
22
27
  */
28
+ import { isOpenRoom, maySeat } from '../chat-session/session-visibility.js'
23
29
 
24
30
  /** 본문에서 `@user{userId:UUID}` 마커의 userId 를 뽑는다 — 첫 등장 순, 중복 제거. */
25
31
  export function extractMentionUserIds(text: string | null | undefined): string[] {
@@ -59,7 +65,7 @@ export function newParticipantUserIds(
59
65
  export interface MentionInviteResult {
60
66
  /** 이번에 새로 앉은 사람들. 알림은 이들에게 간다. */
61
67
  invited: string[]
62
- /** 비공개 대화라 앉히지 못한 사람들 — 이름은 불렸고 방에는 못 들어온다. 화면이 그대로 말한다. */
68
+ /** 앉힐 수 없는 방이거나 부른 사람에게 그 자리가 없어 앉히지 못한 사람들 — 이름은 불렸고 방에는 못 들어온다. */
63
69
  notSeated?: string[]
64
70
  }
65
71
 
@@ -71,51 +77,99 @@ export interface MentionInviteInput {
71
77
  inviter: { id?: string } | undefined | null
72
78
  /** 참여자 저장소 — 시험이 가짜를 넣는다. */
73
79
  participants: any
74
- /** 이 대화가 공개인가(ADR-0073 판정 2). 비공개면 이름만 알리고 앉히지 않는다. */
80
+ /** 이 대화의 공개 범위(ADR-0073). 그룹은 owner 의 멘션만, 비공개는 아무의 멘션도 앉히지 않는다. */
75
81
  visibility?: string | null
76
82
  }
77
83
 
78
- /**
79
- * 멘션된 사람을 참여자로 앉힌다.
80
- *
81
- * 나갔던 사람(soft delete)은 되살린다 — 다시 부른 것이지 새 사람이 아니다.
82
- */
84
+ /** 멘션된 사람을 참여자로 앉힌다 — 앉힐 수 있는 방이고 부른 사람에게 그 자리가 있을 때만(`maySeat`). */
83
85
  export async function inviteMentionedUsers(input: MentionInviteInput): Promise<MentionInviteResult> {
84
86
  const mentioned = extractMentionUserIds(input.content)
85
87
  if (mentioned.length === 0) return { invited: [] }
88
+ return seatParticipants({ ...input, userIds: mentioned })
89
+ }
86
90
 
91
+ export interface SeatInput {
92
+ sessionId: string
93
+ domainId: string
94
+ inviter: { id?: string } | undefined | null
95
+ /** 앉힐 사람들. 이미 앉은 사람과 부른 사람 자신은 빠진다. */
96
+ userIds: ReadonlyArray<string>
97
+ participants: any
98
+ visibility?: string | null
99
+ }
100
+
101
+ /**
102
+ * 사람을 앉힌다 — 멘션과 초대 단추가 함께 쓰는 한 곳.
103
+ *
104
+ * 규칙은 `maySeat` 한 줄이다: 공개 방은 누구나, 그룹은 owner 만, 비공개는 아무도. 앉히지 못하면
105
+ * 아무것도 쓰지 않고 그 사람들을 `notSeated` 로 돌려준다 — 화면이 그대로 말한다.
106
+ * 나갔던 사람(soft delete)은 되살린다 — 다시 부른 것이지 새 사람이 아니다.
107
+ */
108
+ export async function seatParticipants(input: SeatInput): Promise<MentionInviteResult> {
87
109
  const repo = input.participants
88
110
  const existing = await repo.find({
89
111
  where: { session: { id: input.sessionId } as any, domain: { id: input.domainId } as any },
90
112
  withDeleted: true
91
113
  })
92
114
 
93
- const activeIds = existing.filter((row: any) => !row.deletedAt).map((row: any) => row.userId).filter(Boolean)
94
- const fresh = newParticipantUserIds(mentioned, activeIds, input.inviter?.id)
115
+ /* A hide-only `viewer` row is not a seat - the person is brought in over it, as a member. */
116
+ const seated = existing.filter((row: any) => !row.deletedAt && row.role !== 'viewer')
117
+ const activeIds = seated.map((row: any) => row.userId).filter(Boolean)
118
+ const fresh = newParticipantUserIds(input.userIds, activeIds, input.inviter?.id)
95
119
  if (fresh.length === 0) return { invited: [] }
96
120
 
97
121
  /*
98
- * 비공개 대화에서는 이름을 불러도 앉히지 않는다. 공개 대화에서는 앉히는 일이 아무것도 주지 않지만
99
- * (누구나 들어온다), 비공개에서는 그것이 곧 접근을 주는 일이다. 부르는 사람이 아니라 owner 가 열
100
- * 일이다 — 이름이 불렸다는 것만 알린다.
122
+ * 공개 방에서 앉히는 일은 아무것도 주지 않지만(누구나 들어온다), 그룹과 비공개에서는 그것이 곧 접근을
123
+ * 주는 일이다. 그룹에서는 owner 가 주고, 비공개에서는 아무도 주지 않는다 — 이름이 불렸다는 것만 남는다.
101
124
  */
102
- if ((input.visibility ?? 'open') !== 'open') return { invited: [], notSeated: fresh }
125
+ const inviterRole = seated.find((row: any) => row.userId === input.inviter?.id)?.role
126
+ if (!maySeat(input.visibility, inviterRole)) return { invited: [], notSeated: fresh }
103
127
 
104
- for (const userId of fresh) {
105
- const prior = existing.find((row: any) => row.userId === userId && row.deletedAt)
106
- if (prior) {
107
- await repo.update(prior.id, { deletedAt: null, role: 'member', updater: input.inviter } as any)
108
- } else {
109
- await repo.save({
110
- domain: { id: input.domainId } as any,
111
- session: { id: input.sessionId } as any,
112
- user: { id: userId } as any,
113
- role: 'member',
114
- creator: input.inviter,
115
- updater: input.inviter
116
- } as any)
117
- }
118
- }
128
+ for (const userId of fresh) await writeMemberSeat(repo, existing, input, userId)
119
129
 
120
130
  return { invited: fresh }
121
131
  }
132
+
133
+ /**
134
+ * The seats a person's turn gives - what every door that stores a person's words calls.
135
+ *
136
+ * The writer first: in an open room anyone may walk in and speak, and until 2026-10-24 speaking seated nobody. A
137
+ * conversation four people wrote in showed two in its roster - the one who started it and the one who was
138
+ * mentioned - and the other two had no presence, no unread badge and no activity notice. In a group or private
139
+ * room the writer is seated already (the write gate let them in by their seat). Then the people mentioned.
140
+ */
141
+ export async function seatTurn(input: MentionInviteInput): Promise<MentionInviteResult> {
142
+ await seatWriter(input)
143
+ return inviteMentionedUsers(input)
144
+ }
145
+
146
+ /** Seat the person who spoke, in an open room only - there the seat grants nothing, it records who took part. */
147
+ export async function seatWriter(input: Omit<SeatInput, 'userIds'>): Promise<boolean> {
148
+ const writerId = input.inviter?.id
149
+ if (!writerId || !isOpenRoom(input.visibility)) return false
150
+ const repo = input.participants
151
+ const existing = await repo.find({
152
+ where: { session: { id: input.sessionId } as any, domain: { id: input.domainId } as any },
153
+ withDeleted: true
154
+ })
155
+ if (existing.some((row: any) => row.userId === writerId && !row.deletedAt && row.role !== 'viewer')) return false
156
+ await writeMemberSeat(repo, existing, input, writerId)
157
+ return true
158
+ }
159
+
160
+ /** One member seat: someone who left (soft delete) or only hid it (`viewer`) is brought back on that row, never a second one. */
161
+ async function writeMemberSeat(repo: any, existing: any[], input: { sessionId: string; domainId: string; inviter: { id?: string } | undefined | null }, userId: string) {
162
+ const prior = existing.find((row: any) => row.userId === userId && (row.deletedAt || row.role === 'viewer'))
163
+ if (prior) {
164
+ await repo.update(prior.id, { deletedAt: null, role: 'member', updater: input.inviter } as any)
165
+ } else {
166
+ await repo.save({
167
+ domain: { id: input.domainId } as any,
168
+ session: { id: input.sessionId } as any,
169
+ user: { id: userId } as any,
170
+ role: 'member',
171
+ creator: input.inviter,
172
+ updater: input.inviter
173
+ } as any)
174
+ }
175
+ }
@@ -2,17 +2,26 @@ import { readRoster } from './read-roster'
2
2
 
3
3
  describe('participant roster identities', () => {
4
4
  it('loads the participating user relation for public conversations', async () => {
5
- const find = jest.fn(async options => options.relations.includes('user') ? [{id:'p',user:{id:'u',name:'김희태'}}] : [{id:'p'}])
5
+ const find = jest.fn(async options => options.relations.includes('user') ? [{id:'p',role:'owner',user:{id:'u',name:'김희태'}}] : [{id:'p',role:'owner'}])
6
6
  const isParticipant=jest.fn(async()=>false)
7
7
  const rows=await readRoster({id:'room',domainId:'domain',visibility:'open'},{find,isParticipant})
8
- expect(rows).toEqual([{id:'p',user:{id:'u',name:'김희태'}}])
8
+ expect(rows).toEqual([{id:'p',role:'owner',user:{id:'u',name:'김희태'}}])
9
9
  expect(find).toHaveBeenCalledWith(expect.objectContaining({where:{session:{id:'room'},domain:{id:'domain'}},relations:['user']}))
10
10
  expect(isParticipant).not.toHaveBeenCalled()
11
11
  })
12
12
  it('does not expose identities from a private room to an outsider', async () => {
13
- const find=jest.fn(async()=>[{id:'p',user:{name:'Private'}}])
13
+ const find=jest.fn(async()=>[{id:'p',role:'owner',user:{name:'Private'}}])
14
14
  expect(await readRoster({id:'r',domainId:'d',visibility:'private'},{find,isParticipant:async()=>false})).toEqual([])
15
15
  expect(find).not.toHaveBeenCalled()
16
- expect(await readRoster({id:'r',domainId:'d',visibility:'private'},{find,isParticipant:async()=>true})).toEqual([{id:'p',user:{name:'Private'}}])
16
+ expect(await readRoster({id:'r',domainId:'d',visibility:'private'},{find,isParticipant:async()=>true})).toEqual([{id:'p',role:'owner',user:{name:'Private'}}])
17
+ })
18
+ it('★ a group room is closed to an outsider too - it is not a public room', async () => {
19
+ const find=jest.fn(async()=>[{id:'p',role:'owner',user:{name:'Group'}}])
20
+ expect(await readRoster({id:'r',domainId:'d',visibility:'group'},{find,isParticipant:async()=>false})).toEqual([])
21
+ expect(find).not.toHaveBeenCalled()
22
+ })
23
+ it('lists the seats - a hide-only viewer row is not someone in the room', async () => {
24
+ const find=jest.fn(async()=>[{id:'p',role:'owner'},{id:'q',role:'member'},{id:'v',role:'viewer'}])
25
+ expect((await readRoster({id:'r',domainId:'d',visibility:'open'},{find,isParticipant:async()=>false})).map((row:any)=>row.id)).toEqual(['p','q'])
17
26
  })
18
27
  })
@@ -1,14 +1,17 @@
1
- import { mayReadSession } from '../chat-session/session-visibility.js'
1
+ import { isOpenRoom, mayReadSession } from '../chat-session/session-visibility.js'
2
+ import { isRoomRole } from './participant-lookup-roles.js'
2
3
 
3
4
  /** Read the room roster with user identities hydrated, after applying the room's visibility gate. */
4
5
  export async function readRoster<T>(
5
6
  room: { id: string; domainId: string; visibility?: string },
6
7
  port: { isParticipant(): Promise<boolean>; find(options: any): Promise<T[]> }
7
8
  ): Promise<T[]> {
8
- if (!mayReadSession(room.visibility, room.visibility === 'private' ? await port.isParticipant() : false)) return []
9
- return port.find({
9
+ if (!mayReadSession(room.visibility, isOpenRoom(room.visibility) ? false : await port.isParticipant())) return []
10
+ /* A hide-only `viewer` row is not someone in the room - the roster lists the seats. */
11
+ const rows = await port.find({
10
12
  where: { session: { id: room.id }, domain: { id: room.domainId } },
11
13
  relations: ['user'],
12
14
  order: { createdAt: 'ASC' }
13
15
  })
16
+ return rows.filter((row: any) => isRoomRole(row?.role))
14
17
  }
@@ -9,7 +9,7 @@ import { GraphQLJSON } from 'graphql-scalars'
9
9
  import { User } from '@things-factory/auth-base'
10
10
  import { buildDomainUsersQueryBuilder } from '@things-factory/auth-base/dist-server/utils/get-domain-users.js'
11
11
 
12
- import { getRepository } from '@things-factory/shell'
12
+ import { getRepository, pubsub } from '@things-factory/shell'
13
13
  import { In, Like } from 'typeorm'
14
14
 
15
15
  import { ChatSession } from './chat-session/chat-session.js'
@@ -18,28 +18,29 @@ import { buildInbox, applyHidden, activityPatch, searchInbox, filterInbox, searc
18
18
  import { carriedOriginalWhere, planCarriedTurns, quoteCarriedAnswer } from './chat-message/carried-turns.js'
19
19
  import { roomMessageBody } from './chat-message/room-message.js'
20
20
  import { readerGate } from './chat-message/reader-gate.js'
21
- import { initialVisibility, mayReadSession, SESSION_VISIBILITIES } from './chat-session/session-visibility.js'
22
- import { inviteMentionedUsers } from './chat-session-participant/mention-invite.js'
23
- import { buildInviteNotification } from './chat-session-participant/invite-notification.js'
21
+ import { requestTranslator } from './request-translator.js'
22
+ import { initialVisibility, isOpenRoom, mayReadSession, maySeat, SESSION_VISIBILITIES } from './chat-session/session-visibility.js'
23
+ import { seatParticipants, seatTurn } from './chat-session-participant/mention-invite.js'
24
+ import { CHAT_TYPING_TOPIC, typingNotice } from './chat-message/chat-typing.js'
25
+ import { sendInviteNotification } from './chat-session-participant/invite-notification.js'
24
26
  import { readRoster } from './chat-session-participant/read-roster.js'
25
27
  import { isSessionParticipant } from './chat-session-participant/participant-lookup.js'
26
28
  import { assertMayWriteSession } from './chat-session/session-write-gate.js'
27
29
 
30
+ /** 초대 알림 — 발송은 호스트의 기전을 그대로 쓴다(`sendInviteNotification`, 문 셋이 같은 한 곳). */
31
+ async function notifyInvited(context: any, invited: string[], inviter: any, sessionName?: string): Promise<void> {
32
+ await sendInviteNotification(context.state?.notify, invited, inviter, sessionName, undefined, requestTranslator(context))
33
+ }
34
+
28
35
  /**
29
- * 초대 알림 — 발송은 호스트의 기전을 그대로 쓴다(중복 구현 금지).
30
- *
31
- * 알림이 안 가도 초대 자체는 이미 일어났다. 그래서 실패를 삼키되, 초대를 되돌리지는 않는다 —
32
- * 참여자 목록이 정본이고 알림은 그것을 알리는 편지다.
36
+ * A private room is the owner's alone (ADR-0073 decision 3) - closing a room to private sends its members out.
37
+ * Returns who left, so their lists can be told once.
33
38
  */
34
- async function notifyInvited(context: any, invited: string[], inviter: any, sessionName?: string): Promise<void> {
35
- if (invited.length === 0) return
36
- const payload = buildInviteNotification(invited, undefined, inviter, sessionName)
37
- if (!payload) return
38
- try {
39
- await context.state?.notify?.(payload)
40
- } catch {
41
- /* 편지가 못 갔다고 방에 들어온 사실이 취소되지는 않는다. */
42
- }
39
+ async function sendOutMembers(domainId: string, sessionId: string, tx?: any): Promise<string[]> {
40
+ const repo = getRepository(ChatSessionParticipant, tx)
41
+ const seats = await repo.find({ where: { domain: { id: domainId }, session: { id: sessionId }, role: 'member' } as any })
42
+ for (const seat of seats) await repo.softDelete(seat.id!)
43
+ return seats.map(seat => (seat as any).userId).filter(Boolean)
43
44
  }
44
45
 
45
46
  /** Every door that writes into a conversation passes this first (`session-write-gate.ts`). */
@@ -123,7 +124,7 @@ class ChatSessionInboxEntry {
123
124
  hidden?: boolean
124
125
 
125
126
  /* 목록이 공개 범위를 그리고, owner 만 그것을 바꾸는 단추를 받는다 — 판정은 setSessionVisibility 가 다시 한다. */
126
- @Field({ description: "Who may read the session: 'open' (everyone in the domain) or 'private' (participants only)." })
127
+ @Field({ description: "Who may read the session: 'open' (everyone in the domain), 'group' (the owner and the people brought in) or 'private' (the owner alone)." })
127
128
  visibility!: string
128
129
 
129
130
  @Field({ description: 'Whether this viewer is the owner of the session - only the owner may change its visibility.' })
@@ -176,8 +177,15 @@ export class ChatSessionResolver {
176
177
  @Arg('id') id: string,
177
178
  @Ctx() context: ResolverContext
178
179
  ): Promise<ChatSession | null> {
179
- const { domain } = context.state
180
- return (await getRepository(ChatSession).findOneBy({ id, domain: { id: domain.id } })) ?? null
180
+ const { domain, user } = context.state
181
+ const session = await getRepository(ChatSession).findOneBy({ id, domain: { id: domain.id } })
182
+ if (!session) return null
183
+ /*
184
+ * The row carries the name, the last preview and the summary of older messages - the conversation in
185
+ * short. A room the caller cannot enter answers as a missing one (ADR-0073: every reading path passes the gate).
186
+ */
187
+ if (!isOpenRoom((session as any).visibility) && !mayReadSession((session as any).visibility, await isSessionParticipant(domain.id, id, user?.id))) return null
188
+ return session
181
189
  }
182
190
 
183
191
  @Query(() => [ChatSession], {
@@ -453,7 +461,7 @@ export class ChatSessionResolver {
453
461
  */
454
462
  @Mutation(() => Boolean, {
455
463
  description:
456
- "Open this conversation to everyone in the domain, or close it to its participants. Only the owner may change it. Opening exposes what is already written — the screen says so before asking. Door checks on assistant answers are unaffected (ADR-0073)."
464
+ "Set who reads this conversation: 'open' (everyone in the domain), 'group' (the owner and the people the owner brings in) or 'private' (the owner alone - the members are sent out). Only the owner may change it. Opening exposes what is already written — the screen says so before asking. Door checks on assistant answers are unaffected (ADR-0073)."
457
465
  })
458
466
  @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
459
467
  async setSessionVisibility(
@@ -476,7 +484,68 @@ export class ChatSessionResolver {
476
484
 
477
485
  if ((session as any).visibility === visibility) return true
478
486
 
487
+ /*
488
+ * Private is the owner's alone (ADR-0073 decision 3). The members leave with the change - a private room
489
+ * with people still seated in it would be a group under another name. Bringing them back is inviting
490
+ * them again, in a group.
491
+ */
492
+ const sentOut = visibility === 'private' ? await sendOutMembers(domain.id, sessionId) : []
493
+
479
494
  await getRepository(ChatSession).update(sessionId, { visibility, updater: user } as any)
495
+ publishSessionActivity({
496
+ domainId: domain.id,
497
+ sessionId,
498
+ kind: 'meta',
499
+ anchorType: (session as any).anchorType,
500
+ anchorId: (session as any).anchorId,
501
+ actorId: user?.id,
502
+ alsoTo: sentOut
503
+ })
504
+ return true
505
+ }
506
+
507
+ /**
508
+ * Bring people into this conversation - the same seat a mention gives, by the same rule (`maySeat`): anyone
509
+ * in an open room, the owner in a group, nobody in a private one. Each person seated is told
510
+ * (`notifyInvited`). People outside the domain are not seated.
511
+ */
512
+ @Mutation(() => [String], {
513
+ description:
514
+ "Bring people into this conversation and tell them. In a group only the owner may; a private conversation takes nobody (make it a group first). Returns the ids of the people seated now - people already in it are left out."
515
+ })
516
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
517
+ async inviteToAISession(
518
+ @Arg('sessionId') sessionId: string,
519
+ @Arg('userIds', () => [String]) userIds: string[],
520
+ @Ctx() context: ResolverContext
521
+ ): Promise<string[]> {
522
+ const { domain, user } = context.state
523
+ const session = await getRepository(ChatSession).findOneBy({ id: sessionId, domain: { id: domain.id } as any })
524
+ if (!session) throw new Error('chat session not found')
525
+
526
+ /* Only people of this domain - an id from elsewhere is not a colleague here. */
527
+ const wanted = [...new Set((userIds ?? []).filter(id => typeof id === 'string' && id))].slice(0, 50)
528
+ if (wanted.length === 0) return []
529
+ const members = await buildDomainUsersQueryBuilder(domain.id, 'USER').andWhere('USER.id IN (:...ids)', { ids: wanted }).getMany()
530
+ const known = new Set(members.map(member => member.id))
531
+
532
+ const seat = await seatParticipants({
533
+ sessionId,
534
+ domainId: domain.id,
535
+ inviter: user,
536
+ userIds: wanted.filter(id => known.has(id)),
537
+ participants: getRepository(ChatSessionParticipant),
538
+ visibility: (session as any).visibility
539
+ })
540
+ if (seat.notSeated?.length) {
541
+ throw new Error(
542
+ (session as any).visibility === 'private'
543
+ ? requestTranslator(context)('ai-assistant.text.invite-private-refused', { defaultValue: 'a private conversation is yours alone. make it a group to bring people in.' })
544
+ : requestTranslator(context)('ai-assistant.text.invite-owner-only', { defaultValue: 'only the person who started this group conversation can bring people in.' })
545
+ )
546
+ }
547
+
548
+ await notifyInvited(context, seat.invited, user, (session as any).name)
480
549
  publishSessionActivity({
481
550
  domainId: domain.id,
482
551
  sessionId,
@@ -484,7 +553,92 @@ export class ChatSessionResolver {
484
553
  anchorType: (session as any).anchorType,
485
554
  anchorId: (session as any).anchorId,
486
555
  actorId: user?.id
487
- } as any)
556
+ })
557
+ return seat.invited
558
+ }
559
+
560
+ /**
561
+ * Send a member out - the owner only, and never the owner themself. In a group the seat was the key, so the
562
+ * person no longer reads or writes here (every gate asks the seat). Their list is told once and drops it.
563
+ */
564
+ @Mutation(() => Boolean, {
565
+ description: 'Send a member out of this conversation. Only the owner may, and the owner cannot be sent out. In a group the person no longer reads or writes here.'
566
+ })
567
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
568
+ async removeFromAISession(
569
+ @Arg('sessionId') sessionId: string,
570
+ @Arg('userId') userId: string,
571
+ @Ctx() context: ResolverContext
572
+ ): Promise<boolean> {
573
+ const { domain, user } = context.state
574
+ const repo = getRepository(ChatSessionParticipant)
575
+ const seats = await repo.find({ where: { domain: { id: domain.id } as any, session: { id: sessionId } as any } as any })
576
+ const mine = seats.find(seat => (seat as any).userId === user?.id)
577
+ if (mine?.role !== 'owner') {
578
+ throw new Error(requestTranslator(context)('ai-assistant.text.remove-owner-only', { defaultValue: 'only the person who started this conversation can send someone out.' }))
579
+ }
580
+ const theirs = seats.find(seat => (seat as any).userId === userId && seat.role === 'member')
581
+ if (!theirs) return false
582
+ return await this._leave(sessionId, theirs, context)
583
+ }
584
+
585
+ /**
586
+ * Leave this conversation - a member steps out of their own seat. The owner does not leave: the room would be
587
+ * nobody's. They delete it, or make it private.
588
+ */
589
+ @Mutation(() => Boolean, {
590
+ description: 'Leave this conversation. A member may; the owner cannot (delete it or make it private instead). In a group, leaving ends reading and writing here.'
591
+ })
592
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
593
+ async leaveAISession(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
594
+ const { domain, user } = context.state
595
+ if (!user?.id) return false
596
+ const mine = await getRepository(ChatSessionParticipant).findOne({
597
+ where: { domain: { id: domain.id } as any, session: { id: sessionId } as any, user: { id: user.id } as any }
598
+ })
599
+ if (mine?.role === 'owner') {
600
+ throw new Error(requestTranslator(context)('ai-assistant.text.owner-cannot-leave', { defaultValue: 'you started this conversation, so you cannot leave it. delete it, or make it private.' }))
601
+ }
602
+ if (mine?.role !== 'member') return false
603
+ return await this._leave(sessionId, mine, context)
604
+ }
605
+
606
+ private async _leave(sessionId: string, seat: ChatSessionParticipant, context: ResolverContext): Promise<boolean> {
607
+ const { domain, user } = context.state
608
+ const session = await getRepository(ChatSession).findOneBy({ id: sessionId, domain: { id: domain.id } as any })
609
+ await getRepository(ChatSessionParticipant).softDelete(seat.id!)
610
+ publishSessionActivity({
611
+ domainId: domain.id,
612
+ sessionId,
613
+ kind: 'meta',
614
+ anchorType: (session as any)?.anchorType,
615
+ anchorId: (session as any)?.anchorId,
616
+ actorId: user?.id,
617
+ alsoTo: [(seat as any).userId].filter(Boolean)
618
+ })
619
+ return true
620
+ }
621
+
622
+ /**
623
+ * "I am typing" - a notice to the others in the room, stored nowhere. Only a person who may write here is
624
+ * typing here (the write gate), and the listeners pass the room's read gate (`ChatTypingSubscription`).
625
+ */
626
+ @Mutation(() => Boolean, {
627
+ description: 'Tell the others in this conversation that the caller is typing. Nothing is stored; send it every few seconds while typing. Only a person who may write here may send it.'
628
+ })
629
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
630
+ async sayTyping(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
631
+ const { domain, user } = context.state
632
+ const session = await getRepository(ChatSession).findOneBy({ id: sessionId, domain: { id: domain.id } as any })
633
+ if (!session) return false
634
+ await writeGate(session, context)
635
+ const notice = typingNotice({ domainId: domain.id, sessionId, user })
636
+ if (!notice) return false
637
+ try {
638
+ pubsub.publish(CHAT_TYPING_TOPIC, notice)
639
+ } catch {
640
+ /* a missed notice is a line that does not show - nothing else rests on it */
641
+ }
488
642
  return true
489
643
  }
490
644
 
@@ -583,6 +737,24 @@ export class ChatSessionResolver {
583
737
  })
584
738
  }
585
739
 
740
+ /**
741
+ * May the caller seat someone here - the server's answer to the screen, so the screen keeps no copy of the
742
+ * rule (`maySeat`). The invite button and the line under a mention read it; the doors decide again.
743
+ */
744
+ @Query(() => Boolean, {
745
+ description: "Whether the caller may bring people into this conversation now - anyone in an open one, the owner of a group, nobody in a private one. A hint for the screen; the invite and the mention decide again."
746
+ })
747
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
748
+ async mayInviteToAISession(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
749
+ const { domain, user } = context.state
750
+ const session = await getRepository(ChatSession).findOneBy({ id: sessionId, domain: { id: domain.id } as any })
751
+ if (!session || !user?.id) return false
752
+ const seat = await getRepository(ChatSessionParticipant).findOne({
753
+ where: { domain: { id: domain.id } as any, session: { id: sessionId } as any, user: { id: user.id } as any }
754
+ })
755
+ return maySeat((session as any).visibility, seat?.role)
756
+ }
757
+
586
758
  @Mutation(() => Boolean, {
587
759
  description: 'Presence heartbeat — bump current user\'s lastSeenAt in the session (online indicator).'
588
760
  })
@@ -617,7 +789,7 @@ export class ChatSessionResolver {
617
789
  * 공개 대화는 도메인 안이면 누구나 읽는다 — 초대 절차가 없다(ADR-0073 판정 2). 비공개 대화는
618
790
  * 참여자만. 여기서 막히는 것은 **방**이고, 방에 들어온 뒤 값을 읽는 일은 아래 문 판정이 따로 본다.
619
791
  */
620
- if ((session as any).visibility === 'private') {
792
+ if (!isOpenRoom((session as any).visibility)) {
621
793
  /* A hide-only `viewer` row is not being in the room - it must not open a private conversation. */
622
794
  const part = await isSessionParticipant(domain.id, sessionId, user?.id)
623
795
  if (!mayReadSession((session as any).visibility, part)) return []
@@ -902,9 +1074,9 @@ export class ChatSessionResolver {
902
1074
 
903
1075
  /*
904
1076
  * 멘션은 사람을 불러들인다 — **사람의 말을 저장하는 문**만 이것을 한다(ADR-0071 결정 1 ①).
905
- * 초대는 방에 있는 사람(owner · member)만 할 수 있고, 거절되면 그 사실이 화면으로 돌아간다.
1077
+ * 누가 자리를 주는지는 `maySeat` 가 정한다. 공개 방에서는 말한 사람도 앉는다(`seatTurn`).
906
1078
  */
907
- const invite = await inviteMentionedUsers({
1079
+ const invite = await seatTurn({
908
1080
  content: body,
909
1081
  sessionId: session.id!,
910
1082
  domainId: domain.id,
@@ -913,7 +1085,7 @@ export class ChatSessionResolver {
913
1085
  /* 비공개 대화에서는 이름만 닿는다 — ADR-0073 판정 2. */
914
1086
  visibility: (session as any).visibility
915
1087
  })
916
- await notifyInvited(context, invite.invited, user)
1088
+ await notifyInvited(context, invite.invited, user, (session as any).name)
917
1089
 
918
1090
  /* 사람의 말이다 — 목록의 최근 활동과 다른 참여자의 안 읽음이 이것으로 움직인다. */
919
1091
  try {
@@ -1055,7 +1227,12 @@ export class ChatSessionResolver {
1055
1227
  saved.push(previous!)
1056
1228
  }
1057
1229
 
1058
- if (carried && (session as any).visibility !== 'private') {
1230
+ /*
1231
+ * Carried words do not land in front of the whole domain. An open room closes to its owner; a group stays
1232
+ * a group - its people were chosen by the owner, and carrying into it is writing to them.
1233
+ */
1234
+ if (carried && isOpenRoom((session as any).visibility)) {
1235
+ await sendOutMembers(domain.id, session.id!, tx)
1059
1236
  await getRepository(ChatSession, tx).update(session.id!, { visibility: 'private', updater: user } as any)
1060
1237
  }
1061
1238
 
@@ -74,6 +74,17 @@ describe('which record is meant', () => {
74
74
  expect(resolveChangeRefs('office.expense-line', [{ ref: 'current', values: {} }], { record: { target: 'office.contract', id: 'c1' } }).unresolved).toEqual(['current'])
75
75
  })
76
76
 
77
+ /* 2026-10-23: the open record is named to the model by its key; a change naming it so found nothing. */
78
+ it('★ the key a record was shown by names it - `<axis>:<id>`, the axis being the target key\'s last part', () => {
79
+ const id = 'f92c04ec-40c4-4be8-8160-080cff20e750'
80
+ const picked = (key: string, ref: string) => resolveChangeRefs(key, [{ ref, values: {} }], undefined).picked.map(one => one.id)
81
+ expect(picked('office.contract-round', `contract-round:${id}`)).toEqual([id])
82
+ expect(picked('office.contract-round', id)).toEqual([id])
83
+ /* Another record's key is not this record, whatever its uuid - it is looked up as written and found by nobody. */
84
+ expect(picked('office.contract-round', `contract:${id}`)).toEqual([`contract:${id}`])
85
+ expect(picked('office.contract', `contract-round:${id}`)).toEqual([`contract-round:${id}`])
86
+ })
87
+
77
88
  it('★ an id from the screen is only a candidate - a record the person cannot read is not found, and nothing is shown of it', async () => {
78
89
  const result: any = await proposeChanges(expense, { changes: [{ ref: 'current', values: { amount: 13000 } }] }, { host: { record: { id: 'someone-elses' } } }, run)
79
90
  expect(result.rows[0].issues).toEqual([{ code: 'record-not-found', blocking: true }])
@@ -202,6 +213,14 @@ describe('declaring and offering', () => {
202
213
  ])
203
214
  })
204
215
 
216
+ /* 2026-10-24: having read 568 bank lines, the model said nothing was selected and asked the person to pick rows. */
217
+ it('★ the change tool takes the ids a list read returned - nothing need be selected', () => {
218
+ const change = (entryCategoryOf(expense, () => run).specs as any[]).find(spec => spec.name === changeToolName('office.expense-line'))
219
+ expect(change.description).toMatch(/the id a list read or a find tool returned/)
220
+ expect(change.description).toMatch(/nothing need be selected to change rows a read found/)
221
+ expect(change.schema.properties.changes.items.properties.ref.description).toMatch(/a list read or a find tool/)
222
+ })
223
+
205
224
  it('stillAsProposed marks a record gone since as not found', () => {
206
225
  const rows = stillAsProposed(expense, [{ index: 0, id: 'gone', via: 'id', before: { amount: 1 }, values: { amount: 2 }, issues: [] }], new Map())
207
226
  expect(rows[0].issues).toEqual([{ code: 'record-not-found', blocking: true }])