@things-factory/ai-assistant 10.1.206 → 10.1.208
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 +2 -1
- package/client/components/chat-input-builder.test.ts +5 -0
- package/client/components/chat-input-builder.ts +4 -1
- package/client/setup-assistant-dock.ts +1 -0
- package/dist-client/components/assistant-chat.js +2 -1
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/chat-input-builder.d.ts +2 -0
- package/dist-client/components/chat-input-builder.js +2 -1
- package/dist-client/components/chat-input-builder.js.map +1 -1
- package/dist-client/components/chat-input-builder.test.js +4 -0
- package/dist-client/components/chat-input-builder.test.js.map +1 -1
- package/dist-client/setup-assistant-dock.js +1 -0
- package/dist-client/setup-assistant-dock.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/assistant-chat-resolver.d.ts +1 -0
- package/dist-server/service/assistant-chat-resolver.js +20 -0
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/index.d.ts +1 -1
- package/dist-server/service/assistant-mode/index.js +3 -1
- package/dist-server/service/assistant-mode/index.js.map +1 -1
- package/dist-server/service/assistant-mode/mode-categories.d.ts +29 -0
- package/dist-server/service/assistant-mode/mode-categories.js +37 -1
- package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
- package/dist-server/service/assistant-mode/registry.js +20 -0
- package/dist-server/service/assistant-mode/registry.js.map +1 -1
- package/dist-server/service/assistant-mode/screen-rules.d.ts +13 -0
- package/dist-server/service/assistant-mode/screen-rules.js +32 -5
- package/dist-server/service/assistant-mode/screen-rules.js.map +1 -1
- package/dist-server/service/assistant-mode/types.d.ts +6 -1
- package/dist-server/service/assistant-mode/types.js.map +1 -1
- package/dist-server/service/chat-message/used-tools.d.ts +11 -0
- package/dist-server/service/chat-message/used-tools.js +33 -0
- package/dist-server/service/chat-message/used-tools.js.map +1 -0
- package/dist-server/service/data-entry/entry-target.d.ts +13 -0
- package/dist-server/service/data-entry/entry-target.js +20 -5
- package/dist-server/service/data-entry/entry-target.js.map +1 -1
- package/dist-server/service/data-entry/entry-tools.js +2 -2
- package/dist-server/service/data-entry/entry-tools.js.map +1 -1
- package/dist-server/service/data-entry/entry-update.d.ts +2 -1
- package/dist-server/service/data-entry/entry-update.js +4 -2
- package/dist-server/service/data-entry/entry-update.js.map +1 -1
- package/dist-server/service/data-read/read-target.d.ts +2 -0
- package/dist-server/service/data-read/read-target.js +2 -2
- package/dist-server/service/data-read/read-target.js.map +1 -1
- package/dist-server/service/data-read/read-tools.js +1 -1
- package/dist-server/service/data-read/read-tools.js.map +1 -1
- package/dist-server/service/testing/mode-conformance.d.ts +4 -0
- package/dist-server/service/testing/mode-conformance.js +6 -0
- package/dist-server/service/testing/mode-conformance.js.map +1 -1
- package/dist-server/service/unattended-run/run-unattended.js +2 -0
- package/dist-server/service/unattended-run/run-unattended.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -9
- package/server/explicit-graphql-types.test.ts +1 -1
- package/server/service/assistant-chat-resolver.ts +21 -1
- package/server/service/assistant-mode/index.ts +1 -1
- package/server/service/assistant-mode/mode-categories.ts +47 -1
- package/server/service/assistant-mode/mode-front.test.ts +65 -0
- package/server/service/assistant-mode/open-subject-door.test.ts +16 -0
- package/server/service/assistant-mode/registry.test.ts +56 -0
- package/server/service/assistant-mode/registry.ts +20 -2
- package/server/service/assistant-mode/resolve.test.ts +2 -1
- package/server/service/assistant-mode/screen-rules.test.ts +35 -3
- package/server/service/assistant-mode/screen-rules.ts +34 -4
- package/server/service/assistant-mode/types.ts +14 -2
- package/server/service/chat-message/fold-history-window.test.ts +1 -1
- package/server/service/chat-message/used-tools.test.ts +19 -0
- package/server/service/chat-message/used-tools.ts +25 -0
- package/server/service/data-entry/data-entry-update.test.ts +11 -0
- package/server/service/data-entry/data-entry.test.ts +65 -1
- package/server/service/data-entry/entry-target.ts +30 -5
- package/server/service/data-entry/entry-tools.ts +2 -2
- package/server/service/data-entry/entry-update.ts +5 -2
- package/server/service/data-read/read-target.ts +5 -3
- package/server/service/data-read/read-tools.ts +1 -1
- package/server/service/data-read/record-keys.test.ts +36 -0
- package/server/service/testing/mode-conformance.ts +8 -1
- package/server/service/unattended-run/run-unattended.ts +2 -0
- package/test/assistant-dock.test.ts +1 -1
- package/translations/ko.json +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/ai-assistant",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.208",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -30,18 +30,18 @@
|
|
|
30
30
|
"@operato/graphql": "^10.29.39",
|
|
31
31
|
"@operato/i18n": "^10.0.0",
|
|
32
32
|
"@operato/input": "^10.29.39",
|
|
33
|
-
"@operato/layout": "^10.29.
|
|
33
|
+
"@operato/layout": "^10.29.49",
|
|
34
34
|
"@operato/popup": "^10.29.39",
|
|
35
35
|
"@operato/shell": "^10.29.39",
|
|
36
36
|
"@operato/styles": "^10.0.0",
|
|
37
37
|
"@operato/utils": "^10.29.39",
|
|
38
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
39
|
-
"@things-factory/apptool-base": "^10.1.
|
|
40
|
-
"@things-factory/attachment-base": "^10.1.
|
|
41
|
-
"@things-factory/auth-base": "^10.1.
|
|
38
|
+
"@things-factory/ai-client-base": "^10.1.208",
|
|
39
|
+
"@things-factory/apptool-base": "^10.1.208",
|
|
40
|
+
"@things-factory/attachment-base": "^10.1.208",
|
|
41
|
+
"@things-factory/auth-base": "^10.1.208",
|
|
42
42
|
"@things-factory/env": "^10.1.149",
|
|
43
|
-
"@things-factory/setting-base": "^10.1.
|
|
44
|
-
"@things-factory/shell": "^10.1.
|
|
43
|
+
"@things-factory/setting-base": "^10.1.208",
|
|
44
|
+
"@things-factory/shell": "^10.1.208",
|
|
45
45
|
"dompurify": "^3.0.0",
|
|
46
46
|
"exceljs": "4.2.0",
|
|
47
47
|
"graphql-scalars": "^1.22.0",
|
|
@@ -55,5 +55,5 @@
|
|
|
55
55
|
"copyfiles": "^2.4.1",
|
|
56
56
|
"rimraf": "^5.0.0"
|
|
57
57
|
},
|
|
58
|
-
"gitHead": "
|
|
58
|
+
"gitHead": "ffc1ceaa9471b9f4a9b86f46c3021f2fed72d17d"
|
|
59
59
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* 컴파일은 통과하므로 타입검사도 못 잡는다. plant 레인이 설치본을 올리다 잡아 알려 주었다(2026-09-18).
|
|
9
9
|
*
|
|
10
10
|
* ── 왜 스키마를 안 세우고 글자를 읽나 ─────────────────────────────────────
|
|
11
|
-
* 이
|
|
11
|
+
* 이 패키지의 시험기는 babel 이라 데코레이터 metadata 가 없다. 스키마를 세우면 리플렉션이 없어 전부
|
|
12
12
|
* 같은 오류가 나고, 그러면 이 시험은 아무것도 못 가른다. 대신 선언을 읽는다 — 데코레이터가 형을
|
|
13
13
|
* 인자로 받고 있는지만 본다. 그것이 빠진 자리가 정확히 터지는 자리다.
|
|
14
14
|
*/
|
|
@@ -27,7 +27,7 @@ import { GraphQLJSON } from 'graphql-scalars'
|
|
|
27
27
|
import '@things-factory/auth-base'
|
|
28
28
|
|
|
29
29
|
import { getRepository, rethrowSpoken } from '@things-factory/shell'
|
|
30
|
-
import { aiClientFor, findToolSpec, openSubjectLine, registryAssistantChat, type AgentClient } from '@things-factory/ai-client-base'
|
|
30
|
+
import { aiClientFor, findToolSpec, openSubjectLine, OPEN_TOOLS, registryAssistantChat, type AgentClient } from '@things-factory/ai-client-base'
|
|
31
31
|
|
|
32
32
|
import { ChatSession } from './chat-session/chat-session.js'
|
|
33
33
|
import { assertStationSession } from './chat-session/station-session-guard.js'
|
|
@@ -54,6 +54,8 @@ import { attachmentForModel, describeAttachment, findChatAttachment } from './ch
|
|
|
54
54
|
import { readTurnAttachments } from './chat-session/turn-attachments.js'
|
|
55
55
|
import { getAssistantMode } from './assistant-mode/registry.js'
|
|
56
56
|
import { resolveModeSettings } from './assistant-mode/resolve.js'
|
|
57
|
+
import { modeFrontCategories } from './assistant-mode/mode-categories.js'
|
|
58
|
+
import { usedToolNames } from './chat-message/used-tools.js'
|
|
57
59
|
import { splitNextRequest } from './assistant-mode/screen-rules.js'
|
|
58
60
|
import { requestTranslator } from './request-translator.js'
|
|
59
61
|
|
|
@@ -134,6 +136,13 @@ class AssistantChatInput {
|
|
|
134
136
|
'The AI agent the person picked for this turn, from aiAgentChoices. Omitted takes the domain default. An agent this domain cannot use is refused - the provider, the model and the key are always the server\'s.'
|
|
135
137
|
})
|
|
136
138
|
agentId?: string
|
|
139
|
+
|
|
140
|
+
@Field({
|
|
141
|
+
nullable: true,
|
|
142
|
+
description:
|
|
143
|
+
'The route of the page the dock is on (cash-transaction-list). It decides which tools the model is offered first; what a person may use is decided as without it.'
|
|
144
|
+
})
|
|
145
|
+
page?: string
|
|
137
146
|
}
|
|
138
147
|
|
|
139
148
|
@ObjectType()
|
|
@@ -506,6 +515,17 @@ export class AssistantChatResolver {
|
|
|
506
515
|
messages: conversation as any,
|
|
507
516
|
systemPrompt,
|
|
508
517
|
toolCategories: settings.toolCategories,
|
|
518
|
+
/*
|
|
519
|
+
* What goes in front when the mode's tools do not fit in one call (ADR-0061 decision 9): the open page's and the
|
|
520
|
+
* anchored record's, then what this conversation used. The anchor is the stored one, never the client's.
|
|
521
|
+
*/
|
|
522
|
+
front: {
|
|
523
|
+
categories: modeFrontCategories(getAssistantMode(input.mode.kind) ?? {}, {
|
|
524
|
+
page: input.page,
|
|
525
|
+
subjectAxis: (session as any)?.anchorType === 'subject' ? String((session as any).anchorId ?? '').split(':')[0] || undefined : undefined
|
|
526
|
+
}),
|
|
527
|
+
tools: usedToolNames(storedRows as any, [OPEN_TOOLS])
|
|
528
|
+
},
|
|
509
529
|
/* 도구가 「어느 대화에서 불렸나」를 안다 — 서버의 값이다(ADR-0061 결정 3). */
|
|
510
530
|
mode: { kind: input.mode.kind, ...(input.mode.target ? { target: input.mode.target } : {}) },
|
|
511
531
|
toolAccess: toolAccessFor(context),
|
|
@@ -12,6 +12,6 @@ export {
|
|
|
12
12
|
clearAssistantModes
|
|
13
13
|
} from './registry.js'
|
|
14
14
|
export { resolveModeSettings, assistantModeView } from './resolve.js'
|
|
15
|
-
export { ABSENCE_RULE, NEXT_REQUEST_RULE, conversationPrompt, splitNextRequest } from './screen-rules.js'
|
|
15
|
+
export { ABSENCE_RULE, FINISH_RULE, NEXT_REQUEST_RULE, conversationPrompt, isPersonRequest, splitNextRequest } from './screen-rules.js'
|
|
16
16
|
export type { AssistantModeSpec, AssistantModeSlash, AssistantModeWarning, UnattendedLimits } from './types.js'
|
|
17
17
|
export type { AssistantModeRef, ModeSettings, AssistantModeView, AssistantModeSlashView, Translate } from './resolve.js'
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
/*
|
|
1
|
+
/* Read by the resolver and by the boot report, so the report and the conversation open the same tools. */
|
|
2
|
+
import { readTargetOf } from '../data-read/read-target.js'
|
|
2
3
|
/**
|
|
3
4
|
* The categories a mode opens: its own, the AI 작업판 (design/ai/workspace.md), the attachment reader when it
|
|
4
5
|
* takes files (design/ai/shared-dock.md §9-4), and one propose tool per record it may enter (design/ai/data-entry.md).
|
|
@@ -23,3 +24,48 @@ export function modeToolCategories(mode: { toolCategories?: string[]; attachment
|
|
|
23
24
|
export function unattendedToolCategories(mode: { toolCategories?: string[]; readTargets?: string[] }): string[] {
|
|
24
25
|
return [...new Set([...(mode.toolCategories ?? []), ...(mode.readTargets ?? []).map(key => `data-read:${key}`)])]
|
|
25
26
|
}
|
|
27
|
+
|
|
28
|
+
/** The screen a turn is asked from - the page the dock is on, and the record its conversation is anchored to. */
|
|
29
|
+
export interface TurnScreen {
|
|
30
|
+
/** The application's route of the open page (`cash-transaction-list`), as the dock sends it. */
|
|
31
|
+
page?: string
|
|
32
|
+
/** The axis of the record the conversation is anchored to (`contract` of `subject` · `contract:<id>`), from the stored anchor. */
|
|
33
|
+
subjectAxis?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type FrontMode = { toolCategories?: string[]; attachments?: { kinds?: string[] }; entryTargets?: string[]; readTargets?: string[] }
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The records the open screen is about, among those the mode reads and enters (ADR-0061 decision 9). Nothing new is
|
|
40
|
+
* declared: a read target's screen route is the page that lists it, and a key's last part is the axis of its record
|
|
41
|
+
* (`office.contract` is the record of the axis `contract`) - reading and entering one record share its key.
|
|
42
|
+
*/
|
|
43
|
+
export function screenTargetKeys(mode: FrontMode, screen: TurnScreen, routeOf: (key: string) => string | undefined = key => readTargetOf(key)?.screen?.route): string[] {
|
|
44
|
+
const keys = [...new Set([...(mode.readTargets ?? []), ...(mode.entryTargets ?? [])])]
|
|
45
|
+
return keys.filter(key => (!!screen.page && routeOf(key) === screen.page) || (!!screen.subjectAxis && key.split('.').pop() === screen.subjectAxis))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The categories a turn puts in front when the mode's tools do not fit in one call: the mode's own, the AI 작업판, the
|
|
50
|
+
* attachment reader, and the open screen's records. The rest the model opens with `openTools`.
|
|
51
|
+
*/
|
|
52
|
+
export function modeFrontCategories(mode: FrontMode, screen: TurnScreen = {}): string[] {
|
|
53
|
+
const keys = screenTargetKeys(mode, screen)
|
|
54
|
+
return [
|
|
55
|
+
...new Set([
|
|
56
|
+
...(mode.toolCategories ?? []),
|
|
57
|
+
'assistant-workspace',
|
|
58
|
+
...(mode.attachments?.kinds?.length ? ['chat-attachments'] : []),
|
|
59
|
+
...keys.filter(key => (mode.readTargets ?? []).includes(key)).map(key => `data-read:${key}`),
|
|
60
|
+
...keys.filter(key => (mode.entryTargets ?? []).includes(key)).map(key => `data-entry:${key}`)
|
|
61
|
+
])
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Each screen the mode could be asked from - a page that lists one of its records, a record's axis - for the boot report. */
|
|
66
|
+
export function modeScreens(mode: FrontMode, routeOf: (key: string) => string | undefined = key => readTargetOf(key)?.screen?.route): TurnScreen[] {
|
|
67
|
+
const keys = [...new Set([...(mode.readTargets ?? []), ...(mode.entryTargets ?? [])])]
|
|
68
|
+
const pages = [...new Set(keys.map(routeOf).filter(Boolean) as string[])]
|
|
69
|
+
const axes = [...new Set(keys.map(key => key.split('.').pop()!))]
|
|
70
|
+
return [{}, ...pages.map(page => ({ page })), ...axes.map(subjectAxis => ({ subjectAxis }))]
|
|
71
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What a turn puts in front when its mode's tools do not fit in one call (ADR-0061 decision 9) - the open screen's
|
|
3
|
+
* records, found from what is already declared: a list's screen route and a key's last part.
|
|
4
|
+
*/
|
|
5
|
+
import { clearReadTargets, declareReadTarget } from '../data-read/read-target'
|
|
6
|
+
import { listReadTarget } from '../data-read/list-read'
|
|
7
|
+
import { modeFrontCategories, modeScreens, screenTargetKeys } from './mode-categories'
|
|
8
|
+
|
|
9
|
+
const list = (key: string, route?: string) =>
|
|
10
|
+
listReadTarget({ key, title: `title.${key}`, name: key, listQuery: 'rows', selection: 'id name', columns: [{ name: 'name', type: 'string' }], ...(route ? { route } : {}) } as any)
|
|
11
|
+
|
|
12
|
+
const mode = {
|
|
13
|
+
toolCategories: ['office-ops'],
|
|
14
|
+
attachments: { kinds: ['image'] },
|
|
15
|
+
readTargets: ['office.cash-transaction', 'office.contract', 'office.contract-allocation', 'office.loan-summary'],
|
|
16
|
+
entryTargets: ['office.cash-transaction', 'office.contract', 'office.contract-round', 'office.expense-line']
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
clearReadTargets()
|
|
21
|
+
declareReadTarget(list('office.cash-transaction', 'cash-transaction-list'))
|
|
22
|
+
declareReadTarget(list('office.contract', 'contract-list'))
|
|
23
|
+
declareReadTarget(list('office.contract-allocation', 'contract-allocation-list'))
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('the open screen decides what goes in front', () => {
|
|
27
|
+
it('★ a list page: that list read and its record entered - one key, both ways', () => {
|
|
28
|
+
expect(modeFrontCategories(mode, { page: 'cash-transaction-list' })).toEqual([
|
|
29
|
+
'office-ops',
|
|
30
|
+
'assistant-workspace',
|
|
31
|
+
'chat-attachments',
|
|
32
|
+
'data-read:office.cash-transaction',
|
|
33
|
+
'data-entry:office.cash-transaction'
|
|
34
|
+
])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('★ a record\'s conversation: the keys whose last part is its axis - never a key that only starts with it', () => {
|
|
38
|
+
expect(screenTargetKeys(mode, { subjectAxis: 'contract' })).toEqual(['office.contract'])
|
|
39
|
+
expect(screenTargetKeys(mode, { subjectAxis: 'contract-round' })).toEqual(['office.contract-round'])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('★ a record the mode does not read or enter is not put in front', () => {
|
|
43
|
+
expect(modeFrontCategories({ ...mode, readTargets: [], entryTargets: [] }, { page: 'cash-transaction-list' })).toEqual(['office-ops', 'assistant-workspace', 'chat-attachments'])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('no screen: the mode\'s own, the AI 작업판 and the attachment reader', () => {
|
|
47
|
+
expect(modeFrontCategories(mode)).toEqual(['office-ops', 'assistant-workspace', 'chat-attachments'])
|
|
48
|
+
expect(modeFrontCategories(mode, { page: 'some-other-page' })).toEqual(['office-ops', 'assistant-workspace', 'chat-attachments'])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('the screens the boot report walks: none, each list page, each axis', () => {
|
|
52
|
+
expect(modeScreens(mode)).toEqual([
|
|
53
|
+
{},
|
|
54
|
+
{ page: 'cash-transaction-list' },
|
|
55
|
+
{ page: 'contract-list' },
|
|
56
|
+
{ page: 'contract-allocation-list' },
|
|
57
|
+
{ subjectAxis: 'cash-transaction' },
|
|
58
|
+
{ subjectAxis: 'contract' },
|
|
59
|
+
{ subjectAxis: 'contract-allocation' },
|
|
60
|
+
{ subjectAxis: 'loan-summary' },
|
|
61
|
+
{ subjectAxis: 'contract-round' },
|
|
62
|
+
{ subjectAxis: 'expense-line' }
|
|
63
|
+
])
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -27,3 +27,19 @@ describe('the door', () => {
|
|
|
27
27
|
expect(door).not.toMatch(/content: result\.reply/)
|
|
28
28
|
})
|
|
29
29
|
})
|
|
30
|
+
|
|
31
|
+
describe('what the door puts in front (ADR-0061 decision 9)', () => {
|
|
32
|
+
const block = door.slice(door.indexOf(' front: {'), door.indexOf(' /* 도구가 「어느 대화에서 불렸나」를 안다'))
|
|
33
|
+
|
|
34
|
+
it('★ the open page, the stored anchor\'s axis and the tools this conversation used - never the anchor the screen sent', () => {
|
|
35
|
+
expect(block).toMatch(/modeFrontCategories\(getAssistantMode\(input\.mode\.kind\) \?\? \{\}, \{/)
|
|
36
|
+
expect(block).toMatch(/page: input\.page,/)
|
|
37
|
+
expect(block).toMatch(/\(session as any\)\?\.anchorType === 'subject' \? String\(\(session as any\)\.anchorId/)
|
|
38
|
+
expect(block).toMatch(/tools: usedToolNames\(storedRows as any, \[OPEN_TOOLS\]\)/)
|
|
39
|
+
expect(block).not.toMatch(/hostContext/)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('★ the page is a field of its own on the input, never read out of the host context', () => {
|
|
43
|
+
expect(door).toMatch(/\n page\?: string\n\}\n\n@ObjectType\(\)/)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -113,3 +113,59 @@ it('★ a slash sentence may need a read target\'s tool - the check opens what t
|
|
|
113
113
|
registerAssistantMode(spec({ readTargets: ['office.contract'], slashTemplates: [{ name: 'contracts', descriptionKey: 'k', templateKey: 't', tools: ['queryOfficeContract'] }] }))
|
|
114
114
|
expect(getAssistantModeWarnings()).toEqual([])
|
|
115
115
|
})
|
|
116
|
+
|
|
117
|
+
describe('what a turn puts in front fits in one call (ADR-0061 decision 9)', () => {
|
|
118
|
+
const many = (prefix: string, count: number) =>
|
|
119
|
+
Array.from({ length: count }, (_, i) => {
|
|
120
|
+
registerToolCategory({
|
|
121
|
+
name: `${prefix}-${i}`,
|
|
122
|
+
guidance: 'call it before answering',
|
|
123
|
+
specs: [{ kind: 'read', name: `${prefix}Tool${i}`, description: 'one tool', schema: {}, builder: () => ({}) } as any]
|
|
124
|
+
} as any)
|
|
125
|
+
return `${prefix}-${i}`
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('★ a mode whose own tools do not leave room for openTools is reported by code at boot', () => {
|
|
129
|
+
registerAssistantMode(spec({ kind: 'office.finance', toolCategories: many('own', 70) }))
|
|
130
|
+
|
|
131
|
+
expect(getAssistantModeWarnings()).toContainEqual(
|
|
132
|
+
expect.objectContaining({ code: 'assistant-mode-front-over-budget', params: expect.objectContaining({ kind: 'office.finance', front: 70, budget: 64 }) })
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('★ a large mode whose front fits is not reported - the rest is opened as needed', () => {
|
|
137
|
+
const lists = Array.from({ length: 100 }, (_, i) => `office.list-${i}`)
|
|
138
|
+
for (const key of lists) {
|
|
139
|
+
registerToolCategory({
|
|
140
|
+
name: `data-read:${key}`,
|
|
141
|
+
guidance: 'call it before answering',
|
|
142
|
+
specs: [{ kind: 'read', name: `query${key.replace(/\W/g, '')}`, description: 'one list', schema: {}, builder: () => ({}) } as any]
|
|
143
|
+
} as any)
|
|
144
|
+
}
|
|
145
|
+
registerAssistantMode(spec({ kind: 'office.finance', toolCategories: many('own', 10), readTargets: lists }))
|
|
146
|
+
|
|
147
|
+
expect(getAssistantModeWarnings().filter(warning => warning.code === 'assistant-mode-front-over-budget')).toEqual([])
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('★ the front counts the screen\'s record and openTools - 62 of its own fit, 63 do not', () => {
|
|
151
|
+
const lists = Array.from({ length: 10 }, (_, i) => `office.list-${i}`)
|
|
152
|
+
for (const key of lists) {
|
|
153
|
+
registerToolCategory({
|
|
154
|
+
name: `data-read:${key}`,
|
|
155
|
+
guidance: 'call it before answering',
|
|
156
|
+
specs: [{ kind: 'read', name: `query${key.replace(/\W/g, '')}`, description: 'one list', schema: {}, builder: () => ({}) } as any]
|
|
157
|
+
} as any)
|
|
158
|
+
}
|
|
159
|
+
registerAssistantMode(spec({ kind: 'office.fits', toolCategories: many('fits', 62), readTargets: lists }))
|
|
160
|
+
registerAssistantMode(spec({ kind: 'office.full', toolCategories: many('full', 63), readTargets: lists }))
|
|
161
|
+
|
|
162
|
+
const over = getAssistantModeWarnings().filter(warning => warning.code === 'assistant-mode-front-over-budget')
|
|
163
|
+
expect(over.map(warning => [warning.params.kind, warning.params.front])).toEqual([['office.full', 64]])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('a mode that fits whole is offered whole and never reported', () => {
|
|
167
|
+
registerAssistantMode(spec({ toolCategories: many('small', 60) }))
|
|
168
|
+
|
|
169
|
+
expect(getAssistantModeWarnings().filter(warning => warning.code === 'assistant-mode-front-over-budget')).toEqual([])
|
|
170
|
+
})
|
|
171
|
+
})
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* after every module is in.
|
|
13
13
|
*/
|
|
14
14
|
import { entryTargetOf } from '../data-entry/entry-target.js'
|
|
15
|
-
import { getToolSpecsFor } from '@things-factory/ai-client-base'
|
|
16
|
-
import { modeToolCategories } from './mode-categories'
|
|
15
|
+
import { getToolSpecsFor, MAX_OFFERED_TOOLS } from '@things-factory/ai-client-base'
|
|
16
|
+
import { modeFrontCategories, modeScreens, modeToolCategories } from './mode-categories'
|
|
17
17
|
|
|
18
18
|
import type { AssistantModeSpec, AssistantModeWarning } from './types'
|
|
19
19
|
|
|
@@ -107,6 +107,24 @@ export function getAssistantModeWarnings(): ReadonlyArray<AssistantModeWarning>
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
|
+
for (const mode of modes.values()) {
|
|
111
|
+
/*
|
|
112
|
+
* What a turn puts in front, on the screen that puts the most (ADR-0061 decision 9). The front is offered whole or
|
|
113
|
+
* the turn is refused, so a mode whose front does not fit with openTools fails every turn of that screen.
|
|
114
|
+
*/
|
|
115
|
+
const answering = (categories: string[]) => getToolSpecsFor(categories).filter(spec => spec.kind === 'read').length
|
|
116
|
+
if (answering(modeToolCategories(mode) ?? []) <= MAX_OFFERED_TOOLS) continue
|
|
117
|
+
for (const screen of modeScreens(mode)) {
|
|
118
|
+
const front = answering(modeFrontCategories(mode, screen))
|
|
119
|
+
if (front + 1 <= MAX_OFFERED_TOOLS) continue
|
|
120
|
+
out.push({
|
|
121
|
+
code: 'assistant-mode-front-over-budget',
|
|
122
|
+
params: { kind: mode.kind, front, budget: MAX_OFFERED_TOOLS, ...screen },
|
|
123
|
+
message: `mode '${mode.kind}' puts ${front} tools in front${screen.page ? ` on '${screen.page}'` : ''}${screen.subjectAxis ? ` for a '${screen.subjectAxis}'` : ''} - with openTools more than one call carries (${MAX_OFFERED_TOOLS}). Narrow its own tool categories.`
|
|
124
|
+
})
|
|
125
|
+
break
|
|
126
|
+
}
|
|
127
|
+
}
|
|
110
128
|
return out
|
|
111
129
|
}
|
|
112
130
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { clearAssistantModes, registerAssistantMode } from './registry'
|
|
10
10
|
import { assistantModeView, resolveModeSettings } from './resolve'
|
|
11
|
-
import { ABSENCE_RULE, NEXT_REQUEST_RULE, conversationPrompt } from './screen-rules'
|
|
11
|
+
import { ABSENCE_RULE, FINISH_RULE, NEXT_REQUEST_RULE, conversationPrompt } from './screen-rules'
|
|
12
12
|
|
|
13
13
|
/** Stands in for `context.t` — returns the key and params so the test can see what was translated. */
|
|
14
14
|
const t = (key: string, params?: Record<string, any>) => `t(${key}${params?.count !== undefined ? `,${params.count}` : ''})`
|
|
@@ -48,6 +48,7 @@ it('★ every conversation carries the absence rule and the next-request rule on
|
|
|
48
48
|
expect(systemPrompt.startsWith('you answer about one space')).toBe(true)
|
|
49
49
|
expect(systemPrompt.split(ABSENCE_RULE)).toHaveLength(2)
|
|
50
50
|
expect(systemPrompt.split(NEXT_REQUEST_RULE)).toHaveLength(2)
|
|
51
|
+
expect(systemPrompt.split(FINISH_RULE)).toHaveLength(2)
|
|
51
52
|
})
|
|
52
53
|
|
|
53
54
|
it('★ an unregistered kind is refused — no plain-conversation fallback', () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* The rules every conversation follows (operato-application design/ai/screen-pattern.md §4).
|
|
3
3
|
*/
|
|
4
|
-
import { ABSENCE_RULE, NEXT_REQUEST_RULE, conversationPrompt, splitNextRequest } from './screen-rules'
|
|
4
|
+
import { ABSENCE_RULE, FINISH_RULE, NEXT_REQUEST_RULE, conversationPrompt, isPersonRequest, splitNextRequest } from './screen-rules'
|
|
5
5
|
|
|
6
6
|
describe('the next request an answer names', () => {
|
|
7
7
|
it('★ the last line is taken off the reply and becomes the suggestion', () => {
|
|
@@ -24,16 +24,48 @@ describe('the next request an answer names', () => {
|
|
|
24
24
|
expect(splitNextRequest('It has 3 blades.')).toEqual({ reply: 'It has 3 blades.' })
|
|
25
25
|
})
|
|
26
26
|
|
|
27
|
+
/* 2026-10-23, an office link: the input offered a sentence telling the person what to say, with the invoice's key in it. */
|
|
28
|
+
it('★ a line that tells the person what to say is taken off and offered as nothing', () => {
|
|
29
|
+
const answer = '계산서 연결 후보를 찾았습니다.'
|
|
30
|
+
const line = 'Next: "2026-01-20 계산서(tax-invoice:fe0e8503-ec5f-4da4-9c07-2d342c93d498)로 연결 제안 진행해줘”라고 해줘.'
|
|
31
|
+
expect(splitNextRequest(`${answer}\n${line}`)).toEqual({ reply: answer })
|
|
32
|
+
expect(splitNextRequest(`${answer}\nNext: "연결 진행해줘"라고 말해 주세요`)).toEqual({ reply: answer })
|
|
33
|
+
expect(splitNextRequest(`${answer}\nNext: 「연결해 줘」라고 해 줘`)).toEqual({ reply: answer })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('★ a line that carries an id or a record key is no request - nobody types one', () => {
|
|
37
|
+
expect(splitNextRequest('Found it.\nNext: tax-invoice:fe0e8503-ec5f 로 연결해 줘')).toEqual({ reply: 'Found it.' })
|
|
38
|
+
expect(splitNextRequest('Found it.\nNext: fe0e8503-ec5f-4da4-9c07-2d342c93d498 계산서로 연결해 줘')).toEqual({ reply: 'Found it.' })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('a request in the person\'s words is offered, apostrophes and numbers included', () => {
|
|
42
|
+
expect(isPersonRequest("Show me last month's deposits")).toBe(true)
|
|
43
|
+
expect(isPersonRequest('2026-01 승화푸드 입금 550,000원도 연결해 줘')).toBe(true)
|
|
44
|
+
expect(isPersonRequest('')).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
27
47
|
it('a reply that is only the line keeps it - an empty answer is worse than an odd one', () => {
|
|
28
48
|
expect(splitNextRequest('Next: 날개를 늘려 줘')).toEqual({ reply: 'Next: 날개를 늘려 줘' })
|
|
29
49
|
})
|
|
30
50
|
})
|
|
31
51
|
|
|
32
52
|
describe('the prompt every conversation gets', () => {
|
|
33
|
-
it('★ the persona first, then the absence rule and the next-request rule', () => {
|
|
53
|
+
it('★ the persona first, then the absence rule, the finish rule and the next-request rule', () => {
|
|
34
54
|
const prompt = conversationPrompt('You edit figures.')
|
|
35
55
|
expect(prompt.indexOf('You edit figures.')).toBe(0)
|
|
36
56
|
expect(prompt.indexOf(ABSENCE_RULE)).toBeGreaterThan(0)
|
|
37
|
-
expect(prompt.indexOf(
|
|
57
|
+
expect(prompt.indexOf(FINISH_RULE)).toBeGreaterThan(prompt.indexOf(ABSENCE_RULE))
|
|
58
|
+
expect(prompt.indexOf(NEXT_REQUEST_RULE)).toBeGreaterThan(prompt.indexOf(FINISH_RULE))
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/* What the two rules must keep saying - a softer wording let the model ask before a card and hand the person a script. */
|
|
62
|
+
it('★ a card is proposed in the turn, never asked about; the next request is the person\'s own words', () => {
|
|
63
|
+
expect(FINISH_RULE).toMatch(/in the same turn/)
|
|
64
|
+
expect(FINISH_RULE).toMatch(/never ask whether to propose it/)
|
|
65
|
+
expect(FINISH_RULE).toMatch(/never ask the person to confirm, repeat or resend/)
|
|
66
|
+
expect(FINISH_RULE).toMatch(/no internal ids, keys, field names or codes/)
|
|
67
|
+
expect(NEXT_REQUEST_RULE).toMatch(/no quotation marks/)
|
|
68
|
+
expect(NEXT_REQUEST_RULE).toMatch(/Never a yes to your own question/)
|
|
69
|
+
expect(NEXT_REQUEST_RULE).toMatch(/no ids or keys/)
|
|
38
70
|
})
|
|
39
71
|
})
|
|
@@ -13,18 +13,47 @@ export const ABSENCE_RULE =
|
|
|
13
13
|
'When a tool says something is absent or refuses, state that and what the person can do. ' +
|
|
14
14
|
'Do not guess why, and do not describe our machinery - words like passed, delivered or loaded name our plumbing, not their situation.'
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* §4-3 — a request is finished in the turn it is asked (2026-10-23, the user: 「한번에 끝낼 일을 왜 자꾸 확인하고, 프롬프트를
|
|
18
|
+
* 또 입력하게 만드는가」). Asked to link a deposit, the model found the invoice, then asked whether to propose the link
|
|
19
|
+
* and offered 「…진행해줘"라고 해줘」 as the next request - a second turn for what the first could do. A proposal card is
|
|
20
|
+
* the person's review; asking before it is asking twice.
|
|
21
|
+
*/
|
|
22
|
+
export const FINISH_RULE =
|
|
23
|
+
'Finish what was asked in this turn: read what it needs and, when it changes records, propose the card in the same turn - ' +
|
|
24
|
+
'the card is the person\'s review, so never ask whether to propose it, and never ask the person to confirm, repeat or ' +
|
|
25
|
+
'resend the request. Do not ask for an id, a number or a name your tools can read. Ask only when the records leave a ' +
|
|
26
|
+
'choice that only the person can make, and then ask just that choice. Answer briefly: what was found and what the card ' +
|
|
27
|
+
'does, in the person\'s words - no internal ids, keys, field names or codes.'
|
|
28
|
+
|
|
16
29
|
/** The line a reply ends with when a next request is natural (§4-1). */
|
|
17
30
|
const NEXT_PREFIX = 'Next:'
|
|
18
31
|
|
|
19
32
|
/** §4-1 — the reply names the next request, and the input offers it. */
|
|
20
33
|
export const NEXT_REQUEST_RULE =
|
|
21
34
|
`After answering, when there is a natural next request, end the reply with one last line "${NEXT_PREFIX} <request>": ` +
|
|
22
|
-
|
|
23
|
-
'
|
|
35
|
+
'a new request the person is most likely to make next, exactly the words they would type, in the language they wrote in - ' +
|
|
36
|
+
'no quotation marks, no "say ..." or "ask me ...", no ids or keys. Never a yes to your own question and never what this ' +
|
|
37
|
+
'reply could have done - do that now instead. Offer only what this conversation can do with its tools. ' +
|
|
38
|
+
'When nothing natural follows, leave the line out.'
|
|
24
39
|
|
|
25
40
|
/** A mode's persona with the rules every conversation follows. */
|
|
26
41
|
export function conversationPrompt(modePrompt: string): string {
|
|
27
|
-
return [modePrompt.trim(), ABSENCE_RULE, NEXT_REQUEST_RULE].join('\n\n')
|
|
42
|
+
return [modePrompt.trim(), ABSENCE_RULE, FINISH_RULE, NEXT_REQUEST_RULE].join('\n\n')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Quotation marks inside a request: it quotes another sentence, so it is about a request, not one. */
|
|
46
|
+
const QUOTED = /["“”‘「」『』`]/
|
|
47
|
+
/** An id or a record key - nothing a person types. */
|
|
48
|
+
const MACHINE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|\b[a-z]+(?:-[a-z]+)*:[0-9a-f-]{8,}/i
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether the line names a request the person could send as it is. The input offers it to be sent with one key, so a
|
|
52
|
+
* line that tells the person what to say (「"…진행해줘"라고 해줘」) or carries an id is no request: offered, it is sent
|
|
53
|
+
* back to the model as the person's words.
|
|
54
|
+
*/
|
|
55
|
+
export function isPersonRequest(text: string): boolean {
|
|
56
|
+
return !!text && !QUOTED.test(text) && !MACHINE.test(text)
|
|
28
57
|
}
|
|
29
58
|
|
|
30
59
|
/**
|
|
@@ -43,5 +72,6 @@ export function splitNextRequest(reply: string): { reply: string; suggestion?: s
|
|
|
43
72
|
const suggestion = match[1].replace(/^["“'`]+|["”'`]+$/g, '').trim()
|
|
44
73
|
const rest = lines.slice(0, -1).join('\n').replace(/\s+$/, '')
|
|
45
74
|
if (!suggestion || !rest) return { reply: text }
|
|
46
|
-
|
|
75
|
+
/* The line is ours either way - a line that is no request is taken off and offered as nothing. */
|
|
76
|
+
return isPersonRequest(suggestion) ? { reply: rest, suggestion } : { reply: rest }
|
|
47
77
|
}
|
|
@@ -118,9 +118,21 @@ export interface AssistantModeWarning {
|
|
|
118
118
|
* `assistant-mode-slash-outside-tools` — a `/` sentence needs a tool this mode does not open.
|
|
119
119
|
* `assistant-mode-subject-claimed-twice` — two conversations name the same subject type.
|
|
120
120
|
* `assistant-mode-entry-target-unknown` — a record the mode enters that no application registered.
|
|
121
|
+
* `assistant-mode-front-over-budget` — what a turn puts in front on some screen does not fit in one call (ADR-0061 decision 9).
|
|
121
122
|
*/
|
|
122
|
-
code: 'assistant-mode-slash-outside-tools' | 'assistant-mode-subject-claimed-twice' | 'assistant-mode-entry-target-unknown'
|
|
123
|
-
params: {
|
|
123
|
+
code: 'assistant-mode-slash-outside-tools' | 'assistant-mode-subject-claimed-twice' | 'assistant-mode-entry-target-unknown' | 'assistant-mode-front-over-budget'
|
|
124
|
+
params: {
|
|
125
|
+
kind: string
|
|
126
|
+
slash?: string
|
|
127
|
+
tool?: string
|
|
128
|
+
subject?: string
|
|
129
|
+
kinds?: string[]
|
|
130
|
+
target?: string
|
|
131
|
+
front?: number
|
|
132
|
+
budget?: number
|
|
133
|
+
page?: string
|
|
134
|
+
subjectAxis?: string
|
|
135
|
+
}
|
|
124
136
|
/** For a person reading the boot log; callers act on the code and params. */
|
|
125
137
|
message: string
|
|
126
138
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* ④ 워터마크 행이 사라졌으면 전부 미반영으로 본다 — 빠뜨리는 쪽보다 다시 요약하는 쪽이 안전하다.
|
|
18
18
|
*
|
|
19
19
|
* ── 왜 여기로 왔나 (2026-09-18) ────────────────────────────────────────────
|
|
20
|
-
* 요약 접기를 `board-ai` 에서 이
|
|
20
|
+
* 요약 접기를 `board-ai` 에서 이 패키지로 옮기면서(`4257583d1b`) 이 시험만 board-ai 에 남았다. 읽는
|
|
21
21
|
* 파일이 사라졌으니 board-ai 의 `npm test` 가 그날부터 실패했다 — 옮긴 사람이 시험을 두고 온 것이다.
|
|
22
22
|
* 여기 오면서 `dist-server` 를 거치지 않고 소스를 바로 읽는다.
|
|
23
23
|
*/
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { usedToolNames } from './used-tools'
|
|
2
|
+
|
|
3
|
+
const answer = (...names: string[]) => ({ role: 'assistant', toolUsages: JSON.stringify(names.map(name => ({ name, arguments: {}, kind: 'read' }))) })
|
|
4
|
+
|
|
5
|
+
describe('the tools a conversation used, for the front of the next turn (ADR-0061 decision 9)', () => {
|
|
6
|
+
it('★ the latest answer first, the latest call of it first, each once', () => {
|
|
7
|
+
const rows = [answer('readA', 'readB'), { role: 'user', toolUsages: undefined }, answer('readC', 'readA')]
|
|
8
|
+
expect(usedToolNames(rows)).toEqual(['readA', 'readC', 'readB'])
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('★ the opening tool is not a tool to put in front', () => {
|
|
12
|
+
expect(usedToolNames([answer('openTools', 'readA')], ['openTools'])).toEqual(['readA'])
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('a person\'s line and a trace that does not read are skipped', () => {
|
|
16
|
+
const rows = [{ role: 'user', toolUsages: JSON.stringify([{ name: 'readX' }]) }, { role: 'assistant', toolUsages: '{broken' }, answer('readA')]
|
|
17
|
+
expect(usedToolNames(rows)).toEqual(['readA'])
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tools the earlier answers of a conversation called, the latest first (ADR-0061 decision 9).
|
|
3
|
+
*
|
|
4
|
+
* A turn puts them in front after the open screen's records, so a question that follows on from the last one finds the
|
|
5
|
+
* tool it needs without opening it again. Read from the stored trace (`ChatMessage.toolUsages`); a trace that does not
|
|
6
|
+
* read is skipped, as the dock's tool panel skips it.
|
|
7
|
+
*/
|
|
8
|
+
export function usedToolNames(rows: ReadonlyArray<{ role?: string; toolUsages?: string | null }>, skip: ReadonlyArray<string> = []): string[] {
|
|
9
|
+
const out: string[] = []
|
|
10
|
+
for (const row of [...rows].reverse()) {
|
|
11
|
+
if (row.role !== 'assistant' || !row.toolUsages) continue
|
|
12
|
+
let usages: unknown
|
|
13
|
+
try {
|
|
14
|
+
usages = JSON.parse(row.toolUsages)
|
|
15
|
+
} catch {
|
|
16
|
+
continue
|
|
17
|
+
}
|
|
18
|
+
if (!Array.isArray(usages)) continue
|
|
19
|
+
for (const usage of [...usages].reverse()) {
|
|
20
|
+
const name = typeof usage?.name === 'string' ? usage.name : undefined
|
|
21
|
+
if (name && !skip.includes(name) && !out.includes(name)) out.push(name)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return out
|
|
25
|
+
}
|
|
@@ -74,6 +74,17 @@ describe('which record is meant', () => {
|
|
|
74
74
|
expect(resolveChangeRefs('office.expense-line', [{ ref: 'current', values: {} }], { record: { target: 'office.contract', id: 'c1' } }).unresolved).toEqual(['current'])
|
|
75
75
|
})
|
|
76
76
|
|
|
77
|
+
/* 2026-10-23: the open record is named to the model by its key; a change naming it so found nothing. */
|
|
78
|
+
it('★ the key a record was shown by names it - `<axis>:<id>`, the axis being the target key\'s last part', () => {
|
|
79
|
+
const id = 'f92c04ec-40c4-4be8-8160-080cff20e750'
|
|
80
|
+
const picked = (key: string, ref: string) => resolveChangeRefs(key, [{ ref, values: {} }], undefined).picked.map(one => one.id)
|
|
81
|
+
expect(picked('office.contract-round', `contract-round:${id}`)).toEqual([id])
|
|
82
|
+
expect(picked('office.contract-round', id)).toEqual([id])
|
|
83
|
+
/* Another record's key is not this record, whatever its uuid - it is looked up as written and found by nobody. */
|
|
84
|
+
expect(picked('office.contract-round', `contract:${id}`)).toEqual([`contract:${id}`])
|
|
85
|
+
expect(picked('office.contract', `contract-round:${id}`)).toEqual([`contract-round:${id}`])
|
|
86
|
+
})
|
|
87
|
+
|
|
77
88
|
it('★ an id from the screen is only a candidate - a record the person cannot read is not found, and nothing is shown of it', async () => {
|
|
78
89
|
const result: any = await proposeChanges(expense, { changes: [{ ref: 'current', values: { amount: 13000 } }] }, { host: { record: { id: 'someone-elses' } } }, run)
|
|
79
90
|
expect(result.rows[0].issues).toEqual([{ code: 'record-not-found', blocking: true }])
|