@things-factory/ai-assistant 10.1.26 → 10.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/client/components/assistant-chat.ts +653 -59
  2. package/client/components/assistant-mode.test.ts +86 -55
  3. package/client/components/assistant-mode.ts +124 -82
  4. package/client/components/chat-echo-dedup.test.ts +23 -0
  5. package/client/components/chat-echo-dedup.ts +29 -3
  6. package/client/components/chat-input-builder.test.ts +20 -20
  7. package/client/components/chat-input-builder.ts +16 -14
  8. package/client/components/chat-proposal-survival.test.ts +73 -0
  9. package/client/components/mention-popup.ts +31 -8
  10. package/client/components/panel-aria.test.ts +34 -0
  11. package/client/components/proposal-outcome-line.ts +33 -0
  12. package/client/utils/assistant-session-controller.test.ts +75 -0
  13. package/client/utils/assistant-session-controller.ts +23 -0
  14. package/client/utils/assistant-session-transport.ts +11 -3
  15. package/dist-client/components/assistant-chat.d.ts +128 -13
  16. package/dist-client/components/assistant-chat.js +613 -67
  17. package/dist-client/components/assistant-chat.js.map +1 -1
  18. package/dist-client/components/assistant-mode.d.ts +80 -68
  19. package/dist-client/components/assistant-mode.js +63 -19
  20. package/dist-client/components/assistant-mode.js.map +1 -1
  21. package/dist-client/components/assistant-mode.test.js +74 -53
  22. package/dist-client/components/assistant-mode.test.js.map +1 -1
  23. package/dist-client/components/chat-echo-dedup.d.ts +2 -0
  24. package/dist-client/components/chat-echo-dedup.js +27 -4
  25. package/dist-client/components/chat-echo-dedup.js.map +1 -1
  26. package/dist-client/components/chat-echo-dedup.test.js +20 -0
  27. package/dist-client/components/chat-echo-dedup.test.js.map +1 -1
  28. package/dist-client/components/chat-input-builder.d.ts +12 -7
  29. package/dist-client/components/chat-input-builder.js +8 -10
  30. package/dist-client/components/chat-input-builder.js.map +1 -1
  31. package/dist-client/components/chat-input-builder.test.js +17 -18
  32. package/dist-client/components/chat-input-builder.test.js.map +1 -1
  33. package/dist-client/components/chat-proposal-survival.test.d.ts +1 -0
  34. package/dist-client/components/chat-proposal-survival.test.js +61 -0
  35. package/dist-client/components/chat-proposal-survival.test.js.map +1 -0
  36. package/dist-client/components/mention-popup.js +28 -8
  37. package/dist-client/components/mention-popup.js.map +1 -1
  38. package/dist-client/components/panel-aria.test.d.ts +1 -0
  39. package/dist-client/components/panel-aria.test.js +31 -0
  40. package/dist-client/components/panel-aria.test.js.map +1 -0
  41. package/dist-client/components/proposal-outcome-line.d.ts +9 -0
  42. package/dist-client/components/proposal-outcome-line.js +15 -0
  43. package/dist-client/components/proposal-outcome-line.js.map +1 -0
  44. package/dist-client/tsconfig.tsbuildinfo +1 -1
  45. package/dist-client/utils/assistant-session-controller.d.ts +10 -0
  46. package/dist-client/utils/assistant-session-controller.js +16 -0
  47. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  48. package/dist-client/utils/assistant-session-controller.test.d.ts +1 -0
  49. package/dist-client/utils/assistant-session-controller.test.js +56 -0
  50. package/dist-client/utils/assistant-session-controller.test.js.map +1 -0
  51. package/dist-client/utils/assistant-session-transport.js +11 -3
  52. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  53. package/dist-server/service/assistant-capability-resolver.d.ts +17 -0
  54. package/dist-server/service/assistant-capability-resolver.js +88 -0
  55. package/dist-server/service/assistant-capability-resolver.js.map +1 -0
  56. package/dist-server/service/assistant-chat-resolver.d.ts +6 -6
  57. package/dist-server/service/assistant-chat-resolver.js +166 -44
  58. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  59. package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +25 -0
  60. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +140 -0
  61. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -0
  62. package/dist-server/service/assistant-mode/index.d.ts +9 -0
  63. package/dist-server/service/assistant-mode/index.js +19 -0
  64. package/dist-server/service/assistant-mode/index.js.map +1 -0
  65. package/dist-server/service/assistant-mode/registry.d.ts +18 -0
  66. package/dist-server/service/assistant-mode/registry.js +136 -0
  67. package/dist-server/service/assistant-mode/registry.js.map +1 -0
  68. package/dist-server/service/assistant-mode/resolve.d.ts +33 -0
  69. package/dist-server/service/assistant-mode/resolve.js +60 -0
  70. package/dist-server/service/assistant-mode/resolve.js.map +1 -0
  71. package/dist-server/service/assistant-mode/types.d.ts +99 -0
  72. package/dist-server/service/assistant-mode/types.js +12 -0
  73. package/dist-server/service/assistant-mode/types.js.map +1 -0
  74. package/dist-server/service/chat-message/carried-turns.d.ts +48 -0
  75. package/dist-server/service/chat-message/carried-turns.js +80 -0
  76. package/dist-server/service/chat-message/carried-turns.js.map +1 -0
  77. package/dist-server/service/chat-message/chat-message-subscription.d.ts +1 -1
  78. package/dist-server/service/chat-message/chat-message-subscription.js +24 -6
  79. package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -1
  80. package/dist-server/service/chat-message/chat-message.d.ts +32 -0
  81. package/dist-server/service/chat-message/chat-message.js +17 -0
  82. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  83. package/dist-server/service/chat-message/door-access.d.ts +15 -0
  84. package/dist-server/service/chat-message/door-access.js +26 -0
  85. package/dist-server/service/chat-message/door-access.js.map +1 -0
  86. package/dist-server/service/chat-message/fold-session-history.d.ts +30 -0
  87. package/dist-server/service/chat-message/fold-session-history.js +133 -0
  88. package/dist-server/service/chat-message/fold-session-history.js.map +1 -0
  89. package/dist-server/service/chat-message/llm-history.d.ts +7 -0
  90. package/dist-server/service/chat-message/llm-history.js +23 -11
  91. package/dist-server/service/chat-message/llm-history.js.map +1 -1
  92. package/dist-server/service/chat-message/message-doors.d.ts +29 -0
  93. package/dist-server/service/chat-message/message-doors.js +107 -0
  94. package/dist-server/service/chat-message/message-doors.js.map +1 -0
  95. package/dist-server/service/chat-message/proposal-outcome.d.ts +18 -0
  96. package/dist-server/service/chat-message/proposal-outcome.js +31 -0
  97. package/dist-server/service/chat-message/proposal-outcome.js.map +1 -0
  98. package/dist-server/service/chat-message/reader-gate.d.ts +3 -0
  99. package/dist-server/service/chat-message/reader-gate.js +37 -0
  100. package/dist-server/service/chat-message/reader-gate.js.map +1 -0
  101. package/dist-server/service/chat-message/room-message.d.ts +4 -0
  102. package/dist-server/service/chat-message/room-message.js +25 -0
  103. package/dist-server/service/chat-message/room-message.js.map +1 -0
  104. package/dist-server/service/chat-session/chat-session.d.ts +24 -0
  105. package/dist-server/service/chat-session/chat-session.js +26 -2
  106. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  107. package/dist-server/service/chat-session/session-inbox.d.ts +4 -1
  108. package/dist-server/service/chat-session/session-inbox.js +3 -2
  109. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  110. package/dist-server/service/chat-session/session-visibility.d.ts +15 -0
  111. package/dist-server/service/chat-session/session-visibility.js +28 -0
  112. package/dist-server/service/chat-session/session-visibility.js.map +1 -0
  113. package/dist-server/service/chat-session/station-session-guard.d.ts +1 -5
  114. package/dist-server/service/chat-session/station-session-guard.js +7 -2
  115. package/dist-server/service/chat-session/station-session-guard.js.map +1 -1
  116. package/dist-server/service/chat-session-participant/mention-invite.d.ts +29 -0
  117. package/dist-server/service/chat-session-participant/mention-invite.js +103 -0
  118. package/dist-server/service/chat-session-participant/mention-invite.js.map +1 -0
  119. package/dist-server/service/chat-session-resolver.d.ts +15 -0
  120. package/dist-server/service/chat-session-resolver.js +267 -15
  121. package/dist-server/service/chat-session-resolver.js.map +1 -1
  122. package/dist-server/service/grounding-sources.d.ts +14 -0
  123. package/dist-server/service/grounding-sources.js +26 -0
  124. package/dist-server/service/grounding-sources.js.map +1 -0
  125. package/dist-server/service/index.d.ts +8 -1
  126. package/dist-server/service/index.js +19 -0
  127. package/dist-server/service/index.js.map +1 -1
  128. package/dist-server/service/request-translator.d.ts +2 -0
  129. package/dist-server/service/request-translator.js +20 -0
  130. package/dist-server/service/request-translator.js.map +1 -0
  131. package/dist-server/service/testing/conversation-script.d.ts +123 -0
  132. package/dist-server/service/testing/conversation-script.js +202 -0
  133. package/dist-server/service/testing/conversation-script.js.map +1 -0
  134. package/dist-server/service/testing/mode-conformance.d.ts +40 -0
  135. package/dist-server/service/testing/mode-conformance.js +90 -0
  136. package/dist-server/service/testing/mode-conformance.js.map +1 -0
  137. package/dist-server/tool-registry-boot-report.d.ts +3 -0
  138. package/dist-server/tool-registry-boot-report.js +25 -2
  139. package/dist-server/tool-registry-boot-report.js.map +1 -1
  140. package/dist-server/tsconfig.tsbuildinfo +1 -1
  141. package/package.json +5 -5
  142. package/server/explicit-graphql-types.test.ts +56 -0
  143. package/server/service/assistant-capability-resolver.ts +66 -0
  144. package/server/service/assistant-chat-resolver.ts +184 -42
  145. package/server/service/assistant-mode/assistant-mode-resolver.ts +102 -0
  146. package/server/service/assistant-mode/index.ts +16 -0
  147. package/server/service/assistant-mode/registry.test.ts +105 -0
  148. package/server/service/assistant-mode/registry.ts +147 -0
  149. package/server/service/assistant-mode/resolve.test.ts +78 -0
  150. package/server/service/assistant-mode/resolve.ts +96 -0
  151. package/server/service/assistant-mode/subject-defaults.test.ts +86 -0
  152. package/server/service/assistant-mode/types.ts +103 -0
  153. package/server/service/chat-message/carried-turns.test.ts +69 -0
  154. package/server/service/chat-message/carried-turns.ts +99 -0
  155. package/server/service/chat-message/chat-message-subscription.ts +20 -4
  156. package/server/service/chat-message/chat-message.ts +44 -0
  157. package/server/service/chat-message/door-access.ts +45 -0
  158. package/server/service/chat-message/fold-session-history.test.ts +98 -0
  159. package/server/service/chat-message/fold-session-history.ts +115 -0
  160. package/server/service/chat-message/llm-history.test.ts +58 -7
  161. package/server/service/chat-message/llm-history.ts +30 -11
  162. package/server/service/chat-message/message-doors.test.ts +87 -0
  163. package/server/service/chat-message/message-doors.ts +117 -0
  164. package/server/service/chat-message/proposal-outcome.test.ts +48 -0
  165. package/server/service/chat-message/proposal-outcome.ts +42 -0
  166. package/server/service/chat-message/reader-gate.ts +42 -0
  167. package/server/service/chat-message/room-message.test.ts +21 -0
  168. package/server/service/chat-message/room-message.ts +22 -0
  169. package/server/service/chat-session/chat-session.ts +31 -1
  170. package/server/service/chat-session/session-inbox.test.ts +18 -0
  171. package/server/service/chat-session/session-inbox.ts +9 -2
  172. package/server/service/chat-session/session-visibility.test.ts +36 -0
  173. package/server/service/chat-session/session-visibility.ts +26 -0
  174. package/server/service/chat-session/station-session-guard.ts +10 -2
  175. package/server/service/chat-session-participant/mention-invite.test.ts +125 -0
  176. package/server/service/chat-session-participant/mention-invite.ts +121 -0
  177. package/server/service/chat-session-resolver.ts +272 -15
  178. package/server/service/grounding-sources.test.ts +55 -0
  179. package/server/service/grounding-sources.ts +46 -0
  180. package/server/service/index.ts +19 -0
  181. package/server/service/request-translator.test.ts +45 -0
  182. package/server/service/request-translator.ts +33 -0
  183. package/server/service/station-session-guard.test.ts +12 -5
  184. package/server/service/testing/conversation-scenario.test.ts +163 -0
  185. package/server/service/testing/conversation-script.test.ts +131 -0
  186. package/server/service/testing/conversation-script.ts +288 -0
  187. package/server/service/testing/mode-conformance.ts +107 -0
  188. package/server/tool-registry-boot-report.ts +25 -2
  189. package/test/assistant-chat-call-sites.test.ts +82 -0
  190. package/translations/en.json +44 -2
  191. package/translations/ja.json +44 -2
  192. package/translations/ko.json +44 -2
  193. package/translations/ms.json +44 -2
  194. package/translations/zh.json +44 -2
@@ -0,0 +1,105 @@
1
+ /*
2
+ * What this guards: a conversation's character is the server's, not the caller's.
3
+ *
4
+ * Until 2026-09-18 the door took `systemPrompt`, `toolCategories`, `model`, `temperature`,
5
+ * `maxTokens` and `requireGroundingTools` from the request, so anyone holding
6
+ * `ai-assistant:mutation` could rewrite the assistant's persona, open any registered tool
7
+ * category and name an expensive model. ADR-0061 decision 7: the app server registers the mode
8
+ * kinds and the client sends `kind` + `target` + `hostContext`.
9
+ *
10
+ * The registry is also where a mode's promises are checked against what it can actually do:
11
+ * a `/` sentence that needs a tool the mode does not expose is ADR-0058 decision 3's defect, and
12
+ * it is caught at registration rather than when a person presses it.
13
+ */
14
+ import { clearAssistantModes, getAssistantMode, getAssistantModeWarnings, listAssistantModes, registerAssistantMode } from './registry'
15
+
16
+ import { clearToolRegistry, registerToolCategory } from '@things-factory/ai-client-base'
17
+
18
+ const spec = (over: Partial<Parameters<typeof registerAssistantMode>[0]> = {}) => ({
19
+ kind: 'twin.space',
20
+ systemPrompt: 'you answer about one space',
21
+ toolCategories: ['twin-ops'],
22
+ ...over
23
+ })
24
+
25
+ beforeEach(() => {
26
+ clearAssistantModes()
27
+ clearToolRegistry()
28
+ registerToolCategory({
29
+ name: 'twin-ops',
30
+ guidance: 'call getSpace before answering about a space',
31
+ specs: [{ kind: 'read', name: 'getSpace', description: 'read one space', schema: {}, builder: () => async () => ({}) } as any]
32
+ } as any)
33
+ })
34
+
35
+ it('★ a registered mode is found by kind, and carries what the server owns', () => {
36
+ registerAssistantMode(spec({ model: 'claude-sonnet-5', maxTokens: 2048, requireToolOnFirstTurn: true }))
37
+
38
+ const found = getAssistantMode('twin.space')
39
+
40
+ expect(found?.systemPrompt).toBe('you answer about one space')
41
+ expect(found?.toolCategories).toEqual(['twin-ops'])
42
+ expect(found?.model).toBe('claude-sonnet-5')
43
+ expect(found?.maxTokens).toBe(2048)
44
+ expect(found?.requireToolOnFirstTurn).toBe(true)
45
+ })
46
+
47
+ it('★ an unknown kind is absent — the door must refuse, not fall back to a default', () => {
48
+ registerAssistantMode(spec())
49
+
50
+ expect(getAssistantMode('twin.nothing')).toBeUndefined()
51
+ })
52
+
53
+ it('a kind that is not <app>.<surface> is refused at registration', () => {
54
+ expect(() => registerAssistantMode(spec({ kind: 'twinspace' }))).toThrow(/<app>\.<surface>/)
55
+ expect(() => registerAssistantMode(spec({ kind: 'Twin.Space' }))).toThrow(/<app>\.<surface>/)
56
+ })
57
+
58
+ it('a mode with no system prompt is refused — the persona is what the server owns', () => {
59
+ expect(() => registerAssistantMode(spec({ systemPrompt: ' ' }))).toThrow(/systemPrompt/)
60
+ })
61
+
62
+ it('★ registering the same kind twice is refused — two declarations of one conversation disagree', () => {
63
+ registerAssistantMode(spec())
64
+
65
+ expect(() => registerAssistantMode(spec({ systemPrompt: 'a different persona' }))).toThrow(/already registered/)
66
+ expect(getAssistantMode('twin.space')?.systemPrompt).toBe('you answer about one space')
67
+ })
68
+
69
+ it('★ a slash sentence that needs a tool the mode does not expose is reported by code at boot', () => {
70
+ registerAssistantMode(
71
+ spec({
72
+ slashTemplates: [
73
+ { name: 'summarize', descriptionKey: 'twin.slash.summarize', templateKey: 'twin.slash.summarize.template', tools: ['getSpace'] },
74
+ { name: 'place', descriptionKey: 'twin.slash.place', templateKey: 'twin.slash.place.template', tools: ['proposeSceneEdit'] }
75
+ ]
76
+ })
77
+ )
78
+
79
+ const warnings = getAssistantModeWarnings()
80
+
81
+ expect(warnings).toHaveLength(1)
82
+ expect(warnings[0].code).toBe('assistant-mode-slash-outside-tools')
83
+ expect(warnings[0].params).toEqual({ kind: 'twin.space', slash: 'place', tool: 'proposeSceneEdit' })
84
+ })
85
+
86
+ it('the same check reads the tools registered after the mode — boot order must not decide it', () => {
87
+ registerAssistantMode(spec({ slashTemplates: [{ name: 'place', descriptionKey: 'k', templateKey: 't', tools: ['proposeSceneEdit'] }] }))
88
+ expect(getAssistantModeWarnings()).toHaveLength(1)
89
+
90
+ registerToolCategory({
91
+ name: 'twin-authoring',
92
+ guidance: 'propose an edit, never apply it',
93
+ specs: [{ kind: 'read', name: 'proposeSceneEdit', description: 'propose', schema: {}, builder: () => async () => ({}) } as any]
94
+ } as any)
95
+ registerAssistantMode(spec({ kind: 'twin.board', toolCategories: ['twin-ops', 'twin-authoring'], slashTemplates: [{ name: 'place', descriptionKey: 'k', templateKey: 't', tools: ['proposeSceneEdit'] }] }))
96
+
97
+ expect(getAssistantModeWarnings().map(w => w.params.kind)).toEqual(['twin.space'])
98
+ })
99
+
100
+ it('listing returns the modes in registration order', () => {
101
+ registerAssistantMode(spec())
102
+ registerAssistantMode(spec({ kind: 'twin.board' }))
103
+
104
+ expect(listAssistantModes().map(m => m.kind)).toEqual(['twin.space', 'twin.board'])
105
+ })
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The mode registry — one place that knows what each conversation is.
3
+ *
4
+ * Registration refuses what cannot be true later: a kind that is not `<app>.<surface>` (the door
5
+ * looks a mode up by that name), an empty persona (the field this whole change exists to take off
6
+ * the wire), and a second declaration of a kind that is already declared — two declarations of one
7
+ * conversation disagree, and the one that wins would be whichever module booted last.
8
+ *
9
+ * What it does not refuse is a `/` sentence naming a tool the mode does not expose. That check
10
+ * reads the tool registry, which fills up as modules boot, so it runs when asked rather than at
11
+ * registration: `getAssistantModeWarnings()` is the answer as of now, and the boot report reads it
12
+ * after every module is in.
13
+ */
14
+ import { getToolSpecsFor } from '@things-factory/ai-client-base'
15
+
16
+ import type { AssistantModeSpec, AssistantModeWarning } from './types'
17
+
18
+ const modes = new Map<string, AssistantModeSpec>()
19
+
20
+ const KIND = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/
21
+
22
+ export function registerAssistantMode(spec: AssistantModeSpec): void {
23
+ if (!KIND.test(String(spec?.kind ?? ''))) {
24
+ throw new Error(
25
+ `[ai-assistant/assistant-mode] '${spec?.kind}' is not a mode kind. Use <app>.<surface> in lowercase — ` +
26
+ "'twin.space', 'plant.station', 'figure.modeller'. The client names the conversation by this."
27
+ )
28
+ }
29
+
30
+ if (!String(spec.systemPrompt ?? '').trim()) {
31
+ throw new Error(
32
+ `[ai-assistant/assistant-mode] mode '${spec.kind}' has no systemPrompt. Who the assistant is on a ` +
33
+ 'surface belongs to the server that owns the surface (ADR-0061 decision 7).'
34
+ )
35
+ }
36
+
37
+ if (modes.has(spec.kind)) {
38
+ throw new Error(
39
+ `[ai-assistant/assistant-mode] mode '${spec.kind}' is already registered. Two declarations of one ` +
40
+ 'conversation disagree, and which one wins would depend on boot order. Give the second one its own kind.'
41
+ )
42
+ }
43
+
44
+ modes.set(spec.kind, spec)
45
+ }
46
+
47
+ export function getAssistantMode(kind: string): AssistantModeSpec | undefined {
48
+ return modes.get(kind)
49
+ }
50
+
51
+ export function listAssistantModes(): ReadonlyArray<AssistantModeSpec> {
52
+ return Array.from(modes.values())
53
+ }
54
+
55
+ /**
56
+ * The promises a mode makes that its tools cannot keep, as of now.
57
+ *
58
+ * Read after boot: a mode may register before the category its slash sentences use.
59
+ */
60
+ export function getAssistantModeWarnings(): ReadonlyArray<AssistantModeWarning> {
61
+ const out: AssistantModeWarning[] = []
62
+
63
+ /*
64
+ * 한 화면이 어느 대화를 기본으로 여는가 — 등록 순서로 정하지 않는다(ADR-0072 A17 ②). 없으면 화면이
65
+ * 무엇을 열지 모르고, 둘이면 마지막에 부팅한 모듈이 조용히 이긴다. 둘 다 여기서 말한다.
66
+ */
67
+ const defaults = new Map<string, string[]>()
68
+ const claimed = new Set<string>()
69
+ for (const mode of modes.values()) {
70
+ for (const subject of mode.subjects ?? []) claimed.add(subject)
71
+ for (const subject of mode.defaultFor ?? []) {
72
+ defaults.set(subject, [...(defaults.get(subject) ?? []), mode.kind])
73
+ }
74
+ }
75
+ for (const subject of claimed) {
76
+ const owners = defaults.get(subject) ?? []
77
+ if (owners.length === 0) {
78
+ out.push({
79
+ code: 'assistant-mode-subject-without-default',
80
+ params: { kind: '', subject },
81
+ message: `no conversation is the default for subject '${subject}' — a screen showing it has nothing to open`
82
+ })
83
+ } else if (owners.length > 1) {
84
+ out.push({
85
+ code: 'assistant-mode-subject-default-twice',
86
+ params: { kind: owners[0], subject, kinds: owners },
87
+ message: `subject '${subject}' has more than one default (${owners.join(', ')}) — boot order would decide it`
88
+ })
89
+ }
90
+ }
91
+
92
+ for (const mode of modes.values()) {
93
+ /*
94
+ * 고를 수 있는 대화에는 부를 이름이 있어야 한다 — 고르개에 종류 이름(`twin.space`)이 뜨면 그것은
95
+ * 우리 내부 단위를 사람에게 떠넘기는 것이다.
96
+ */
97
+ const shared = (mode.subjects ?? []).some(subject => countModesFor(subject) > 1)
98
+ if (shared && !mode.labelKey) {
99
+ out.push({
100
+ code: 'assistant-mode-offered-without-label',
101
+ params: { kind: mode.kind },
102
+ message: `mode '${mode.kind}' can be offered beside another on the same subject but has no labelKey`
103
+ })
104
+ }
105
+ }
106
+
107
+ for (const mode of modes.values()) {
108
+ const exposed = new Set(getToolSpecsFor(mode.toolCategories ?? []).map(spec => spec.name))
109
+ for (const slash of mode.slashTemplates ?? []) {
110
+ for (const tool of slash.tools ?? []) {
111
+ if (exposed.has(tool)) continue
112
+ out.push({
113
+ code: 'assistant-mode-slash-outside-tools',
114
+ params: { kind: mode.kind, slash: slash.name, tool },
115
+ message:
116
+ `mode '${mode.kind}' offers '/${slash.name}', which needs '${tool}' — not in its tool categories ` +
117
+ `[${(mode.toolCategories ?? []).join(', ')}]. The person presses it and the model answers without doing it.`
118
+ })
119
+ }
120
+ }
121
+ }
122
+ return out
123
+ }
124
+
125
+ /** 이 subject 종류에 서는 대화가 몇인가 — 고르개가 필요한지, 이름이 필요한지를 가른다. */
126
+ function countModesFor(subject: string): number {
127
+ let count = 0
128
+ for (const mode of modes.values()) {
129
+ if ((mode.subjects ?? []).includes(subject)) count++
130
+ }
131
+ return count
132
+ }
133
+
134
+ /**
135
+ * 이 subject 종류에 서는 대화들 — 등록 순서대로, 기본이 먼저.
136
+ *
137
+ * 화면은 이 목록으로 고르개를 낸다. 하나뿐이면 고르개를 내지 않는다(ADR-0072 A17 ①).
138
+ */
139
+ export function assistantModesForSubject(subject: string): ReadonlyArray<AssistantModeSpec> {
140
+ const standing = [...modes.values()].filter(mode => (mode.subjects ?? []).includes(subject))
141
+ return standing.sort((a, b) => Number((b.defaultFor ?? []).includes(subject)) - Number((a.defaultFor ?? []).includes(subject)))
142
+ }
143
+
144
+ /** Test only. */
145
+ export function clearAssistantModes(): void {
146
+ modes.clear()
147
+ }
@@ -0,0 +1,78 @@
1
+ /*
2
+ * What this guards: the two things the door does with a mode — take the conversation's settings
3
+ * from the server's declaration, and hand the screen its words already translated.
4
+ *
5
+ * A caller naming a kind nobody registered must be refused. Falling back to "a plain conversation"
6
+ * would answer with no persona and no tools, and the screen would show an answer as though the
7
+ * mode had been honoured (ADR-0053: no silent "none").
8
+ */
9
+ import { clearAssistantModes, registerAssistantMode } from './registry'
10
+ import { assistantModeView, resolveModeSettings } from './resolve'
11
+
12
+ /** Stands in for `context.t` — returns the key and params so the test can see what was translated. */
13
+ const t = (key: string, params?: Record<string, any>) => `t(${key}${params?.count !== undefined ? `,${params.count}` : ''})`
14
+
15
+ beforeEach(() => {
16
+ clearAssistantModes()
17
+ registerAssistantMode({
18
+ kind: 'twin.space',
19
+ systemPrompt: 'you answer about one space',
20
+ toolCategories: ['twin-ops'],
21
+ requireToolOnFirstTurn: true,
22
+ model: 'claude-sonnet-5',
23
+ maxTokens: 2048,
24
+ temperature: 0.2,
25
+ slashTemplates: [{ name: 'summarize', descriptionKey: 'twin.slash.summarize', templateKey: 'twin.slash.summarize.template' }],
26
+ catalogEntries: [{ id: 'space', label: 'space' }],
27
+ footerNoticeKey: 'twin.notice.footer',
28
+ stagedNoticeKey: 'twin.notice.staged'
29
+ })
30
+ })
31
+
32
+ it('★ the settings come from the registered mode', () => {
33
+ const settings = resolveModeSettings({ kind: 'twin.space', target: 'S-12' }, t)
34
+
35
+ expect(settings).toEqual({
36
+ systemPrompt: 'you answer about one space',
37
+ toolCategories: ['twin-ops'],
38
+ requireToolOnFirstTurn: true,
39
+ model: 'claude-sonnet-5',
40
+ temperature: 0.2,
41
+ maxTokens: 2048
42
+ })
43
+ })
44
+
45
+ it('★ an unregistered kind is refused — no plain-conversation fallback', () => {
46
+ expect(() => resolveModeSettings({ kind: 'twin.nothing' }, t)).toThrow(/t\(ai-assistant.text.unknown-mode\)/)
47
+ try {
48
+ resolveModeSettings({ kind: 'twin.nothing' }, t)
49
+ } catch (error: any) {
50
+ expect(error.code).toBe('unknown-assistant-mode')
51
+ expect(error.params).toEqual({ kind: 'twin.nothing' })
52
+ }
53
+ })
54
+
55
+ it('★ a request with no mode is refused — the door has no default conversation', () => {
56
+ expect(() => resolveModeSettings(undefined as any, t)).toThrow()
57
+ })
58
+
59
+ it('★ the screen gets sentences, not keys — the server translates (ADR-0054)', () => {
60
+ const view = assistantModeView('twin.space', t)
61
+
62
+ expect(view.slashTemplates).toEqual([
63
+ { name: 'summarize', description: 't(twin.slash.summarize)', template: 't(twin.slash.summarize.template)' }
64
+ ])
65
+ expect(view.footerNotice).toBe('t(twin.notice.footer)')
66
+ expect(view.stagedNotice).toBe('t(twin.notice.staged)')
67
+ expect(view.catalogEntries).toEqual([{ id: 'space', label: 'space' }])
68
+ })
69
+
70
+ it('a mode that declares no wording gets empty lists, not undefined holes', () => {
71
+ registerAssistantMode({ kind: 'twin.board', systemPrompt: 'you edit the open board' })
72
+
73
+ const view = assistantModeView('twin.board', t)
74
+
75
+ expect(view.slashTemplates).toEqual([])
76
+ expect(view.catalogEntries).toEqual([])
77
+ expect(view.footerNotice).toBeUndefined()
78
+ })
@@ -0,0 +1,96 @@
1
+ /**
2
+ * What the door and the screen each take from a mode.
3
+ *
4
+ * Both start from the same declaration, so a screen cannot show `/` sentences for a conversation
5
+ * the server would answer differently.
6
+ */
7
+ import { getAssistantMode } from './registry'
8
+ import type { AssistantModeSlash } from './types'
9
+
10
+ export type Translate = (key: string, params?: Record<string, any>) => string
11
+
12
+ export interface AssistantModeRef {
13
+ /** Which conversation this is — a registered `<app>.<surface>`. */
14
+ kind: string
15
+ /** What it is about. Part of the conversation's lifetime; the tools read it, not the settings. */
16
+ target?: string
17
+ }
18
+
19
+ /**
20
+ * A kind nobody registered. The sentence is the person's (ADR-0054); the code and the kind are for
21
+ * whoever reads the log — a screen naming a conversation the server does not have is a mismatched
22
+ * deployment, not something the person did.
23
+ */
24
+ function unknownModeError(kind: string, t: Translate): Error {
25
+ const error: any = new Error(
26
+ t('ai-assistant.text.unknown-mode', {
27
+ defaultValue: 'this screen asked for a conversation this server does not have ({kind}). reload the page.',
28
+ kind: kind || '—'
29
+ })
30
+ )
31
+ error.code = 'unknown-assistant-mode'
32
+ error.params = { kind }
33
+ return error
34
+ }
35
+
36
+ export interface ModeSettings {
37
+ systemPrompt: string
38
+ toolCategories?: string[]
39
+ requireToolOnFirstTurn?: boolean
40
+ model?: string
41
+ temperature?: number
42
+ maxTokens?: number
43
+ }
44
+
45
+ /** The conversation's settings, from the server's declaration. */
46
+ export function resolveModeSettings(ref: AssistantModeRef, t: Translate): ModeSettings {
47
+ const kind = String(ref?.kind ?? '')
48
+ const mode = kind ? getAssistantMode(kind) : undefined
49
+
50
+ if (!mode) throw unknownModeError(kind, t)
51
+
52
+ return {
53
+ systemPrompt: mode.systemPrompt,
54
+ toolCategories: mode.toolCategories,
55
+ requireToolOnFirstTurn: mode.requireToolOnFirstTurn,
56
+ model: mode.model,
57
+ temperature: mode.temperature,
58
+ maxTokens: mode.maxTokens
59
+ }
60
+ }
61
+
62
+ export interface AssistantModeSlashView {
63
+ name: string
64
+ description: string
65
+ template: string
66
+ }
67
+
68
+ export interface AssistantModeView {
69
+ kind: string
70
+ slashTemplates: AssistantModeSlashView[]
71
+ catalogEntries: any[]
72
+ proposalChoices: any[]
73
+ placeholder?: string
74
+ footerNotice?: string
75
+ stagedNotice?: string
76
+ }
77
+
78
+ /** What the screen shows, in the words of the request's locale. */
79
+ export function assistantModeView(kind: string, t: Translate): AssistantModeView {
80
+ const mode = getAssistantMode(kind)
81
+ if (!mode) throw unknownModeError(kind, t)
82
+
83
+ return {
84
+ kind: mode.kind,
85
+ slashTemplates: (mode.slashTemplates ?? []).map((slash: AssistantModeSlash) => ({
86
+ name: slash.name,
87
+ description: t(slash.descriptionKey),
88
+ template: t(slash.templateKey)
89
+ })),
90
+ catalogEntries: mode.catalogEntries ?? [],
91
+ proposalChoices: mode.proposalChoices ?? [],
92
+ placeholder: mode.placeholderKey ? t(mode.placeholderKey) : undefined,
93
+ footerNotice: mode.footerNoticeKey ? t(mode.footerNoticeKey) : undefined,
94
+ stagedNotice: mode.stagedNoticeKey ? t(mode.stagedNoticeKey) : undefined
95
+ }
96
+ }
@@ -0,0 +1,86 @@
1
+ /*
2
+ * 화면이 무엇을 여는가 — ADR-0072 A17 ②.
3
+ *
4
+ * 한 subject 종류에 기본 대화가 없거나 둘이면 화면이 여는 대화는 부팅 순서가 정한다. 그것은 화면을
5
+ * 열어봐야 알 수 있고, 모듈 하나가 늘면 소리 없이 바뀐다. 부팅 보고가 말하게 하고, 여기서 셋 다
6
+ * 문다.
7
+ */
8
+ import {
9
+ assistantModesForSubject,
10
+ clearAssistantModes,
11
+ getAssistantModeWarnings,
12
+ registerAssistantMode
13
+ } from './registry'
14
+
15
+ const persona = 'you are here'
16
+
17
+ describe('assistant mode — subjects and defaults', () => {
18
+ beforeEach(() => clearAssistantModes())
19
+ afterAll(() => clearAssistantModes())
20
+
21
+ it('★ a subject nobody claims as default is a boot warning, not a silent pick', () => {
22
+ registerAssistantMode({ kind: 'twin.space', systemPrompt: persona, subjects: ['space'] })
23
+
24
+ const codes = getAssistantModeWarnings().map(w => w.code)
25
+ expect(codes).toContain('assistant-mode-subject-without-default')
26
+ })
27
+
28
+ it('★ two defaults on one subject is a boot warning — boot order must not decide it', () => {
29
+ registerAssistantMode({
30
+ kind: 'twin.space',
31
+ systemPrompt: persona,
32
+ subjects: ['board'],
33
+ defaultFor: ['board'],
34
+ labelKey: 'a'
35
+ })
36
+ registerAssistantMode({
37
+ kind: 'twin.authoring',
38
+ systemPrompt: persona,
39
+ subjects: ['board'],
40
+ defaultFor: ['board'],
41
+ labelKey: 'b'
42
+ })
43
+
44
+ const twice = getAssistantModeWarnings().find(w => w.code === 'assistant-mode-subject-default-twice')
45
+ expect(twice?.params.kinds).toEqual(['twin.space', 'twin.authoring'])
46
+ })
47
+
48
+ it('★ a conversation offered beside another needs something to call it', () => {
49
+ registerAssistantMode({
50
+ kind: 'twin.space',
51
+ systemPrompt: persona,
52
+ subjects: ['board'],
53
+ defaultFor: ['board'],
54
+ labelKey: 'twin.ai.mode.space'
55
+ })
56
+ registerAssistantMode({ kind: 'twin.authoring', systemPrompt: persona, subjects: ['board'] })
57
+
58
+ const unnamed = getAssistantModeWarnings().find(w => w.code === 'assistant-mode-offered-without-label')
59
+ expect(unnamed?.params.kind).toBe('twin.authoring')
60
+ })
61
+
62
+ it('one conversation alone on its subject needs no label — there is nothing to choose between', () => {
63
+ registerAssistantMode({
64
+ kind: 'plant.station',
65
+ systemPrompt: persona,
66
+ subjects: ['station'],
67
+ defaultFor: ['station']
68
+ })
69
+
70
+ expect(getAssistantModeWarnings()).toEqual([])
71
+ })
72
+
73
+ it('the default comes first, so a screen can open the list head without knowing the rule', () => {
74
+ registerAssistantMode({ kind: 'twin.authoring', systemPrompt: persona, subjects: ['board'], labelKey: 'b' })
75
+ registerAssistantMode({
76
+ kind: 'twin.space',
77
+ systemPrompt: persona,
78
+ subjects: ['board'],
79
+ defaultFor: ['board'],
80
+ labelKey: 'a'
81
+ })
82
+
83
+ expect(assistantModesForSubject('board').map(m => m.kind)).toEqual(['twin.space', 'twin.authoring'])
84
+ expect(assistantModesForSubject('nothing-shows-this')).toEqual([])
85
+ })
86
+ })
@@ -0,0 +1,103 @@
1
+ /**
2
+ * What a conversation is, declared by the app server that owns the screen (ADR-0061 decision 7).
3
+ *
4
+ * ADR-0058 put these five in one value so they could not fall out of step, but the value lived in
5
+ * the browser and travelled on every request: the persona, the tool categories, the model and the
6
+ * limits were the caller's to choose. A caller choosing its own tool categories is a caller
7
+ * choosing its own authority, so the declaration moved here and the client sends only which one it
8
+ * is talking in (`kind`) and what about (`target`).
9
+ */
10
+
11
+ /** A `/` sentence this mode offers, and what it needs to be answerable. */
12
+ export interface AssistantModeSlash {
13
+ /** Short name shown in the list. */
14
+ name: string
15
+ /** i18n key for the one-line description. The server translates it into the request's locale. */
16
+ descriptionKey: string
17
+ /** i18n key for the sentence that lands in the input. */
18
+ templateKey: string
19
+ /**
20
+ * The tools this sentence needs. A mode must not offer work it cannot do (ADR-0058 decision 3),
21
+ * and a name outside `toolCategories` is reported at boot rather than when a person presses it.
22
+ */
23
+ tools?: string[]
24
+ }
25
+
26
+ export interface AssistantModeSpec {
27
+ /** `<app>.<surface>` — `twin.space`, `plant.station`, `figure.modeller`. */
28
+ kind: string
29
+
30
+ /**
31
+ * Which kinds of subject this conversation stands for — `['board', 'space']` (ADR-0072 A17 ①).
32
+ *
33
+ * A screen says what it is showing (`context.subject`), and these say which conversations that
34
+ * subject can carry. It is a list of types and not a predicate on purpose: a list can be counted
35
+ * at boot and pinned by a test, while a function can only be run.
36
+ *
37
+ * On the twin modeller nothing could be asked about operations, because an open board pinned the
38
+ * authoring conversation and that conversation opens one category. A conversation that also
39
+ * stands for `board` is how the same screen carries both.
40
+ */
41
+ subjects?: string[]
42
+
43
+ /**
44
+ * The conversation a screen opens when the person has not chosen — one per subject type
45
+ * (ADR-0072 A17 ②).
46
+ *
47
+ * Registration order must not decide this: whichever module booted last would silently become
48
+ * the default. Exactly one mode declares each subject type, and the boot report says so when a
49
+ * type has none or more than one.
50
+ */
51
+ defaultFor?: string[]
52
+
53
+ /**
54
+ * i18n key for what this conversation is called where a person chooses between them —
55
+ * "이 그림 고치기", "이 현장 지금 어때". A mode a screen may offer needs one.
56
+ */
57
+ labelKey?: string
58
+ /** Who the assistant is here, and what it must not claim to do. The server's, not the caller's. */
59
+ systemPrompt: string
60
+ /** Which tool categories this conversation may use. Absent or empty exposes none. */
61
+ toolCategories?: string[]
62
+ /** Make the model read before it answers — for surfaces whose subject is live state. */
63
+ requireToolOnFirstTurn?: boolean
64
+ /** Model and limits are policy, not a request field. */
65
+ model?: string
66
+ temperature?: number
67
+ maxTokens?: number
68
+ /** What `/` offers. Declared as keys; the server translates. */
69
+ slashTemplates?: AssistantModeSlash[]
70
+ /** What `@` offers. */
71
+ catalogEntries?: any[]
72
+ /**
73
+ * i18n key for the words in the empty input.
74
+ *
75
+ * The screen says what this conversation is for, and that differs by mode: figure's modeller had
76
+ * "describe the figure you want" while the person was there to change the figure already open.
77
+ */
78
+ placeholderKey?: string
79
+ /** i18n key for the line under an empty conversation. */
80
+ footerNoticeKey?: string
81
+ /** i18n key for the line shown once the host reports a proposal staged. */
82
+ stagedNoticeKey?: string
83
+ /** Follow-up chips for a proposal that arrived without its own. */
84
+ proposalChoices?: any[]
85
+ }
86
+
87
+ /** A declaration that registered but is not what the contract asks for. Code + params, not prose. */
88
+ export interface AssistantModeWarning {
89
+ /**
90
+ * `assistant-mode-slash-outside-tools` — a `/` sentence needs a tool this mode does not open.
91
+ * `assistant-mode-subject-without-default` — a subject type no conversation claims as its default.
92
+ * `assistant-mode-subject-default-twice` — two conversations claim the same subject type.
93
+ * `assistant-mode-offered-without-label` — a conversation a screen may offer with nothing to call it.
94
+ */
95
+ code:
96
+ | 'assistant-mode-slash-outside-tools'
97
+ | 'assistant-mode-subject-without-default'
98
+ | 'assistant-mode-subject-default-twice'
99
+ | 'assistant-mode-offered-without-label'
100
+ params: { kind: string; slash?: string; tool?: string; subject?: string; kinds?: string[] }
101
+ /** For a person reading the boot log; callers act on the code and params. */
102
+ message: string
103
+ }