@things-factory/ai-assistant 10.1.38 → 10.1.40
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 +30 -1
- package/client/utils/assistant-request-context.ts +32 -0
- package/dist-client/components/assistant-chat.d.ts +12 -0
- package/dist-client/components/assistant-chat.js +24 -1
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-request-context.d.ts +23 -0
- package/dist-client/utils/assistant-request-context.js +25 -0
- package/dist-client/utils/assistant-request-context.js.map +1 -1
- package/dist-server/service/chat-message/chat-message-broadcast.d.ts +6 -6
- package/dist-server/service/chat-message/chat-message-broadcast.js +5 -5
- package/dist-server/service/chat-message/chat-message-broadcast.js.map +1 -1
- package/dist-server/service/chat-message/chat-message-room.d.ts +3 -3
- package/dist-server/service/chat-message/chat-message-room.js +10 -7
- package/dist-server/service/chat-message/chat-message-room.js.map +1 -1
- package/dist-server/service/chat-message/chat-message-subscription.js +6 -5
- package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -1
- package/dist-server/service/chat-session/chat-session.js +1 -1
- package/dist-server/service/chat-session/chat-session.js.map +1 -1
- package/dist-server/service/chat-session/session-anchor.d.ts +5 -1
- package/dist-server/service/chat-session/session-anchor.js +23 -1
- package/dist-server/service/chat-session/session-anchor.js.map +1 -1
- package/dist-server/service/testing/mode-conformance.js +6 -5
- package/dist-server/service/testing/mode-conformance.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/server/service/chat-message/chat-message-broadcast.ts +15 -13
- package/server/service/chat-message/chat-message-room.ts +10 -7
- package/server/service/chat-message/chat-message-subscription.ts +6 -5
- package/server/service/chat-session/chat-session.ts +1 -1
- package/server/service/chat-session/session-anchor.ts +26 -1
- package/server/service/session-anchor.test.ts +55 -1
- package/server/service/testing/conversation-script.test.ts +1 -1
- package/server/service/testing/mode-conformance.test.ts +2 -2
- package/server/service/testing/mode-conformance.ts +6 -5
- package/test/assistant-request-context.test.ts +41 -1
- package/test/open-doors.test.ts +132 -0
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.40",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
"@operato/graphql": "^10.0.0",
|
|
30
30
|
"@operato/i18n": "^10.0.0",
|
|
31
31
|
"@operato/styles": "^10.0.0",
|
|
32
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
33
|
-
"@things-factory/auth-base": "^10.1.
|
|
32
|
+
"@things-factory/ai-client-base": "^10.1.39",
|
|
33
|
+
"@things-factory/auth-base": "^10.1.40",
|
|
34
34
|
"@things-factory/env": "^10.1.20",
|
|
35
|
-
"@things-factory/shell": "^10.1.
|
|
35
|
+
"@things-factory/shell": "^10.1.40",
|
|
36
36
|
"dompurify": "^3.0.0",
|
|
37
37
|
"graphql-scalars": "^1.22.0",
|
|
38
38
|
"graphql-tag": "^2.12.6",
|
|
@@ -44,5 +44,5 @@
|
|
|
44
44
|
"copyfiles": "^2.4.1",
|
|
45
45
|
"rimraf": "^5.0.0"
|
|
46
46
|
},
|
|
47
|
-
"gitHead": "
|
|
47
|
+
"gitHead": "51c5d804c14ad8fcc6d4707e6ef80c9deede2766"
|
|
48
48
|
}
|
|
@@ -28,39 +28,41 @@ export function chatMessageFilter(domainId: string, sessionId: string) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/*
|
|
31
|
-
* ──
|
|
31
|
+
* ── Is the room open to this person — ADR-0073 decision 2 ─────────────────────
|
|
32
32
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* An open conversation is readable by anyone in the domain, a private one by its participants. The
|
|
34
|
+
* stored path (`chatMessages`) asked that and the broadcast path did not — subscribing checked only
|
|
35
|
+
* domain membership, and the filter matched only domain + session. So anyone in the domain holding a
|
|
36
|
+
* private session's id received that conversation as it was written.
|
|
36
37
|
*
|
|
37
|
-
*
|
|
38
|
+
* The rule is one line (`mayReadSession`) and both paths read that line. Writing it again here would
|
|
39
|
+
* make two copies from that day on.
|
|
38
40
|
*/
|
|
39
|
-
/**
|
|
41
|
+
/** The two questions about a room. The caller supplies the storage — this file does not know it. */
|
|
40
42
|
export interface ChatRoomLookup {
|
|
41
|
-
/**
|
|
43
|
+
/** The session's visibility now; `undefined` when this domain has no such session. */
|
|
42
44
|
visibility(): Promise<string | null | undefined>
|
|
43
|
-
/**
|
|
45
|
+
/** Whether the caller sits in that session now. */
|
|
44
46
|
isParticipant(): Promise<boolean>
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
export async function mayHearRoom(room: ChatRoomLookup): Promise<boolean> {
|
|
48
50
|
const visibility = await room.visibility()
|
|
49
51
|
|
|
50
|
-
/*
|
|
52
|
+
/* A session this domain does not have — never read as open. */
|
|
51
53
|
if (visibility === undefined) return false
|
|
52
54
|
|
|
53
|
-
/*
|
|
55
|
+
/* An open room does not ask about membership — one query fewer per message. */
|
|
54
56
|
if (mayReadSession(visibility, false)) return true
|
|
55
57
|
|
|
56
58
|
return mayReadSession(visibility, await room.isParticipant())
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
/**
|
|
60
|
-
*
|
|
62
|
+
* Passes only the same domain + session, and only while **that room is open to this person**.
|
|
61
63
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
+
* The room is asked again for every message — a session can be made private while someone is
|
|
65
|
+
* listening. People write these messages, so they are infrequent, and an open room costs one query.
|
|
64
66
|
*/
|
|
65
67
|
export function chatMessageRoomFilter(domainId: string, sessionId: string, room: ChatRoomLookup) {
|
|
66
68
|
const sameSession = chatMessageFilter(domainId, sessionId)
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/*
|
|
2
|
-
*
|
|
2
|
+
* The room on the broadcast path — the stream the subscription returns, and the two lookups that
|
|
3
|
+
* ask about the room.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
5
|
+
* jest cannot read the subscription resolver (parameter decorators: @Root · @Arg), so composing the
|
|
6
|
+
* stream lives here — the test runs **the same thing** the resolver returns
|
|
7
|
+
* (`chat-message-room.test.ts`).
|
|
6
8
|
*
|
|
7
|
-
*
|
|
9
|
+
* The caller passes the storage in. Importing the entities here would make this file unreadable to
|
|
10
|
+
* the tests too.
|
|
8
11
|
*/
|
|
9
12
|
import { filter, pipe } from 'graphql-yoga'
|
|
10
13
|
import { pubsub } from '@things-factory/shell'
|
|
@@ -16,8 +19,8 @@ interface FindOne {
|
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
22
|
+
* The two lookups about a room. **Both search within the caller's domain** — the same condition as
|
|
23
|
+
* the stored path's query (`chatMessages`).
|
|
21
24
|
*/
|
|
22
25
|
export function chatRoomLookup(
|
|
23
26
|
repos: { sessions: FindOne; participants: FindOne },
|
|
@@ -39,7 +42,7 @@ export function chatRoomLookup(
|
|
|
39
42
|
}
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
/**
|
|
45
|
+
/** The stream the subscription returns — same domain + session, and the room open to this person at every message. */
|
|
43
46
|
export function chatMessageStream(domainId: string, sessionId: string, room: ChatRoomLookup) {
|
|
44
47
|
return pipe(pubsub.subscribe(CHAT_MESSAGE_TOPIC), filter(chatMessageRoomFilter(domainId, sessionId, room)))
|
|
45
48
|
}
|
|
@@ -45,12 +45,13 @@ export class ChatMessageSubscription {
|
|
|
45
45
|
await assertDomainSubscribeAllowed(domain, user, (d, u) => process.superUserGranted(d, u))
|
|
46
46
|
|
|
47
47
|
/*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* **Ask about the room** — ADR-0073 decision 2. An open room is for anyone in the domain, a private
|
|
49
|
+
* one for its participants. The stored path (`chatMessages`) asked this and the broadcast path did
|
|
50
|
+
* not, so anyone in the domain holding a private session's id received it live.
|
|
51
51
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
52
|
+
* A room the caller cannot enter is refused rather than handed an empty stream, in the same words
|
|
53
|
+
* as a missing session — so a private room's existence is not confirmed either. Once subscribed,
|
|
54
|
+
* the room is asked again for every message (`chatMessageRoomFilter`).
|
|
54
55
|
*/
|
|
55
56
|
const room = chatRoomLookup(
|
|
56
57
|
{ sessions: getRepository(ChatSession), participants: getRepository(ChatSessionParticipant) },
|
|
@@ -80,7 +80,7 @@ export class ChatSession {
|
|
|
80
80
|
@Field({
|
|
81
81
|
nullable: true,
|
|
82
82
|
description:
|
|
83
|
-
"What this conversation is anchored to: 'board' | 'figure' (authoring a Figure) | 'space' | 'instance' | 'domain' | 'none'. Absent on legacy rows — treated as 'board' when boardId is present."
|
|
83
|
+
"What this conversation is anchored to: 'board' | 'figure' (authoring a Figure) | 'application' | 'space' | 'instance' | 'domain' | 'subject' (one subject in a 360, '<axis>:<itemId>') | 'none'. Absent on legacy rows — treated as 'board' when boardId is present."
|
|
84
84
|
})
|
|
85
85
|
anchorType?: string
|
|
86
86
|
|
|
@@ -50,11 +50,15 @@
|
|
|
50
50
|
* space 공간 운영 대화 — 사이트와 그 공간에서 도는 트윈들
|
|
51
51
|
* instance 트윈 하나에 대한 대화
|
|
52
52
|
* domain 테넌트 전체가 주제인 대화 — 공장 전체 문답처럼 더 좁게 가리킬 것이 없을 때
|
|
53
|
+
* subject one subject in a 360 — `<axis>:<itemId>`: the 360 frame's axis word (ADR-0067, the shared
|
|
54
|
+
* frame; no new words are made here) and that row's uuid. Anchoring to `domain` instead
|
|
55
|
+
* would put every subject's conversations in one bucket and lose the per-subject list and
|
|
56
|
+
* history boundary (ruled 2026-09-24).
|
|
53
57
|
* none 앵커로 찾을 수 없는 대화. 「아직 아무것도 고르지 않았다」는 화면 상태다
|
|
54
58
|
*/
|
|
55
59
|
// Figure-specific history requested explicitly: reuse the existing varchar
|
|
56
60
|
// anchor columns, without writing Figure ids into the legacy boardId column.
|
|
57
|
-
export const SESSION_ANCHOR_TYPES = ['board', 'figure', 'application', 'space', 'instance', 'domain', 'none'] as const
|
|
61
|
+
export const SESSION_ANCHOR_TYPES = ['board', 'figure', 'application', 'space', 'instance', 'domain', 'subject', 'none'] as const
|
|
58
62
|
|
|
59
63
|
export type SessionAnchorType = (typeof SESSION_ANCHOR_TYPES)[number]
|
|
60
64
|
|
|
@@ -76,6 +80,25 @@ function assertAnchorType(anchorType: string | undefined, where: string): Sessio
|
|
|
76
80
|
return type
|
|
77
81
|
}
|
|
78
82
|
|
|
83
|
+
/**
|
|
84
|
+
* A `subject` anchor id is `<axis>:<itemId>`, the item being that row's uuid (ruled 2026-09-24).
|
|
85
|
+
*
|
|
86
|
+
* Checked because the shape is the whole contract: a screen that passes what it shows — an EPC like
|
|
87
|
+
* `urn:epc:id:sgtin:…`, or an order's name — would store a pointer to nothing stable. A name changes
|
|
88
|
+
* and the conversation is orphaned; an EPC splits on its own colons. Refused here, on write and on
|
|
89
|
+
* read, rather than stored and never found.
|
|
90
|
+
*/
|
|
91
|
+
const SUBJECT_ANCHOR_ID = /^[a-z][a-z0-9-]*:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
92
|
+
|
|
93
|
+
function assertSubjectAnchorId(type: SessionAnchorType, id: string | undefined, where: string): void {
|
|
94
|
+
if (type !== 'subject' || !id || SUBJECT_ANCHOR_ID.test(id)) return
|
|
95
|
+
throw new Error(
|
|
96
|
+
`[ai-assistant/session-anchor] ${where}: '${id}' is not a subject anchor. ` +
|
|
97
|
+
"A subject anchor is '<axis>:<itemId>' — the 360 frame's axis word and the row's uuid, " +
|
|
98
|
+
"for example 'items:6f1c…'. Resolve what the screen shows (an EPC, a name) to the row's uuid first."
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
79
102
|
/** 앵커 → 저장할 컬럼 값. 보드 앵커는 boardId 에도 같은 값을 넣어 기존 조회가 그대로 동작하게 한다. */
|
|
80
103
|
export function anchorColumns(
|
|
81
104
|
anchorType: string | undefined,
|
|
@@ -91,6 +114,7 @@ export function anchorColumns(
|
|
|
91
114
|
"Use 'none' for a conversation that is deliberately not addressable by anchor."
|
|
92
115
|
)
|
|
93
116
|
}
|
|
117
|
+
assertSubjectAnchorId(type, id, 'anchorColumns')
|
|
94
118
|
return { anchorType: type, anchorId: id, boardId: type === 'board' ? id : undefined }
|
|
95
119
|
}
|
|
96
120
|
|
|
@@ -112,6 +136,7 @@ export function anchorColumns(
|
|
|
112
136
|
*/
|
|
113
137
|
export function anchorWhere(domainId: string, anchorType: string, anchorId: string, kind?: string): any {
|
|
114
138
|
const type = assertAnchorType(anchorType, 'anchorWhere')
|
|
139
|
+
assertSubjectAnchorId(type, anchorId, 'anchorWhere')
|
|
115
140
|
const where = type === 'board' ? { boardId: anchorId, domain: { id: domainId } } : { anchorType: type, anchorId, domain: { id: domainId } }
|
|
116
141
|
return kind ? { ...where, kind } : where
|
|
117
142
|
}
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* ③ 공간·인스턴스 앵커는 boardId 를 오염시키지 않는다(보드 조회에 섞이면 이력 경계가 무너진다).
|
|
13
13
|
*/
|
|
14
14
|
import { anchorColumns, anchorWhere, isSessionAnchorType, SESSION_ANCHOR_TYPES } from './chat-session/session-anchor'
|
|
15
|
+
import { initialVisibility } from './chat-session/session-visibility'
|
|
16
|
+
|
|
17
|
+
const SUBJECT_ID = 'items:6f1c2a90-3b4d-4e5f-8a6b-7c8d9e0f1a2b'
|
|
15
18
|
|
|
16
19
|
describe('앵커 저장 값 (anchorColumns)', () => {
|
|
17
20
|
it('board 앵커는 anchorId 와 boardId 에 같은 값을 쓴다 — 기존 조회 하위호환', () => {
|
|
@@ -134,7 +137,9 @@ describe('앵커 조회 조건 (anchorWhere)', () => {
|
|
|
134
137
|
|
|
135
138
|
it('모든 조회에 domain 조건이 포함된다 — 빠지면 남의 테넌트 대화가 보인다', () => {
|
|
136
139
|
for (const type of SESSION_ANCHOR_TYPES) {
|
|
137
|
-
|
|
140
|
+
/* A subject anchor has a shape of its own (below); every other type takes any id. */
|
|
141
|
+
const id = type === 'subject' ? SUBJECT_ID : 'X'
|
|
142
|
+
expect(anchorWhere('dom-9', type, id).domain).toEqual({ id: 'dom-9' })
|
|
138
143
|
}
|
|
139
144
|
})
|
|
140
145
|
|
|
@@ -142,3 +147,52 @@ describe('앵커 조회 조건 (anchorWhere)', () => {
|
|
|
142
147
|
expect(anchorWhere('dom-1', 'space', 'busan').boardId).toBeUndefined()
|
|
143
148
|
})
|
|
144
149
|
})
|
|
150
|
+
|
|
151
|
+
/*
|
|
152
|
+
* The subject anchor — one subject in a 360 (ruled 2026-09-24).
|
|
153
|
+
*
|
|
154
|
+
* `<axis>:<itemId>`: the 360 frame's axis word and that row's uuid. Tested on both sides the ruling
|
|
155
|
+
* split: without an id it throws, and with one it is stored as given and finds its own list again.
|
|
156
|
+
*/
|
|
157
|
+
describe('the subject anchor', () => {
|
|
158
|
+
it('★ without an id it throws — a pointer to nothing would never be found again', () => {
|
|
159
|
+
expect(() => anchorColumns('subject', '')).toThrow(/was given no id/)
|
|
160
|
+
expect(() => anchorColumns('subject', undefined)).toThrow(/was given no id/)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('★ `<axis>:<itemId>` is stored as given, and the lookup for that subject finds it', () => {
|
|
164
|
+
const stored = anchorColumns('subject', SUBJECT_ID)
|
|
165
|
+
expect(stored).toEqual({ anchorType: 'subject', anchorId: SUBJECT_ID, boardId: undefined })
|
|
166
|
+
|
|
167
|
+
const where = anchorWhere('dom-1', 'subject', SUBJECT_ID)
|
|
168
|
+
expect(where).toEqual({ anchorType: stored.anchorType, anchorId: stored.anchorId, domain: { id: 'dom-1' } })
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it("another subject, another axis or another domain is another list", () => {
|
|
172
|
+
const other = 'items:0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d'
|
|
173
|
+
const sameRowOtherAxis = SUBJECT_ID.replace('items:', 'locations:')
|
|
174
|
+
|
|
175
|
+
expect(anchorWhere('dom-1', 'subject', other)).not.toEqual(anchorWhere('dom-1', 'subject', SUBJECT_ID))
|
|
176
|
+
expect(anchorWhere('dom-1', 'subject', sameRowOtherAxis)).not.toEqual(anchorWhere('dom-1', 'subject', SUBJECT_ID))
|
|
177
|
+
expect(anchorWhere('dom-2', 'subject', SUBJECT_ID)).not.toEqual(anchorWhere('dom-1', 'subject', SUBJECT_ID))
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it.each([
|
|
181
|
+
['an EPC, as the screen shows a pallet', 'items:urn:epc:id:sgtin:0614141.107346.2017'],
|
|
182
|
+
['a name, as the screen shows an order', 'orders:SO-A887417'],
|
|
183
|
+
['a uuid with no axis', '6f1c2a90-3b4d-4e5f-8a6b-7c8d9e0f1a2b'],
|
|
184
|
+
['an axis with no item', 'items:']
|
|
185
|
+
])('★ refuses %s — on write and on read', (_what, id) => {
|
|
186
|
+
expect(() => anchorColumns('subject', id)).toThrow(/is not a subject anchor/)
|
|
187
|
+
expect(() => anchorWhere('dom-1', 'subject', id)).toThrow(/is not a subject anchor/)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('the shape rule is the subject anchor\'s alone — other anchors keep taking the ids they take', () => {
|
|
191
|
+
expect(anchorColumns('space', 'busan').anchorId).toBe('busan')
|
|
192
|
+
expect(anchorColumns('application', 'operato-figure').anchorId).toBe('operato-figure')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('★ a subject conversation is born open — an anchored conversation is about a shared thing (ADR-0073 decision 2)', () => {
|
|
196
|
+
expect(initialVisibility({ anchorType: 'subject' })).toBe('open')
|
|
197
|
+
})
|
|
198
|
+
})
|
|
@@ -101,7 +101,7 @@ it('★ a proposal belongs to the turn that asked for it', async () => {
|
|
|
101
101
|
{
|
|
102
102
|
kind: 'read',
|
|
103
103
|
name: 'proposeSceneEdit',
|
|
104
|
-
/*
|
|
104
|
+
/* An authoring tool reads only the open scene — it reaches no storage, so it has no doors. */
|
|
105
105
|
doors: [],
|
|
106
106
|
description: 'propose placing a component',
|
|
107
107
|
schema: {},
|
|
@@ -26,9 +26,9 @@ beforeEach(() => {
|
|
|
26
26
|
name: 'twin-authoring',
|
|
27
27
|
guidance: 'read the scene before proposing an edit',
|
|
28
28
|
specs: [
|
|
29
|
-
/*
|
|
29
|
+
/* Hands back what the surface sent — reaches no storage. */
|
|
30
30
|
{ kind: 'read', name: 'getOpenScene', description: 'the open scene', schema: {}, doors: [], builder: () => ({}) },
|
|
31
|
-
/*
|
|
31
|
+
/* Reads storage — names its door. This is where the gate splits. */
|
|
32
32
|
{ kind: 'read', name: 'getTwinStructure', description: 'the twin structure', schema: {}, doors: ['Query.twinInstances'], builder: () => ({}) }
|
|
33
33
|
]
|
|
34
34
|
} as any)
|
|
@@ -53,11 +53,12 @@ export async function runRegisteredMode(
|
|
|
53
53
|
maxTokens: settings.maxTokens,
|
|
54
54
|
hostContext: options.hostContext,
|
|
55
55
|
/*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
56
|
+
* This harness asks about **the mode** — "what does this mode open", not "does this person pass the
|
|
57
|
+
* doors". Since the gate moved onto `doors` (ADR-0071 decision 2), passing no check closes every tool
|
|
58
|
+
* that names a door, and an app's conformance test would quietly lose **exactly the tools that read
|
|
59
|
+
* real data**. So the default is someone who passes every door; an app that wants the other case
|
|
60
|
+
* writes `toolAccess`. The permission axis has its own tests (`tool-access.test.ts` ·
|
|
61
|
+
* `tool-scope.test.ts`).
|
|
61
62
|
*/
|
|
62
63
|
toolAccess: options.toolAccess ?? (async () => true),
|
|
63
64
|
state: options.state ?? { domain: { id: 'dom-1' }, user: { id: 'u-1' } }
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { readFileSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
-
import { assistantAutoAskKey, assistantModelFingerprint, assistantRecentSessions } from '../client/utils/assistant-request-context.ts'
|
|
4
|
+
import { assistantAutoAskKey, assistantDraftDecision, assistantModelFingerprint, assistantRecentSessions } from '../client/utils/assistant-request-context.ts'
|
|
5
5
|
|
|
6
6
|
test('model signature ignores object key order but detects nested edits and array order', () => {
|
|
7
7
|
assert.equal(assistantModelFingerprint({ b: 2, a: { y: 3, x: 1 } }), assistantModelFingerprint({ a: { x: 1, y: 3 }, b: 2 }))
|
|
@@ -28,3 +28,43 @@ test('send captures current context before optimistic input and attaches request
|
|
|
28
28
|
assert.match(send, /detail: \{ actions: out.actions, sessionId: out.sessionId, requestBase \}/)
|
|
29
29
|
assert.match(send, /requestContext\.hostContext/)
|
|
30
30
|
})
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
* The draft — a sentence placed in the input for the person to edit and send (twin's "new twin → AI").
|
|
34
|
+
*/
|
|
35
|
+
test('★ a draft fills an empty input once per request, and a re-render does not fill it again', () => {
|
|
36
|
+
const first = assistantDraftDecision({ draft: 'make a twin of line 3', draftRequestId: 'r1', typed: '' })
|
|
37
|
+
assert.deepEqual(first, { fill: true, consumed: assistantAutoAskKey('make a twin of line 3', 'r1') })
|
|
38
|
+
|
|
39
|
+
const again = assistantDraftDecision({ draft: 'make a twin of line 3', draftRequestId: 'r1', drafted: first.consumed, typed: '' })
|
|
40
|
+
assert.equal(again.fill, false)
|
|
41
|
+
|
|
42
|
+
const next = assistantDraftDecision({ draft: 'make a twin of line 3', draftRequestId: 'r2', drafted: first.consumed, typed: '' })
|
|
43
|
+
assert.equal(next.fill, true, 'the same sentence under a new request is a new draft')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('★ a draft never overwrites what the person is typing — and does not surface later', () => {
|
|
47
|
+
const out = assistantDraftDecision({ draft: 'make a twin', draftRequestId: 'r1', typed: 'how many forklifts' })
|
|
48
|
+
assert.equal(out.fill, false)
|
|
49
|
+
assert.ok(out.consumed, 'skipped, and remembered as consumed')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('★ when autoAsk comes for the same request, autoAsk wins', () => {
|
|
53
|
+
const out = assistantDraftDecision({ draft: 'edit me', draftRequestId: 'r1', typed: '', autoAsk: 'send me', autoAskRequestId: 'r1' })
|
|
54
|
+
assert.equal(out.fill, false)
|
|
55
|
+
|
|
56
|
+
const otherRequest = assistantDraftDecision({ draft: 'edit me', draftRequestId: 'r2', typed: '', autoAsk: 'send me', autoAskRequestId: 'r1' })
|
|
57
|
+
assert.equal(otherRequest.fill, true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('no draft, nothing to do', () => {
|
|
61
|
+
assert.deepEqual(assistantDraftDecision({ typed: '' }), { fill: false })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('the component places the draft and never sends it', () => {
|
|
65
|
+
const chat = readFileSync(join(__dirname, '../client/components/assistant-chat.ts'), 'utf8')
|
|
66
|
+
const updated = chat.slice(chat.indexOf(' updated(changed'), chat.indexOf('if (changed.has(\'subject\'))'))
|
|
67
|
+
const draftPart = updated.slice(updated.indexOf('assistantDraftDecision('))
|
|
68
|
+
assert.match(draftPart, /this\.input = this\.draft!/)
|
|
69
|
+
assert.doesNotMatch(draftPart, /this\.send\(\)/)
|
|
70
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Which of ai-assistant's doors are open, and why each one is allowed to be.
|
|
3
|
+
*
|
|
4
|
+
* ── The thing this catches ──────────────────────────────────────────────────
|
|
5
|
+
* `@privilege` is opt-in. A root field that declares nothing is recorded as open, and that is
|
|
6
|
+
* indistinguishable from a field somebody decided to open. The list below turns the absence into a
|
|
7
|
+
* statement: anything open that is not on it fails, and the reason has to be written first. Same
|
|
8
|
+
* measure as `worklist/tests/open-doors.test.ts` and `headless-twin/test/open-doors.test.ts`.
|
|
9
|
+
*
|
|
10
|
+
* ── What the census found (2026-09-24) ──────────────────────────────────────
|
|
11
|
+
* 27 root fields, 25 declare `ai-assistant:query` or `:mutation`. The two open ones are the
|
|
12
|
+
* subscriptions — a stream has no `@privilege` to carry, so what keeps it inside the tenant is its
|
|
13
|
+
* subscribe and its filter. Writing their reasons found one false: `chatMessageActivity` asked the
|
|
14
|
+
* domain and the session but not the room, so a private conversation streamed to anyone in the
|
|
15
|
+
* domain holding its id (ADR-0073 decision 2). Fixed in `8264dc46e7`; the reason below is true
|
|
16
|
+
* from then on, and `chat-message-room.test.ts` keeps it true.
|
|
17
|
+
*
|
|
18
|
+
* ── Why the compiled output ──────────────────────────────────────────────────
|
|
19
|
+
* type-graphql needs `emitDecoratorMetadata` to build the schema; jest's babel transform does not
|
|
20
|
+
* emit it. So this reads `dist-server` — and a build older than its sources would miss a door added
|
|
21
|
+
* since and pass, hence the freshness check before anything else.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
24
|
+
import { join, relative } from 'path'
|
|
25
|
+
|
|
26
|
+
import { buildSchema } from 'type-graphql'
|
|
27
|
+
import type { GraphQLSchema } from 'graphql'
|
|
28
|
+
|
|
29
|
+
const PKG = join(__dirname, '..')
|
|
30
|
+
const DIST = join(PKG, 'dist-server')
|
|
31
|
+
const AUTH_BASE_DIST = join(PKG, '..', 'auth-base', 'dist-server')
|
|
32
|
+
|
|
33
|
+
/** Resolver sources newer than their build: the schema below would not be the one served. */
|
|
34
|
+
function staleResolvers(): string[] {
|
|
35
|
+
const out: string[] = []
|
|
36
|
+
const walk = (dir: string) => {
|
|
37
|
+
for (const name of readdirSync(dir)) {
|
|
38
|
+
const path = join(dir, name)
|
|
39
|
+
if (statSync(path).isDirectory()) walk(path)
|
|
40
|
+
else if (path.endsWith('.ts') && !path.endsWith('.test.ts') && /@(Query|Mutation|Subscription)\(/.test(readFileSync(path, 'utf8'))) {
|
|
41
|
+
const built = join(DIST, relative(join(PKG, 'server'), path)).replace(/\.ts$/, '.js')
|
|
42
|
+
if (!existsSync(built) || statSync(built).mtimeMs < statSync(path).mtimeMs) out.push(relative(PKG, path))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
walk(join(PKG, 'server'))
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type Root = 'Query' | 'Mutation' | 'Subscription'
|
|
51
|
+
|
|
52
|
+
/** Open on purpose, each with what keeps it inside the caller's tenant — and, for a room, inside the room. */
|
|
53
|
+
const OPEN_BY_DECISION: Record<Root, Record<string, string>> = {
|
|
54
|
+
Query: {},
|
|
55
|
+
Mutation: {},
|
|
56
|
+
Subscription: {
|
|
57
|
+
chatMessageActivity:
|
|
58
|
+
"subscribe: a member of the domain, and the room open to the caller — open to the domain, private to its participants " +
|
|
59
|
+
'(ADR-0073 decision 2), refused with the words of a missing session otherwise. Every message: same domain + session, ' +
|
|
60
|
+
'and the room asked again. The body is redacted by doors for each receiver (ADR-0073 ruling 1).',
|
|
61
|
+
mySessionActivity:
|
|
62
|
+
"subscribe: a member of the domain. Every message: same domain, and the caller among the payload's participants. " +
|
|
63
|
+
'Badge fields and a one-line preview only, no body.'
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let schema: GraphQLSchema
|
|
68
|
+
let privilegeOfField: (typeName: string, field: string) => { known: boolean; gate?: unknown }
|
|
69
|
+
|
|
70
|
+
beforeAll(async () => {
|
|
71
|
+
const stale = staleResolvers()
|
|
72
|
+
if (stale.length) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`dist-server is older than ${stale.length} resolver source(s), so the schema below is not the one served:\n ` +
|
|
75
|
+
stale.join('\n ') +
|
|
76
|
+
'\nBuild first: yarn workspace @things-factory/ai-assistant build:server'
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { schema: declared } = require(join(DIST, 'service', 'index.js'))
|
|
81
|
+
const directive = require(join(AUTH_BASE_DIST, 'service', 'privilege', 'privilege-directive.js'))
|
|
82
|
+
privilegeOfField = directive.privilegeOfField
|
|
83
|
+
|
|
84
|
+
schema = await buildSchema({
|
|
85
|
+
resolvers: declared.resolverClasses,
|
|
86
|
+
validate: false,
|
|
87
|
+
/* The subscriptions name their own subscribe; the walk only needs a pubSub to accept them. */
|
|
88
|
+
pubSub: { publish() {}, subscribe() {} } as any
|
|
89
|
+
})
|
|
90
|
+
directive.privilegeDirectiveResolver(schema)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
function fieldsOf(typeName: Root): string[] {
|
|
94
|
+
const type = typeName === 'Query' ? schema.getQueryType() : typeName === 'Mutation' ? schema.getMutationType() : schema.getSubscriptionType()
|
|
95
|
+
return Object.keys(type?.getFields() ?? {})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function openFields(typeName: Root): string[] {
|
|
99
|
+
return fieldsOf(typeName).filter(field => {
|
|
100
|
+
const gate = privilegeOfField(typeName, field)
|
|
101
|
+
return gate.known && gate.gate === null
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe('every open door has a written reason', () => {
|
|
106
|
+
it.each(['Query', 'Mutation', 'Subscription'] as const)('%s', typeName => {
|
|
107
|
+
expect(openFields(typeName).sort()).toEqual(Object.keys(OPEN_BY_DECISION[typeName]).sort())
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('what the walk itself has to be true for', () => {
|
|
112
|
+
it('reaches the whole schema, not a handful of fields', () => {
|
|
113
|
+
/* If the resolver list shrank to nothing, the lists above would pass by having nothing to look at. */
|
|
114
|
+
expect(fieldsOf('Query').length).toBeGreaterThanOrEqual(10)
|
|
115
|
+
expect(fieldsOf('Mutation').length).toBeGreaterThanOrEqual(13)
|
|
116
|
+
expect(fieldsOf('Subscription')).toHaveLength(2)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('tells an undeclared field from one the schema does not have', () => {
|
|
120
|
+
/* `known: false` must never read as open — that is how a typo becomes a gate that is not there. */
|
|
121
|
+
expect(privilegeOfField('Query', 'noSuchField')).toEqual({ known: false })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('the census, in plain numbers', () => {
|
|
125
|
+
/* 2026-09-24: 27 root fields, 25 declared, 2 open — both streams. */
|
|
126
|
+
const all = fieldsOf('Query').length + fieldsOf('Mutation').length + fieldsOf('Subscription').length
|
|
127
|
+
const open = openFields('Query').length + openFields('Mutation').length + openFields('Subscription').length
|
|
128
|
+
|
|
129
|
+
expect(all).toBe(27)
|
|
130
|
+
expect(open).toBe(2)
|
|
131
|
+
})
|
|
132
|
+
})
|