@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.207",
3
+ "version": "10.1.210",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -30,18 +30,18 @@
30
30
  "@operato/graphql": "^10.29.39",
31
31
  "@operato/i18n": "^10.0.0",
32
32
  "@operato/input": "^10.29.39",
33
- "@operato/layout": "^10.29.39",
33
+ "@operato/layout": "^10.29.49",
34
34
  "@operato/popup": "^10.29.39",
35
35
  "@operato/shell": "^10.29.39",
36
36
  "@operato/styles": "^10.0.0",
37
37
  "@operato/utils": "^10.29.39",
38
- "@things-factory/ai-client-base": "^10.1.207",
39
- "@things-factory/apptool-base": "^10.1.206",
40
- "@things-factory/attachment-base": "^10.1.206",
41
- "@things-factory/auth-base": "^10.1.206",
38
+ "@things-factory/ai-client-base": "^10.1.208",
39
+ "@things-factory/apptool-base": "^10.1.208",
40
+ "@things-factory/attachment-base": "^10.1.208",
41
+ "@things-factory/auth-base": "^10.1.208",
42
42
  "@things-factory/env": "^10.1.149",
43
- "@things-factory/setting-base": "^10.1.206",
44
- "@things-factory/shell": "^10.1.206",
43
+ "@things-factory/setting-base": "^10.1.208",
44
+ "@things-factory/shell": "^10.1.208",
45
45
  "dompurify": "^3.0.0",
46
46
  "exceljs": "4.2.0",
47
47
  "graphql-scalars": "^1.22.0",
@@ -55,5 +55,5 @@
55
55
  "copyfiles": "^2.4.1",
56
56
  "rimraf": "^5.0.0"
57
57
  },
58
- "gitHead": "e6e3fbf5c353629adbeb8850093150b14254e059"
58
+ "gitHead": "89fbda12d43d8532ba4e95ba6d6d7e05e2da2502"
59
59
  }
@@ -8,7 +8,7 @@
8
8
  * 컴파일은 통과하므로 타입검사도 못 잡는다. plant 레인이 설치본을 올리다 잡아 알려 주었다(2026-09-18).
9
9
  *
10
10
  * ── 왜 스키마를 안 세우고 글자를 읽나 ─────────────────────────────────────
11
- * 이 꾸러미의 시험기는 babel 이라 데코레이터 metadata 가 없다. 스키마를 세우면 리플렉션이 없어 전부
11
+ * 이 패키지의 시험기는 babel 이라 데코레이터 metadata 가 없다. 스키마를 세우면 리플렉션이 없어 전부
12
12
  * 같은 오류가 나고, 그러면 이 시험은 아무것도 못 가른다. 대신 선언을 읽는다 — 데코레이터가 형을
13
13
  * 인자로 받고 있는지만 본다. 그것이 빠진 자리가 정확히 터지는 자리다.
14
14
  */
@@ -45,8 +45,8 @@ import { ChatSessionParticipant } from './chat-session-participant/chat-session-
45
45
  import { doorsForAnswer } from './chat-message/message-doors.js'
46
46
  import { judgeSessionKind } from './chat-session/session-kind.js'
47
47
  import { readerGate } from './chat-message/reader-gate.js'
48
- import { inviteMentionedUsers } from './chat-session-participant/mention-invite.js'
49
- import { buildInviteNotification } from './chat-session-participant/invite-notification.js'
48
+ import { seatTurn } from './chat-session-participant/mention-invite.js'
49
+ import { sendInviteNotification } from './chat-session-participant/invite-notification.js'
50
50
  import { isSessionParticipant } from './chat-session-participant/participant-lookup.js'
51
51
  import { assertMayWriteSession } from './chat-session/session-write-gate.js'
52
52
  import { attachmentHistoryLine, refuseAttachments, textCharsOf } from './chat-session/attachment-policy.js'
@@ -383,9 +383,10 @@ export class AssistantChatResolver {
383
383
 
384
384
  /*
385
385
  * 사람이 `@이름` 으로 부른 사람은 이 대화에 들어온다(ADR-0071 결정 1). 이 문은 **사람의 턴**을
386
- * 저장하는 문이라 그 일을 한다 — 모델의 답이나 도구 결과는 아무도 초대하지 않는다.
386
+ * 저장하는 문이라 그 일을 한다 — 모델의 답이나 도구 결과는 아무도 초대하지 않는다. 공개 방에서 말한 사람도
387
+ * 이때 자리에 앉는다(`seatTurn`) — 참여자 줄 · 접속 표시 · 안 읽음이 실제로 말한 사람과 맞도록.
387
388
  */
388
- const invite = await inviteMentionedUsers({
389
+ const invite = await seatTurn({
389
390
  content: lastUserMessage.content,
390
391
  sessionId: session.id!,
391
392
  domainId: domain.id,
@@ -393,14 +394,8 @@ export class AssistantChatResolver {
393
394
  participants: getRepository(ChatSessionParticipant),
394
395
  visibility: (session as any).visibility
395
396
  })
396
- if (invite.invited.length > 0) {
397
- const payload = buildInviteNotification(invite.invited, undefined, user, (session as any)?.name)
398
- try {
399
- if (payload) await (context.state as any)?.notify?.(payload)
400
- } catch {
401
- /* 편지가 못 갔다고 방에 들어온 사실이 취소되지는 않는다. */
402
- }
403
- }
397
+ /* 편지가 못 갔다고 방에 들어온 사실이 취소되지는 않는다(`sendInviteNotification`). */
398
+ await sendInviteNotification((context.state as any)?.notify, invite.invited, user, (session as any)?.name, undefined, requestTranslator(context))
404
399
 
405
400
  publishChatMessage({ domainId: domain.id, sessionId: session.id, message: saved } as any)
406
401
  publishSessionActivity({
@@ -12,6 +12,6 @@ export {
12
12
  clearAssistantModes
13
13
  } from './registry.js'
14
14
  export { resolveModeSettings, assistantModeView } from './resolve.js'
15
- export { ABSENCE_RULE, NEXT_REQUEST_RULE, conversationPrompt, splitNextRequest } from './screen-rules.js'
15
+ export { ABSENCE_RULE, FINISH_RULE, NEXT_REQUEST_RULE, SPEAKER_RULE, conversationPrompt, isPersonRequest, splitNextRequest } from './screen-rules.js'
16
16
  export type { AssistantModeSpec, AssistantModeSlash, AssistantModeWarning, UnattendedLimits } from './types.js'
17
17
  export type { AssistantModeRef, ModeSettings, AssistantModeView, AssistantModeSlashView, Translate } from './resolve.js'
@@ -1,5 +1,6 @@
1
1
  /* Read by the resolver and by the boot report, so the report and the conversation open the same tools. */
2
- import { readTargetOf } from '../data-read/read-target.js'
2
+ import { entryTargetOf } from '../data-entry/entry-target.js'
3
+ import { detailReadOf, readTargetOf } from '../data-read/read-target.js'
3
4
  /**
4
5
  * The categories a mode opens: its own, the AI 작업판 (design/ai/workspace.md), the attachment reader when it
5
6
  * takes files (design/ai/shared-dock.md §9-4), and one propose tool per record it may enter (design/ai/data-entry.md).
@@ -42,7 +43,14 @@ type FrontMode = { toolCategories?: string[]; attachments?: { kinds?: string[] }
42
43
  */
43
44
  export function screenTargetKeys(mode: FrontMode, screen: TurnScreen, routeOf: (key: string) => string | undefined = key => readTargetOf(key)?.screen?.route): string[] {
44
45
  const keys = [...new Set([...(mode.readTargets ?? []), ...(mode.entryTargets ?? [])])]
45
- return keys.filter(key => (!!screen.page && routeOf(key) === screen.page) || (!!screen.subjectAxis && key.split('.').pop() === screen.subjectAxis))
46
+ return keys.filter(key => (!!screen.page && routeOf(key) === screen.page) || (!!screen.subjectAxis && axesOfKey(mode, key).includes(screen.subjectAxis)))
47
+ }
48
+
49
+ /** The records a key is about: its last part, and the axes a card joins or a whole answer covers (EntryTarget.axes · DetailRead.axes). */
50
+ function axesOfKey(mode: FrontMode, key: string): string[] {
51
+ const card = (mode.entryTargets ?? []).includes(key) ? entryTargetOf(key)?.axes ?? [] : []
52
+ const answer = (mode.readTargets ?? []).includes(key) ? detailReadOf(key)?.axes ?? [] : []
53
+ return [key.split('.').pop()!, ...card, ...answer]
46
54
  }
47
55
 
48
56
  /**
@@ -66,6 +74,6 @@ export function modeFrontCategories(mode: FrontMode, screen: TurnScreen = {}): s
66
74
  export function modeScreens(mode: FrontMode, routeOf: (key: string) => string | undefined = key => readTargetOf(key)?.screen?.route): TurnScreen[] {
67
75
  const keys = [...new Set([...(mode.readTargets ?? []), ...(mode.entryTargets ?? [])])]
68
76
  const pages = [...new Set(keys.map(routeOf).filter(Boolean) as string[])]
69
- const axes = [...new Set(keys.map(key => key.split('.').pop()!))]
77
+ const axes = [...new Set(keys.flatMap(key => axesOfKey(mode, key)))]
70
78
  return [{}, ...pages.map(page => ({ page })), ...axes.map(subjectAxis => ({ subjectAxis }))]
71
79
  }
@@ -2,9 +2,10 @@
2
2
  * What a turn puts in front when its mode's tools do not fit in one call (ADR-0061 decision 9) - the open screen's
3
3
  * records, found from what is already declared: a list's screen route and a key's last part.
4
4
  */
5
- import { clearReadTargets, declareReadTarget } from '../data-read/read-target'
5
+ import { clearReadTargets, declareDetailRead, declareReadTarget } from '../data-read/read-target'
6
6
  import { listReadTarget } from '../data-read/list-read'
7
7
  import { modeFrontCategories, modeScreens, screenTargetKeys } from './mode-categories'
8
+ import { clearEntryTargets, declareEntryTarget } from '../data-entry/entry-target'
8
9
 
9
10
  const list = (key: string, route?: string) =>
10
11
  listReadTarget({ key, title: `title.${key}`, name: key, listQuery: 'rows', selection: 'id name', columns: [{ name: 'name', type: 'string' }], ...(route ? { route } : {}) } as any)
@@ -63,3 +64,54 @@ describe('the open screen decides what goes in front', () => {
63
64
  ])
64
65
  })
65
66
  })
67
+
68
+ /* 2026-10-24: asked to link a deposit from its 360, the model read the evidence and never opened the link card. */
69
+ describe('a card about several records is in front on each of their screens', () => {
70
+ const link = {
71
+ key: 'office.tax-invoice-binding',
72
+ labelKey: 'label.link',
73
+ description: 'a link of an invoice to a bank line or a round.',
74
+ mutation: 'mutation ($binding: Binding!) { bind(binding: $binding) { id } }',
75
+ variables: (row: any) => ({ binding: row.values }),
76
+ appliedOf: (data: any) => ({ id: data?.bind?.id }),
77
+ fields: [{ name: 'amount', type: 'number' as const, required: true }],
78
+ axes: ['cash-transaction', 'tax-invoice', 'contract-round']
79
+ }
80
+ const linking = { ...mode, entryTargets: [...mode.entryTargets, link.key] }
81
+ beforeEach(() => {
82
+ clearEntryTargets()
83
+ declareEntryTarget(link as any)
84
+ })
85
+
86
+ it('★ a deposit\'s 360 puts the link card in front with the deposit\'s own', () => {
87
+ expect(screenTargetKeys(linking, { subjectAxis: 'cash-transaction' })).toEqual(['office.cash-transaction', 'office.tax-invoice-binding'])
88
+ expect(modeFrontCategories(linking, { subjectAxis: 'cash-transaction' })).toContain('data-entry:office.tax-invoice-binding')
89
+ expect(screenTargetKeys(linking, { subjectAxis: 'contract-round' })).toEqual(['office.contract-round', 'office.tax-invoice-binding'])
90
+ })
91
+
92
+ it('★ not on a screen of a record it does not join, and not for a mode that does not enter it', () => {
93
+ expect(screenTargetKeys(linking, { subjectAxis: 'contract' })).toEqual(['office.contract'])
94
+ expect(screenTargetKeys(mode, { subjectAxis: 'cash-transaction' })).toEqual(['office.cash-transaction'])
95
+ })
96
+
97
+ it('the boot report walks the axes a card declares, so its screens are counted against the budget', () => {
98
+ expect(modeScreens(linking)).toContainEqual({ subjectAxis: 'tax-invoice' })
99
+ })
100
+ })
101
+
102
+ /* 2026-10-24: asked on a deposit's 360 why it was in the management check, the model never opened the check read. */
103
+ describe('a whole answer about any record is in front on their screens', () => {
104
+ const checks = { key: 'office.checks', description: 'the management check.', query: 'query { officeChecks { today } }', axes: ['cash-transaction', 'contract'] }
105
+ const reading = { ...mode, readTargets: [...mode.readTargets, checks.key] }
106
+ beforeEach(() => declareDetailRead(checks as any))
107
+
108
+ it('★ the check read is in front on a deposit\'s and a contract\'s 360', () => {
109
+ expect(modeFrontCategories(reading, { subjectAxis: 'cash-transaction' })).toContain('data-read:office.checks')
110
+ expect(modeFrontCategories(reading, { subjectAxis: 'contract' })).toContain('data-read:office.checks')
111
+ })
112
+
113
+ it('★ not on a record it does not cover, nor for a mode that does not read it', () => {
114
+ expect(modeFrontCategories(reading, { subjectAxis: 'expense-line' })).not.toContain('data-read:office.checks')
115
+ expect(modeFrontCategories(mode, { subjectAxis: 'cash-transaction' })).not.toContain('data-read:office.checks')
116
+ })
117
+ })
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { clearAssistantModes, registerAssistantMode } from './registry'
10
10
  import { assistantModeView, resolveModeSettings } from './resolve'
11
- import { ABSENCE_RULE, NEXT_REQUEST_RULE, conversationPrompt } from './screen-rules'
11
+ import { ABSENCE_RULE, FINISH_RULE, NEXT_REQUEST_RULE, conversationPrompt } from './screen-rules'
12
12
 
13
13
  /** Stands in for `context.t` — returns the key and params so the test can see what was translated. */
14
14
  const t = (key: string, params?: Record<string, any>) => `t(${key}${params?.count !== undefined ? `,${params.count}` : ''})`
@@ -48,6 +48,7 @@ it('★ every conversation carries the absence rule and the next-request rule on
48
48
  expect(systemPrompt.startsWith('you answer about one space')).toBe(true)
49
49
  expect(systemPrompt.split(ABSENCE_RULE)).toHaveLength(2)
50
50
  expect(systemPrompt.split(NEXT_REQUEST_RULE)).toHaveLength(2)
51
+ expect(systemPrompt.split(FINISH_RULE)).toHaveLength(2)
51
52
  })
52
53
 
53
54
  it('★ an unregistered kind is refused — no plain-conversation fallback', () => {
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * The rules every conversation follows (operato-application design/ai/screen-pattern.md §4).
3
3
  */
4
- import { ABSENCE_RULE, NEXT_REQUEST_RULE, conversationPrompt, splitNextRequest } from './screen-rules'
4
+ import { ABSENCE_RULE, FINISH_RULE, NEXT_REQUEST_RULE, SPEAKER_RULE, conversationPrompt, isPersonRequest, splitNextRequest } from './screen-rules'
5
5
 
6
6
  describe('the next request an answer names', () => {
7
7
  it('★ the last line is taken off the reply and becomes the suggestion', () => {
@@ -24,16 +24,54 @@ describe('the next request an answer names', () => {
24
24
  expect(splitNextRequest('It has 3 blades.')).toEqual({ reply: 'It has 3 blades.' })
25
25
  })
26
26
 
27
+ /* 2026-10-23, an office link: the input offered a sentence telling the person what to say, with the invoice's key in it. */
28
+ it('★ a line that tells the person what to say is taken off and offered as nothing', () => {
29
+ const answer = '계산서 연결 후보를 찾았습니다.'
30
+ const line = 'Next: "2026-01-20 계산서(tax-invoice:fe0e8503-ec5f-4da4-9c07-2d342c93d498)로 연결 제안 진행해줘”라고 해줘.'
31
+ expect(splitNextRequest(`${answer}\n${line}`)).toEqual({ reply: answer })
32
+ expect(splitNextRequest(`${answer}\nNext: "연결 진행해줘"라고 말해 주세요`)).toEqual({ reply: answer })
33
+ expect(splitNextRequest(`${answer}\nNext: 「연결해 줘」라고 해 줘`)).toEqual({ reply: answer })
34
+ })
35
+
36
+ it('★ a line that carries an id or a record key is no request - nobody types one', () => {
37
+ expect(splitNextRequest('Found it.\nNext: tax-invoice:fe0e8503-ec5f 로 연결해 줘')).toEqual({ reply: 'Found it.' })
38
+ expect(splitNextRequest('Found it.\nNext: fe0e8503-ec5f-4da4-9c07-2d342c93d498 계산서로 연결해 줘')).toEqual({ reply: 'Found it.' })
39
+ })
40
+
41
+ it('a request in the person\'s words is offered, apostrophes and numbers included', () => {
42
+ expect(isPersonRequest("Show me last month's deposits")).toBe(true)
43
+ expect(isPersonRequest('2026-01 승화푸드 입금 550,000원도 연결해 줘')).toBe(true)
44
+ expect(isPersonRequest('')).toBe(false)
45
+ })
46
+
27
47
  it('a reply that is only the line keeps it - an empty answer is worse than an odd one', () => {
28
48
  expect(splitNextRequest('Next: 날개를 늘려 줘')).toEqual({ reply: 'Next: 날개를 늘려 줘' })
29
49
  })
30
50
  })
31
51
 
32
52
  describe('the prompt every conversation gets', () => {
33
- it('★ the persona first, then the absence rule and the next-request rule', () => {
53
+ it('★ the persona first, then the absence rule, the finish rule and the next-request rule', () => {
34
54
  const prompt = conversationPrompt('You edit figures.')
35
55
  expect(prompt.indexOf('You edit figures.')).toBe(0)
36
56
  expect(prompt.indexOf(ABSENCE_RULE)).toBeGreaterThan(0)
37
- expect(prompt.indexOf(NEXT_REQUEST_RULE)).toBeGreaterThan(prompt.indexOf(ABSENCE_RULE))
57
+ expect(prompt.indexOf(FINISH_RULE)).toBeGreaterThan(prompt.indexOf(ABSENCE_RULE))
58
+ expect(prompt.indexOf(NEXT_REQUEST_RULE)).toBeGreaterThan(prompt.indexOf(FINISH_RULE))
59
+ })
60
+
61
+ /* What the two rules must keep saying - a softer wording let the model ask before a card and hand the person a script. */
62
+ it('★ a card is proposed in the turn, never asked about; the next request is the person\'s own words', () => {
63
+ expect(FINISH_RULE).toMatch(/in the same turn/)
64
+ expect(FINISH_RULE).toMatch(/never ask whether to propose it/)
65
+ expect(FINISH_RULE).toMatch(/never ask the person to confirm, repeat or resend/)
66
+ expect(FINISH_RULE).toMatch(/no internal ids, keys, field names or codes/)
67
+ expect(NEXT_REQUEST_RULE).toMatch(/no quotation marks/)
68
+ expect(NEXT_REQUEST_RULE).toMatch(/Never a yes to your own question/)
69
+ expect(NEXT_REQUEST_RULE).toMatch(/no ids or keys/)
70
+ /* 2026-10-24, gpt-5.4-nano answering in Korean wrote 「다음:」 - the line stayed in the answer. */
71
+ expect(NEXT_REQUEST_RULE).toMatch(/the word Next: in English whatever language you answer in/)
72
+ /* 2026-10-24: the asker's name in the message's [writer → addressee] bracket was searched as a counterparty. */
73
+ expect(conversationPrompt('x')).toContain(SPEAKER_RULE)
74
+ expect(SPEAKER_RULE).toMatch(/\[writer → addressee\]/)
75
+ expect(SPEAKER_RULE).toMatch(/Never search for, filter by or fill a field with the writer's name/)
38
76
  })
39
77
  })
@@ -13,18 +13,56 @@ export const ABSENCE_RULE =
13
13
  'When a tool says something is absent or refuses, state that and what the person can do. ' +
14
14
  'Do not guess why, and do not describe our machinery - words like passed, delivered or loaded name our plumbing, not their situation.'
15
15
 
16
+ /**
17
+ * §4-3 — a request is finished in the turn it is asked (2026-10-23, the user: 「한번에 끝낼 일을 왜 자꾸 확인하고, 프롬프트를
18
+ * 또 입력하게 만드는가」). Asked to link a deposit, the model found the invoice, then asked whether to propose the link
19
+ * and offered 「…진행해줘"라고 해줘」 as the next request - a second turn for what the first could do. A proposal card is
20
+ * the person's review; asking before it is asking twice.
21
+ */
22
+ export const FINISH_RULE =
23
+ 'Finish what was asked in this turn: read what it needs and, when it changes records, propose the card in the same turn - ' +
24
+ 'the card is the person\'s review, so never ask whether to propose it, and never ask the person to confirm, repeat or ' +
25
+ 'resend the request. Do not ask for an id, a number or a name your tools can read. Ask only when the records leave a ' +
26
+ 'choice that only the person can make, and then ask just that choice. Answer briefly: what was found and what the card ' +
27
+ 'does, in the person\'s words - no internal ids, keys, field names or codes.'
28
+
29
+ /**
30
+ * Who spoke, never what was asked (ADR-0070 A15 decision 2 labels every person's message `[writer → addressee]`, a
31
+ * conversation of one included). Asked 「거래처 미등록 입출금에 등록 거래처를 연결해 줘」, the model read the asker's name in
32
+ * that bracket as part of the request and searched the bank lines for it (2026-10-24).
33
+ */
34
+ export const SPEAKER_RULE =
35
+ "A person's message begins with [writer → addressee]: it says who wrote it and to whom, nothing more. Never search for, " +
36
+ 'filter by or fill a field with the writer\'s name unless the request itself names them.'
37
+
16
38
  /** The line a reply ends with when a next request is natural (§4-1). */
17
39
  const NEXT_PREFIX = 'Next:'
18
40
 
19
41
  /** §4-1 — the reply names the next request, and the input offers it. */
20
42
  export const NEXT_REQUEST_RULE =
21
- `After answering, when there is a natural next request, end the reply with one last line "${NEXT_PREFIX} <request>": ` +
22
- "the request the person is most likely to make next, written as they would type it, in the language they wrote in. " +
23
- 'Offer only what this conversation can do with its tools. When nothing natural follows, leave the line out.'
43
+ `After answering, when there is a natural next request, end the reply with one last line "${NEXT_PREFIX} <request>" - the word ${NEXT_PREFIX} in English whatever language you answer in: ` +
44
+ 'a new request the person is most likely to make next, exactly the words they would type, in the language they wrote in - ' +
45
+ 'no quotation marks, no "say ..." or "ask me ...", no ids or keys. Never a yes to your own question and never what this ' +
46
+ 'reply could have done - do that now instead. Offer only what this conversation can do with its tools. ' +
47
+ 'When nothing natural follows, leave the line out.'
24
48
 
25
49
  /** A mode's persona with the rules every conversation follows. */
26
50
  export function conversationPrompt(modePrompt: string): string {
27
- return [modePrompt.trim(), ABSENCE_RULE, NEXT_REQUEST_RULE].join('\n\n')
51
+ return [modePrompt.trim(), ABSENCE_RULE, FINISH_RULE, SPEAKER_RULE, NEXT_REQUEST_RULE].join('\n\n')
52
+ }
53
+
54
+ /** Quotation marks inside a request: it quotes another sentence, so it is about a request, not one. */
55
+ const QUOTED = /["“”‘「」『』`]/
56
+ /** An id or a record key - nothing a person types. */
57
+ const MACHINE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|\b[a-z]+(?:-[a-z]+)*:[0-9a-f-]{8,}/i
58
+
59
+ /**
60
+ * Whether the line names a request the person could send as it is. The input offers it to be sent with one key, so a
61
+ * line that tells the person what to say (「"…진행해줘"라고 해줘」) or carries an id is no request: offered, it is sent
62
+ * back to the model as the person's words.
63
+ */
64
+ export function isPersonRequest(text: string): boolean {
65
+ return !!text && !QUOTED.test(text) && !MACHINE.test(text)
28
66
  }
29
67
 
30
68
  /**
@@ -43,5 +81,6 @@ export function splitNextRequest(reply: string): { reply: string; suggestion?: s
43
81
  const suggestion = match[1].replace(/^["“'`]+|["”'`]+$/g, '').trim()
44
82
  const rest = lines.slice(0, -1).join('\n').replace(/\s+$/, '')
45
83
  if (!suggestion || !rest) return { reply: text }
46
- return { reply: rest, suggestion }
84
+ /* The line is ours either way - a line that is no request is taken off and offered as nothing. */
85
+ return isPersonRequest(suggestion) ? { reply: rest, suggestion } : { reply: rest }
47
86
  }
@@ -92,7 +92,7 @@ describe('chatRoomLookup — both questions stay inside the caller\'s domain', (
92
92
  }
93
93
 
94
94
  it('★ the session and the seat are both looked up with the domain', async () => {
95
- const { deps, seen } = repos({ id: 's1', visibility: 'private' }, { id: 'p1' })
95
+ const { deps, seen } = repos({ id: 's1', visibility: 'private' }, { id: 'p1', role: 'member' })
96
96
  const lookup = chatRoomLookup(deps, { domainId: 'd1', sessionId: 's1', userId: 'u1' })
97
97
 
98
98
  expect(await lookup.visibility()).toBe('private')
@@ -106,8 +106,13 @@ describe('chatRoomLookup — both questions stay inside the caller\'s domain', (
106
106
  expect(await chatRoomLookup(deps, { domainId: 'd1', sessionId: 's1', userId: 'u1' }).visibility()).toBeUndefined()
107
107
  })
108
108
 
109
+ it('★ a hide-only viewer row is not a seat - the stream asks what the stored path asks', async () => {
110
+ const { deps } = repos({ id: 's1', visibility: 'group' }, { id: 'p1', role: 'viewer' })
111
+ expect(await chatRoomLookup(deps, { domainId: 'd1', sessionId: 's1', userId: 'u1' }).isParticipant()).toBe(false)
112
+ })
113
+
109
114
  it('a caller with no user sits nowhere', async () => {
110
- const { deps, seen } = repos({ id: 's1', visibility: 'private' }, { id: 'p1' })
115
+ const { deps, seen } = repos({ id: 's1', visibility: 'private' }, { id: 'p1', role: 'member' })
111
116
  expect(await chatRoomLookup(deps, { domainId: 'd1', sessionId: 's1', userId: undefined }).isParticipant()).toBe(false)
112
117
  expect(seen).toEqual([])
113
118
  })
@@ -13,6 +13,7 @@ import { filter, pipe } from 'graphql-yoga'
13
13
  import { pubsub } from '@things-factory/shell'
14
14
 
15
15
  import { CHAT_MESSAGE_TOPIC, chatMessageRoomFilter, type ChatRoomLookup } from './chat-message-broadcast.js'
16
+ import { isRoomRole } from '../chat-session-participant/participant-lookup-roles.js'
16
17
 
17
18
  interface FindOne {
18
19
  findOne(options: { where: any; select?: any }): Promise<any>
@@ -37,7 +38,8 @@ export function chatRoomLookup(
37
38
  const seat = await repos.participants.findOne({
38
39
  where: { session: { id: who.sessionId }, user: { id: who.userId }, domain: { id: who.domainId } }
39
40
  })
40
- return !!seat
41
+ /* A hide-only `viewer` row is not a seat - the stored path asks the same (`isSessionParticipant`). */
42
+ return isRoomRole(seat?.role)
41
43
  }
42
44
  }
43
45
  }
@@ -0,0 +1,52 @@
1
+ /*
2
+ * ChatTypingSubscription - "someone is typing" in a conversation, live. The rules are in `chat-typing.ts`;
3
+ * this file holds only pubsub and the decorators, which jest cannot read.
4
+ */
5
+ import { Resolver, Subscription, Root, Arg, ObjectType, Field } from 'type-graphql'
6
+ import { filter, pipe } from 'graphql-yoga'
7
+ import { assertDomainSubscribeAllowed, getRepository, pubsub } from '@things-factory/shell'
8
+
9
+ import { ChatSession } from '../chat-session/chat-session.js'
10
+ import { ChatSessionParticipant } from '../chat-session-participant/chat-session-participant.js'
11
+ import { mayHearRoom } from './chat-message-broadcast.js'
12
+ import { chatRoomLookup } from './chat-message-room.js'
13
+ import { CHAT_TYPING_TOPIC, chatTypingFilter, type ChatTypingPayload } from './chat-typing.js'
14
+
15
+ @ObjectType({ description: 'Someone in the conversation is typing. Nothing is stored; the screen lets the notice fade without the next one.' })
16
+ export class ChatTyping {
17
+ @Field({ description: 'Conversation the person is typing in.' })
18
+ sessionId!: string
19
+
20
+ @Field({ description: 'User who is typing.' })
21
+ userId!: string
22
+
23
+ @Field({ description: 'Name the room shows for that user.' })
24
+ name!: string
25
+
26
+ @Field({ description: 'When the notice was sent (ISO).' })
27
+ at!: string
28
+ }
29
+
30
+ @Resolver(ChatTyping)
31
+ export class ChatTypingSubscription {
32
+ @Subscription(() => ChatTyping, {
33
+ description: 'Subscribes to "someone is typing" notices of a conversation the caller may read - never the caller\'s own.',
34
+ subscribe: async ({ args, context }) => {
35
+ const { domain, user } = context.state
36
+ const { sessionId } = args
37
+ await assertDomainSubscribeAllowed(domain, user, (d, u) => process.superUserGranted(d, u))
38
+
39
+ /* The same room question the messages ask - refused in the words of a missing session. */
40
+ const room = chatRoomLookup(
41
+ { sessions: getRepository(ChatSession), participants: getRepository(ChatSessionParticipant) },
42
+ { domainId: domain.id, sessionId, userId: user?.id }
43
+ )
44
+ if (!(await mayHearRoom(room))) throw new Error('chat session not found')
45
+
46
+ return pipe(pubsub.subscribe(CHAT_TYPING_TOPIC), filter(chatTypingFilter(domain.id, sessionId, user?.id, room)))
47
+ }
48
+ })
49
+ chatTyping(@Root() payload: ChatTypingPayload, @Arg('sessionId') sessionId: string): ChatTyping {
50
+ return { sessionId: payload.sessionId, userId: payload.userId, name: payload.name, at: payload.at }
51
+ }
52
+ }
@@ -0,0 +1,45 @@
1
+ /*
2
+ * "Someone is typing" - who hears it. The same room question the messages ask, asked again at every notice,
3
+ * and never the listener's own notice.
4
+ */
5
+ import { chatTypingFilter, typingNotice } from './chat-typing'
6
+
7
+ const room = (visibility: string | null | undefined, seated: boolean) => ({ visibility: async () => visibility, isParticipant: async () => seated })
8
+ const notice = (over: any = {}) => ({ domainId: 'd1', sessionId: 's1', userId: 'u2', name: 'kim', at: '2026-10-24T00:00:00.000Z', ...over })
9
+
10
+ describe('chat typing', () => {
11
+ it('★ a seated listener of a group hears the others typing', async () => {
12
+ expect(await chatTypingFilter('d1', 's1', 'u1', room('group', true))(notice())).toBe(true)
13
+ })
14
+
15
+ it('★ someone outside a group or private room hears nothing', async () => {
16
+ expect(await chatTypingFilter('d1', 's1', 'u1', room('group', false))(notice())).toBe(false)
17
+ expect(await chatTypingFilter('d1', 's1', 'u1', room('private', false))(notice())).toBe(false)
18
+ })
19
+
20
+ it('★ sent out while listening, the next notice does not reach them', async () => {
21
+ let seated = true
22
+ const filter = chatTypingFilter('d1', 's1', 'u1', { visibility: async () => 'group', isParticipant: async () => seated })
23
+ expect(await filter(notice())).toBe(true)
24
+ seated = false
25
+ expect(await filter(notice())).toBe(false)
26
+ })
27
+
28
+ it('anyone in the domain hears an open room', async () => {
29
+ expect(await chatTypingFilter('d1', 's1', 'u1', room('open', false))(notice())).toBe(true)
30
+ })
31
+
32
+ it('never your own notice, never another session or domain, never a missing room', async () => {
33
+ const filter = chatTypingFilter('d1', 's1', 'u1', room('open', false))
34
+ expect(await filter(notice({ userId: 'u1' }))).toBe(false)
35
+ expect(await filter(notice({ sessionId: 's2' }))).toBe(false)
36
+ expect(await filter(notice({ domainId: 'd2' }))).toBe(false)
37
+ expect(await chatTypingFilter('d1', 's1', 'u1', room(undefined, true))(notice())).toBe(false)
38
+ })
39
+
40
+ it('the notice says who and when, a name on one line', () => {
41
+ const now = new Date('2026-10-24T01:02:03.000Z')
42
+ expect(typingNotice({ domainId: 'd1', sessionId: 's1', user: { id: 'u2', name: 'Kim\nJi' } }, now)).toEqual({ domainId: 'd1', sessionId: 's1', userId: 'u2', name: 'Kim Ji', at: '2026-10-24T01:02:03.000Z' })
43
+ expect(typingNotice({ domainId: 'd1', sessionId: 's1', user: {} })).toBeNull()
44
+ })
45
+ })
@@ -0,0 +1,48 @@
1
+ /*
2
+ * "Someone is typing" - the room's live line, pure part (topic, payload, filter). No pubsub, no decorators,
3
+ * so the tests run what the subscription runs (`chat-typing.test.ts`).
4
+ *
5
+ * Nothing is stored. A person typing is not a message, and a line that outlives the typing would lie, so a
6
+ * notice says only who and when, and the screen lets it fade after a few seconds without a new one.
7
+ *
8
+ * Who hears it is the room's question, asked as the messages ask it (`mayHearRoom`): an open room anyone in
9
+ * the domain, a group or private room the people seated in it - at subscribe time and again at every notice,
10
+ * because a member can be sent out while listening. Who may say it is the write gate's question: only a
11
+ * person who may write here is typing here.
12
+ */
13
+ import { mayHearRoom, type ChatRoomLookup } from './chat-message-broadcast.js'
14
+
15
+ export const CHAT_TYPING_TOPIC = 'ai-assistant-chat-typing'
16
+
17
+ /** How long a notice stands on the screen without the next one. The client sends one every few seconds while typing. */
18
+ export const TYPING_NOTICE_MS = 6_000
19
+
20
+ export interface ChatTypingPayload {
21
+ domainId: string
22
+ sessionId: string
23
+ userId: string
24
+ /** The name the room shows - the same name a message's sender line shows. */
25
+ name: string
26
+ /** ISO time the notice was sent. */
27
+ at: string
28
+ }
29
+
30
+ /** The notice a typing person sends - a name is cut to one line, as the room's other names are. */
31
+ export function typingNotice(input: { domainId: string; sessionId: string; user: { id?: string; name?: string | null; email?: string | null } }, now = new Date()): ChatTypingPayload | null {
32
+ if (!input.user?.id || !input.sessionId) return null
33
+ const name = String(input.user.name || input.user.email || '').replace(/[\r\n\t]+/g, ' ').trim().slice(0, 80)
34
+ return { domainId: input.domainId, sessionId: input.sessionId, userId: input.user.id, name, at: now.toISOString() }
35
+ }
36
+
37
+ /**
38
+ * Passes a notice of the same domain and session, not the listener's own, and only while the room is open to
39
+ * the listener.
40
+ */
41
+ export function chatTypingFilter(domainId: string, sessionId: string, listenerId: string | undefined, room: ChatRoomLookup) {
42
+ return async (payload: ChatTypingPayload): Promise<boolean> =>
43
+ !!payload &&
44
+ payload.domainId === domainId &&
45
+ payload.sessionId === sessionId &&
46
+ payload.userId !== listenerId &&
47
+ (await mayHearRoom(room))
48
+ }
@@ -17,7 +17,7 @@
17
17
  * ④ 워터마크 행이 사라졌으면 전부 미반영으로 본다 — 빠뜨리는 쪽보다 다시 요약하는 쪽이 안전하다.
18
18
  *
19
19
  * ── 왜 여기로 왔나 (2026-09-18) ────────────────────────────────────────────
20
- * 요약 접기를 `board-ai` 에서 이 꾸러미로 옮기면서(`4257583d1b`) 이 시험만 board-ai 에 남았다. 읽는
20
+ * 요약 접기를 `board-ai` 에서 이 패키지로 옮기면서(`4257583d1b`) 이 시험만 board-ai 에 남았다. 읽는
21
21
  * 파일이 사라졌으니 board-ai 의 `npm test` 가 그날부터 실패했다 — 옮긴 사람이 시험을 두고 온 것이다.
22
22
  * 여기 오면서 `dist-server` 를 거치지 않고 소스를 바로 읽는다.
23
23
  */
@@ -4,7 +4,7 @@
4
4
  /* The source, not the built package - the two land together and the test must see the change before a build. */
5
5
  import { takeToolImages } from '../../../../ai-client-base/server/service/agentic-loop/agentic-loop'
6
6
 
7
- import { attachmentToolResult } from './attachment-tool-result'
7
+ import { attachmentToolResult, NO_SUCH_ATTACHMENT } from './attachment-tool-result'
8
8
 
9
9
  describe('a file opened again in a later turn', () => {
10
10
  const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 9])
@@ -24,3 +24,11 @@ describe('a file opened again in a later turn', () => {
24
24
  expect(attachmentToolResult('t1', { type: 'text', text: 'a', name: 'x.csv', truncated: true })).toMatchObject({ note: 'the file is longer; this is its beginning.' })
25
25
  })
26
26
  })
27
+
28
+ /* 2026-10-24: a deposit's id was asked for as a file eight times in one turn. */
29
+ describe('an id that names no file', () => {
30
+ it('★ is told it is no attachment and not to ask again', () => {
31
+ expect(NO_SUCH_ATTACHMENT).toMatch(/it is not an attachment/)
32
+ expect(NO_SUCH_ATTACHMENT).toMatch(/Do not ask for it again/)
33
+ })
34
+ })
@@ -16,3 +16,10 @@ export function attachmentToolResult(attachmentId: string, file: FileForModel) {
16
16
  ? { attachmentId, name: file.name, type: 'image', images: [{ data: file.data, mediaType: file.mediaType }], note: 'the picture is attached right after this result - look at it.' }
17
17
  : { attachmentId, name: file.name, type: 'text', text: file.text, ...(file.truncated ? { note: 'the file is longer; this is its beginning.' } : {}) }
18
18
  }
19
+
20
+ /*
21
+ * What an id that names no file of the conversation gets (2026-10-24): given a deposit's id, the model asked for it as a
22
+ * file eight times in one turn. The answer says the id is no file and where a record is read instead.
23
+ */
24
+ export const NO_SUCH_ATTACHMENT =
25
+ 'there is no file with this id in a conversation here - it is not an attachment. Do not ask for it again; a record is read with its own read tool.'
@@ -18,7 +18,7 @@ import { isSessionParticipant } from '../chat-session-participant/participant-lo
18
18
  import { attachmentForModel, CHAT_ATTACHMENT_REF } from './session-attachments.js'
19
19
  import { textCharsOf } from './attachment-policy.js'
20
20
  import { getAssistantMode } from '../assistant-mode/registry.js'
21
- import { attachmentToolResult } from './attachment-tool-result.js'
21
+ import { attachmentToolResult, NO_SUCH_ATTACHMENT } from './attachment-tool-result.js'
22
22
 
23
23
  export const CHAT_ATTACHMENT_CATEGORY = 'chat-attachments'
24
24
 
@@ -41,12 +41,12 @@ const readChatAttachment: ToolSpec = {
41
41
 
42
42
  const row: any = await getRepository(Attachment).findOne({ where: { id, domain: { id: domain.id }, refType: CHAT_ATTACHMENT_REF } as any })
43
43
  const session: any = row && (await getRepository(ChatSession).findOne({ where: { id: row.refBy, domain: { id: domain.id } } as any }))
44
- if (!row || !session) return { absent: 'not-found', attachmentId: id, message: 'there is no such attachment in a conversation here.' }
44
+ if (!row || !session) return { absent: 'not-found', attachmentId: id, message: NO_SUCH_ATTACHMENT }
45
45
 
46
46
  const participant = await isSessionParticipant(domain.id, session.id, user?.id)
47
47
  if (!mayReadSession(session.visibility, participant)) {
48
48
  /* Same answer as absent - saying "it exists but is private" would itself leak. */
49
- return { absent: 'not-found', attachmentId: id, message: 'there is no such attachment in a conversation here.' }
49
+ return { absent: 'not-found', attachmentId: id, message: NO_SUCH_ATTACHMENT }
50
50
  }
51
51
 
52
52
  const file = await attachmentForModel(row, textCharsOf(getAssistantMode(session.kind || '')?.attachments))