@things-factory/ai-assistant 10.1.206 → 10.1.208

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/client/components/assistant-chat.ts +2 -1
  2. package/client/components/chat-input-builder.test.ts +5 -0
  3. package/client/components/chat-input-builder.ts +4 -1
  4. package/client/setup-assistant-dock.ts +1 -0
  5. package/dist-client/components/assistant-chat.js +2 -1
  6. package/dist-client/components/assistant-chat.js.map +1 -1
  7. package/dist-client/components/chat-input-builder.d.ts +2 -0
  8. package/dist-client/components/chat-input-builder.js +2 -1
  9. package/dist-client/components/chat-input-builder.js.map +1 -1
  10. package/dist-client/components/chat-input-builder.test.js +4 -0
  11. package/dist-client/components/chat-input-builder.test.js.map +1 -1
  12. package/dist-client/setup-assistant-dock.js +1 -0
  13. package/dist-client/setup-assistant-dock.js.map +1 -1
  14. package/dist-client/tsconfig.tsbuildinfo +1 -1
  15. package/dist-server/service/assistant-chat-resolver.d.ts +1 -0
  16. package/dist-server/service/assistant-chat-resolver.js +20 -0
  17. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  18. package/dist-server/service/assistant-mode/index.d.ts +1 -1
  19. package/dist-server/service/assistant-mode/index.js +3 -1
  20. package/dist-server/service/assistant-mode/index.js.map +1 -1
  21. package/dist-server/service/assistant-mode/mode-categories.d.ts +29 -0
  22. package/dist-server/service/assistant-mode/mode-categories.js +37 -1
  23. package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
  24. package/dist-server/service/assistant-mode/registry.js +20 -0
  25. package/dist-server/service/assistant-mode/registry.js.map +1 -1
  26. package/dist-server/service/assistant-mode/screen-rules.d.ts +13 -0
  27. package/dist-server/service/assistant-mode/screen-rules.js +32 -5
  28. package/dist-server/service/assistant-mode/screen-rules.js.map +1 -1
  29. package/dist-server/service/assistant-mode/types.d.ts +6 -1
  30. package/dist-server/service/assistant-mode/types.js.map +1 -1
  31. package/dist-server/service/chat-message/used-tools.d.ts +11 -0
  32. package/dist-server/service/chat-message/used-tools.js +33 -0
  33. package/dist-server/service/chat-message/used-tools.js.map +1 -0
  34. package/dist-server/service/data-entry/entry-target.d.ts +13 -0
  35. package/dist-server/service/data-entry/entry-target.js +20 -5
  36. package/dist-server/service/data-entry/entry-target.js.map +1 -1
  37. package/dist-server/service/data-entry/entry-tools.js +2 -2
  38. package/dist-server/service/data-entry/entry-tools.js.map +1 -1
  39. package/dist-server/service/data-entry/entry-update.d.ts +2 -1
  40. package/dist-server/service/data-entry/entry-update.js +4 -2
  41. package/dist-server/service/data-entry/entry-update.js.map +1 -1
  42. package/dist-server/service/data-read/read-target.d.ts +2 -0
  43. package/dist-server/service/data-read/read-target.js +2 -2
  44. package/dist-server/service/data-read/read-target.js.map +1 -1
  45. package/dist-server/service/data-read/read-tools.js +1 -1
  46. package/dist-server/service/data-read/read-tools.js.map +1 -1
  47. package/dist-server/service/testing/mode-conformance.d.ts +4 -0
  48. package/dist-server/service/testing/mode-conformance.js +6 -0
  49. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  50. package/dist-server/service/unattended-run/run-unattended.js +2 -0
  51. package/dist-server/service/unattended-run/run-unattended.js.map +1 -1
  52. package/dist-server/tsconfig.tsbuildinfo +1 -1
  53. package/package.json +9 -9
  54. package/server/explicit-graphql-types.test.ts +1 -1
  55. package/server/service/assistant-chat-resolver.ts +21 -1
  56. package/server/service/assistant-mode/index.ts +1 -1
  57. package/server/service/assistant-mode/mode-categories.ts +47 -1
  58. package/server/service/assistant-mode/mode-front.test.ts +65 -0
  59. package/server/service/assistant-mode/open-subject-door.test.ts +16 -0
  60. package/server/service/assistant-mode/registry.test.ts +56 -0
  61. package/server/service/assistant-mode/registry.ts +20 -2
  62. package/server/service/assistant-mode/resolve.test.ts +2 -1
  63. package/server/service/assistant-mode/screen-rules.test.ts +35 -3
  64. package/server/service/assistant-mode/screen-rules.ts +34 -4
  65. package/server/service/assistant-mode/types.ts +14 -2
  66. package/server/service/chat-message/fold-history-window.test.ts +1 -1
  67. package/server/service/chat-message/used-tools.test.ts +19 -0
  68. package/server/service/chat-message/used-tools.ts +25 -0
  69. package/server/service/data-entry/data-entry-update.test.ts +11 -0
  70. package/server/service/data-entry/data-entry.test.ts +65 -1
  71. package/server/service/data-entry/entry-target.ts +30 -5
  72. package/server/service/data-entry/entry-tools.ts +2 -2
  73. package/server/service/data-entry/entry-update.ts +5 -2
  74. package/server/service/data-read/read-target.ts +5 -3
  75. package/server/service/data-read/read-tools.ts +1 -1
  76. package/server/service/data-read/record-keys.test.ts +36 -0
  77. package/server/service/testing/mode-conformance.ts +8 -1
  78. package/server/service/unattended-run/run-unattended.ts +2 -0
  79. package/test/assistant-dock.test.ts +1 -1
  80. package/translations/ko.json +2 -2
@@ -2,7 +2,7 @@
2
2
  * Data entry from material (design/ai/data-entry.md) - the rules a row passes, what the tool hands back,
3
3
  * and what the server asks again before it writes.
4
4
  */
5
- import { offsetAt, zonedDateTimes, clearEntryTargets, declareEntryTarget, entryDoorOf, entryToolName, entryTotals, lineHas, normalizeEntryRows, printedTotalOf, verifyRowsAgainstSources, writtenForms, type EntryTarget } from './entry-target'
5
+ import { readFieldValue, recordIdOfKey, resolveReferences, offsetAt, zonedDateTimes, clearEntryTargets, declareEntryTarget, entryDoorOf, entryToolName, entryTotals, lineHas, normalizeEntryRows, printedTotalOf, verifyRowsAgainstSources, writtenForms, type EntryTarget } from './entry-target'
6
6
  import { entryCategoryOf, proposeEntries } from './entry-tools'
7
7
  import { applyEntryProposal, entryProposalAnswered, findEntryProposal, type EntryApplyDeps } from './entry-apply'
8
8
  import { modeToolCategories } from '../assistant-mode/resolve'
@@ -410,3 +410,67 @@ describe("a moment is the domain's wall time", () => {
410
410
  expect(result.rows[0].values.startTime).toBe('2026-10-01T08:30:00+09:00')
411
411
  })
412
412
  })
413
+
414
+ /*
415
+ * 2026-10-23: an office link card took `tax-invoice:<uuid>` - the key the link evidence and the open record show - and
416
+ * looked it up as an approval number. A reference that names a record of an axis reads that axis's key as its id.
417
+ */
418
+ describe('a reference names the record the model was shown', () => {
419
+ const ID = 'fe0e8503-ec5f-4da4-9c07-2d342c93d498'
420
+ const invoice = {
421
+ name: 'invoice',
422
+ type: 'reference' as const,
423
+ axis: 'tax-invoice',
424
+ reference: {
425
+ query: 'query ($text: String) { invoices(text: $text) { items { id approvalNo } } }',
426
+ variables: (text: string) => ({ text }),
427
+ recordsOf: (data: any, text: string) => (data?.invoices?.items ?? []).filter((one: any) => one.id === text || one.approvalNo === text)
428
+ }
429
+ }
430
+ const asked: string[] = []
431
+ const run = async (_query: string, variables: any) => {
432
+ asked.push(variables.text)
433
+ return { data: { invoices: { items: [{ id: ID, approvalNo: '20260120-10260120-17151515' }] } } }
434
+ }
435
+ const resolved = async (text: string, field: any = invoice) => {
436
+ const [row] = await resolveReferences([field], [{ values: { invoice: { text } }, issues: [] as any[] }], run)
437
+ return row.issues.length ? row.issues[0].code : row.values.invoice.id
438
+ }
439
+ beforeEach(() => (asked.length = 0))
440
+
441
+ it('★ the key `<axis>:<uuid>`, the bare uuid and the record\'s own words all find it', async () => {
442
+ expect(await resolved(`tax-invoice:${ID}`)).toBe(ID)
443
+ expect(asked).toEqual([ID])
444
+ expect(await resolved(ID)).toBe(ID)
445
+ expect(await resolved('20260120-10260120-17151515')).toBe(ID)
446
+ })
447
+
448
+ it('★ the key of a record of another axis is not this record, whatever its uuid', async () => {
449
+ expect(await resolved(`cash-transaction:${ID}`)).toBe('reference-not-found')
450
+ })
451
+
452
+ it('a reference with no axis reads what was written, as before', async () => {
453
+ const { axis, ...plain } = invoice
454
+ expect(await resolved(`tax-invoice:${ID}`, plain)).toBe('reference-not-found')
455
+ expect(asked).toEqual([`tax-invoice:${ID}`])
456
+ })
457
+
458
+ it('★ a field that holds a record\'s id reads its key as the id - one axis or any of several', () => {
459
+ const question = { name: 'questionId', type: 'string' as const, axis: 'strategy-question' }
460
+ expect(readFieldValue(question, ` strategy-question:${ID} `)).toEqual({ read: ID })
461
+ expect(readFieldValue(question, `initiative:${ID}`)).toEqual({ read: `initiative:${ID}` })
462
+ const owner = { name: 'ownerId', type: 'string' as const, axis: ['strategy-question', 'initiative'] }
463
+ expect(readFieldValue(owner, `initiative:${ID}`)).toEqual({ read: ID })
464
+ expect(readFieldValue({ name: 'note', type: 'string' }, `initiative:${ID}`)).toEqual({ read: `initiative:${ID}` })
465
+ /* A model that hands a resolved reference the key, not the id. */
466
+ expect(readFieldValue(invoice, { id: `tax-invoice:${ID}` })).toEqual({ read: { id: ID } })
467
+ })
468
+
469
+ it('a key names an id only when it is one', () => {
470
+ expect(recordIdOfKey('contract', `contract:${ID}`)).toBe(ID)
471
+ expect(recordIdOfKey('contract', ID.toUpperCase())).toBe(ID.toUpperCase())
472
+ expect(recordIdOfKey('contract', 'contract:H2026012')).toBeUndefined()
473
+ expect(recordIdOfKey('contract', 'H2026012')).toBeUndefined()
474
+ expect(recordIdOfKey('contract', `contract-round:${ID}`)).toBeUndefined()
475
+ })
476
+ })
@@ -36,6 +36,14 @@ export interface EntryField {
36
36
  * taken; none or several keep the row out and say so - the server never picks among several.
37
37
  */
38
38
  reference?: EntryReference
39
+ /**
40
+ * The axis of the record the field names (`tax-invoice`, `contract`) - several when it may name a record of each - for a
41
+ * record the model is shown by its key: an open record's anchor and an application's link evidence write
42
+ * `<axis>:<id>`. The key is read as the id it names, so what the model was shown is what it may write (2026-10-23: a
43
+ * card took `tax-invoice:<uuid>` from the evidence, looked it up as an approval number and found nothing). A reference
44
+ * reads it before its query runs; a string field holding an id reads it as the id.
45
+ */
46
+ axis?: string | readonly string[]
39
47
  }
40
48
 
41
49
  export interface EntryReference {
@@ -46,6 +54,22 @@ export interface EntryReference {
46
54
  recordsOf(data: any, text: string): { id: string; label?: string }[]
47
55
  }
48
56
 
57
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
58
+
59
+ /**
60
+ * The id a record's key names - `<axis>:<uuid>` as an anchor writes it, or the uuid alone. Nothing for a word, or for
61
+ * the key of a record of another axis: that one is not this record, whatever its uuid.
62
+ */
63
+ export function recordIdOfKey(axis: string | readonly string[], text: string): string | undefined {
64
+ const value = String(text ?? '').trim()
65
+ const prefix = (typeof axis === 'string' ? [axis] : axis).map(one => `${one}:`).find(one => value.startsWith(one))
66
+ const id = prefix ? value.slice(prefix.length) : value
67
+ return UUID.test(id) ? id : undefined
68
+ }
69
+
70
+ /** What the model wrote for a field that names a record - the id, when it wrote the record's key. */
71
+ const shownId = (field: EntryField, text: string): string => (field.axis && recordIdOfKey(field.axis, text)) || text
72
+
49
73
  /** A resolved reference, as a row keeps it. */
50
74
  export interface ReferenceValue {
51
75
  id: string
@@ -381,11 +405,11 @@ export function readFieldValue(field: EntryField, value: unknown): { read?: unkn
381
405
  return field.values!.includes(String(value).trim()) ? { read: String(value).trim() } : { code: 'not-in-list' }
382
406
  case 'reference':
383
407
  /* Resolved already ({ id }), or a name to look up ({ text }) - resolveReferences settles the second. */
384
- if (value && typeof value === 'object' && typeof (value as any).id === 'string') return { read: { id: (value as any).id, ...((value as any).label ? { label: String((value as any).label) } : {}) } }
408
+ if (value && typeof value === 'object' && typeof (value as any).id === 'string') return { read: { id: shownId(field, (value as any).id), ...((value as any).label ? { label: String((value as any).label) } : {}) } }
385
409
  if (value && typeof value === 'object' && typeof (value as any).text === 'string') return { read: { text: (value as any).text } }
386
410
  return typeof value === 'string' || typeof value === 'number' ? { read: { text: String(value).trim() } } : { code: 'not-a-reference' }
387
411
  default:
388
- return { read: String(value).trim() }
412
+ return { read: shownId(field, String(value).trim()) }
389
413
  }
390
414
  }
391
415
 
@@ -651,11 +675,12 @@ export async function resolveReferences<T extends { values: Record<string, unkno
651
675
  for (const field of references) {
652
676
  const value: any = row.values[field.name]
653
677
  if (!value || typeof value !== 'object' || typeof value.text !== 'string') continue
654
- const key = `${field.name}\u0000${value.text}`
678
+ const text = shownId(field, value.text)
679
+ const key = `${field.name}\u0000${text}`
655
680
  if (!asked.has(key)) {
656
681
  try {
657
- const answer = await run(field.reference!.query, field.reference!.variables(value.text))
658
- asked.set(key, answer.errors?.length ? new Error(answer.errors[0].message) : field.reference!.recordsOf(answer.data, value.text) ?? [])
682
+ const answer = await run(field.reference!.query, field.reference!.variables(text))
683
+ asked.set(key, answer.errors?.length ? new Error(answer.errors[0].message) : field.reference!.recordsOf(answer.data, text) ?? [])
659
684
  } catch (error: any) {
660
685
  asked.set(key, error instanceof Error ? error : new Error(String(error)))
661
686
  }
@@ -153,7 +153,7 @@ function changeSpecs(target: EntryTarget, run: (context: any) => GraphqlRunner):
153
153
  doors: [...new Set([rootFieldOf(update.mutation, 'mutation'), readDoor, ...referenceDoorsOf(changeableFields(target))])],
154
154
  description:
155
155
  `Propose changes to existing records: ${target.description} ` +
156
- 'Name each record by ref: "current" (the one on screen), "selected" (every row selected on screen) or an id a find tool returned. ' +
156
+ 'Name each record by ref: "current" (the one on screen), "selected" (every row selected on screen), an id a find tool returned, or the record\'s key (`<kind>:<id>`) as you were shown it. ' +
157
157
  'The server reads each record, and the card shows what it holds now and what it becomes; nothing changes until the person presses it.',
158
158
  schema: {
159
159
  type: 'object',
@@ -163,7 +163,7 @@ function changeSpecs(target: EntryTarget, run: (context: any) => GraphqlRunner):
163
163
  items: {
164
164
  type: 'object',
165
165
  properties: {
166
- ref: { type: 'string', description: '"current", "selected", or a record id from a find tool - never a guessed id.' },
166
+ ref: { type: 'string', description: '"current", "selected", a record id from a find tool, or the record\'s key as shown - never a guessed id.' },
167
167
  values: { type: 'object', description: 'Only the fields that change, with their new values.', properties: Object.fromEntries(changeableFields(target).map(field => [field.name, fieldSchema(field)])) }
168
168
  },
169
169
  required: ['ref', 'values']
@@ -13,6 +13,7 @@ import {
13
13
  changeToolName,
14
14
  findToolName,
15
15
  readFieldValue,
16
+ recordIdOfKey,
16
17
  resolveReferences,
17
18
  zonedDateTimes,
18
19
  sameFieldValue,
@@ -52,7 +53,8 @@ export function changeableFields(target: EntryTarget): EntryField[] {
52
53
  /**
53
54
  * The records the changes name. `current` is the record on screen, `selected` every row selected there -
54
55
  * both from the surface's host context, which is **not trusted**: an id from it is only a candidate, read
55
- * through the application before anything is shown. Any other ref is an id, from the find tool.
56
+ * through the application before anything is shown. Any other ref is an id, from the find tool, or the key the record
57
+ * was shown by.
56
58
  */
57
59
  export function resolveChangeRefs(
58
60
  targetKey: string,
@@ -74,7 +76,8 @@ export function resolveChangeRefs(
74
76
  if (ids.length) for (const id of ids) picked.push({ id, via: 'selected', values })
75
77
  else unresolved.push('selected')
76
78
  } else if (ref) {
77
- picked.push({ id: ref, via: 'id', values })
79
+ /* An open record's key - `contract-round:<id>` for `office.contract-round` - names the record of this target's axis. */
80
+ picked.push({ id: recordIdOfKey(targetKey.split('.').pop()!, ref) ?? ref, via: 'id', values })
78
81
  } else {
79
82
  unresolved.push('no-ref')
80
83
  }
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * Pure but for the GraphQL runner, which comes in as an argument - the rules are tested without a server.
11
11
  */
12
- import { readFieldValue, rootFieldOf, type EntryField } from '../data-entry/entry-target.js'
12
+ import { readFieldValue, recordIdOfKey, rootFieldOf, type EntryField } from '../data-entry/entry-target.js'
13
13
  import { encodeListQuery } from '@operato/utils/list-query.js'
14
14
  import type { GraphqlRunner } from '../data-entry/entry-update.js'
15
15
  import { parse } from 'graphql'
@@ -143,6 +143,8 @@ export interface DetailArg {
143
143
  values?: string[]
144
144
  description?: string
145
145
  required?: boolean
146
+ /** A record's id: the axis (or axes) of the record, whose key `<axis>:<id>` the model may have been shown (EntryField.axis). */
147
+ axis?: string | readonly string[]
146
148
  }
147
149
 
148
150
  /**
@@ -200,8 +202,8 @@ export function detailReadOf(key: string | undefined): DetailRead | undefined {
200
202
  export function readCriteria(target: ReadTarget, args: any): { criteria: Record<string, unknown>; refused: { name: string; code: string }[] } {
201
203
  const criteria: Record<string, unknown> = {}
202
204
  const refused: { name: string; code: string }[] = []
203
- /* One record by its id - every target takes it (the id a list answer or a find tool gave). */
204
- if (typeof args?.id === 'string' && args.id.trim()) criteria.id = args.id.trim()
205
+ /* One record by its id - every target takes it (the id a list answer or a find tool gave, or the key `<axis>:<id>` an open record is shown by, the axis being the key's last part). */
206
+ if (typeof args?.id === 'string' && args.id.trim()) criteria.id = recordIdOfKey(target.key.split('.').pop()!, args.id) ?? args.id.trim()
205
207
  if (target.search && typeof args?.text === 'string' && args.text.trim()) criteria.text = args.text.trim()
206
208
  for (const criterion of target.criteria) {
207
209
  const value = args?.[criterion.name]
@@ -246,7 +246,7 @@ export async function queryDetail(target: DetailRead, args: any, run: GraphqlRun
246
246
  if (arg.required) refused.push({ name: arg.name, code: 'required' })
247
247
  continue
248
248
  }
249
- const { read, code } = readFieldValue({ name: arg.name, type: arg.type === 'string' ? 'text' : arg.type, values: arg.values } as any, value)
249
+ const { read, code } = readFieldValue({ name: arg.name, type: arg.type === 'string' ? 'text' : arg.type, values: arg.values, axis: arg.axis } as any, value)
250
250
  if (read === undefined) refused.push({ name: arg.name, code: code ?? 'unreadable' })
251
251
  else variables[arg.name] = read
252
252
  }
@@ -0,0 +1,36 @@
1
+ /*
2
+ * A read takes a record by the key it was shown by (2026-10-23) - `<axis>:<uuid>`, as an open record's anchor and an
3
+ * application's evidence write it. A 360 read and a list read asked by id took the key as the id and found nothing.
4
+ */
5
+ import { readCriteria, type DetailRead, type ReadTarget } from './read-target'
6
+ import { queryDetail } from './read-tools'
7
+
8
+ const ID = 'f92c04ec-40c4-4be8-8160-080cff20e750'
9
+
10
+ describe('a read names the record the model was shown', () => {
11
+ const lines = { key: 'office.cash-transaction', criteria: [] } as unknown as ReadTarget
12
+
13
+ it('★ a list read by id takes the key of its own axis - the target key\'s last part', () => {
14
+ expect(readCriteria(lines, { id: `cash-transaction:${ID}` }).criteria).toEqual({ id: ID })
15
+ expect(readCriteria(lines, { id: ID }).criteria).toEqual({ id: ID })
16
+ /* Another record's key is not this record - asked as written, it finds nothing. */
17
+ expect(readCriteria(lines, { id: `contract:${ID}` }).criteria).toEqual({ id: `contract:${ID}` })
18
+ })
19
+
20
+ it('★ a whole answer takes the key of the axis its id argument declares', async () => {
21
+ const summary: DetailRead = {
22
+ key: 'office.contract-360',
23
+ description: "one contract's 360.",
24
+ query: 'query ($id: String!) { contract360(id: $id) { code } }',
25
+ args: [{ name: 'id', type: 'string', required: true, axis: 'contract' }]
26
+ }
27
+ const asked: any[] = []
28
+ const run = async (_document: string, variables: any) => {
29
+ asked.push(variables)
30
+ return { data: { contract360: { code: 'H2026012' } } }
31
+ }
32
+ await queryDetail(summary, { id: `contract:${ID}` }, run)
33
+ await queryDetail(summary, { id: `cash-transaction:${ID}` }, run)
34
+ expect(asked).toEqual([{ id: ID }, { id: `cash-transaction:${ID}` }])
35
+ })
36
+ })
@@ -16,10 +16,13 @@ import { openSubjectLine, registryAssistantChat } from '@things-factory/ai-clien
16
16
 
17
17
  import { getAssistantMode, getAssistantModeWarnings } from '../assistant-mode/registry.js'
18
18
  import { assistantModeView, resolveModeSettings, type Translate } from '../assistant-mode/resolve.js'
19
+ import { modeFrontCategories, type TurnScreen } from '../assistant-mode/mode-categories.js'
19
20
 
20
21
  export interface ModeRunResult {
21
22
  /** 이 모드가 모델에게 실제로 건넨 도구 이름. */
22
23
  offeredTools: string[]
24
+ /** 건네지 않았지만 모델이 openTools 로 꺼낼 수 있던 도구 — 둘을 합치면 이 모드로 쓸 수 있는 전부(ADR-0061 결정 9). */
25
+ openableTools: string[]
23
26
  /** 모델이 받은 시스템 프롬프트 — 서버의 것인지 확인할 수 있다. */
24
27
  systemPrompt: string
25
28
  /** 첫 턴에 도구를 강제했는가. */
@@ -37,7 +40,7 @@ export const keyEchoTranslate: Translate = key => key
37
40
  */
38
41
  export async function runRegisteredMode(
39
42
  kind: string,
40
- options: { client: any; hostContext?: any; state?: any; toolAccess?: any; reply?: string } = {} as any
43
+ options: { client: any; hostContext?: any; state?: any; toolAccess?: any; reply?: string; screen?: TurnScreen } = {} as any
41
44
  ): Promise<ModeRunResult> {
42
45
  const settings = resolveModeSettings({ kind }, keyEchoTranslate)
43
46
  let requiredToolOnFirstTurn = false
@@ -47,6 +50,8 @@ export async function runRegisteredMode(
47
50
  messages: [{ role: 'user', content: '한 턴' }] as any,
48
51
  systemPrompt: settings.systemPrompt,
49
52
  toolCategories: settings.toolCategories,
53
+ /* What the door puts in front on that screen (ADR-0061 decision 9) - a mode too large for one call is offered as a person meets it. */
54
+ front: { categories: modeFrontCategories(getAssistantMode(kind) ?? {}, options.screen ?? {}) },
50
55
  requireToolOnFirstTurn: (requiredToolOnFirstTurn = Boolean(settings.requireToolOnFirstTurn)),
51
56
  temperature: settings.temperature,
52
57
  maxTokens: settings.maxTokens,
@@ -65,6 +70,7 @@ export async function runRegisteredMode(
65
70
 
66
71
  return {
67
72
  offeredTools: [...result.offeredTools],
73
+ openableTools: [...result.openableTools],
68
74
  systemPrompt: settings.systemPrompt,
69
75
  requiredToolOnFirstTurn,
70
76
  reply: result.reply
@@ -139,6 +145,7 @@ export function modeCarriesSharedRules(kind: string): string[] {
139
145
  const found: string[] = []
140
146
  if (/name our plumbing|do not describe our machinery/i.test(prompt)) found.push('absence-rule-copy')
141
147
  if (/^\s*Next:/im.test(prompt) || /end the reply with one last line/i.test(prompt)) found.push('next-request-rule-copy')
148
+ if (/finish what was asked in this turn|never ask whether to propose/i.test(prompt)) found.push('finish-rule-copy')
142
149
  return found
143
150
  }
144
151
 
@@ -187,6 +187,8 @@ export async function runAssistantUnattended(input: UnattendedRunInput, deps: Un
187
187
  messages,
188
188
  systemPrompt,
189
189
  toolCategories: categories,
190
+ /* No screen and no conversation - the mode's own tools in front, the lists opened as the run needs (ADR-0061 decision 9). */
191
+ front: { categories: mode.toolCategories ?? [] },
190
192
  mode: { kind: mode.kind, ...(input.mode.target ? { target: input.mode.target } : {}) },
191
193
  toolAccess: deps.toolAccess(state),
192
194
  hostContext: input.hostContext,
@@ -144,7 +144,7 @@ describe('attachments in the chat follow the mode', () => {
144
144
  /* Without it the client sends the File as {} and the server refuses the upload (found on :3700, 2026-09-25). */
145
145
  assert.match(chat, /context: \{ hasUpload: true \}/)
146
146
  /* Sent with the turn's input - the agent the person picked follows it (design/ai/agent-settings.md §5). */
147
- assert.match(chat, /\n\s*attachmentIds,\s*\n\s*agentId: this\.agentId\s*\n\s*\}\)/)
147
+ assert.match(chat, /\n\s*attachmentIds,\s*\n\s*agentId: this\.agentId,\s*\n\s*page: \(store\.getState\(\) as any\)\?\.route\?\.page \|\| undefined\s*\n\s*\}\)/)
148
148
  })
149
149
 
150
150
  it('a stored file opens through the conversation route', () => {
@@ -260,7 +260,7 @@
260
260
  "ai-assistant.entry.change": "{count}건 수정",
261
261
  "ai-assistant.entry.title-create": "{name} 입력안",
262
262
  "ai-assistant.entry.title-update": "{name} 수정안",
263
- "ai-assistant.entry.changed": "{count}건을 고쳤습니다.",
263
+ "ai-assistant.entry.changed": "{count}건을 수정했습니다.",
264
264
  "ai-assistant.entry.changed-none": "수정된 기록이 없습니다.",
265
265
  "ai-assistant.entry.changes": "{count}건",
266
266
  "ai-assistant.entry.not-changed": "{count}건은 변경하지 않습니다.",
@@ -399,5 +399,5 @@
399
399
  "ai-assistant.run.assistant-run-model-error": "실행 중 AI 모델 호출이 실패했습니다.",
400
400
  "ai-assistant.run.assistant-run-abandoned": "실행이 도중에 멈춰 다시 시작했습니다.",
401
401
  "ai-assistant.run.ai-not-configured": "이 사업장에는 AI 가 설정되어 있지 않습니다.",
402
- "ai-assistant.run.ai-agent-not-available": "이 실행에 고른 AI 에이전트를 여기서 쓸 수 없습니다."
402
+ "ai-assistant.run.ai-agent-not-available": "이 실행에서 선택한 AI 에이전트는 현재 사용할 수 없습니다."
403
403
  }