@things-factory/ai-assistant 10.1.39 → 10.1.42
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/assistant-capability-resolver.js +1 -1
- package/dist-server/service/assistant-capability-resolver.js.map +1 -1
- package/dist-server/service/assistant-chat-resolver.js +1 -1
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js +2 -2
- package/dist-server/service/assistant-mode/assistant-mode-resolver.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/chat-session-resolver.js +21 -21
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/server/service/assistant-capability-resolver.ts +1 -1
- package/server/service/assistant-chat-resolver.ts +1 -1
- package/server/service/assistant-mode/assistant-mode-resolver.ts +2 -2
- package/server/service/chat-session/chat-session.ts +1 -1
- package/server/service/chat-session/session-anchor.ts +26 -1
- package/server/service/chat-session/session-visibility.test.ts +47 -0
- package/server/service/chat-session-resolver.ts +21 -21
- package/server/service/session-anchor.test.ts +55 -1
- package/test/assistant-request-context.test.ts +41 -1
- package/test/open-doors.test.ts +16 -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.42",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"@operato/i18n": "^10.0.0",
|
|
31
31
|
"@operato/styles": "^10.0.0",
|
|
32
32
|
"@things-factory/ai-client-base": "^10.1.39",
|
|
33
|
-
"@things-factory/auth-base": "^10.1.
|
|
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": "982aa01403b979c4e348fd207a3fb4d1a7b5f521"
|
|
48
48
|
}
|
|
@@ -52,7 +52,7 @@ export class AssistantCapabilityResolver {
|
|
|
52
52
|
@Query(() => AiCapabilityView, {
|
|
53
53
|
description: 'Whether this deployment has an AI configured, and which registrations are incomplete. Read this before offering to ask the assistant — do not find out by failing.'
|
|
54
54
|
})
|
|
55
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "query")')
|
|
55
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
|
|
56
56
|
async aiCapability(@Ctx() _context: any): Promise<AiCapabilityView> {
|
|
57
57
|
const capability = aiCapability()
|
|
58
58
|
const warnings = [...getToolRegistryWarnings(), ...getAssistantModeWarnings()].map(warning => ({
|
|
@@ -200,7 +200,7 @@ export class AssistantChatResolver {
|
|
|
200
200
|
* 2026-09-10. No `legacy` alias: this operation is new, so no one was ever passing under an old
|
|
201
201
|
* name, and declaring an alias would open a second name that nothing needs.
|
|
202
202
|
*/
|
|
203
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
|
|
203
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
204
204
|
async assistantChat(@Arg('input') input: AssistantChatInput, @Ctx() context: any): Promise<AssistantChatOutput> {
|
|
205
205
|
const client = resolveAIClient()
|
|
206
206
|
if (!client) {
|
|
@@ -83,7 +83,7 @@ export class AssistantModeResolver {
|
|
|
83
83
|
description:
|
|
84
84
|
'Conversations that stand for this kind of subject, the default first. A screen showing one subject opens the head of this list and offers the rest; a single entry means there is nothing to choose.'
|
|
85
85
|
})
|
|
86
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "query")')
|
|
86
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
|
|
87
87
|
async assistantModes(@Arg('subject') subject: string, @Ctx() context: any): Promise<AssistantModeChoice[]> {
|
|
88
88
|
const t = requestTranslator(context)
|
|
89
89
|
|
|
@@ -101,7 +101,7 @@ export class AssistantModeResolver {
|
|
|
101
101
|
description: 'What this conversation offers on screen — `/` sentences, the `@` catalog and the notices, translated.'
|
|
102
102
|
})
|
|
103
103
|
/* Reading what a conversation offers is reading, and the same door the session list uses. */
|
|
104
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "query")')
|
|
104
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
|
|
105
105
|
async assistantMode(@Arg('kind') kind: string, @Ctx() context: any): Promise<AssistantModeView> {
|
|
106
106
|
return assistantModeView(kind, requestTranslator(context)) as AssistantModeView
|
|
107
107
|
}
|
|
@@ -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
|
}
|
|
@@ -50,3 +50,50 @@ describe("'none' is not an anchor", () => {
|
|
|
50
50
|
expect(initialVisibility({ anchorType: 'space' })).toBe('open')
|
|
51
51
|
})
|
|
52
52
|
})
|
|
53
|
+
|
|
54
|
+
/*
|
|
55
|
+
* Calling a door is not reading a conversation (ruled 2026-09-24).
|
|
56
|
+
*
|
|
57
|
+
* Every ai-assistant door now admits the domain owner (`test/open-doors.test.ts`), so the owner may
|
|
58
|
+
* open the dock anywhere in the domain. That says who may *call* — not whose private conversation they
|
|
59
|
+
* may read. The room rule is asked with two things only, the visibility and whether the caller sits in
|
|
60
|
+
* the room, and neither place that reads a room asks anything about ownership. If one ever did, a domain
|
|
61
|
+
* owner would read every private conversation in the domain, and nothing on the screen would say so.
|
|
62
|
+
*/
|
|
63
|
+
describe('the domain owner still reads a private conversation only as a participant', () => {
|
|
64
|
+
const { readFileSync } = require('fs')
|
|
65
|
+
const { join } = require('path')
|
|
66
|
+
const source = (path: string) => readFileSync(join(__dirname, '..', path), 'utf8') as string
|
|
67
|
+
|
|
68
|
+
/** The body of `chatMessages` — the stored path. The stream's path is the room lookup and `mayHearRoom`. */
|
|
69
|
+
const storedPath = () => {
|
|
70
|
+
const resolver = source('chat-session-resolver.ts')
|
|
71
|
+
const start = resolver.indexOf('async chatMessages(')
|
|
72
|
+
expect(start).toBeGreaterThan(0)
|
|
73
|
+
const end = resolver.indexOf('\n @', start)
|
|
74
|
+
return resolver.slice(start, end > start ? end : undefined)
|
|
75
|
+
}
|
|
76
|
+
const streamPath = () => {
|
|
77
|
+
const broadcast = source('chat-message/chat-message-broadcast.ts')
|
|
78
|
+
return source('chat-message/chat-message-room.ts') + broadcast.slice(broadcast.indexOf('export interface ChatRoomLookup'))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const OWNERSHIP = /owner|superUser|checkPermission|hasPrivilege|privilege/i
|
|
82
|
+
|
|
83
|
+
it('★ the rule has no input for ownership — visibility and participation, nothing else', () => {
|
|
84
|
+
expect(mayReadSession.length).toBe(2)
|
|
85
|
+
expect(mayReadSession('private', false)).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('★ the stored path asks the rule, and asks nothing about ownership', () => {
|
|
89
|
+
const body = storedPath()
|
|
90
|
+
expect(body).toMatch(/mayReadSession\(/)
|
|
91
|
+
expect(body).not.toMatch(OWNERSHIP)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('★ the stream asks the same rule, and asks nothing about ownership', () => {
|
|
95
|
+
const body = streamPath()
|
|
96
|
+
expect(body).toMatch(/mayReadSession\(/)
|
|
97
|
+
expect(body).not.toMatch(OWNERSHIP)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -100,7 +100,7 @@ class ChatSessionInboxEntry {
|
|
|
100
100
|
@Resolver()
|
|
101
101
|
export class ChatSessionResolver {
|
|
102
102
|
@Query(() => ChatSession, { nullable: true, description: 'Get AI chat session by id.' })
|
|
103
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "query")')
|
|
103
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
|
|
104
104
|
async chatSession(
|
|
105
105
|
@Arg('id') id: string,
|
|
106
106
|
@Ctx() context: ResolverContext
|
|
@@ -113,7 +113,7 @@ export class ChatSessionResolver {
|
|
|
113
113
|
description:
|
|
114
114
|
'List AI chat sessions anchored to a target (oldest first). Anchor decides the nature and the history boundary of the conversation: board = authoring, space = the site and the twins running there. Board anchor also matches legacy rows that only have boardId.'
|
|
115
115
|
})
|
|
116
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "query")')
|
|
116
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
|
|
117
117
|
async chatSessionsByAnchor(
|
|
118
118
|
@Arg('anchorType') anchorType: string,
|
|
119
119
|
@Arg('anchorId') anchorId: string,
|
|
@@ -135,7 +135,7 @@ export class ChatSessionResolver {
|
|
|
135
135
|
description:
|
|
136
136
|
"List sessions anchored to a target with this viewer's unread state, most recent activity first. Supports free-text search over name and last preview, a mine-only filter, hidden inclusion, and paging. Uses the denormalized last-activity timestamp compared against the viewer's read point — no per-session aggregation."
|
|
137
137
|
})
|
|
138
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "query")')
|
|
138
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
|
|
139
139
|
async chatSessionInbox(
|
|
140
140
|
@Arg('anchorType') anchorType: string,
|
|
141
141
|
@Arg('anchorId') anchorId: string,
|
|
@@ -164,7 +164,7 @@ export class ChatSessionResolver {
|
|
|
164
164
|
description:
|
|
165
165
|
'Total number of sessions matching the same filters as chatSessionInbox (before paging). Request it alongside the list so the UI can tell "end of page" from "end of data".'
|
|
166
166
|
})
|
|
167
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "query")')
|
|
167
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
|
|
168
168
|
async chatSessionInboxTotal(
|
|
169
169
|
@Arg('anchorType') anchorType: string,
|
|
170
170
|
@Arg('anchorId') anchorId: string,
|
|
@@ -199,7 +199,7 @@ export class ChatSessionResolver {
|
|
|
199
199
|
description:
|
|
200
200
|
"Number of sessions on this anchor that the caller has hidden and that have had no activity since. Meant to be requested alongside chatSessionInbox in the same operation so the filter can say how many are folded away."
|
|
201
201
|
})
|
|
202
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "query")')
|
|
202
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
|
|
203
203
|
async hiddenSessionCount(
|
|
204
204
|
@Arg('anchorType') anchorType: string,
|
|
205
205
|
@Arg('anchorId') anchorId: string,
|
|
@@ -270,7 +270,7 @@ export class ChatSessionResolver {
|
|
|
270
270
|
description:
|
|
271
271
|
'Delete a chat session — creator only, since a shared deliberation cannot be un-deleted from the UI. Participants who are not the creator can hide it from their own list instead (hideAISession). Soft delete; broadcast as meta activity so every list drops it.'
|
|
272
272
|
})
|
|
273
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
|
|
273
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
274
274
|
async deleteAISession(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
|
|
275
275
|
const { domain, user } = context.state
|
|
276
276
|
const repo = getRepository(ChatSession)
|
|
@@ -302,7 +302,7 @@ export class ChatSessionResolver {
|
|
|
302
302
|
description:
|
|
303
303
|
'Hide (or unhide) a session from the calling user\'s own list. Per-user and reversible — the session and other participants are unaffected. Activity newer than the hide makes it reappear; the inbox filter (showHidden) reveals hidden ones.'
|
|
304
304
|
})
|
|
305
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
|
|
305
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
306
306
|
async hideAISession(
|
|
307
307
|
@Arg('sessionId') sessionId: string,
|
|
308
308
|
@Ctx() context: ResolverContext,
|
|
@@ -357,7 +357,7 @@ export class ChatSessionResolver {
|
|
|
357
357
|
description:
|
|
358
358
|
"Open this conversation to everyone in the domain, or close it to its participants. Only the owner may change it. Opening exposes what is already written — the screen says so before asking. Door checks on assistant answers are unaffected (ADR-0073)."
|
|
359
359
|
})
|
|
360
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
|
|
360
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
361
361
|
async setSessionVisibility(
|
|
362
362
|
@Arg('sessionId') sessionId: string,
|
|
363
363
|
@Arg('visibility') visibility: string,
|
|
@@ -394,7 +394,7 @@ export class ChatSessionResolver {
|
|
|
394
394
|
description:
|
|
395
395
|
'Rename a chat session. Any participant may rename it — the name is the topic label of a shared deliberation and the change is reversible. Broadcast as meta activity so every participant sees it without raising unread badges.'
|
|
396
396
|
})
|
|
397
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
|
|
397
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
398
398
|
async renameAISession(
|
|
399
399
|
@Arg('sessionId') sessionId: string,
|
|
400
400
|
@Arg('name') name: string,
|
|
@@ -429,7 +429,7 @@ export class ChatSessionResolver {
|
|
|
429
429
|
description:
|
|
430
430
|
"Mark a session as read up to now for the current user. Idempotent. Distinct from presence (lastSeenAt) — a window can be open without being read."
|
|
431
431
|
})
|
|
432
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
|
|
432
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
433
433
|
async markSessionRead(
|
|
434
434
|
@Arg('sessionId') sessionId: string,
|
|
435
435
|
@Ctx() context: ResolverContext
|
|
@@ -449,7 +449,7 @@ export class ChatSessionResolver {
|
|
|
449
449
|
description:
|
|
450
450
|
'List users in the current domain for the `@` mention popup. Anyone with the chat privilege may search here; this is separate from the admin-only users().'
|
|
451
451
|
})
|
|
452
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "query")')
|
|
452
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
|
|
453
453
|
async domainUsersForMention(
|
|
454
454
|
@Arg('query', { nullable: true, description: 'Substring to match against name/email (case-insensitive). Empty → all.' }) query: string | undefined,
|
|
455
455
|
@Arg('limit', () => Int, { nullable: true, defaultValue: 50 }) limit: number,
|
|
@@ -468,7 +468,7 @@ export class ChatSessionResolver {
|
|
|
468
468
|
@Query(() => [ChatSessionParticipant], {
|
|
469
469
|
description: 'List participants of a ChatSession (members / owner).'
|
|
470
470
|
})
|
|
471
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "query")')
|
|
471
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
|
|
472
472
|
async chatSessionParticipants(
|
|
473
473
|
@Arg('sessionId') sessionId: string,
|
|
474
474
|
@Ctx() context: ResolverContext
|
|
@@ -488,7 +488,7 @@ export class ChatSessionResolver {
|
|
|
488
488
|
@Mutation(() => Boolean, {
|
|
489
489
|
description: 'Presence heartbeat — bump current user\'s lastSeenAt in the session (online indicator).'
|
|
490
490
|
})
|
|
491
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
|
|
491
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
492
492
|
async touchPresence(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
|
|
493
493
|
const { domain, user } = context.state
|
|
494
494
|
const repo = getRepository(ChatSessionParticipant)
|
|
@@ -501,7 +501,7 @@ export class ChatSessionResolver {
|
|
|
501
501
|
}
|
|
502
502
|
|
|
503
503
|
@Query(() => [ChatMessage], { description: 'List chat messages of a session, oldest first.' })
|
|
504
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "query")')
|
|
504
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
|
|
505
505
|
async chatMessages(
|
|
506
506
|
@Arg('sessionId') sessionId: string,
|
|
507
507
|
@Arg('limit', () => Int, { nullable: true, defaultValue: 100 }) limit: number,
|
|
@@ -547,7 +547,7 @@ export class ChatSessionResolver {
|
|
|
547
547
|
description:
|
|
548
548
|
'Start (or get existing) AI chat session anchored to a target. Idempotent — returns first existing or creates one. Use anchorType board for board authoring, space for a site (its co-located twins).'
|
|
549
549
|
})
|
|
550
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
|
|
550
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
551
551
|
async startAISession(
|
|
552
552
|
@Arg('anchorType') anchorType: string,
|
|
553
553
|
@Arg('anchorId') anchorId: string,
|
|
@@ -584,7 +584,7 @@ export class ChatSessionResolver {
|
|
|
584
584
|
description:
|
|
585
585
|
'Always create a new AI chat session anchored to a target (no idempotent reuse). For multi-session UX — opening another tab.'
|
|
586
586
|
})
|
|
587
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
|
|
587
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
588
588
|
async createAISession(
|
|
589
589
|
@Arg('anchorType') anchorType: string,
|
|
590
590
|
@Arg('anchorId') anchorId: string,
|
|
@@ -626,7 +626,7 @@ export class ChatSessionResolver {
|
|
|
626
626
|
@Mutation(() => Boolean, {
|
|
627
627
|
description: 'Rename a ChatSession (tab label).'
|
|
628
628
|
})
|
|
629
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
|
|
629
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
630
630
|
async renameChatSession(
|
|
631
631
|
@Arg('sessionId') sessionId: string,
|
|
632
632
|
@Arg('name') name: string,
|
|
@@ -645,7 +645,7 @@ export class ChatSessionResolver {
|
|
|
645
645
|
description:
|
|
646
646
|
"What the host did with a proposal the assistant made. The host calls this once per card it was asked to run; the server records it as a system row, so the panel shows it, a refreshed panel still shows it, and the model reads on its next turn that its proposal was refused instead of assuming it landed (ADR-0070)."
|
|
647
647
|
})
|
|
648
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
|
|
648
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
649
649
|
async reportProposalOutcome(
|
|
650
650
|
@Arg('sessionId') sessionId: string,
|
|
651
651
|
@Arg('proposalKey') proposalKey: string,
|
|
@@ -695,7 +695,7 @@ export class ChatSessionResolver {
|
|
|
695
695
|
description:
|
|
696
696
|
"Say something to the people in this session. The turn is stored as the speaker's, addressed to the room, and the assistant is not called — it reads the line as context the next time someone asks it something. Returns the stored message id, or null when there was nothing to say (ADR-0070)."
|
|
697
697
|
})
|
|
698
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
|
|
698
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
699
699
|
async sayToRoom(
|
|
700
700
|
@Arg('sessionId') sessionId: string,
|
|
701
701
|
@Arg('content') content: string,
|
|
@@ -763,7 +763,7 @@ export class ChatSessionResolver {
|
|
|
763
763
|
description:
|
|
764
764
|
'Append a system note to a session — context that did not come from a participant typing (e.g. a conversation carried over from another surface, an external state change). The AI sees it as part of the shared history.'
|
|
765
765
|
})
|
|
766
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
|
|
766
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
767
767
|
async appendSessionNote(
|
|
768
768
|
@Arg('sessionId') sessionId: string,
|
|
769
769
|
@Arg('content') content: string,
|
|
@@ -813,7 +813,7 @@ export class ChatSessionResolver {
|
|
|
813
813
|
description:
|
|
814
814
|
"Carry a conversation from another surface into this session as its own turns, preserving order. A person's turns are stored as theirs, addressed to the room. An assistant turn is stored as the assistant's only when it names its original (sourceSessionId, sourceMessageId) and the server can read it; otherwise it is kept as a quotation on a system row — roles are the server's to write (ADR-0070)."
|
|
815
815
|
})
|
|
816
|
-
@Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
|
|
816
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
817
817
|
async appendSessionTurns(
|
|
818
818
|
@Arg('sessionId') sessionId: string,
|
|
819
819
|
@Arg('turns', () => GraphQLJSON) turns: { role?: string; content?: string }[],
|
|
@@ -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
|
+
})
|
|
@@ -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
|
+
})
|
package/test/open-doors.test.ts
CHANGED
|
@@ -108,6 +108,22 @@ describe('every open door has a written reason', () => {
|
|
|
108
108
|
})
|
|
109
109
|
})
|
|
110
110
|
|
|
111
|
+
describe('the domain owner may call every door (ruled 2026-09-24)', () => {
|
|
112
|
+
/*
|
|
113
|
+
* The conversation doors did not admit domain owners, and warehouse has no role that grants
|
|
114
|
+
* ai-assistant — so on :3500 nobody could open the AI dock, the owner included. Every business door
|
|
115
|
+
* of the apps admits the owner; these now do too. Calling a door is not reading a conversation:
|
|
116
|
+
* private ones stay with their participants (`session-visibility.test.ts`).
|
|
117
|
+
*/
|
|
118
|
+
it.each(['Query', 'Mutation'] as const)('%s — every declared door admits the owner', typeName => {
|
|
119
|
+
const notForOwners = fieldsOf(typeName).filter(field => {
|
|
120
|
+
const gate: any = privilegeOfField(typeName, field)
|
|
121
|
+
return gate.known && gate.gate && gate.gate.owner !== true
|
|
122
|
+
})
|
|
123
|
+
expect(notForOwners).toEqual([])
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
111
127
|
describe('what the walk itself has to be true for', () => {
|
|
112
128
|
it('reaches the whole schema, not a handful of fields', () => {
|
|
113
129
|
/* If the resolver list shrank to nothing, the lists above would pass by having nothing to look at. */
|