@things-factory/ai-assistant 10.1.210 → 10.1.213

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 (119) hide show
  1. package/client/components/assistant-chat.ts +115 -2
  2. package/client/components/assistant-dock.ts +106 -3
  3. package/client/components/workspace-view.ts +56 -3
  4. package/client/index.ts +3 -0
  5. package/client/utils/assistant-ask.test.ts +38 -0
  6. package/client/utils/assistant-ask.ts +35 -1
  7. package/client/utils/assistant-proposals.ts +23 -0
  8. package/client/utils/assistant-selection.test.ts +30 -0
  9. package/client/utils/assistant-selection.ts +47 -0
  10. package/client/utils/assistant-session-controller.ts +2 -0
  11. package/client/utils/assistant-session-transport.ts +1 -1
  12. package/client/utils/invite-proposal-register.ts +31 -0
  13. package/client/utils/invite-proposal.test.ts +40 -0
  14. package/client/utils/invite-proposal.ts +54 -0
  15. package/dist-client/components/assistant-chat.d.ts +18 -0
  16. package/dist-client/components/assistant-chat.js +117 -2
  17. package/dist-client/components/assistant-chat.js.map +1 -1
  18. package/dist-client/components/assistant-dock.d.ts +11 -0
  19. package/dist-client/components/assistant-dock.js +97 -4
  20. package/dist-client/components/assistant-dock.js.map +1 -1
  21. package/dist-client/components/workspace-view.d.ts +2 -0
  22. package/dist-client/components/workspace-view.js +58 -3
  23. package/dist-client/components/workspace-view.js.map +1 -1
  24. package/dist-client/index.d.ts +2 -0
  25. package/dist-client/index.js +3 -0
  26. package/dist-client/index.js.map +1 -1
  27. package/dist-client/tsconfig.tsbuildinfo +1 -1
  28. package/dist-client/utils/assistant-ask.d.ts +22 -1
  29. package/dist-client/utils/assistant-ask.js +19 -0
  30. package/dist-client/utils/assistant-ask.js.map +1 -1
  31. package/dist-client/utils/assistant-ask.test.d.ts +1 -0
  32. package/dist-client/utils/assistant-ask.test.js +40 -0
  33. package/dist-client/utils/assistant-ask.test.js.map +1 -0
  34. package/dist-client/utils/assistant-proposals.d.ts +14 -0
  35. package/dist-client/utils/assistant-proposals.js +7 -0
  36. package/dist-client/utils/assistant-proposals.js.map +1 -1
  37. package/dist-client/utils/assistant-selection.d.ts +19 -0
  38. package/dist-client/utils/assistant-selection.js +35 -0
  39. package/dist-client/utils/assistant-selection.js.map +1 -0
  40. package/dist-client/utils/assistant-selection.test.d.ts +1 -0
  41. package/dist-client/utils/assistant-selection.test.js +26 -0
  42. package/dist-client/utils/assistant-selection.test.js.map +1 -0
  43. package/dist-client/utils/assistant-session-controller.d.ts +2 -0
  44. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  45. package/dist-client/utils/assistant-session-transport.js +1 -1
  46. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  47. package/dist-client/utils/invite-proposal-register.d.ts +1 -0
  48. package/dist-client/utils/invite-proposal-register.js +24 -0
  49. package/dist-client/utils/invite-proposal-register.js.map +1 -0
  50. package/dist-client/utils/invite-proposal.d.ts +17 -0
  51. package/dist-client/utils/invite-proposal.js +37 -0
  52. package/dist-client/utils/invite-proposal.js.map +1 -0
  53. package/dist-client/utils/invite-proposal.test.d.ts +1 -0
  54. package/dist-client/utils/invite-proposal.test.js +35 -0
  55. package/dist-client/utils/invite-proposal.test.js.map +1 -0
  56. package/dist-server/service/assistant-chat-resolver.js +2 -0
  57. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  58. package/dist-server/service/assistant-mode/index.d.ts +2 -1
  59. package/dist-server/service/assistant-mode/index.js +6 -1
  60. package/dist-server/service/assistant-mode/index.js.map +1 -1
  61. package/dist-server/service/assistant-mode/mode-categories.d.ts +14 -1
  62. package/dist-server/service/assistant-mode/mode-categories.js +29 -4
  63. package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
  64. package/dist-server/service/assistant-mode/screen-rules.d.ts +6 -0
  65. package/dist-server/service/assistant-mode/screen-rules.js +21 -6
  66. package/dist-server/service/assistant-mode/screen-rules.js.map +1 -1
  67. package/dist-server/service/data-read/read-target.d.ts +24 -3
  68. package/dist-server/service/data-read/read-target.js +42 -10
  69. package/dist-server/service/data-read/read-target.js.map +1 -1
  70. package/dist-server/service/data-read/read-tools.d.ts +18 -1
  71. package/dist-server/service/data-read/read-tools.js +19 -5
  72. package/dist-server/service/data-read/read-tools.js.map +1 -1
  73. package/dist-server/service/index.d.ts +5 -1
  74. package/dist-server/service/index.js +10 -1
  75. package/dist-server/service/index.js.map +1 -1
  76. package/dist-server/service/people/index.d.ts +4 -0
  77. package/dist-server/service/people/index.js +22 -0
  78. package/dist-server/service/people/index.js.map +1 -0
  79. package/dist-server/service/people/invite-tools.d.ts +34 -0
  80. package/dist-server/service/people/invite-tools.js +144 -0
  81. package/dist-server/service/people/invite-tools.js.map +1 -0
  82. package/dist-server/service/people/people-keys.d.ts +8 -0
  83. package/dist-server/service/people/people-keys.js +16 -0
  84. package/dist-server/service/people/people-keys.js.map +1 -0
  85. package/dist-server/service/people/people-read.d.ts +4 -0
  86. package/dist-server/service/people/people-read.js +57 -0
  87. package/dist-server/service/people/people-read.js.map +1 -0
  88. package/dist-server/service/testing/mode-conformance.js +3 -0
  89. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  90. package/dist-server/tsconfig.tsbuildinfo +1 -1
  91. package/package.json +6 -6
  92. package/server/service/assistant-chat-resolver.ts +2 -0
  93. package/server/service/assistant-mode/conversation-category.test.ts +35 -0
  94. package/server/service/assistant-mode/index.ts +3 -1
  95. package/server/service/assistant-mode/mode-categories.ts +41 -4
  96. package/server/service/assistant-mode/mode-front.test.ts +5 -3
  97. package/server/service/assistant-mode/resolve.test.ts +1 -1
  98. package/server/service/assistant-mode/screen-rules.test.ts +23 -1
  99. package/server/service/assistant-mode/screen-rules.ts +22 -5
  100. package/server/service/data-entry/data-entry.test.ts +2 -2
  101. package/server/service/data-read/detail-lists.test.ts +61 -0
  102. package/server/service/data-read/read-target.ts +54 -9
  103. package/server/service/data-read/read-tools.ts +32 -6
  104. package/server/service/index.ts +7 -1
  105. package/server/service/people/index.ts +13 -0
  106. package/server/service/people/invite-tools.ts +159 -0
  107. package/server/service/people/invite.test.ts +159 -0
  108. package/server/service/people/people-keys.ts +16 -0
  109. package/server/service/people/people-read.test.ts +82 -0
  110. package/server/service/people/people-read.ts +56 -0
  111. package/server/service/testing/mode-conformance.test.ts +14 -1
  112. package/server/service/testing/mode-conformance.ts +2 -0
  113. package/test/assistant-dock.test.ts +73 -0
  114. package/translations/en.json +18 -1
  115. package/translations/es.json +18 -1
  116. package/translations/ja.json +18 -1
  117. package/translations/ko.json +19 -2
  118. package/translations/ms.json +18 -1
  119. package/translations/zh.json +18 -1
@@ -0,0 +1,159 @@
1
+ /*
2
+ * proposeInvite - the model offers a card that brings someone into this conversation (ADR-0073 decision 3).
3
+ *
4
+ * The card is a proposal: the model seats nobody. What an assistant writes must not seat people (mention-invite.ts,
5
+ * rule ①), so the seat is given when a person presses the card - the dock calls `inviteToAISession` with that
6
+ * person's request, and the door asks `maySeat` with their seat. This tool only finds who is meant and says whether
7
+ * this person could press it here.
8
+ *
9
+ * Who is meant is the server's to settle, never the model's memory: the name is looked up in the organization's own
10
+ * list as the person (the read people-read.ts opens), so a user id the model made up is never on a card. One person
11
+ * by that name - the card invites them. Several - the card lists each and the person picks one; the model does not ask.
12
+ */
13
+ import { registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
14
+ import { getRepository } from '@things-factory/shell'
15
+
16
+ import { runAsCaller } from '../data-entry/entry-tools.js'
17
+ import type { GraphqlRunner } from '../data-entry/entry-update.js'
18
+ import { ChatSession } from '../chat-session/chat-session.js'
19
+ import { maySeat } from '../chat-session/session-visibility.js'
20
+ import { ChatSessionParticipant } from '../chat-session-participant/chat-session-participant.js'
21
+ import { isRoomRole } from '../chat-session-participant/participant-lookup-roles.js'
22
+ import { PEOPLE_SELECTION } from './people-read.js'
23
+ import { INVITE_CATEGORY } from './people-keys.js'
24
+
25
+ /** At most this many people on one card - a name that matches more is too broad to pick from. */
26
+ export const MAX_INVITE_CANDIDATES = 8
27
+
28
+ export interface InviteCandidate {
29
+ userId: string
30
+ name: string
31
+ department?: string
32
+ position?: string
33
+ }
34
+
35
+ const PEOPLE_QUERY =
36
+ 'query ($filters: [Filter!], $pagination: Pagination, $search: String) ' +
37
+ `{ employees(filters: $filters, pagination: $pagination, search: $search) { items { ${PEOPLE_SELECTION} } total } }`
38
+
39
+ const ACTIVE = { name: 'active', operator: 'eq', value: true }
40
+
41
+ const candidateOf = (row: any): InviteCandidate | undefined =>
42
+ row?.user?.id
43
+ ? {
44
+ userId: String(row.user.id),
45
+ name: String(row.name ?? ''),
46
+ ...(row.department?.name ? { department: String(row.department.name) } : {}),
47
+ ...(row.jobPosition ? { position: String(row.jobPosition) } : {})
48
+ }
49
+ : undefined
50
+
51
+ /**
52
+ * The people a name means, as the person may see them: the exact name first, then the name as a search term. Only
53
+ * people with an account - an invite seats a user.
54
+ */
55
+ export async function findPeopleNamed(run: GraphqlRunner, name: string): Promise<{ people: InviteCandidate[]; error?: string }> {
56
+ const ask = async (variables: Record<string, unknown>) => {
57
+ const answer = await run(PEOPLE_QUERY, { pagination: { page: 1, limit: MAX_INVITE_CANDIDATES + 1 }, ...variables })
58
+ if (answer.errors?.length) return { error: answer.errors.map(one => one.message).join('; ') }
59
+ return { rows: (answer.data?.employees?.items ?? []) as any[] }
60
+ }
61
+ const exact = await ask({ filters: [ACTIVE, { name: 'name', operator: 'eq', value: name }] })
62
+ if ('error' in exact) return { people: [], error: exact.error }
63
+ const rows = exact.rows!.length ? exact.rows! : ((await ask({ filters: [ACTIVE], search: name })) as any).rows ?? []
64
+ return { people: rows.map(candidateOf).filter(Boolean) as InviteCandidate[] }
65
+ }
66
+
67
+ /** Why this person cannot bring anyone into this room, in their words - or nothing when they can. */
68
+ function seatRefusal(visibility: string | null | undefined, t: (key: string, options: any) => string): string | undefined {
69
+ if (visibility === 'private')
70
+ return t('ai-assistant.text.invite-private-refused', { defaultValue: 'a private conversation is yours alone. make it a group to bring people in.' })
71
+ return t('ai-assistant.text.invite-owner-only', { defaultValue: 'only the person who started this group conversation can bring people in.' })
72
+ }
73
+
74
+ export async function proposeInvite(args: any, context: any, run: GraphqlRunner = runAsCaller(context)) {
75
+ const t = (key: string, options: any) => (typeof context?.t === 'function' ? context.t(key, options) : String(options?.defaultValue ?? key))
76
+ const { domain, user } = context?.state ?? {}
77
+ const name = typeof args?.name === 'string' ? args.name.trim() : ''
78
+ const wanted = typeof args?.userId === 'string' ? args.userId.trim() : ''
79
+ if (!name) return { rejected: true, error: 'no-name', message: 'say whom to invite - the name the person used.' }
80
+
81
+ const conversationId = context?.conversation?.id
82
+ const session: any = conversationId && domain?.id ? await getRepository(ChatSession).findOne({ where: { id: conversationId, domain: { id: domain.id } } as any }) : undefined
83
+ if (!session) return { rejected: true, error: 'no-conversation', message: 'this conversation is not stored yet, so nobody can be brought into it.' }
84
+
85
+ /* Asked before anyone is looked up: the card must be one this person can press here. */
86
+ const seats = await getRepository(ChatSessionParticipant).find({ where: { domain: { id: domain.id }, session: { id: session.id } } as any })
87
+ const mine = seats.find((seat: any) => seat.userId === user?.id)?.role
88
+ if (!maySeat(session.visibility, mine)) {
89
+ /* Who reads the conversation is the person's to change, in the conversation list - the model has no tool for it (2026-10-24: it offered 「그룹 대화로 바꿔줘」). */
90
+ return {
91
+ rejected: true,
92
+ error: 'may-not-invite',
93
+ message: `${seatRefusal(session.visibility, t)} Say so in one line - do not offer a card. Who reads this conversation is changed by the person who started it, from the conversation list (⋯); you cannot change it, so never offer to or suggest asking you.`
94
+ }
95
+ }
96
+
97
+ const found = await findPeopleNamed(run, name)
98
+ if (found.error) return { rejected: true, error: 'people-unreadable', message: `the people list could not be read: ${found.error}` }
99
+ const here = new Set(seats.filter((seat: any) => isRoomRole(seat.role)).map((seat: any) => seat.userId))
100
+ const already = found.people.filter(one => here.has(one.userId))
101
+ let people = found.people.filter(one => !here.has(one.userId) && one.userId !== user?.id)
102
+ if (wanted) people = people.filter(one => one.userId === wanted)
103
+
104
+ if (!people.length) {
105
+ return already.length
106
+ ? { rejected: true, error: 'already-here', message: `${already.map(one => one.name).join(', ')} is already in this conversation.` }
107
+ : { rejected: true, error: 'not-found', message: `no one named "${name}" with an account is in the people list this person may see.` }
108
+ }
109
+ if (people.length > MAX_INVITE_CANDIDATES) {
110
+ return { rejected: true, error: 'too-many', message: `more than ${MAX_INVITE_CANDIDATES} people match "${name}" - ask for the full name or the department.` }
111
+ }
112
+
113
+ const one = people.length === 1 ? people[0] : undefined
114
+ return {
115
+ proposed: true,
116
+ kind: 'invite',
117
+ icon: 'person_add',
118
+ label: one
119
+ ? t('ai-assistant.people.invite-one', { name: one.name, defaultValue: 'Invite {name} to this conversation' })
120
+ : t('ai-assistant.people.invite-pick', { name, count: people.length, defaultValue: '{count} people named {name} - pick whom to invite' }),
121
+ ...(one && (one.department || one.position) ? { reason: [one.department, one.position].filter(Boolean).join(' · ') } : {}),
122
+ people,
123
+ message: one
124
+ ? `the card invites ${one.name}${one.department ? ` (${one.department})` : ''}. The person presses it; they are told when they join. Say that in one line.`
125
+ : `${people.length} people match "${name}"; the card lists each with department and position and the person picks one. Do not ask which - the card does.`
126
+ }
127
+ }
128
+
129
+ const spec = {
130
+ kind: 'read',
131
+ name: 'proposeInvite',
132
+ /* It reads people through the organization's list - the same door that list has, so it is offered where that list is. */
133
+ doors: ['Query.employees'],
134
+ description:
135
+ 'Offer a card that brings someone into this conversation. Give the name the person used; the server finds them in ' +
136
+ "the organization's people list and puts them on the card (several people by that name: the card lists each, the " +
137
+ 'person picks). The person presses the card - nobody joins until then. One call per person.',
138
+ schema: {
139
+ type: 'object',
140
+ properties: {
141
+ name: { type: 'string', description: 'The name of the person to invite, as the person said it.' },
142
+ userId: { type: 'string', description: 'The user id from the people list, when it was read already and the person meant that one.' }
143
+ },
144
+ required: ['name']
145
+ },
146
+ builder: (args: any, context: any) => proposeInvite(args, context)
147
+ } as ToolSpec
148
+
149
+ export const inviteCategory: ToolCategory = {
150
+ name: INVITE_CATEGORY,
151
+ description: 'bring someone into this conversation - a card the person presses',
152
+ guidance: [
153
+ '- When the person asks to invite, add or bring someone into this conversation, call proposeInvite with that name in the same turn. Never send them to a screen to do it.',
154
+ '- To answer who someone is or who works where, read the people list (queryOrganizationEmployee).'
155
+ ].join('\n'),
156
+ specs: [spec]
157
+ }
158
+
159
+ registerToolCategory(inviteCategory)
@@ -0,0 +1,159 @@
1
+ /*
2
+ * The invite card (people/invite-tools.ts) on sqlite - the room's rules are the stored ones, the people list is a
3
+ * stand-in for the organization's query as the person.
4
+ *
5
+ * Asked 「정연희 초대해 줘」 the model said it had no tool to look people up and sent the person to a screen
6
+ * (2026-10-24). Now the name is found in the people list and the card carries whom it means; the card itself seats
7
+ * nobody - a person pressing it does, through the invite door.
8
+ */
9
+ import type { DataSource } from 'typeorm'
10
+
11
+ const { DataSource: TypeOrmDataSource } = require('typeorm')
12
+ const shell = require('@things-factory/shell')
13
+ const authBase = require('@things-factory/auth-base')
14
+
15
+ function loadCompiled(modulePath: string): any {
16
+ const { existsSync } = require('fs')
17
+ const { join } = require('path')
18
+ const resolved = join(__dirname, '..', '..', '..', 'dist-server', 'service', `${modulePath}.js`)
19
+ if (!existsSync(resolved)) throw new Error(`ai-assistant has no build output: ${resolved}\nBuild first:\n yarn workspace @things-factory/ai-assistant build:server`)
20
+ return require(resolved)
21
+ }
22
+ const { ChatSession } = loadCompiled('chat-session/chat-session')
23
+ const { ChatMessage } = loadCompiled('chat-message/chat-message')
24
+ const { ChatSessionParticipant } = loadCompiled('chat-session-participant/chat-session-participant')
25
+ const { proposeInvite, MAX_INVITE_CANDIDATES } = loadCompiled('people/invite-tools')
26
+
27
+ let db: DataSource
28
+ let domain: any
29
+ let owner: any
30
+ let member: any
31
+ let session: any
32
+ let asked: Array<{ document: string; variables: any }>
33
+
34
+ beforeAll(async () => {
35
+ db = new TypeOrmDataSource({
36
+ type: 'sqlite',
37
+ database: ':memory:',
38
+ synchronize: true,
39
+ dropSchema: true,
40
+ namingStrategy: new shell.NamingStrategy(),
41
+ entities: [...shell.entities, ...authBase.entities, ChatSession, ChatMessage, ChatSessionParticipant],
42
+ logging: false
43
+ })
44
+ await db.initialize()
45
+ shell.addDataSource('default', db)
46
+ domain = await db.getRepository(shell.Domain).save({ name: 'acme', subdomain: 'acme' })
47
+ owner = await db.getRepository(authBase.User).save({ name: 'owner', username: 'owner', email: 'owner@example.com', status: 'activated' })
48
+ member = await db.getRepository(authBase.User).save({ name: 'member', username: 'member', email: 'member@example.com', status: 'activated' })
49
+ })
50
+
51
+ afterAll(async () => {
52
+ shell.removeDataSource('default')
53
+ await db?.destroy()
54
+ })
55
+
56
+ beforeEach(async () => {
57
+ asked = []
58
+ session = await db.getRepository(ChatSession).save({ domain, anchorType: 'domain', anchorId: domain.id, name: 'pricing', visibility: 'group', creator: owner })
59
+ await db.getRepository(ChatSessionParticipant).save({ domain, session: { id: session.id }, user: { id: owner.id }, role: 'owner' })
60
+ await db.getRepository(ChatSessionParticipant).save({ domain, session: { id: session.id }, user: { id: member.id }, role: 'member' })
61
+ })
62
+
63
+ const person = (name: string, userId: string | null, department = 'Sales', position = 'Manager') => ({
64
+ id: `e-${name}-${userId}`,
65
+ name,
66
+ jobPosition: position,
67
+ department: { id: `d-${department}`, name: department },
68
+ user: userId ? { id: userId } : null
69
+ })
70
+
71
+ /** The people list as the organization's query answers it: by exact name, then by search. */
72
+ function peopleList(rows: any[]) {
73
+ return async (document: string, variables: any) => {
74
+ asked.push({ document, variables })
75
+ const exact = (variables.filters ?? []).find((one: any) => one.name === 'name')
76
+ const items = exact ? rows.filter(row => row.name === exact.value) : rows.filter(row => row.name.includes(variables.search ?? ''))
77
+ return { data: { employees: { items, total: items.length } } }
78
+ }
79
+ }
80
+
81
+ const as = (user: any, conversation: any = session) => ({ state: { domain, user }, ...(conversation ? { conversation: { id: conversation.id } } : {}) })
82
+ const seats = async () => (await db.getRepository(ChatSessionParticipant).find({ where: { session: { id: session.id } } as any })).map((row: any) => [row.userId, row.role]).sort()
83
+
84
+ describe('the invite card', () => {
85
+ it('★ one person by that name - the card invites them, with where they work', async () => {
86
+ const card = await proposeInvite({ name: '정연희' }, as(owner), peopleList([person('정연희', 'u-jung'), person('김민수', 'u-kim')]))
87
+ expect(card).toMatchObject({ proposed: true, kind: 'invite', people: [{ userId: 'u-jung', name: '정연희', department: 'Sales', position: 'Manager' }], reason: 'Sales · Manager' })
88
+ })
89
+
90
+ it('★ the card seats nobody - only a person pressing it does (the invite door)', async () => {
91
+ const before = await seats()
92
+ await proposeInvite({ name: '정연희' }, as(owner), peopleList([person('정연희', 'u-jung')]))
93
+ expect(await seats()).toEqual(before)
94
+ })
95
+
96
+ it('★ two people of one name - the card lists both and the person picks', async () => {
97
+ const card = await proposeInvite({ name: '정연희' }, as(owner), peopleList([person('정연희', 'u-1', 'Sales'), person('정연희', 'u-2', 'Finance')]))
98
+ expect(card.people.map((one: any) => one.userId)).toEqual(['u-1', 'u-2'])
99
+ expect(card.reason).toBeUndefined()
100
+ expect(card.message).toMatch(/do not ask which/i)
101
+ })
102
+
103
+ it('a user id read before narrows the card to that one', async () => {
104
+ const card = await proposeInvite({ name: '정연희', userId: 'u-2' }, as(owner), peopleList([person('정연희', 'u-1'), person('정연희', 'u-2')]))
105
+ expect(card.people.map((one: any) => one.userId)).toEqual(['u-2'])
106
+ })
107
+
108
+ it('★ a user id the model made up is never on a card', async () => {
109
+ const card = await proposeInvite({ name: '정연희', userId: 'made-up' }, as(owner), peopleList([person('정연희', 'u-1')]))
110
+ expect(card).toMatchObject({ rejected: true, error: 'not-found' })
111
+ })
112
+
113
+ it('no exact name - the name is searched', async () => {
114
+ const card = await proposeInvite({ name: '연희' }, as(owner), peopleList([person('정연희', 'u-1')]))
115
+ expect(card.people.map((one: any) => one.userId)).toEqual(['u-1'])
116
+ expect(asked[1].variables.search).toBe('연희')
117
+ })
118
+
119
+ it('people who left are never asked for, and no contact or bank field is read', async () => {
120
+ await proposeInvite({ name: '정연희' }, as(owner), peopleList([person('정연희', 'u-1')]))
121
+ expect(asked[0].variables.filters).toContainEqual({ name: 'active', operator: 'eq', value: true })
122
+ expect(asked[0].document).not.toMatch(/phone|email|address|bank|emergency|contact|photo/i)
123
+ })
124
+
125
+ it('someone without an account cannot be invited; someone already here is said', async () => {
126
+ expect(await proposeInvite({ name: '정연희' }, as(owner), peopleList([person('정연희', null)]))).toMatchObject({ rejected: true, error: 'not-found' })
127
+ expect(await proposeInvite({ name: 'member' }, as(owner), peopleList([person('member', member.id)]))).toMatchObject({ rejected: true, error: 'already-here' })
128
+ })
129
+
130
+ it('a name that matches too many is narrowed first', async () => {
131
+ const many = Array.from({ length: MAX_INVITE_CANDIDATES + 1 }, (_, i) => person('정연희', `u-${i}`))
132
+ expect(await proposeInvite({ name: '정연희' }, as(owner), peopleList(many))).toMatchObject({ rejected: true, error: 'too-many' })
133
+ })
134
+ })
135
+
136
+ describe('the card is offered only where the person could press it', () => {
137
+ it('★ a member of a group cannot bring anyone in - no card, and nobody is looked up', async () => {
138
+ const card = await proposeInvite({ name: '정연희' }, as(member), peopleList([person('정연희', 'u-1')]))
139
+ expect(card).toMatchObject({ rejected: true, error: 'may-not-invite' })
140
+ expect(card.message).toMatch(/you cannot change it, so never offer/)
141
+ expect(asked).toEqual([])
142
+ })
143
+
144
+ it('★ a private conversation takes nobody', async () => {
145
+ await db.getRepository(ChatSession).update(session.id, { visibility: 'private' })
146
+ expect(await proposeInvite({ name: '정연희' }, as(owner), peopleList([person('정연희', 'u-1')]))).toMatchObject({ rejected: true, error: 'may-not-invite' })
147
+ })
148
+
149
+ it('in an open conversation anyone may point a colleague at it', async () => {
150
+ await db.getRepository(ChatSession).update(session.id, { visibility: 'open' })
151
+ expect(await proposeInvite({ name: '정연희' }, as(member), peopleList([person('정연희', 'u-1')]))).toMatchObject({ proposed: true })
152
+ })
153
+
154
+ it('a turn without a stored conversation, or one of another domain, has no room to invite into', async () => {
155
+ expect(await proposeInvite({ name: '정연희' }, as(owner, null), peopleList([]))).toMatchObject({ rejected: true, error: 'no-conversation' })
156
+ const other = await db.getRepository(shell.Domain).save({ name: 'other', subdomain: 'other' })
157
+ expect(await proposeInvite({ name: '정연희' }, { state: { domain: other, user: owner }, conversation: { id: session.id } }, peopleList([]))).toMatchObject({ rejected: true, error: 'no-conversation' })
158
+ })
159
+ })
@@ -0,0 +1,16 @@
1
+ /*
2
+ * The names of the people tools - every conversation carries them (mode-categories.ts), so they live apart from the
3
+ * tools themselves and the categories can be named without loading them.
4
+ */
5
+
6
+ /** The organization's people as a read target - its `employees` list, read as the person (people-read.ts). */
7
+ export const PEOPLE_READ_KEY = 'organization.employee'
8
+
9
+ /** The category of that read - `data-read:<key>`, as every read target's. */
10
+ export const PEOPLE_READ_CATEGORY = `data-read:${PEOPLE_READ_KEY}`
11
+
12
+ /** The invite card - bring someone into this conversation (invite-tools.ts). */
13
+ export const INVITE_CATEGORY = 'chat-invite'
14
+
15
+ /** Both, in the order a turn puts them in front: find the person, then the card. */
16
+ export const PEOPLE_CATEGORIES = [PEOPLE_READ_CATEGORY, INVITE_CATEGORY]
@@ -0,0 +1,82 @@
1
+ /*
2
+ * The people list every conversation reads (people-read.ts) - the organization's own query, behind its own door, and
3
+ * nothing private in what it reads.
4
+ */
5
+ import { peopleReadTarget, PEOPLE_SELECTION } from './people-read'
6
+ import { PEOPLE_CATEGORIES, PEOPLE_READ_CATEGORY, INVITE_CATEGORY } from './people-keys'
7
+ import { modeFrontCategories, modeToolCategories, unattendedToolCategories } from '../assistant-mode/mode-categories'
8
+ import { rootFieldOf } from '../data-entry/entry-target'
9
+
10
+ describe('the people list', () => {
11
+ const target = peopleReadTarget()
12
+
13
+ it("★ reads the organization's own list - its door decides who may read people", () => {
14
+ expect(rootFieldOf(target.query, 'query')).toBe('Query.employees')
15
+ expect(target.key).toBe('organization.employee')
16
+ })
17
+
18
+ it('★ the invite card reads people through the same door - offered only where the list is', () => {
19
+ /* The tool module holds entities - read as built, as the database suites do. */
20
+ const { inviteCategory } = require(require('path').join(__dirname, '..', '..', '..', 'dist-server', 'service', 'people', 'invite-tools.js'))
21
+ expect(inviteCategory.specs.map((spec: any) => spec.doors)).toEqual([[rootFieldOf(target.query, 'query')]])
22
+ })
23
+
24
+ it('★ reads no contact, address, bank or emergency field', () => {
25
+ expect(target.query).not.toMatch(/phone|email|address|bank|emergency|contact|photo|profile|note/i)
26
+ expect(PEOPLE_SELECTION.split(/[\s{}]+/).filter(Boolean).sort()).toEqual(['alias', 'department', 'id', 'id', 'id', 'jobPosition', 'jobResponsibility', 'name', 'name', 'user'].sort())
27
+ })
28
+
29
+ it('people who left are out of reach - a fixed filter the model cannot lift', () => {
30
+ expect(target.fixedFilters).toEqual([{ name: 'active', operator: 'eq', value: true }])
31
+ })
32
+
33
+ it('★ a department, a position, a responsibility each has its own condition - never the name search', () => {
34
+ const by = (name: string) => target.criteria.find(one => one.name === name)!
35
+ expect(by('position').filter('프로')).toEqual({ name: 'jobPosition', operator: 'i_like', value: '프로' })
36
+ expect(by('responsibility').filter('회계')).toEqual({ name: 'jobResponsibility', operator: 'i_like', value: '회계' })
37
+ expect(by('department').filter('영업')).toEqual({ name: 'departmentName', operator: 'i_like', value: '영업' })
38
+ })
39
+
40
+ it("★ every part-of condition is on a field the organization's list lets LIKE reach - one outside is dropped without a word", () => {
41
+ const source = require('fs').readFileSync(require('path').join(__dirname, '..', '..', '..', '..', 'organization', 'server', 'service', 'employee', 'employee-query.ts'), 'utf8') as string
42
+ const searchables = JSON.parse(source.match(/searchables: (\[[^\]]*\])/)![1].replace(/'/g, '"')) as string[]
43
+ const liked = target.criteria.map(one => [one.filter('x')].flat()).flat().filter((f: any) => /like/.test(f.operator)).map((f: any) => f.name)
44
+ expect(liked.length).toBeGreaterThan(0)
45
+ expect(liked.filter(name => !searchables.includes(name))).toEqual([])
46
+ })
47
+
48
+ it("the organization's list takes a department by its name", () => {
49
+ const source = require('fs').readFileSync(require('path').join(__dirname, '..', '..', '..', '..', 'organization', 'server', 'service', 'employee', 'employee-query.ts'), 'utf8') as string
50
+ expect(source).toMatch(/filtersMap: \{ departmentName: \{ columnName: 'name', relationColumn: 'department' \} \}/)
51
+ })
52
+
53
+ it('carries the user id an invite takes, and finds by name', () => {
54
+ expect(target.columns.find(one => one.name === 'userId')?.path).toBe('user.id')
55
+ expect(target.search).toBeTruthy()
56
+ })
57
+ })
58
+
59
+ describe('every conversation carries the people tools', () => {
60
+ const mode = { toolCategories: ['office-reads'], readTargets: ['office.contract'] }
61
+
62
+ it('★ offered in every mode, and in front of every turn', () => {
63
+ for (const category of PEOPLE_CATEGORIES) {
64
+ expect(modeToolCategories(mode)).toContain(category)
65
+ expect(modeToolCategories({})).toContain(category)
66
+ expect(modeFrontCategories(mode)).toContain(category)
67
+ expect(modeFrontCategories({}, { page: 'anything' })).toContain(category)
68
+ }
69
+ })
70
+
71
+ it('not in an unattended run - nobody is there to press an invite', () => {
72
+ expect(unattendedToolCategories(mode)).not.toContain(INVITE_CATEGORY)
73
+ expect(unattendedToolCategories(mode)).not.toContain(PEOPLE_READ_CATEGORY)
74
+ })
75
+ })
76
+
77
+ describe('the invite card knows which conversation it is in', () => {
78
+ it("★ the conversation door hands the stored session to the tools - the server's value, never the client's", () => {
79
+ const source = require('fs').readFileSync(require('path').join(__dirname, '..', 'assistant-chat-resolver.ts'), 'utf8') as string
80
+ expect(source).toMatch(/\.\.\.\(session\?\.id \? \{ conversation: \{ id: session\.id \} \} : \{\}\)/)
81
+ })
82
+ })
@@ -0,0 +1,56 @@
1
+ /*
2
+ * Who works here - the organization's `employees` list opened to every conversation as a read target
3
+ * (operato-application/design/ai/read-targets.md), the way an application opens its own lists.
4
+ *
5
+ * Asked to bring 정연희 into a group conversation, the model answered that it had no tool to look up people and
6
+ * sent the person to search a screen (2026-10-24). The list existed; no conversation could read it.
7
+ *
8
+ * Nothing new is built: the read runs the organization module's own query as the person, so its door
9
+ * (`employee` query, or the domain owner) decides who may read people, and an application without the organization
10
+ * module has no such door - the tool is not offered there. The selection is the projection: name, department,
11
+ * position and the user an invite takes. No phone, e-mail, address, bank account or emergency contact is read.
12
+ */
13
+ import { listReadTarget } from '../data-read/list-read.js'
14
+ import type { ReadTarget } from '../data-read/read-target.js'
15
+ import { PEOPLE_READ_KEY } from './people-keys.js'
16
+
17
+ /** The fields read of each person - the answer may carry nothing else. */
18
+ export const PEOPLE_SELECTION = 'id name alias jobPosition jobResponsibility department { id name } user { id }'
19
+
20
+ export function peopleReadTarget(): ReadTarget {
21
+ const target = listReadTarget({
22
+ key: PEOPLE_READ_KEY,
23
+ title: 'ai-assistant.people.title',
24
+ name: 'people of the organization',
25
+ listQuery: 'employees',
26
+ /* People who left are not offered - an invite, a mention or an answer about who does what is about who is here. */
27
+ fixedFilters: [{ name: 'active', operator: 'eq', value: true }],
28
+ selection: PEOPLE_SELECTION,
29
+ columns: [
30
+ { name: 'name', type: 'string', labelKey: 'ai-assistant.people.name' },
31
+ { name: 'department', type: 'string', path: 'department.name', labelKey: 'ai-assistant.people.department' },
32
+ { name: 'position', type: 'string', path: 'jobPosition', labelKey: 'ai-assistant.people.position' },
33
+ { name: 'responsibility', type: 'string', path: 'jobResponsibility', labelKey: 'ai-assistant.people.responsibility' },
34
+ { name: 'userId', type: 'string', path: 'user.id', labelKey: 'ai-assistant.people.user' }
35
+ ],
36
+ search: 'name, employee number and alias'
37
+ })
38
+ /*
39
+ * Who holds a position, who is in a team - asked 「프로 직책인 사람은 누구야?」 the model put the position into the search
40
+ * term, which matches names, numbers and aliases only, and answered that nobody held it (2026-10-24).
41
+ */
42
+ const contains = (name: string) => (value: any) => ({ name, operator: 'i_like', value })
43
+ return {
44
+ ...target,
45
+ criteria: [
46
+ ...target.criteria,
47
+ { name: 'department', type: 'string', labelKey: 'ai-assistant.people.department', description: "part of the department's name", filter: contains('departmentName') },
48
+ { name: 'position', type: 'string', labelKey: 'ai-assistant.people.position', description: 'part of the position (직책)', filter: contains('jobPosition') },
49
+ { name: 'responsibility', type: 'string', labelKey: 'ai-assistant.people.responsibility', description: 'part of what they are responsible for (담당)', filter: contains('jobResponsibility') }
50
+ ],
51
+ description:
52
+ 'the people who work here, as the organization lists them - name, department, position, responsibility, and the user id ' +
53
+ 'an invite into a conversation takes (empty when the person has no account). Read only who the asker may see. ' +
54
+ 'The text condition finds by name, employee number or alias only - a department, position or responsibility has its own condition.'
55
+ }
56
+ }
@@ -13,7 +13,7 @@
13
13
  import { clearAssistantModes, registerAssistantMode } from '../assistant-mode/registry'
14
14
  import { clearToolRegistry, registerToolCategory } from '@things-factory/ai-client-base'
15
15
 
16
- import { runRegisteredMode } from './mode-conformance'
16
+ import { modeCarriesSharedRules, runRegisteredMode } from './mode-conformance'
17
17
 
18
18
  const client = {
19
19
  chatWithTools: async () => ({ text: '한 턴', toolCalls: [], stopReason: 'end_turn' })
@@ -46,3 +46,16 @@ it('an app that wants to see the other case names it — the door the caller can
46
46
 
47
47
  expect(result.offeredTools).toEqual(['getOpenScene'])
48
48
  })
49
+
50
+ describe('an app prompt that copies a shared rule', () => {
51
+ afterEach(() => clearAssistantModes())
52
+
53
+ it('★ reading the picked records in its own words is a copy of the selection rule', () => {
54
+ registerAssistantMode({ kind: 'office.copy', systemPrompt: 'explicit-page-selection is what the person picked.' } as any)
55
+ registerAssistantMode({ kind: 'office.keys', systemPrompt: 'read the [keys] line.' } as any)
56
+ registerAssistantMode({ kind: 'office.clean', systemPrompt: 'you answer about the books.' } as any)
57
+ expect(modeCarriesSharedRules('office.copy')).toContain('selection-rule-copy')
58
+ expect(modeCarriesSharedRules('office.keys')).toContain('selection-rule-copy')
59
+ expect(modeCarriesSharedRules('office.clean')).toEqual([])
60
+ })
61
+ })
@@ -147,6 +147,8 @@ export function modeCarriesSharedRules(kind: string): string[] {
147
147
  if (/^\s*Next:/im.test(prompt) || /end the reply with one last line/i.test(prompt)) found.push('next-request-rule-copy')
148
148
  if (/finish what was asked in this turn|never ask whether to propose/i.test(prompt)) found.push('finish-rule-copy')
149
149
  if (/\[writer → addressee\]/.test(prompt)) found.push('speaker-rule-copy')
150
+ /* The picked records' note is the framework's form (SELECTION_RULE) - an app reading its own form of it is a copy. */
151
+ if (/explicit-page-selection|\[keys\]/i.test(prompt)) found.push('selection-rule-copy')
150
152
  return found
151
153
  }
152
154
 
@@ -13,6 +13,7 @@ const code = (file: string) => read(file).replace(/\/\*[\s\S]*?\*\//g, '').repla
13
13
  const dock = code('../client/components/assistant-dock.ts')
14
14
  /* Not comment-stripped: the chat has strings like 'image/*' that a comment regex would eat. */
15
15
  const chat = read('../client/components/assistant-chat.ts')
16
+ const transport = read('../client/utils/assistant-session-transport.ts')
16
17
 
17
18
  describe('no empty panel', () => {
18
19
  it('not configured, unreadable, no surface and a stuck anchor are each said', () => {
@@ -171,6 +172,20 @@ describe('what twin needed of the dock', () => {
171
172
  assert.match(dock, /this\.startCarried\(ask\)\.catch\(\(?\w*\)? => \{\s*notify\(\{ level: 'error'/)
172
173
  })
173
174
 
175
+ it('★ a request made on the screen is never dropped while an answer is written - it waits and is taken after', () => {
176
+ const busy = dock.slice(dock.indexOf('if (this.navigationState.busy) {', dock.indexOf('private async takeAsk')), dock.indexOf('if (this.navigationState.draft', dock.indexOf('private async takeAsk')))
177
+ assert.match(busy, /this\.takeWhenIdle\(\)\s*return/)
178
+ assert.doesNotMatch(busy, /takeAssistantAsk\(\)/)
179
+ })
180
+
181
+ it('★ unsent words are asked about before a new request takes the dock - never dropped without asking', () => {
182
+ assert.match(dock, /if \(this\.navigationState\.draft && !\(await this\.mayLeaveConversation\(\)\)\) \{\s*const waiting = takeAssistantAsk\(\)/)
183
+ })
184
+
185
+ it('a note is drawn without the line only the model reads', () => {
186
+ assert.match(chat, />\$\{noteForDisplay\(content\)\}<\/span/)
187
+ })
188
+
174
189
  it('★ and nothing is sent into the previous conversation after it - the request depended on the context that failed', () => {
175
190
  assert.match(dock, /this\.startCarried\(ask\)\.catch\([\s\S]*?return undefined\s*\}\)[\s\S]{0,200}?if \(!id\) return\n/)
176
191
  })
@@ -203,3 +218,61 @@ describe('closing and opening the dock again (design/ai/shared-dock.md §2-1, §
203
218
  assert.match(opening, /if \(this\.hasUpdated && this\.sessionId\) \{\s*this\.loadHistory\(\)\s*this\._startPresence\(\)\s*this\._startMessageSubscription\(\)/)
204
219
  })
205
220
  })
221
+
222
+ /*
223
+ * A screen request (「AI와 작업」) - design/ai/shared-dock.md §2-11-1, the user (2026-10-24): 「"AI와 작업" 기능이 unstable
224
+ * 하다. 작동 방식을 신뢰할 수 있도록 새로운 세션을 여는지, 기존 세션을 활용하는지 등등을 명확하게 하라.」 One test per rule.
225
+ */
226
+ describe('a screen request', () => {
227
+ const take = dock.slice(dock.indexOf('private async takeScreenRequest()'), dock.indexOf('\n }\n', dock.indexOf('private async takeScreenRequest()')))
228
+ const send = chat.slice(chat.indexOf('async sendScreenRequest('), chat.indexOf('\n }\n', chat.indexOf('async sendScreenRequest(')))
229
+
230
+ it('goes its own way - a request carrying a selection is a screen request', () => {
231
+ assert.match(dock, /if \(peekAssistantAsk\(\)\?\.selection\) return this\.takeScreenRequest\(\)/)
232
+ })
233
+
234
+ it('★ 1. to this screen\'s conversation - never a new one; another screen\'s conversation open comes back here', () => {
235
+ assert.doesNotMatch(take, /newConversation|startCarried|createAISession|controller\.reset|conversationView\+\+/)
236
+ assert.match(take, /if \(this\.scopeKey\(here\) !== this\.openedFor[^\n]*\) \{\s*(?:\/\*[^*]*\*\/\s*)?this\.keepDraft\(\)\s*this\.surface = here\s*await this\.open\(\)/)
237
+ assert.match(take, /this\.showingHistory = false/)
238
+ /* Someone else's open conversation the dock showed by default is not this person's - their own is started (draft along). */
239
+ assert.match(take, /if \(this\.sessions\.find\(one => one\.id === this\.sessionId\)\?\.participant === false\) await this\.goToOwnConversation\(\)/)
240
+ /* ...and that is their own most recent one here, a new one only when they have none. */
241
+ assert.match(dock, /const own = this\.sessions\.find\(one => one\.participant !== false && one\.id !== this\.sessionId\)/)
242
+ assert.match(dock, /if \(own\) \{\s*if \(this\.controller\.select\(own\.id\)\) this\.conversationView\+\+\s*\} else \{\s*await this\.controller\.reset\(\)/)
243
+ assert.match(transport, /participant\}\}/)
244
+ /* No conversation yet: the chat makes this screen's one on sending, through the controller - never a second. */
245
+ assert.match(send, /if \(!this\.sessionId && this\.prepareSession\) \{\s*await this\.prepareSession\(/)
246
+ })
247
+
248
+ it('★ 2. the selection is noted for that turn, before the request', () => {
249
+ assert.match(take, /chat\.sendScreenRequest\(request, selectionNote\(ask\.selection!, t\)\)/)
250
+ const noted = send.indexOf('APPEND_SESSION_NOTE_MUTATION')
251
+ assert.ok(noted > 0 && noted < send.indexOf('await this.send()'))
252
+ })
253
+
254
+ it('★ 3. sent, always - the request goes out, never as a draft', () => {
255
+ assert.match(send, /this\.input = text\s*await this\.send\(\)/)
256
+ assert.doesNotMatch(take, /asking|draft:/)
257
+ assert.match(take, /ask\.ask\?\.trim\(\) \|\| t\('screen-request-default'/)
258
+ })
259
+
260
+ it('★ 4. what the person was writing is set aside and put back', () => {
261
+ assert.match(send, /const kept = this\.takeDraft\(\)/)
262
+ assert.match(send, /finally \{[\s\S]*if \(kept && !this\.input\.trim\(\) && !this\.attachments\.length\) this\.restoreDraft\(kept\)/)
263
+ assert.match(send, /if \(this\.input\.trim\(\) === text\.trim\(\)\) this\.input = ''/)
264
+ })
265
+
266
+ it('★ 5. not sent: said, and the screen is told', () => {
267
+ assert.match(take, /const fail = \(reason: string\) => \{\s*notify\(\{ level: 'error', message: reason \}\)\s*settleAssistantAsk\(ask, \{ sent: false, reason \}\)/)
268
+ assert.match(take, /if \(!outcome\?\.sent\) return fail\(/)
269
+ assert.match(take, /settleAssistantAsk\(ask, \{ sent: true \}\)/)
270
+ })
271
+
272
+ it('★ 6. an answer being written: said once, and the request goes right after it', () => {
273
+ const busy = take.slice(take.indexOf('if (this.navigationState.busy)'), take.indexOf('const here'))
274
+ assert.match(busy, /notify\(\{ level: 'info', message: t\('screen-request-waits'/)
275
+ assert.match(busy, /this\.takeWhenIdle\(\)\s*return/)
276
+ assert.doesNotMatch(busy, /takeAssistantAsk\(\)/)
277
+ })
278
+ })
@@ -424,5 +424,22 @@
424
424
  "ai-assistant.text.invite-body-named": "{who} brought you into the conversation \"{name}\".",
425
425
  "ai-assistant.text.invite-body": "{who} brought you into a conversation.",
426
426
  "ai-assistant.text.board-invite-title": "Board collaboration invite",
427
- "ai-assistant.text.board-invite-body": "{who} invited you to collaborate on a board."
427
+ "ai-assistant.text.board-invite-body": "{who} invited you to collaborate on a board.",
428
+ "ai-assistant.people.title": "People",
429
+ "ai-assistant.people.name": "Name",
430
+ "ai-assistant.people.department": "Department",
431
+ "ai-assistant.people.position": "Position",
432
+ "ai-assistant.people.responsibility": "Responsibility",
433
+ "ai-assistant.people.user": "User",
434
+ "ai-assistant.people.invite-one": "Invite {name} to this conversation",
435
+ "ai-assistant.people.invite-pick": "{count} people named {name} - pick whom to invite",
436
+ "ai-assistant.people.invited": "{names} joined this conversation and was told.",
437
+ "ai-assistant.dock.selection-note": "Selected {count} · {title}",
438
+ "ai-assistant.dock.selection-more": "and {count} more",
439
+ "ai-assistant.dock.screen-request-waits": "An answer is being written - your request goes right after it.",
440
+ "ai-assistant.dock.screen-request-not-ready": "The conversation is not ready yet. Try again in a moment.",
441
+ "ai-assistant.dock.screen-request-default": "Read the selected records and suggest what can be done with them now.",
442
+ "ai-assistant.dock.screen-request-failed": "The request was not sent. Try again.",
443
+ "ai-assistant.text.screen-request-busy": "an answer is still being written.",
444
+ "ai-assistant.text.screen-request-no-session": "this screen has no conversation to send to."
428
445
  }