@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.
- package/client/components/assistant-chat.ts +115 -2
- package/client/components/assistant-dock.ts +106 -3
- package/client/components/workspace-view.ts +56 -3
- package/client/index.ts +3 -0
- package/client/utils/assistant-ask.test.ts +38 -0
- package/client/utils/assistant-ask.ts +35 -1
- package/client/utils/assistant-proposals.ts +23 -0
- package/client/utils/assistant-selection.test.ts +30 -0
- package/client/utils/assistant-selection.ts +47 -0
- package/client/utils/assistant-session-controller.ts +2 -0
- package/client/utils/assistant-session-transport.ts +1 -1
- package/client/utils/invite-proposal-register.ts +31 -0
- package/client/utils/invite-proposal.test.ts +40 -0
- package/client/utils/invite-proposal.ts +54 -0
- package/dist-client/components/assistant-chat.d.ts +18 -0
- package/dist-client/components/assistant-chat.js +117 -2
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.d.ts +11 -0
- package/dist-client/components/assistant-dock.js +97 -4
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/components/workspace-view.d.ts +2 -0
- package/dist-client/components/workspace-view.js +58 -3
- package/dist-client/components/workspace-view.js.map +1 -1
- package/dist-client/index.d.ts +2 -0
- package/dist-client/index.js +3 -0
- package/dist-client/index.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-ask.d.ts +22 -1
- package/dist-client/utils/assistant-ask.js +19 -0
- package/dist-client/utils/assistant-ask.js.map +1 -1
- package/dist-client/utils/assistant-ask.test.d.ts +1 -0
- package/dist-client/utils/assistant-ask.test.js +40 -0
- package/dist-client/utils/assistant-ask.test.js.map +1 -0
- package/dist-client/utils/assistant-proposals.d.ts +14 -0
- package/dist-client/utils/assistant-proposals.js +7 -0
- package/dist-client/utils/assistant-proposals.js.map +1 -1
- package/dist-client/utils/assistant-selection.d.ts +19 -0
- package/dist-client/utils/assistant-selection.js +35 -0
- package/dist-client/utils/assistant-selection.js.map +1 -0
- package/dist-client/utils/assistant-selection.test.d.ts +1 -0
- package/dist-client/utils/assistant-selection.test.js +26 -0
- package/dist-client/utils/assistant-selection.test.js.map +1 -0
- package/dist-client/utils/assistant-session-controller.d.ts +2 -0
- package/dist-client/utils/assistant-session-controller.js.map +1 -1
- package/dist-client/utils/assistant-session-transport.js +1 -1
- package/dist-client/utils/assistant-session-transport.js.map +1 -1
- package/dist-client/utils/invite-proposal-register.d.ts +1 -0
- package/dist-client/utils/invite-proposal-register.js +24 -0
- package/dist-client/utils/invite-proposal-register.js.map +1 -0
- package/dist-client/utils/invite-proposal.d.ts +17 -0
- package/dist-client/utils/invite-proposal.js +37 -0
- package/dist-client/utils/invite-proposal.js.map +1 -0
- package/dist-client/utils/invite-proposal.test.d.ts +1 -0
- package/dist-client/utils/invite-proposal.test.js +35 -0
- package/dist-client/utils/invite-proposal.test.js.map +1 -0
- package/dist-server/service/assistant-chat-resolver.js +2 -0
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/index.d.ts +2 -1
- package/dist-server/service/assistant-mode/index.js +6 -1
- package/dist-server/service/assistant-mode/index.js.map +1 -1
- package/dist-server/service/assistant-mode/mode-categories.d.ts +14 -1
- package/dist-server/service/assistant-mode/mode-categories.js +29 -4
- package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
- package/dist-server/service/assistant-mode/screen-rules.d.ts +6 -0
- package/dist-server/service/assistant-mode/screen-rules.js +21 -6
- package/dist-server/service/assistant-mode/screen-rules.js.map +1 -1
- package/dist-server/service/data-read/read-target.d.ts +24 -3
- package/dist-server/service/data-read/read-target.js +42 -10
- package/dist-server/service/data-read/read-target.js.map +1 -1
- package/dist-server/service/data-read/read-tools.d.ts +18 -1
- package/dist-server/service/data-read/read-tools.js +19 -5
- package/dist-server/service/data-read/read-tools.js.map +1 -1
- package/dist-server/service/index.d.ts +5 -1
- package/dist-server/service/index.js +10 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/people/index.d.ts +4 -0
- package/dist-server/service/people/index.js +22 -0
- package/dist-server/service/people/index.js.map +1 -0
- package/dist-server/service/people/invite-tools.d.ts +34 -0
- package/dist-server/service/people/invite-tools.js +144 -0
- package/dist-server/service/people/invite-tools.js.map +1 -0
- package/dist-server/service/people/people-keys.d.ts +8 -0
- package/dist-server/service/people/people-keys.js +16 -0
- package/dist-server/service/people/people-keys.js.map +1 -0
- package/dist-server/service/people/people-read.d.ts +4 -0
- package/dist-server/service/people/people-read.js +57 -0
- package/dist-server/service/people/people-read.js.map +1 -0
- package/dist-server/service/testing/mode-conformance.js +3 -0
- package/dist-server/service/testing/mode-conformance.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/service/assistant-chat-resolver.ts +2 -0
- package/server/service/assistant-mode/conversation-category.test.ts +35 -0
- package/server/service/assistant-mode/index.ts +3 -1
- package/server/service/assistant-mode/mode-categories.ts +41 -4
- package/server/service/assistant-mode/mode-front.test.ts +5 -3
- package/server/service/assistant-mode/resolve.test.ts +1 -1
- package/server/service/assistant-mode/screen-rules.test.ts +23 -1
- package/server/service/assistant-mode/screen-rules.ts +22 -5
- package/server/service/data-entry/data-entry.test.ts +2 -2
- package/server/service/data-read/detail-lists.test.ts +61 -0
- package/server/service/data-read/read-target.ts +54 -9
- package/server/service/data-read/read-tools.ts +32 -6
- package/server/service/index.ts +7 -1
- package/server/service/people/index.ts +13 -0
- package/server/service/people/invite-tools.ts +159 -0
- package/server/service/people/invite.test.ts +159 -0
- package/server/service/people/people-keys.ts +16 -0
- package/server/service/people/people-read.test.ts +82 -0
- package/server/service/people/people-read.ts +56 -0
- package/server/service/testing/mode-conformance.test.ts +14 -1
- package/server/service/testing/mode-conformance.ts +2 -0
- package/test/assistant-dock.test.ts +73 -0
- package/translations/en.json +18 -1
- package/translations/es.json +18 -1
- package/translations/ja.json +18 -1
- package/translations/ko.json +19 -2
- package/translations/ms.json +18 -1
- 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
|
+
})
|
package/translations/en.json
CHANGED
|
@@ -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
|
}
|