@things-factory/ai-assistant 10.1.113 → 10.1.116

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/client/components/assistant-chat.ts +19 -0
  2. package/client/components/assistant-dock.ts +26 -3
  3. package/client/setup-assistant-dock.ts +3 -2
  4. package/client/utils/entry-proposal.ts +14 -1
  5. package/client/utils/open-page.ts +21 -0
  6. package/dist-client/components/assistant-chat.d.ts +2 -0
  7. package/dist-client/components/assistant-chat.js +19 -0
  8. package/dist-client/components/assistant-chat.js.map +1 -1
  9. package/dist-client/components/assistant-dock.d.ts +5 -0
  10. package/dist-client/components/assistant-dock.js +25 -3
  11. package/dist-client/components/assistant-dock.js.map +1 -1
  12. package/dist-client/setup-assistant-dock.d.ts +1 -0
  13. package/dist-client/setup-assistant-dock.js +1 -0
  14. package/dist-client/setup-assistant-dock.js.map +1 -1
  15. package/dist-client/tsconfig.tsbuildinfo +1 -1
  16. package/dist-client/utils/entry-proposal.d.ts +10 -1
  17. package/dist-client/utils/entry-proposal.js +13 -1
  18. package/dist-client/utils/entry-proposal.js.map +1 -1
  19. package/dist-client/utils/open-page.d.ts +5 -0
  20. package/dist-client/utils/open-page.js +19 -0
  21. package/dist-client/utils/open-page.js.map +1 -0
  22. package/dist-server/service/assistant-mode/resolve.d.ts +1 -0
  23. package/dist-server/service/assistant-mode/resolve.js +2 -1
  24. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  25. package/dist-server/service/assistant-mode/types.d.ts +2 -0
  26. package/dist-server/service/assistant-mode/types.js.map +1 -1
  27. package/dist-server/service/chat-session/attachment-tool-result.d.ts +28 -0
  28. package/dist-server/service/chat-session/attachment-tool-result.js +9 -0
  29. package/dist-server/service/chat-session/attachment-tool-result.js.map +1 -0
  30. package/dist-server/service/chat-session/attachment-tools.js +4 -5
  31. package/dist-server/service/chat-session/attachment-tools.js.map +1 -1
  32. package/dist-server/service/data-entry/entry-apply.d.ts +8 -0
  33. package/dist-server/service/data-entry/entry-apply.js +19 -2
  34. package/dist-server/service/data-entry/entry-apply.js.map +1 -1
  35. package/dist-server/service/data-entry/entry-refusal.d.ts +6 -0
  36. package/dist-server/service/data-entry/entry-refusal.js +25 -0
  37. package/dist-server/service/data-entry/entry-refusal.js.map +1 -0
  38. package/dist-server/service/data-entry/entry-resolver.js +2 -0
  39. package/dist-server/service/data-entry/entry-resolver.js.map +1 -1
  40. package/dist-server/service/data-read/list-read.d.ts +36 -0
  41. package/dist-server/service/data-read/list-read.js +64 -0
  42. package/dist-server/service/data-read/list-read.js.map +1 -0
  43. package/dist-server/service/data-read/read-target.d.ts +109 -0
  44. package/dist-server/service/data-read/read-target.js +189 -0
  45. package/dist-server/service/data-read/read-target.js.map +1 -0
  46. package/dist-server/service/data-read/read-tools.d.ts +228 -0
  47. package/dist-server/service/data-read/read-tools.js +197 -0
  48. package/dist-server/service/data-read/read-tools.js.map +1 -0
  49. package/dist-server/service/index.d.ts +3 -0
  50. package/dist-server/service/index.js +7 -1
  51. package/dist-server/service/index.js.map +1 -1
  52. package/dist-server/tsconfig.tsbuildinfo +1 -1
  53. package/package.json +5 -5
  54. package/server/service/assistant-mode/resolve.ts +3 -2
  55. package/server/service/assistant-mode/types.ts +2 -0
  56. package/server/service/chat-session/attachment-tool-result.test.ts +26 -0
  57. package/server/service/chat-session/attachment-tool-result.ts +18 -0
  58. package/server/service/chat-session/attachment-tools.ts +4 -5
  59. package/server/service/data-entry/data-entry-update.test.ts +10 -1
  60. package/server/service/data-entry/data-entry.test.ts +29 -1
  61. package/server/service/data-entry/entry-apply.ts +27 -2
  62. package/server/service/data-entry/entry-refusal.test.ts +73 -0
  63. package/server/service/data-entry/entry-refusal.ts +20 -0
  64. package/server/service/data-entry/entry-resolver.ts +2 -0
  65. package/server/service/data-read/data-read.test.ts +229 -0
  66. package/server/service/data-read/list-read.ts +98 -0
  67. package/server/service/data-read/read-target.ts +230 -0
  68. package/server/service/data-read/read-tools.ts +212 -0
  69. package/server/service/index.ts +4 -0
  70. package/test/entry-proposal.test.ts +31 -1
  71. package/test/open-page.test.ts +31 -0
  72. package/translations/en.json +14 -0
  73. package/translations/ja.json +14 -0
  74. package/translations/ko.json +14 -0
  75. package/translations/ms.json +14 -0
  76. package/translations/zh.json +14 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.113",
3
+ "version": "10.1.116",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -33,10 +33,10 @@
33
33
  "@operato/popup": "^10.16.0",
34
34
  "@operato/shell": "^10.0.0",
35
35
  "@operato/styles": "^10.0.0",
36
- "@things-factory/ai-client-base": "^10.1.81",
36
+ "@things-factory/ai-client-base": "^10.1.114",
37
37
  "@things-factory/apptool-base": "^10.1.106",
38
- "@things-factory/attachment-base": "^10.1.113",
39
- "@things-factory/auth-base": "^10.1.113",
38
+ "@things-factory/attachment-base": "^10.1.116",
39
+ "@things-factory/auth-base": "^10.1.116",
40
40
  "@things-factory/env": "^10.1.20",
41
41
  "@things-factory/shell": "^10.1.106",
42
42
  "dompurify": "^3.0.0",
@@ -52,5 +52,5 @@
52
52
  "copyfiles": "^2.4.1",
53
53
  "rimraf": "^5.0.0"
54
54
  },
55
- "gitHead": "9da1e467ddce23e600e64f08454289cb3b806212"
55
+ "gitHead": "49fa935cc703be70200eef4d29f473de98f871ce"
56
56
  }
@@ -47,12 +47,13 @@ export interface ModeSettings {
47
47
  * The categories a mode opens: its own, the AI 작업판 (design/ai/workspace.md), the attachment reader when it
48
48
  * takes files (design/ai/shared-dock.md §9-4), and one propose tool per record it may enter (design/ai/data-entry.md).
49
49
  */
50
- export function modeToolCategories(mode: { toolCategories?: string[]; attachments?: { kinds?: string[] }; entryTargets?: string[] }): string[] | undefined {
50
+ export function modeToolCategories(mode: { toolCategories?: string[]; attachments?: { kinds?: string[] }; entryTargets?: string[]; readTargets?: string[] }): string[] | undefined {
51
51
  const extra = [
52
52
  /* Every conversation may put a large result on the AI 작업판 (design/ai/workspace.md). */
53
53
  'assistant-workspace',
54
54
  ...(mode.attachments?.kinds?.length ? ['chat-attachments'] : []),
55
- ...(mode.entryTargets ?? []).map(key => `data-entry:${key}`)
55
+ ...(mode.entryTargets ?? []).map(key => `data-entry:${key}`),
56
+ ...(mode.readTargets ?? []).map(key => `data-read:${key}`)
56
57
  ]
57
58
  return [...new Set([...(mode.toolCategories ?? []), ...extra])]
58
59
  }
@@ -105,6 +105,8 @@ export interface AssistantModeSpec {
105
105
  * with `registerEntryTarget`. Each opens that target's propose tool; its door still decides who gets it.
106
106
  */
107
107
  entryTargets?: string[]
108
+ /** The lists of the application the conversation may read - read targets by key (design/ai/read-targets.md). */
109
+ readTargets?: string[]
108
110
  /** i18n key for the line under an empty conversation. */
109
111
  footerNoticeKey?: string
110
112
  /** i18n key for the line shown once the host reports a proposal staged. */
@@ -0,0 +1,26 @@
1
+ /*
2
+ * readChatAttachment's answer (design/ai/shared-dock.md §9-4) - a picture of an earlier turn comes back as the picture.
3
+ */
4
+ /* The source, not the built package - the two land together and the test must see the change before a build. */
5
+ import { takeToolImages } from '../../../../ai-client-base/server/service/agentic-loop/agentic-loop'
6
+
7
+ import { attachmentToolResult } from './attachment-tool-result'
8
+
9
+ describe('a file opened again in a later turn', () => {
10
+ const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 9])
11
+
12
+ it('★ a picture comes back as the picture - not as a request to send it again', () => {
13
+ const answer: any = attachmentToolResult('a1', { type: 'image', data: png, mediaType: 'image/png', name: '영수증.png' })
14
+ expect(answer.images).toEqual([{ data: png, mediaType: 'image/png' }])
15
+ expect(answer.note).not.toMatch(/send it again/)
16
+ /* What the loop makes of it: the picture as an image part, the rest as the JSON the model reads. */
17
+ const { value, images } = takeToolImages(answer)
18
+ expect(images).toHaveLength(1)
19
+ expect(value).toEqual({ attachmentId: 'a1', name: '영수증.png', type: 'image', note: answer.note, imagesAttached: 1 })
20
+ })
21
+
22
+ it('a text file comes back as its text, and a cut one says so', () => {
23
+ expect(attachmentToolResult('t1', { type: 'text', text: 'a,b', name: 'x.csv', truncated: false })).toEqual({ attachmentId: 't1', name: 'x.csv', type: 'text', text: 'a,b' })
24
+ expect(attachmentToolResult('t1', { type: 'text', text: 'a', name: 'x.csv', truncated: true })).toMatchObject({ note: 'the file is longer; this is its beginning.' })
25
+ })
26
+ })
@@ -0,0 +1,18 @@
1
+ /*
2
+ * What readChatAttachment answers for a file of the conversation (design/ai/shared-dock.md §9-4).
3
+ *
4
+ * A text file comes back as its text. A picture comes back as the picture: the reserved `images` field is taken out
5
+ * of the JSON by the loop (ai-client-base takeToolImages) and sent as an image part of the same turn - so a receipt
6
+ * sent three turns ago can be read again without asking the person to send it again.
7
+ *
8
+ * Pure, so it is tested without storage or the shell.
9
+ */
10
+ export type FileForModel =
11
+ | { type: 'image'; data: Buffer; mediaType: string; name: string }
12
+ | { type: 'text'; text: string; name: string; truncated: boolean }
13
+
14
+ export function attachmentToolResult(attachmentId: string, file: FileForModel) {
15
+ return file.type === 'image'
16
+ ? { attachmentId, name: file.name, type: 'image', images: [{ data: file.data, mediaType: file.mediaType }], note: 'the picture is attached right after this result - look at it.' }
17
+ : { attachmentId, name: file.name, type: 'text', text: file.text, ...(file.truncated ? { note: 'the file is longer; this is its beginning.' } : {}) }
18
+ }
@@ -2,7 +2,7 @@
2
2
  * readChatAttachment - the model opens a file sent in an earlier turn (design/ai/shared-dock.md §9-4).
3
3
  *
4
4
  * A file reaches the model in full only in the turn it was sent; later turns carry one line naming it.
5
- * When the model needs it again it asks here. The caller must be able to read the conversation the file
5
+ * When the model needs it again it asks here - a text file comes back as its text, a picture as the picture itself. The caller must be able to read the conversation the file
6
6
  * belongs to - the same gate as reading its messages (ADR-0073 decision 1) - so a file id from elsewhere
7
7
  * opens nothing.
8
8
  *
@@ -18,6 +18,7 @@ import { isSessionParticipant } from '../chat-session-participant/participant-lo
18
18
  import { attachmentForModel, CHAT_ATTACHMENT_REF } from './session-attachments.js'
19
19
  import { textCharsOf } from './attachment-policy.js'
20
20
  import { getAssistantMode } from '../assistant-mode/registry.js'
21
+ import { attachmentToolResult } from './attachment-tool-result.js'
21
22
 
22
23
  export const CHAT_ATTACHMENT_CATEGORY = 'chat-attachments'
23
24
 
@@ -27,7 +28,7 @@ const readChatAttachment: ToolSpec = {
27
28
  doors: ['Query.chatMessages'],
28
29
  description:
29
30
  'Open a file that was sent earlier in this conversation. History names such files as "[attachment <id>: <name> ...]"; ' +
30
- 'pass that id. An image comes back as a description request, a text file as its text (cut when long - the answer says so).',
31
+ 'pass that id. An image comes back as the picture itself, a text file as its text (cut when long - the answer says so).',
31
32
  schema: {
32
33
  type: 'object',
33
34
  properties: { attachmentId: { type: 'string', description: 'The id from the "[attachment <id>: ...]" line.' } },
@@ -49,9 +50,7 @@ const readChatAttachment: ToolSpec = {
49
50
  }
50
51
 
51
52
  const file = await attachmentForModel(row, textCharsOf(getAssistantMode(session.kind || '')?.attachments))
52
- return file.type === 'image'
53
- ? { attachmentId: id, name: file.name, type: 'image', note: 'images are shown to you only in the turn they were sent; ask the person to send it again to look at it.' }
54
- : { attachmentId: id, name: file.name, type: 'text', text: file.text, ...(file.truncated ? { note: 'the file is longer; this is its beginning.' } : {}) }
53
+ return attachmentToolResult(id, file)
55
54
  }
56
55
  } as ToolSpec
57
56
 
@@ -167,6 +167,16 @@ describe('changing what the person pressed', () => {
167
167
  expect(records.l1.amount).toBe('12400.00')
168
168
  })
169
169
 
170
+ it.each(['Mutation.changeExpenseLine', 'Query.myExpenseLines'])('enriches only the failed update door: %s', async door => {
171
+ const proposal = await card([{ ref: 'l1', values: { amount: 13000 } }])
172
+ const refusal = { message: 'required', extensions: { operation: door.startsWith('Query.') ? 'query' : 'mutation' }, path: [door.split('.')[1]] }
173
+ const refusalFor = jest.fn().mockResolvedValue(refusal)
174
+ const outcome = await applyEntryProposal('u1', deps(proposal, { passesDoor: async asked => asked !== door, refusalFor }))
175
+ expect(outcome).toMatchObject({ verdict: 'refused', errorCode: 'door-closed', applied: 0, missed: 0, refusal })
176
+ expect(refusalFor.mock.calls).toEqual([[door]])
177
+ expect(records.l1.amount).toBe('12400.00')
178
+ })
179
+
170
180
  it('★ a newer 수정안 replaces an older one - an 입력안 of the same record does not', async () => {
171
181
  const older = await card([{ ref: 'l1', values: { amount: 13000 } }])
172
182
  const entry = { key: 'e1', kind: 'entry', target: 'office.expense-line', rows: [] }
@@ -347,4 +357,3 @@ describe('a check that cannot run', () => {
347
357
  expect(proposal.rows[0].issues).toEqual([{ code: 'check-failed', blocking: true }])
348
358
  })
349
359
  })
350
-
@@ -157,6 +157,35 @@ describe('entering what the person pressed', () => {
157
157
  expect(asked[0].variables).toEqual({ line: { spentOn: '2026-09-12', amount: 1 } })
158
158
  })
159
159
 
160
+ it('a closed mutation carries its authoritative refusal without entering any rows', async () => {
161
+ const refusal = { message: 'requires warehouse:mutation', extensions: { code: 'FORBIDDEN', because: 'role', operation: 'mutation', requires: { category: 'warehouse', privilege: 'mutation' } }, path: ['addExpenseLine'] }
162
+ const refusalFor = jest.fn().mockResolvedValue(refusal)
163
+ const messages = [{ id: 'm1', proposals: [proposal('k1', [row({ amount: 1 })])] }]
164
+ expect(await applyEntryProposal('k1', deps(messages, { passesDoor: async () => false, refusalFor }))).toEqual({ verdict: 'refused', errorCode: 'door-closed', applied: 0, missed: 0, rows: [], refusal })
165
+ expect(refusalFor).toHaveBeenCalledWith('Mutation.addExpenseLine')
166
+ expect(asked).toEqual([])
167
+ })
168
+
169
+ it('failed refusal enrichment keeps the original refusal and no writes', async () => {
170
+ const messages = [{ id: 'm1', proposals: [proposal('k1', [row({ amount: 1 })])] }]
171
+ expect(await applyEntryProposal('k1', deps(messages, { passesDoor: async () => false, refusalFor: async () => { throw new Error('lookup unavailable') } }))).toEqual({ verdict: 'refused', errorCode: 'door-closed', applied: 0, missed: 0, rows: [] })
172
+ expect(asked).toEqual([])
173
+ })
174
+
175
+ it('audits the closed door before best-effort enrichment and tolerates a broken diagnostic sink', async () => {
176
+ const order: string[] = []
177
+ const target = { ...expense, record: async () => { order.push('audit') } }
178
+ const messages = [{ id: 'm1', proposals: [proposal('k1', [row({ amount: 1 })])] }]
179
+ const outcome = await applyEntryProposal('k1', deps(messages, {
180
+ passesDoor: async () => false,
181
+ refusalFor: async () => { order.push('explain'); throw new Error('unavailable') },
182
+ report: () => { throw new Error('diagnostic unavailable') }
183
+ }), () => target)
184
+ expect(order).toEqual(['audit', 'explain'])
185
+ expect(outcome).toMatchObject({ verdict: 'refused', errorCode: 'door-closed', applied: 0 })
186
+ expect(asked).toEqual([])
187
+ })
188
+
160
189
  it('★ asks the checks again - a record entered since the card was made keeps its row out', async () => {
161
190
  const messages = [{ id: 'm1', proposals: [proposal('k1', [row({ spentOn: '2026-09-12', amount: 1, memo: 'dup' })])] }]
162
191
  const outcome = await applyEntryProposal('k1', deps(messages))
@@ -381,4 +410,3 @@ describe("a moment is the domain's wall time", () => {
381
410
  expect(result.rows[0].values.startTime).toBe('2026-10-01T08:30:00+09:00')
382
411
  })
383
412
  })
384
-
@@ -29,12 +29,20 @@ export interface EntryRowResult {
29
29
  error?: string
30
30
  }
31
31
 
32
+ export interface EntryRefusal {
33
+ message: string
34
+ extensions: Record<string, unknown>
35
+ path: string[]
36
+ }
37
+
32
38
  export interface EntryApplyOutcome {
33
39
  verdict: 'accepted' | 'refused' | 'unavailable'
34
40
  errorCode?: string
35
41
  applied: number
36
42
  missed: number
37
43
  rows: EntryRowResult[]
44
+ /** AUTH's serialized refusal, only when the closed door could be identified. */
45
+ refusal?: EntryRefusal
38
46
  }
39
47
 
40
48
  export interface EntryApplyDeps {
@@ -43,6 +51,7 @@ export interface EntryApplyDeps {
43
51
  /** Whether an outcome line already answers this key. */
44
52
  answered: boolean
45
53
  passesDoor(door: string): Promise<boolean>
54
+ refusalFor?(door: string): Promise<EntryRefusal | undefined>
46
55
  /**
47
56
  * Runs one of the application's queries or mutations with the person's request state - its API, its directives
48
57
  * and its own checks. Answers what GraphQL answers.
@@ -56,6 +65,22 @@ export interface EntryApplyDeps {
56
65
 
57
66
  const nothing = (verdict: EntryApplyOutcome['verdict'], errorCode: string): EntryApplyOutcome => ({ verdict, errorCode, applied: 0, missed: 0, rows: [] })
58
67
 
68
+ /** Called after the existing audit. An unavailable explanation must not change the refusal. */
69
+ async function closedDoor(door: string, deps: EntryApplyDeps): Promise<EntryApplyOutcome> {
70
+ const outcome = nothing('refused', 'door-closed')
71
+ try {
72
+ const refusal = await deps.refusalFor?.(door)
73
+ return refusal ? { ...outcome, refusal } : outcome
74
+ } catch (error) {
75
+ try {
76
+ deps.report?.('[ai-assistant] entry refusal explanation unavailable', error)
77
+ } catch {
78
+ /* A diagnostic sink cannot turn a closed door into a failed proposal request. */
79
+ }
80
+ return outcome
81
+ }
82
+ }
83
+
59
84
  export function findEntryProposal(messages: StoredProposalMessage[], key: string): { proposal?: any; superseded: boolean } {
60
85
  let at = -1
61
86
  let proposal: any
@@ -85,7 +110,7 @@ export async function applyEntryProposal(key: string, deps: EntryApplyDeps, targ
85
110
  const door = entryDoorOf(target.mutation)
86
111
  if (!(await deps.passesDoor(door))) {
87
112
  await recordEntryEvent(target, { phase: 'apply', tool: entryToolName(target.key), rows: 0, ok: false, error: 'door-closed', detail: { proposalKey: key } }, { state: deps.state })
88
- return nothing('refused', 'door-closed')
113
+ return closedDoor(door, deps)
89
114
  }
90
115
 
91
116
  /* Read again from the stored values - the same rules, and today's records for the target's checks. */
@@ -176,7 +201,7 @@ async function applyChanges(key: string, proposal: any, target: EntryTarget, dep
176
201
  for (const door of [rootFieldOf(update.mutation, 'mutation'), rootFieldOf(update.read.query, 'query')]) {
177
202
  if (!(await deps.passesDoor(door))) {
178
203
  await recordEntryEvent(target, { phase: 'apply', tool, rows: 0, ok: false, error: 'door-closed', detail: { proposalKey: key } }, { state: deps.state })
179
- return nothing('refused', 'door-closed')
204
+ return closedDoor(door, deps)
180
205
  }
181
206
  }
182
207
 
@@ -0,0 +1,73 @@
1
+ const mockJudge = jest.fn()
2
+ const mockLookup = jest.fn()
3
+ jest.mock('@things-factory/auth-base', () => ({
4
+ judgePermission: (...args: any[]) => mockJudge(...args),
5
+ privilegeOfField: (...args: any[]) => mockLookup(...args),
6
+ privilegeRejection: jest.requireActual('../../../../auth-base/server/service/privilege/privilege-rejection').privilegeRejection
7
+ }))
8
+
9
+ import { entryRefusalFor } from './entry-refusal'
10
+ import { requestableFrom } from '../../../../auth-ui/client/utils/access-request'
11
+
12
+ const gate = { category: 'warehouse', privilege: 'mutation', owner: true, super: true }
13
+ const state = { domain: { id: 'd' }, user: { id: 'u' }, unsafeIP: true, prohibitedPrivileges: [{ category: 'warehouse', privilege: 'mutation', by: 'license' }] }
14
+
15
+ beforeEach(() => {
16
+ mockJudge.mockReset().mockResolvedValue({ permitted: false, because: 'role' })
17
+ mockLookup.mockReset().mockReturnValue({ known: true, gate })
18
+ })
19
+
20
+ it('uses AUTH\'s full judgement and declaration, not a guess from a closed boolean', async () => {
21
+ const refusal = await entryRefusalFor({ state })('Mutation.updateInboundPlan')
22
+ expect(mockLookup).toHaveBeenCalledWith('Mutation', 'updateInboundPlan')
23
+ expect(mockJudge).toHaveBeenCalledWith(gate, state.user, state.domain, true, state.prohibitedPrivileges)
24
+ expect(refusal).toEqual({
25
+ message: 'unauthorized! warehouse:mutation privilege required',
26
+ extensions: { code: 'FORBIDDEN', requires: { category: 'warehouse', privilege: 'mutation' }, because: 'role', operation: 'mutation' },
27
+ path: ['updateInboundPlan']
28
+ })
29
+ expect(requestableFrom(refusal?.extensions, refusal?.path[0])).toEqual({ category: 'warehouse', privilege: 'mutation', operationName: 'updateInboundPlan' })
30
+ })
31
+
32
+ it.each(['address', 'license', 'ownership', 'unknown'])('carries %s without offering a role request', async because => {
33
+ mockJudge.mockResolvedValue({ permitted: false, because })
34
+ const refusal = await entryRefusalFor({ state })('Mutation.updateInboundPlan')
35
+ expect(refusal?.extensions.because).toBe(because)
36
+ expect(requestableFrom(refusal?.extensions)).toBeNull()
37
+ })
38
+
39
+ it('a failed read stays a query even when applyEntryProposal is a mutation', async () => {
40
+ const refusal = await entryRefusalFor({ state })('Query.inboundPlans')
41
+ expect(refusal?.extensions.operation).toBe('query')
42
+ expect(refusal?.path).toEqual(['inboundPlans'])
43
+ expect(requestableFrom(refusal?.extensions)).toBeNull()
44
+ })
45
+
46
+ it('ownership-only carries no named requirement', async () => {
47
+ mockLookup.mockReturnValue({ known: true, gate: { owner: true } })
48
+ mockJudge.mockResolvedValue({ permitted: false, because: 'ownership' })
49
+ expect((await entryRefusalFor({ state })('Mutation.reset'))?.extensions.requires).toBe('ownership')
50
+ })
51
+
52
+ it('unknown, malformed and open doors supply no invented refusal', async () => {
53
+ expect(await entryRefusalFor({ state })('not-a-door')).toBeUndefined()
54
+ expect(mockLookup).not.toHaveBeenCalled()
55
+ for (const lookup of [{ known: false }, { known: true, gate: null }]) {
56
+ mockLookup.mockReturnValue(lookup)
57
+ expect(await entryRefusalFor({ state })('Mutation.updateInboundPlan')).toBeUndefined()
58
+ }
59
+ expect(mockJudge).not.toHaveBeenCalled()
60
+ })
61
+
62
+ it('missing caller and a newly permitted judgement supply no refusal', async () => {
63
+ for (const context of [undefined, { state: { user: state.user } }, { state: { domain: state.domain } }])
64
+ expect(await entryRefusalFor(context)('Mutation.updateInboundPlan')).toBeUndefined()
65
+ expect(mockJudge).not.toHaveBeenCalled()
66
+ mockJudge.mockResolvedValue({ permitted: true })
67
+ expect(await entryRefusalFor({ state })('Mutation.updateInboundPlan')).toBeUndefined()
68
+ })
69
+
70
+ it('uses the request translator for the refusal sentence', async () => {
71
+ const refusal = await entryRefusalFor({ state, t: () => '권한이 필요합니다.' })('Mutation.updateInboundPlan')
72
+ expect(refusal?.message).toBe('권한이 필요합니다.')
73
+ })
@@ -0,0 +1,20 @@
1
+ import { judgePermission, privilegeOfField, privilegeRejection } from '@things-factory/auth-base'
2
+ import { doorField } from '../chat-message/door-access.js'
3
+ import type { EntryRefusal } from './entry-apply.js'
4
+
5
+ /** Presentation only: AUTH still guards the application's mutation when it is executed. */
6
+ export function entryRefusalFor(context: { state?: any; t?: any } | undefined): (door: string) => Promise<EntryRefusal | undefined> {
7
+ return async door => {
8
+ const { domain, user, unsafeIP, prohibitedPrivileges } = context?.state ?? {}
9
+ const field = doorField(door)
10
+ if (!domain || !user || !field) return undefined
11
+ const asked = privilegeOfField(field.typeName, field.fieldName)
12
+ if (!asked.known || !asked.gate) return undefined
13
+ const judgement = await judgePermission(asked.gate as any, user, domain, unsafeIP, prohibitedPrivileges)
14
+ /* The gate may have opened since the preflight: never invent a refusal to explain that race. */
15
+ if (judgement.permitted !== false) return undefined
16
+ const operation = field.typeName === 'Mutation' ? 'mutation' : field.typeName === 'Query' ? 'query' : 'subscription'
17
+ const error = privilegeRejection(context, asked.gate.category, asked.gate.privilege, judgement.because, operation)
18
+ return { message: error.message, extensions: error.extensions, path: [field.fieldName] }
19
+ }
20
+ }
@@ -13,6 +13,7 @@ import { PROPOSAL_OUTCOME_PREFIX } from '../chat-message/proposal-outcome.js'
13
13
  import { toolAccessFor } from '../tool-access.js'
14
14
  import { applyEntryProposal, entryProposalAnswered, type EntryApplyOutcome } from './entry-apply.js'
15
15
  import { runAsCaller } from './entry-tools.js'
16
+ import { entryRefusalFor } from './entry-refusal.js'
16
17
 
17
18
  @Resolver()
18
19
  export class EntryProposalResolver {
@@ -57,6 +58,7 @@ export class EntryProposalResolver {
57
58
  messages: carrying.map((message: any) => ({ id: message.id, proposals: message.proposalsJson ?? [] })),
58
59
  answered: entryProposalAnswered(key, lines.map((line: any) => line.content)),
59
60
  passesDoor: door => passes(door),
61
+ refusalFor: entryRefusalFor(context),
60
62
  state: context.state,
61
63
  /*
62
64
  * The application's API, as the person - the same request state the door would have seen had they typed
@@ -0,0 +1,229 @@
1
+ /*
2
+ * AI 조회 (design/ai/read-targets.md) - the conditions become the application's filters, the rows are read through
3
+ * its query page by page, the server counts and sums, a long result goes on the workspace as read, and the screen
4
+ * card says which conditions it does not take.
5
+ */
6
+ import { modeToolCategories } from '../assistant-mode/resolve'
7
+ import { clearReadTargets, declareReadTarget, groupsOf, MAX_READ_ROWS, queryToolName, readCategoryName, screenAddress, screenToolName, type ReadTarget } from './read-target'
8
+ import { openScreen, queryRecords, readCategoryOf } from './read-tools'
9
+
10
+ /* The application, as a fake: a bank ledger behind a list query that pages and filters like ListParam. */
11
+ let ledger: any[]
12
+ const calls: any[] = []
13
+ const run = async (_document: string, variables: any) => {
14
+ calls.push(variables)
15
+ const keep = (row: any) =>
16
+ variables.filters.every((f: any) =>
17
+ f.operator === 'gte' ? row[f.name] >= f.value : f.operator === 'lte' ? row[f.name] <= f.value : f.operator === 'eq' ? row[f.name] === f.value : String(row[f.name] ?? '').includes(f.value)
18
+ )
19
+ const all = ledger.filter(keep)
20
+ const { page, limit } = variables.pagination
21
+ return { data: { cashTransactions: { items: all.slice((page - 1) * limit, page * limit), total: all.length } } }
22
+ }
23
+
24
+ const target: ReadTarget = {
25
+ key: 'office.cash-transaction',
26
+ labelKey: 'label.office-read-cash-transaction',
27
+ description: 'bank transactions.',
28
+ query: 'query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) { cashTransactions(filters: $filters, pagination: $pagination, sortings: $sortings) { items { id transactionDate type amount customerName account { name } } total } }',
29
+ criteria: [
30
+ { name: 'from', type: 'date', filter: v => ({ name: 'transactionDate', operator: 'gte', value: v }) },
31
+ { name: 'to', type: 'date', filter: v => ({ name: 'transactionDate', operator: 'lte', value: v }) },
32
+ { name: 'flow', type: 'enum', values: ['INCOME', 'EXPENSE'], filter: v => ({ name: 'type', operator: 'eq', value: v }) },
33
+ { name: 'text', type: 'string', filter: v => ({ name: 'customerName', operator: 'search', value: v }) }
34
+ ],
35
+ columns: [
36
+ { name: 'transactionDate', type: 'date' },
37
+ { name: 'account', path: 'account.name', type: 'string' },
38
+ { name: 'type', type: 'string' },
39
+ { name: 'amount', type: 'number', total: true },
40
+ { name: 'customerName', type: 'string' }
41
+ ],
42
+ rowsOf: data => ({ rows: data.cashTransactions.items, total: data.cashTransactions.total }),
43
+ screen: { route: 'cash-transaction-list', takes: ['flow', 'text'], params: c => ({ flow: c.flow as string, search: c.text as string }) }
44
+ }
45
+
46
+ const tx = (i: number, date: string, type: string, amount: number, customerName: string) => ({ id: `t${i}`, transactionDate: date, type, amount, customerName, account: { name: '우리은행' } })
47
+
48
+ beforeEach(() => {
49
+ clearReadTargets()
50
+ calls.length = 0
51
+ ledger = [
52
+ tx(1, '2026-04-03', 'EXPENSE', 1_000_000, '외주A'),
53
+ tx(2, '2026-05-10', 'EXPENSE', 250_000, '카카오T'),
54
+ tx(3, '2026-05-20', 'INCOME', 9_000_000, '고객사'),
55
+ tx(4, '2026-09-30', 'EXPENSE', 2_000_000, '외주A'),
56
+ tx(5, '2026-10-01', 'EXPENSE', 70_000, '스타벅스')
57
+ ]
58
+ })
59
+
60
+ describe('reading a list', () => {
61
+ it('★ the conditions become the application\'s filters - the model never names the query\'s columns', async () => {
62
+ const result: any = await queryRecords(target, { from: '2026-04-01', to: '2026-09-30', flow: 'EXPENSE' }, {}, run)
63
+ expect(calls[0].filters).toEqual([
64
+ { name: 'transactionDate', operator: 'gte', value: '2026-04-01' },
65
+ { name: 'transactionDate', operator: 'lte', value: '2026-09-30' },
66
+ { name: 'type', operator: 'eq', value: 'EXPENSE' }
67
+ ])
68
+ expect(result).toMatchObject({ total: 3, read: 3, complete: true, sums: { amount: 3_250_000 } })
69
+ expect(result.rows[0]).toEqual({ id: 't1', transactionDate: '2026-04-03', account: '우리은행', type: 'EXPENSE', amount: 1_000_000, customerName: '외주A' })
70
+ expect(result.proposed).toBeUndefined()
71
+ })
72
+
73
+ it('★ the server groups and sums - by a column largest first, by a date column per month', async () => {
74
+ const byName: any = await queryRecords(target, { flow: 'EXPENSE', groupBy: 'customerName' }, {}, run)
75
+ expect(byName.groups[0]).toEqual({ key: '외주A', count: 2, sums: { amount: 3_000_000 } })
76
+ const byMonth: any = await queryRecords(target, { flow: 'EXPENSE', groupBy: 'transactionDate' }, {}, run)
77
+ expect(byMonth.groups.map((group: any) => group.key)).toEqual(['2026-04', '2026-05', '2026-09', '2026-10'])
78
+ expect(groupsOf(target, [], 'nothing')).toBeUndefined()
79
+ })
80
+
81
+ it('a condition that does not fit its type is refused, not guessed at', async () => {
82
+ const result: any = await queryRecords(target, { from: '지난달', flow: 'OUT' }, {}, run)
83
+ expect(result).toMatchObject({ rejected: true, error: 'condition-unreadable' })
84
+ expect(result.refused.map((one: any) => one.name)).toEqual(['from', 'flow'])
85
+ expect(calls).toHaveLength(0)
86
+ })
87
+
88
+ it('★ a long result goes on the workspace as read - the model gets 20 rows, the card every row', async () => {
89
+ ledger = Array.from({ length: 450 }, (_, i) => tx(i, `2026-06-${String((i % 28) + 1).padStart(2, '0')}`, 'EXPENSE', 1000 + i, `거래처${i % 7}`))
90
+ const result: any = await queryRecords(target, { flow: 'EXPENSE', groupBy: 'customerName' }, { t: (key: string) => (key === 'label.office-read-cash-transaction' ? '은행 입출금' : key) }, run)
91
+ expect(calls.map(one => one.pagination.page)).toEqual([1, 2, 3])
92
+ expect(result).toMatchObject({ proposed: true, kind: 'workspace', total: 450, read: 450, complete: true, rowsNotShown: 430 })
93
+ expect(result.rows).toHaveLength(20)
94
+ const tables = result.forScreen.workspace.blocks.filter((block: any) => block.kind === 'table')
95
+ expect(tables.map((table: any) => table.rows.length)).toEqual([7, 450])
96
+ expect(result.forScreen.workspace.title).toBe('은행 입출금 · 450')
97
+ expect(result.forScreen.workspace.name).toBe('office-cash-transaction')
98
+ })
99
+
100
+ it('★ more than the read limit is said - the sums are of the rows read, never called the total', async () => {
101
+ ledger = Array.from({ length: MAX_READ_ROWS + 30 }, (_, i) => tx(i, '2026-06-01', 'EXPENSE', 1, 'x'))
102
+ const result: any = await queryRecords(target, {}, {}, run)
103
+ expect(result).toMatchObject({ total: MAX_READ_ROWS + 30, read: MAX_READ_ROWS, complete: false, sums: { amount: MAX_READ_ROWS } })
104
+ expect(result.message).toMatch(/Only 1000 of 1030 rows were read/)
105
+ })
106
+
107
+ it('a query the person may not run is said as it failed', async () => {
108
+ const result: any = await queryRecords(target, {}, {}, async () => ({ errors: [{ message: 'unauthorized! office:query privilege required' }] }))
109
+ expect(result).toMatchObject({ rejected: true, error: 'query-failed' })
110
+ })
111
+ })
112
+
113
+ describe('the screen card', () => {
114
+ it('★ the address carries what the screen takes, and the card names what it does not', () => {
115
+ expect(screenAddress(target, { flow: 'EXPENSE', from: '2026-04-01', text: '외주' })).toEqual({
116
+ href: 'cash-transaction-list?flow=EXPENSE&search=%EC%99%B8%EC%A3%BC',
117
+ applied: ['flow', 'text'],
118
+ notApplied: ['from']
119
+ })
120
+ const card: any = openScreen(target, { flow: 'EXPENSE', from: '2026-04-01' }, {})
121
+ expect(card).toMatchObject({ proposed: true, kind: 'open-page', href: 'cash-transaction-list?flow=EXPENSE', notApplied: ['from'] })
122
+ expect(card.message).toMatch(/does not take from/)
123
+ })
124
+ })
125
+
126
+ describe('declaring and offering', () => {
127
+ it('★ the tools are locked by the list query\'s door, and a mode opens them by key', () => {
128
+ const category = readCategoryOf(target, () => run)
129
+ expect(category.name).toBe(readCategoryName(target.key))
130
+ expect(category.specs.map(spec => spec.name)).toEqual([queryToolName(target.key), screenToolName(target.key)])
131
+ expect(category.specs.map(spec => (spec as any).doors)).toEqual([['Query.cashTransactions'], ['Query.cashTransactions']])
132
+ expect(modeToolCategories({ readTargets: [target.key] })).toContain('data-read:office.cash-transaction')
133
+ })
134
+
135
+ it('a declaration that would mislead is refused at boot', () => {
136
+ declareReadTarget(target)
137
+ expect(() => declareReadTarget(target)).toThrow(/declared twice/)
138
+ expect(() => declareReadTarget({ ...target, key: 'office.x', screen: { ...target.screen!, takes: ['amount'] } })).toThrow(/takes 'amount', which is no condition/)
139
+ expect(() => declareReadTarget({ ...target, key: 'office.y', columns: [] })).toThrow(/shows no column/)
140
+ })
141
+
142
+ it('every condition has one schema type - Gemini refuses a list of types', () => {
143
+ const [query] = readCategoryOf(target, () => run).specs as any[]
144
+ for (const [name, schema] of Object.entries<any>(query.schema.properties)) expect(typeof schema.type).toBe('string')
145
+ })
146
+ })
147
+
148
+ describe('one record', () => {
149
+ it('★ one record by its id - every target takes it, and its every column comes back', async () => {
150
+ const result: any = await queryRecords(target, { id: 't3' }, {}, run)
151
+ expect(calls[0].filters).toEqual([{ name: 'id', operator: 'eq', value: 't3' }])
152
+ expect(result.record).toEqual({ id: 't3', transactionDate: '2026-05-20', account: '우리은행', type: 'INCOME', amount: 9_000_000, customerName: '고객사' })
153
+ expect(result.message).toMatch(/one record/)
154
+ })
155
+
156
+ it('a list answer names each row by its id - the next question can ask for one', async () => {
157
+ const result: any = await queryRecords(target, { flow: 'INCOME', text: '고객' }, {}, run)
158
+ expect(result.record?.id).toBe('t3')
159
+ const list: any = await queryRecords(target, { flow: 'EXPENSE' }, {}, run)
160
+ expect(list.rows.every((row: any) => typeof row.id === 'string')).toBe(true)
161
+ })
162
+
163
+ it('a target may not declare id itself', () => {
164
+ expect(() => declareReadTarget({ ...target, key: 'office.z', criteria: [{ name: 'id', type: 'string', filter: v => ({ name: 'id', operator: 'eq', value: v }) }] })).toThrow(/every target takes it/)
165
+ })
166
+ })
167
+
168
+ describe('the text condition', () => {
169
+ it('★ a target that takes ListParam search sends the words as search - the resolver finds them in its own fields', async () => {
170
+ const searching: ReadTarget = { ...target, key: 'office.searching', criteria: target.criteria.filter(one => one.name !== 'text'), search: { description: 'the memo and the counterparty' } }
171
+ const seen: any[] = []
172
+ await queryRecords(searching, { text: '외주', flow: 'EXPENSE' }, {}, async (_d, variables) => (seen.push(variables), { data: { cashTransactions: { items: [], total: 0 } } }))
173
+ expect(seen[0].search).toBe('외주')
174
+ expect(seen[0].filters).toEqual([{ name: 'type', operator: 'eq', value: 'EXPENSE' }])
175
+ const [query] = readCategoryOf(searching, () => run).specs as any[]
176
+ expect(query.schema.properties.text.description).toMatch(/the memo and the counterparty/)
177
+ expect(() => declareReadTarget({ ...target, key: 'office.both', search: { description: 'x' } })).toThrow(/search is the text condition/)
178
+ })
179
+ })
180
+
181
+ describe('a list declared in one line', () => {
182
+ const { listReadTarget } = require('./list-read')
183
+ const line = {
184
+ key: 'office.cash-transaction',
185
+ title: 'menu.cash-transaction-list',
186
+ name: '자금 입출금 관리',
187
+ listQuery: 'cashTransactions',
188
+ selection: 'id transactionDate type amount balance customerName',
189
+ columns: [
190
+ { name: 'transactionDate', type: 'date' },
191
+ { name: 'type', type: 'string' },
192
+ { name: 'amount', type: 'number', total: true },
193
+ { name: 'balance', type: 'number' }
194
+ ],
195
+ dateField: 'transactionDate',
196
+ narrowing: { year: 'year', month: 'month', flow: 'type' },
197
+ search: 'memo and counterparty',
198
+ route: 'cash-transaction-list',
199
+ screenTakes: ['year', 'month', 'flow', 'text']
200
+ }
201
+
202
+ it('★ becomes a target with the conditions its fields allow - and only those', async () => {
203
+ const made: ReadTarget = listReadTarget(line)
204
+ declareReadTarget(made)
205
+ expect(made.criteria.map(one => one.name)).toEqual(['from', 'to', 'year', 'month', 'flow', 'minAmount', 'maxAmount'])
206
+ expect(made.query).toContain('cashTransactions(filters: $filters, pagination: $pagination, sortings: $sortings, search: $search) { items { id transactionDate type amount balance customerName } total }')
207
+ const bare: ReadTarget = listReadTarget({ ...line, key: 'office.bare', narrowing: undefined, dateField: undefined, search: undefined, columns: [{ name: 'name', type: 'string' }] })
208
+ expect(bare.criteria).toEqual([])
209
+ expect(bare.query).not.toContain('$search')
210
+ expect(bare.screen!.takes).toEqual([])
211
+ })
212
+
213
+ it('★ the to day takes the whole day of a date-time, and the screen gets only what it reads', () => {
214
+ const made: ReadTarget = listReadTarget(line)
215
+ const to = made.criteria.find(one => one.name === 'to')!
216
+ expect(to.filter('2026-09-30')).toEqual({ name: 'transactionDate', operator: 'lte', value: '2026-09-30T23:59:59.999' })
217
+ expect(made.screen!.params({ year: 2026, month: 9, flow: 'EXPENSE', text: '외주', from: '2026-01-01' })).toEqual({ year: 2026, month: 9, flow: 'EXPENSE', search: '외주' })
218
+ expect(made.screen!.params({ month: 9 })).toEqual({})
219
+ const plain: ReadTarget = listReadTarget({ ...line, key: 'plant.x', screenTakes: undefined })
220
+ expect(plain.screen!.takes).toEqual([])
221
+ expect(plain.screen!.params({ year: 2026 })).toEqual({})
222
+ })
223
+
224
+ it('a minimum or maximum amount means the amount column, else the first summed one', () => {
225
+ const made: ReadTarget = listReadTarget({ ...line, key: 'office.claims', columns: [{ name: 'totalAmount', type: 'number', total: true }] })
226
+ expect(made.criteria.find(one => one.name === 'minAmount')!.filter(5)).toEqual({ name: 'totalAmount', operator: 'gte', value: 5 })
227
+ })
228
+ })
229
+