@things-factory/ai-assistant 10.1.203 → 10.1.205
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/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/assistant-mode/index.d.ts +1 -1
- package/dist-server/service/assistant-mode/index.js.map +1 -1
- package/dist-server/service/assistant-mode/mode-categories.d.ts +10 -0
- package/dist-server/service/assistant-mode/mode-categories.js +10 -0
- package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
- package/dist-server/service/assistant-mode/types.d.ts +14 -0
- package/dist-server/service/assistant-mode/types.js.map +1 -1
- package/dist-server/service/index.d.ts +2 -1
- package/dist-server/service/index.js +4 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/unattended-run/assistant-run.d.ts +45 -0
- package/dist-server/service/unattended-run/assistant-run.js +248 -0
- package/dist-server/service/unattended-run/assistant-run.js.map +1 -0
- package/dist-server/service/unattended-run/index.d.ts +8 -0
- package/dist-server/service/unattended-run/index.js +15 -0
- package/dist-server/service/unattended-run/index.js.map +1 -0
- package/dist-server/service/unattended-run/limits.d.ts +9 -0
- package/dist-server/service/unattended-run/limits.js +24 -0
- package/dist-server/service/unattended-run/limits.js.map +1 -0
- package/dist-server/service/unattended-run/run-as.d.ts +24 -0
- package/dist-server/service/unattended-run/run-as.js +26 -0
- package/dist-server/service/unattended-run/run-as.js.map +1 -0
- package/dist-server/service/unattended-run/run-budget.d.ts +24 -0
- package/dist-server/service/unattended-run/run-budget.js +46 -0
- package/dist-server/service/unattended-run/run-budget.js.map +1 -0
- package/dist-server/service/unattended-run/run-ledger.d.ts +13 -0
- package/dist-server/service/unattended-run/run-ledger.js +29 -0
- package/dist-server/service/unattended-run/run-ledger.js.map +1 -0
- package/dist-server/service/unattended-run/run-outcome.d.ts +9 -0
- package/dist-server/service/unattended-run/run-outcome.js +46 -0
- package/dist-server/service/unattended-run/run-outcome.js.map +1 -0
- package/dist-server/service/unattended-run/run-store.d.ts +31 -0
- package/dist-server/service/unattended-run/run-store.js +108 -0
- package/dist-server/service/unattended-run/run-store.js.map +1 -0
- package/dist-server/service/unattended-run/run-unattended.d.ts +50 -0
- package/dist-server/service/unattended-run/run-unattended.js +214 -0
- package/dist-server/service/unattended-run/run-unattended.js.map +1 -0
- package/dist-server/service/unattended-run/unattended-prompt.d.ts +3 -0
- package/dist-server/service/unattended-run/unattended-prompt.js +27 -0
- package/dist-server/service/unattended-run/unattended-prompt.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/server/service/assistant-mode/index.ts +1 -1
- package/server/service/assistant-mode/mode-categories.ts +10 -0
- package/server/service/assistant-mode/types.ts +15 -0
- package/server/service/index.ts +4 -1
- package/server/service/unattended-run/assistant-run.ts +199 -0
- package/server/service/unattended-run/index.ts +10 -0
- package/server/service/unattended-run/limits.ts +24 -0
- package/server/service/unattended-run/run-as.ts +38 -0
- package/server/service/unattended-run/run-budget.ts +58 -0
- package/server/service/unattended-run/run-ledger.ts +31 -0
- package/server/service/unattended-run/run-outcome.ts +43 -0
- package/server/service/unattended-run/run-store-race.pg.test.ts +83 -0
- package/server/service/unattended-run/run-store.ts +162 -0
- package/server/service/unattended-run/run-unattended.test.ts +477 -0
- package/server/service/unattended-run/run-unattended.ts +246 -0
- package/server/service/unattended-run/unattended-prompt.ts +24 -0
- package/translations/en.json +15 -1
- package/translations/es.json +15 -1
- package/translations/ja.json +15 -1
- package/translations/ko.json +15 -1
- package/translations/ms.json +15 -1
- package/translations/zh.json +15 -1
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Where runs are kept - one row per occurrence, attempts on the same row (design/ai/unattended-run.md §5).
|
|
3
|
+
*
|
|
4
|
+
* Two calls for one occurrence at once are parted by the unique index: one insert wins, the other reads the row it
|
|
5
|
+
* made. Taking an occurrence over is one update guarded by the attempt it saw, so two servers cannot both take it.
|
|
6
|
+
* Finishing is guarded the same way: an attempt that was taken over and ends late does not write over the next one.
|
|
7
|
+
*/
|
|
8
|
+
import type { Repository } from 'typeorm'
|
|
9
|
+
|
|
10
|
+
import { AssistantRun } from './assistant-run.js'
|
|
11
|
+
import { nextStep } from './run-ledger.js'
|
|
12
|
+
|
|
13
|
+
export interface RunIdentity {
|
|
14
|
+
domainId: string
|
|
15
|
+
source: string
|
|
16
|
+
subject: string
|
|
17
|
+
scheduledAt: Date
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RunStart {
|
|
21
|
+
name: string
|
|
22
|
+
modeKind: string
|
|
23
|
+
modeTarget?: string
|
|
24
|
+
/** Absent when the account named does not exist - the row still says the run was refused. */
|
|
25
|
+
runAsId?: string
|
|
26
|
+
startedAt: Date
|
|
27
|
+
deadlineAt: Date
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type RunEnding = Partial<
|
|
31
|
+
Pick<
|
|
32
|
+
AssistantRun,
|
|
33
|
+
| 'status'
|
|
34
|
+
| 'finishedAt'
|
|
35
|
+
| 'draft'
|
|
36
|
+
| 'reads'
|
|
37
|
+
| 'doors'
|
|
38
|
+
| 'outcomeCode'
|
|
39
|
+
| 'outcomeParams'
|
|
40
|
+
| 'groundingWarnings'
|
|
41
|
+
| 'agentId'
|
|
42
|
+
| 'clientId'
|
|
43
|
+
| 'modelCalls'
|
|
44
|
+
| 'inputTokens'
|
|
45
|
+
| 'outputTokens'
|
|
46
|
+
| 'cardsDropped'
|
|
47
|
+
>
|
|
48
|
+
>
|
|
49
|
+
|
|
50
|
+
export interface RunStore {
|
|
51
|
+
/** The row of this occurrence, and whether this call holds its attempt now. */
|
|
52
|
+
claim(identity: RunIdentity, start: RunStart, now: Date): Promise<{ claimed: boolean; row: AssistantRun }>
|
|
53
|
+
/** Ends the attempt the caller holds. A later attempt's row is returned untouched. */
|
|
54
|
+
finish(held: { id: string; attempt: number }, ending: RunEnding): Promise<AssistantRun>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What a fresh attempt starts with - nothing of the attempt before it. */
|
|
58
|
+
const CLEARED = {
|
|
59
|
+
finishedAt: null,
|
|
60
|
+
draft: null,
|
|
61
|
+
reads: null,
|
|
62
|
+
doors: null,
|
|
63
|
+
outcomeCode: null,
|
|
64
|
+
outcomeParams: null,
|
|
65
|
+
groundingWarnings: null,
|
|
66
|
+
agentId: null,
|
|
67
|
+
clientId: null,
|
|
68
|
+
modelCalls: 0,
|
|
69
|
+
inputTokens: 0,
|
|
70
|
+
outputTokens: 0,
|
|
71
|
+
cardsDropped: 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function endingOf(row: AssistantRun) {
|
|
75
|
+
/* A running attempt being taken over did not end by itself - it is closed as abandoned. */
|
|
76
|
+
const abandoned = row.status === 'running'
|
|
77
|
+
return {
|
|
78
|
+
attempt: row.attempt,
|
|
79
|
+
status: abandoned ? 'failed' : row.status,
|
|
80
|
+
outcomeCode: abandoned ? 'assistant-run-abandoned' : row.outcomeCode ?? null,
|
|
81
|
+
startedAt: row.startedAt ?? null,
|
|
82
|
+
finishedAt: abandoned ? null : row.finishedAt ?? null
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function typeormRunStore(repository: () => Repository<AssistantRun>): RunStore {
|
|
87
|
+
const where = (identity: RunIdentity) => ({
|
|
88
|
+
domain: { id: identity.domainId },
|
|
89
|
+
source: identity.source,
|
|
90
|
+
subject: identity.subject,
|
|
91
|
+
scheduledAt: identity.scheduledAt
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const read = (identity: RunIdentity) => repository().findOne({ where: where(identity) as any })
|
|
95
|
+
|
|
96
|
+
async function retake(row: AssistantRun, start: RunStart): Promise<boolean> {
|
|
97
|
+
const history = [...((row as any).attemptsJson ?? []), endingOf(row)]
|
|
98
|
+
const result = await repository()
|
|
99
|
+
.createQueryBuilder()
|
|
100
|
+
.update(AssistantRun)
|
|
101
|
+
.set({
|
|
102
|
+
...CLEARED,
|
|
103
|
+
status: 'running',
|
|
104
|
+
attempt: row.attempt + 1,
|
|
105
|
+
attempts: JSON.stringify(history),
|
|
106
|
+
startedAt: start.startedAt,
|
|
107
|
+
deadlineAt: start.deadlineAt,
|
|
108
|
+
modeKind: start.modeKind,
|
|
109
|
+
modeTarget: start.modeTarget ?? null,
|
|
110
|
+
runAs: start.runAsId ? { id: start.runAsId } : null,
|
|
111
|
+
updater: start.runAsId ? { id: start.runAsId } : null
|
|
112
|
+
} as any)
|
|
113
|
+
.where('id = :id AND attempt = :attempt AND status = :status', { id: row.id, attempt: row.attempt, status: row.status })
|
|
114
|
+
.execute()
|
|
115
|
+
return result.affected === 1
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
async claim(identity, start, now) {
|
|
120
|
+
let row = await read(identity)
|
|
121
|
+
if (!row) {
|
|
122
|
+
try {
|
|
123
|
+
await repository().insert({
|
|
124
|
+
...where(identity),
|
|
125
|
+
name: start.name,
|
|
126
|
+
modeKind: start.modeKind,
|
|
127
|
+
modeTarget: start.modeTarget,
|
|
128
|
+
...(start.runAsId ? { runAs: { id: start.runAsId }, creator: { id: start.runAsId }, updater: { id: start.runAsId } } : {}),
|
|
129
|
+
status: 'running',
|
|
130
|
+
attempt: 1,
|
|
131
|
+
startedAt: start.startedAt,
|
|
132
|
+
deadlineAt: start.deadlineAt,
|
|
133
|
+
modelCalls: 0,
|
|
134
|
+
inputTokens: 0,
|
|
135
|
+
outputTokens: 0,
|
|
136
|
+
cardsDropped: 0
|
|
137
|
+
} as any)
|
|
138
|
+
return { claimed: true, row: (await read(identity))! }
|
|
139
|
+
} catch (error) {
|
|
140
|
+
/* Another call made the row first - the unique index said so. Anything else is not that and is thrown. */
|
|
141
|
+
row = await read(identity)
|
|
142
|
+
if (!row) throw error
|
|
143
|
+
return { claimed: false, row }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (nextStep(row, now) === 'return') return { claimed: false, row }
|
|
148
|
+
const took = await retake(row, start)
|
|
149
|
+
return { claimed: took, row: (await read(identity))! }
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
async finish(held, ending) {
|
|
153
|
+
await repository()
|
|
154
|
+
.createQueryBuilder()
|
|
155
|
+
.update(AssistantRun)
|
|
156
|
+
.set(ending as any)
|
|
157
|
+
.where('id = :id AND attempt = :attempt AND status = :status', { id: held.id, attempt: held.attempt, status: 'running' })
|
|
158
|
+
.execute()
|
|
159
|
+
return (await repository().findOne({ where: { id: held.id } as any }))!
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A mode run with no person in it (operato-application/design/ai/unattended-run.md), on sqlite with stand-in models.
|
|
3
|
+
*
|
|
4
|
+
* What it guards, in the order the design note says it:
|
|
5
|
+
* §2 the run is the owner and no one else - judged again now, refused before any model is asked
|
|
6
|
+
* §3 only the mode's reads, behind the owner's own doors; the draft and the reads it rested on are what is kept
|
|
7
|
+
* §4 the mode's limits: requests, tokens, wall time - each a failure by its own code
|
|
8
|
+
* §5 one occurrence is one row - a retry hands it back, a dead attempt is taken over, a late one writes nothing
|
|
9
|
+
* §6 every call goes to the recorders, and they can tell the run's from a turn's
|
|
10
|
+
* §7 a failure is a failure: no empty draft, no cut-short draft, never "succeeded" without text
|
|
11
|
+
*
|
|
12
|
+
* Races between two servers are measured on postgres (`run-store-race.pg.test.ts`); sqlite cannot run them.
|
|
13
|
+
*/
|
|
14
|
+
import type { DataSource } from 'typeorm'
|
|
15
|
+
|
|
16
|
+
const { DataSource: TypeOrmDataSource } = require('typeorm')
|
|
17
|
+
const shell = require('@things-factory/shell')
|
|
18
|
+
const authBase = require('@things-factory/auth-base')
|
|
19
|
+
const { clearToolRegistry, registerToolCategory, registerToolCallRecorder, clearToolCallRecorders } = require('@things-factory/ai-client-base')
|
|
20
|
+
|
|
21
|
+
import type { UnattendedRunDeps } from './run-unattended'
|
|
22
|
+
|
|
23
|
+
/* The entity is a type-graphql class and needs tsc's decorator metadata, so the compiled module is read - as the
|
|
24
|
+
other database suites of this repository do. */
|
|
25
|
+
function loadCompiled(modulePath: string): any {
|
|
26
|
+
const { existsSync } = require('fs')
|
|
27
|
+
const { join } = require('path')
|
|
28
|
+
const resolved = join(__dirname, '..', '..', '..', 'dist-server', 'service', `${modulePath}.js`)
|
|
29
|
+
if (!existsSync(resolved)) throw new Error(`ai-assistant has no build output: ${resolved}\nBuild first:\n yarn workspace @things-factory/ai-assistant build:server`)
|
|
30
|
+
return require(resolved)
|
|
31
|
+
}
|
|
32
|
+
const { clearAssistantModes, registerAssistantMode } = loadCompiled('assistant-mode/registry')
|
|
33
|
+
const { unattendedToolCategories } = loadCompiled('assistant-mode/mode-categories')
|
|
34
|
+
const { AssistantRun } = loadCompiled('unattended-run/assistant-run')
|
|
35
|
+
const { runAssistantUnattended } = loadCompiled('unattended-run/run-unattended')
|
|
36
|
+
const { typeormRunStore } = loadCompiled('unattended-run/run-store')
|
|
37
|
+
const { nextStep } = loadCompiled('unattended-run/run-ledger')
|
|
38
|
+
const { unattendedLimits, UNATTENDED_DEFAULTS, ABANDON_MARGIN_MS } = loadCompiled('unattended-run/limits')
|
|
39
|
+
const { languageName, UNATTENDED_RULES } = loadCompiled('unattended-run/unattended-prompt')
|
|
40
|
+
const { assistantRunOutcomeText, ASSISTANT_RUN_OUTCOME_ENGLISH } = loadCompiled('unattended-run/run-outcome')
|
|
41
|
+
|
|
42
|
+
let db: DataSource
|
|
43
|
+
let domain: any
|
|
44
|
+
let owner: any
|
|
45
|
+
let locked: any
|
|
46
|
+
let admin: any
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
db = new TypeOrmDataSource({
|
|
50
|
+
type: 'sqlite',
|
|
51
|
+
database: ':memory:',
|
|
52
|
+
synchronize: true,
|
|
53
|
+
dropSchema: true,
|
|
54
|
+
namingStrategy: new shell.NamingStrategy(),
|
|
55
|
+
entities: [...shell.entities, ...authBase.entities, AssistantRun],
|
|
56
|
+
logging: false
|
|
57
|
+
})
|
|
58
|
+
await db.initialize()
|
|
59
|
+
domain = await db.getRepository(shell.Domain).save({ name: 'acme', subdomain: 'acme' })
|
|
60
|
+
owner = await db.getRepository(authBase.User).save({ name: 'owner', username: 'owner', email: 'owner@example.com', status: 'activated', locale: 'ko' })
|
|
61
|
+
locked = await db.getRepository(authBase.User).save({ name: 'locked', username: 'locked', email: 'locked@example.com', status: 'locked' })
|
|
62
|
+
admin = await db.getRepository(authBase.User).save({ name: 'admin', username: 'admin', email: 'admin@example.com', status: 'activated' })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
afterAll(async () => db?.destroy())
|
|
66
|
+
|
|
67
|
+
const T = (name: string, args: any = {}, id = name) => ({ id, name, arguments: args, type: 'tool_use' })
|
|
68
|
+
const said = (text?: string, calls: any[] = []) => ({ text, toolCalls: calls, stopReason: calls.length ? 'tool_use' : 'end_turn' })
|
|
69
|
+
|
|
70
|
+
/** A stand-in model: answers from a script, reports usage, and keeps what it was handed. */
|
|
71
|
+
function scriptedModel(script: any[], usage = { inputTokens: 100, outputTokens: 20 }) {
|
|
72
|
+
const seen: Array<{ messages: any[]; tools: any[]; options: any }> = []
|
|
73
|
+
let turn = 0
|
|
74
|
+
const client: any = {
|
|
75
|
+
id: 'stand-in:model',
|
|
76
|
+
agent: { id: 'agent-1', name: 'stand-in', provider: 'stand-in', model: 'model' },
|
|
77
|
+
chat: async () => ({ text: '' }),
|
|
78
|
+
generateJSON: async () => ({}),
|
|
79
|
+
listModels: async () => [],
|
|
80
|
+
chatWithTools: async (messages: any[], tools: any[], options: any) => {
|
|
81
|
+
seen.push({ messages, tools, options })
|
|
82
|
+
const step = script[Math.min(turn++, script.length - 1)]
|
|
83
|
+
options?.onUsage?.(usage)
|
|
84
|
+
if (typeof step === 'function') return step(options)
|
|
85
|
+
if (step instanceof Error) throw step
|
|
86
|
+
return step
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { client, seen }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let ran: Array<{ tool: string; context: any }>
|
|
93
|
+
let gatesAsked: Array<{ gate: any; userId: string }>
|
|
94
|
+
let clientRequests: any[]
|
|
95
|
+
|
|
96
|
+
function deps(over: Partial<UnattendedRunDeps> & { model?: any; member?: boolean; granted?: boolean; doors?: string[]; now?: () => Date } = {}): UnattendedRunDeps {
|
|
97
|
+
const doors = new Set(over.doors ?? ['Query.goals', 'Query.sales', 'Query.cards'])
|
|
98
|
+
return {
|
|
99
|
+
store: typeormRunStore(() => db.getRepository(AssistantRun)),
|
|
100
|
+
runAs: {
|
|
101
|
+
findUser: id => db.getRepository(authBase.User).findOne({ where: { id } }),
|
|
102
|
+
mayCheckin: async () => over.member ?? true,
|
|
103
|
+
permits: async (gate, user) => {
|
|
104
|
+
gatesAsked.push({ gate, userId: user.id })
|
|
105
|
+
return over.granted ?? true
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
clientFor:
|
|
109
|
+
over.clientFor ??
|
|
110
|
+
(async request => {
|
|
111
|
+
clientRequests.push(request)
|
|
112
|
+
return over.model ?? scriptedModel([said('the draft')]).client
|
|
113
|
+
}),
|
|
114
|
+
toolAccess: over.toolAccess ?? (state => async (door: string) => state.user?.id === owner.id && doors.has(door)),
|
|
115
|
+
now: over.now ?? (() => new Date())
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let occurrence = 0
|
|
120
|
+
const input = (over: any = {}) => ({
|
|
121
|
+
domain,
|
|
122
|
+
runAs: owner.id,
|
|
123
|
+
run: { source: 'office.strategy-review', subject: 'policy-9', scheduledAt: new Date(Date.UTC(2026, 10, 1, 7, 0, 0) + ++occurrence * 60000) },
|
|
124
|
+
mode: { kind: 'office.strategy-review', target: 'policy-9' },
|
|
125
|
+
instruction: 'Review the monthly goals against sales and write the draft.',
|
|
126
|
+
...over
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
beforeEach(() => {
|
|
130
|
+
ran = []
|
|
131
|
+
gatesAsked = []
|
|
132
|
+
clientRequests = []
|
|
133
|
+
clearToolRegistry()
|
|
134
|
+
clearToolCallRecorders()
|
|
135
|
+
clearAssistantModes()
|
|
136
|
+
|
|
137
|
+
const read = (name: string, door: string, builder: (args: any) => any) => ({
|
|
138
|
+
kind: 'read',
|
|
139
|
+
name,
|
|
140
|
+
description: name,
|
|
141
|
+
doors: [door],
|
|
142
|
+
schema: { type: 'object', properties: {} },
|
|
143
|
+
builder: (args: any, context: any) => {
|
|
144
|
+
ran.push({ tool: name, context })
|
|
145
|
+
return builder(args)
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
registerToolCategory({
|
|
149
|
+
name: 'strategy',
|
|
150
|
+
guidance: 'Read before you write.',
|
|
151
|
+
specs: [
|
|
152
|
+
read('readGoals', 'Query.goals', () => ({ read: 2, rows: [{ id: 'g-1' }, { id: 'g-2' }] })),
|
|
153
|
+
read('readSales', 'Query.sales', () => {
|
|
154
|
+
throw new Error('sales could not be read')
|
|
155
|
+
}),
|
|
156
|
+
read('readCard', 'Query.cards', () => ({ proposed: true, kind: 'workspace', label: 'a table', forScreen: {} })),
|
|
157
|
+
read('readPayroll', 'Query.payroll', () => ({ rows: [] }))
|
|
158
|
+
]
|
|
159
|
+
} as any)
|
|
160
|
+
/* What a conversation of this mode would also open - an unattended run must not. */
|
|
161
|
+
registerToolCategory({ name: 'data-entry:expense', guidance: 'Propose rows.', specs: [read('proposeExpense', 'Query.goals', () => ({ proposed: true, kind: 'entry' }))] } as any)
|
|
162
|
+
registerToolCategory({ name: 'assistant-workspace', guidance: 'Put tables there.', specs: [read('showOnWorkspace', 'Query.goals', () => ({ proposed: true, kind: 'workspace' }))] } as any)
|
|
163
|
+
|
|
164
|
+
registerAssistantMode({
|
|
165
|
+
kind: 'office.strategy-review',
|
|
166
|
+
systemPrompt: 'You review a strategy for its owner.',
|
|
167
|
+
toolCategories: ['strategy'],
|
|
168
|
+
entryTargets: ['expense'],
|
|
169
|
+
unattended: { maxModelCalls: 4, maxTokens: 1000, timeoutMs: 300 }
|
|
170
|
+
} as any)
|
|
171
|
+
registerAssistantMode({ kind: 'office.strategy', systemPrompt: 'You talk about strategy with a person.', toolCategories: ['strategy'] } as any)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe('§3 what runs, and what is kept', () => {
|
|
175
|
+
it('★ the draft, the reads it rested on with each one ok or failed, the doors, what it spent', async () => {
|
|
176
|
+
const { client, seen } = scriptedModel([
|
|
177
|
+
said(undefined, [T('readGoals', { goalId: 'g-1' }), T('readSales'), T('readCard')]),
|
|
178
|
+
said('Goals are behind; sales could not be read.')
|
|
179
|
+
])
|
|
180
|
+
const run = await runAssistantUnattended(input({ lng: 'ko' }), deps({ model: client }))
|
|
181
|
+
|
|
182
|
+
expect(run.status).toBe('succeeded')
|
|
183
|
+
expect(run.draft).toBe('Goals are behind; sales could not be read.')
|
|
184
|
+
expect(run.attempt).toBe(1)
|
|
185
|
+
expect(run.runAsId).toBe(owner.id)
|
|
186
|
+
const reads = (run as any).readsJson
|
|
187
|
+
expect(reads.map((one: any) => [one.tool, one.ok, one.errorCode ?? null])).toEqual([
|
|
188
|
+
['readGoals', true, null],
|
|
189
|
+
['readSales', false, 'tool-threw'],
|
|
190
|
+
['readCard', true, null]
|
|
191
|
+
])
|
|
192
|
+
expect(reads[0]).toMatchObject({ category: 'strategy', targetIds: ['g-1'], resultCount: 2 })
|
|
193
|
+
expect(run.doors).toEqual(['Query.cards', 'Query.goals', 'Query.sales'])
|
|
194
|
+
expect(run.cardsDropped).toBe(1)
|
|
195
|
+
expect(run).toMatchObject({ modelCalls: 2, inputTokens: 200, outputTokens: 40, agentId: 'agent-1', clientId: 'stand-in:model' })
|
|
196
|
+
expect(run.outcomeCode).toBeNull()
|
|
197
|
+
expect(seen).toHaveLength(2)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('★ only the mode\'s own reads are offered - no 입력안, no 작업판, and none behind a door the owner cannot pass', async () => {
|
|
201
|
+
const { client, seen } = scriptedModel([said('drafted')])
|
|
202
|
+
await runAssistantUnattended(input(), deps({ model: client }))
|
|
203
|
+
expect(seen[0].tools.map((tool: any) => tool.name).sort()).toEqual(['readCard', 'readGoals', 'readSales'])
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('★ the tools see the owner as the request user, and the run as context.run', async () => {
|
|
207
|
+
const { client } = scriptedModel([said(undefined, [T('readGoals')]), said('drafted')])
|
|
208
|
+
const run = await runAssistantUnattended(input({ hostContext: { policyId: 'policy-9' } }), deps({ model: client }))
|
|
209
|
+
expect(ran[0].context.state.user.id).toBe(owner.id)
|
|
210
|
+
expect(ran[0].context.state.domain.id).toBe(domain.id)
|
|
211
|
+
expect(ran[0].context.host).toEqual({ policyId: 'policy-9' })
|
|
212
|
+
expect(ran[0].context.mode).toEqual({ kind: 'office.strategy-review', target: 'policy-9' })
|
|
213
|
+
expect(ran[0].context.run).toEqual({ id: run.id, source: 'office.strategy-review', subject: 'policy-9', scheduledAt: run.scheduledAt.toISOString?.() ?? new Date(run.scheduledAt).toISOString() })
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('★ the model is told no one is reading and to write in the owner\'s language - their account\'s when none is given', async () => {
|
|
217
|
+
const { client, seen } = scriptedModel([said('drafted')])
|
|
218
|
+
await runAssistantUnattended(input(), deps({ model: client }))
|
|
219
|
+
expect(seen[0].options.systemPrompt).toContain('You review a strategy for its owner.')
|
|
220
|
+
expect(seen[0].options.systemPrompt).toContain(UNATTENDED_RULES)
|
|
221
|
+
expect(seen[0].options.systemPrompt).toContain('Write the draft in Korean.')
|
|
222
|
+
expect(seen[0].messages).toEqual([{ role: 'user', content: 'Review the monthly goals against sales and write the draft.' }])
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('★ every call is handed to the recorders as a turn\'s are, and they can tell it is this run', async () => {
|
|
226
|
+
const recorded: any[] = []
|
|
227
|
+
registerToolCallRecorder((record: any, context: any) => {
|
|
228
|
+
recorded.push({ record, context })
|
|
229
|
+
})
|
|
230
|
+
const { client } = scriptedModel([said(undefined, [T('readGoals'), T('readSales')]), said('drafted')])
|
|
231
|
+
const run = await runAssistantUnattended(input(), deps({ model: client }))
|
|
232
|
+
expect(recorded.map(one => [one.record.tool, one.record.ok])).toEqual([
|
|
233
|
+
['readGoals', true],
|
|
234
|
+
['readSales', false]
|
|
235
|
+
])
|
|
236
|
+
expect(recorded.every(one => one.context.run?.id === run.id && one.context.mode.kind === 'office.strategy-review')).toBe(true)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('the categories an unattended run opens - the mode\'s own and the lists it reads', () => {
|
|
240
|
+
expect(unattendedToolCategories({ toolCategories: ['strategy'], readTargets: ['sales.orders'] })).toEqual(['strategy', 'data-read:sales.orders'])
|
|
241
|
+
expect(unattendedToolCategories({ toolCategories: ['strategy'], entryTargets: ['expense'], attachments: { kinds: ['image'] } } as any)).toEqual(['strategy'])
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
describe('§2 who it runs as', () => {
|
|
246
|
+
it('★ an owner who is no longer active is refused, and no model is asked', async () => {
|
|
247
|
+
const run = await runAssistantUnattended(input({ runAs: locked.id }), deps())
|
|
248
|
+
expect(run).toMatchObject({ status: 'refused', outcomeCode: 'assistant-run-as-inactive', draft: null })
|
|
249
|
+
expect(clientRequests).toHaveLength(0)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('★ an owner who lost the privilege is refused - the gate asked is the one a person passes to ask, of the owner and no one else', async () => {
|
|
253
|
+
const run = await runAssistantUnattended(input(), deps({ granted: false }))
|
|
254
|
+
expect(run).toMatchObject({ status: 'refused', outcomeCode: 'assistant-run-as-not-permitted' })
|
|
255
|
+
expect(gatesAsked).toEqual([{ gate: { category: 'ai-assistant', privilege: 'mutation', owner: true }, userId: owner.id }])
|
|
256
|
+
expect(clientRequests).toHaveLength(0)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('★ an owner who cannot enter the domain is refused', async () => {
|
|
260
|
+
const run = await runAssistantUnattended(input(), deps({ member: false }))
|
|
261
|
+
expect(run).toMatchObject({ status: 'refused', outcomeCode: 'assistant-run-as-not-member' })
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('an owner who does not exist is refused', async () => {
|
|
265
|
+
const run = await runAssistantUnattended(input({ runAs: '00000000-0000-0000-0000-000000000000' }), deps())
|
|
266
|
+
expect(run).toMatchObject({ status: 'refused', outcomeCode: 'assistant-run-as-unknown', runAsId: null })
|
|
267
|
+
expect((run as any).outcomeParamsJson).toEqual({ userId: '00000000-0000-0000-0000-000000000000' })
|
|
268
|
+
expect(clientRequests).toHaveLength(0)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('★ the model client is the owner\'s - asked for this domain, this person and this mode', async () => {
|
|
272
|
+
await runAssistantUnattended(input(), deps())
|
|
273
|
+
expect(clientRequests).toHaveLength(1)
|
|
274
|
+
expect(clientRequests[0].domain.id).toBe(domain.id)
|
|
275
|
+
expect(clientRequests[0].user.id).toBe(owner.id)
|
|
276
|
+
expect(clientRequests[0].modeKind).toBe('office.strategy-review')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('a domain with no AI is refused by its own code', async () => {
|
|
280
|
+
const error: any = new Error('AI is not set up for this domain.')
|
|
281
|
+
error.code = 'ai-not-configured'
|
|
282
|
+
const run = await runAssistantUnattended(input(), deps({ clientFor: async () => Promise.reject(error) }))
|
|
283
|
+
expect(run).toMatchObject({ status: 'refused', outcomeCode: 'ai-not-configured' })
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('★ a mode that did not declare unattended does not run without a person; nor does one nobody registered', async () => {
|
|
287
|
+
const plain = await runAssistantUnattended(input({ mode: { kind: 'office.strategy' } }), deps())
|
|
288
|
+
expect(plain).toMatchObject({ status: 'refused', outcomeCode: 'assistant-mode-not-unattended' })
|
|
289
|
+
const unknown = await runAssistantUnattended(input({ mode: { kind: 'office.nothing' } }), deps())
|
|
290
|
+
expect(unknown).toMatchObject({ status: 'refused', outcomeCode: 'unknown-assistant-mode' })
|
|
291
|
+
expect(clientRequests).toHaveLength(0)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('an unusable request is thrown back and stores nothing', async () => {
|
|
295
|
+
const before = await db.getRepository(AssistantRun).count()
|
|
296
|
+
await expect(runAssistantUnattended(input({ run: { source: 'office.strategy-review', subject: 'p', scheduledAt: new Date('nope') } }), deps())).rejects.toMatchObject({
|
|
297
|
+
code: 'assistant-run-input-invalid',
|
|
298
|
+
params: { field: 'run.scheduledAt' }
|
|
299
|
+
})
|
|
300
|
+
await expect(runAssistantUnattended(input({ instruction: ' ' }), deps())).rejects.toMatchObject({ params: { field: 'instruction' } })
|
|
301
|
+
expect(await db.getRepository(AssistantRun).count()).toBe(before)
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
describe('§4 · §7 limits, and failures said as failures', () => {
|
|
306
|
+
it('★ past the token limit the next request is refused, and the run failed by that limit', async () => {
|
|
307
|
+
const { client, seen } = scriptedModel([said(undefined, [T('readGoals')])], { inputTokens: 700, outputTokens: 100 })
|
|
308
|
+
const run = await runAssistantUnattended(input(), deps({ model: client }))
|
|
309
|
+
expect(run).toMatchObject({ status: 'failed', outcomeCode: 'assistant-run-budget-exceeded', draft: null, modelCalls: 2, inputTokens: 1400 })
|
|
310
|
+
expect((run as any).outcomeParamsJson).toEqual({ limit: 'tokens', max: 1000, used: 1600 })
|
|
311
|
+
expect(seen).toHaveLength(2)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('★ a run that reaches its last step without an answer failed - no wrap-up call, no cut-short draft', async () => {
|
|
315
|
+
const { client, seen } = scriptedModel([said('half a thought', [T('readGoals')])])
|
|
316
|
+
const run = await runAssistantUnattended(input(), deps({ model: client }))
|
|
317
|
+
expect(run).toMatchObject({ status: 'failed', outcomeCode: 'assistant-run-step-limit', draft: null })
|
|
318
|
+
expect(seen).toHaveLength(4)
|
|
319
|
+
expect(seen.every(one => one.options.toolChoice !== 'none')).toBe(true)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('★ past its time the run is stopped and failed by time - the provider request was told to stop', async () => {
|
|
323
|
+
let signal: AbortSignal | undefined
|
|
324
|
+
const { client } = scriptedModel([
|
|
325
|
+
(options: any) =>
|
|
326
|
+
new Promise(() => {
|
|
327
|
+
signal = options.signal
|
|
328
|
+
})
|
|
329
|
+
])
|
|
330
|
+
const started = Date.now()
|
|
331
|
+
const run = await runAssistantUnattended(input(), deps({ model: client }))
|
|
332
|
+
expect(run).toMatchObject({ status: 'failed', outcomeCode: 'assistant-run-timeout', draft: null })
|
|
333
|
+
expect((run as any).outcomeParamsJson).toEqual({ timeoutMs: 300 })
|
|
334
|
+
expect(Date.now() - started).toBeLessThan(2000)
|
|
335
|
+
expect(signal?.aborted).toBe(true)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('★ an empty answer is not a draft', async () => {
|
|
339
|
+
const { client } = scriptedModel([said(' ')])
|
|
340
|
+
const run = await runAssistantUnattended(input(), deps({ model: client }))
|
|
341
|
+
expect(run).toMatchObject({ status: 'failed', outcomeCode: 'assistant-run-empty-draft', draft: null })
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('★ a provider that fails is a failure, never a success with the stop notice as its draft', async () => {
|
|
345
|
+
const failure: any = new Error('upstream exploded')
|
|
346
|
+
failure.status = 400
|
|
347
|
+
const { client } = scriptedModel([failure])
|
|
348
|
+
const run = await runAssistantUnattended(input(), deps({ model: client }))
|
|
349
|
+
expect(run).toMatchObject({ status: 'failed', outcomeCode: 'assistant-run-model-error', draft: null })
|
|
350
|
+
expect((run as any).outcomeParamsJson.message).toContain('upstream exploded')
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('the limits are the mode\'s, each absent one at its default', () => {
|
|
354
|
+
expect(unattendedLimits(undefined)).toEqual(UNATTENDED_DEFAULTS)
|
|
355
|
+
expect(unattendedLimits({ maxModelCalls: 3, timeoutMs: -1 } as any)).toEqual({ ...UNATTENDED_DEFAULTS, maxModelCalls: 3 })
|
|
356
|
+
expect(languageName('ko-KR')).toBe('Korean')
|
|
357
|
+
expect(languageName(undefined)).toBe('English')
|
|
358
|
+
})
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
describe('§5 one occurrence, one row', () => {
|
|
362
|
+
it('★ the same occurrence again hands the stored draft back and asks no model', async () => {
|
|
363
|
+
const first = scriptedModel([said('the draft')])
|
|
364
|
+
const at = input()
|
|
365
|
+
const run = await runAssistantUnattended(at, deps({ model: first.client }))
|
|
366
|
+
clientRequests.length = 0
|
|
367
|
+
const again = await runAssistantUnattended({ ...at, run: { ...at.run, scheduledAt: new Date(at.run.scheduledAt) } }, deps())
|
|
368
|
+
expect(again.id).toBe(run.id)
|
|
369
|
+
expect(again).toMatchObject({ status: 'succeeded', draft: 'the draft', attempt: 1 })
|
|
370
|
+
expect(clientRequests).toHaveLength(0)
|
|
371
|
+
expect(await db.getRepository(AssistantRun).count({ where: { subject: 'policy-9', scheduledAt: at.run.scheduledAt } as any })).toBe(1)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('★ a failed occurrence runs again as the next attempt, and the first attempt\'s ending is kept', async () => {
|
|
375
|
+
const at = input()
|
|
376
|
+
const failing = scriptedModel([said('')])
|
|
377
|
+
await runAssistantUnattended(at, deps({ model: failing.client }))
|
|
378
|
+
const run = await runAssistantUnattended(at, deps({ model: scriptedModel([said('second time')]).client }))
|
|
379
|
+
expect(run).toMatchObject({ status: 'succeeded', draft: 'second time', attempt: 2, outcomeCode: null })
|
|
380
|
+
expect((run as any).attemptsJson).toEqual([expect.objectContaining({ attempt: 1, status: 'failed', outcomeCode: 'assistant-run-empty-draft' })])
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('★ a refused occurrence runs again once the owner may - and is judged again, not remembered', async () => {
|
|
384
|
+
const at = input()
|
|
385
|
+
const refused = await runAssistantUnattended(at, deps({ granted: false }))
|
|
386
|
+
expect(refused.status).toBe('refused')
|
|
387
|
+
const run = await runAssistantUnattended(at, deps())
|
|
388
|
+
expect(run).toMatchObject({ status: 'succeeded', attempt: 2 })
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('★ a running attempt is left alone until its deadline, then taken over as abandoned', async () => {
|
|
392
|
+
const at = input()
|
|
393
|
+
const t0 = Date.now()
|
|
394
|
+
/* A server that started the run and died: the row is running with a deadline. */
|
|
395
|
+
const store = typeormRunStore(() => db.getRepository(AssistantRun))
|
|
396
|
+
const { row } = await store.claim(
|
|
397
|
+
{ domainId: domain.id, ...at.run },
|
|
398
|
+
{ name: 'x', modeKind: at.mode.kind, runAsId: owner.id, startedAt: new Date(t0), deadlineAt: new Date(t0 + 300 + ABANDON_MARGIN_MS) },
|
|
399
|
+
new Date(t0)
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
const early = await runAssistantUnattended(at, deps({ now: () => new Date(t0 + 1000) }))
|
|
403
|
+
expect(early).toMatchObject({ id: row.id, status: 'running', attempt: 1 })
|
|
404
|
+
expect(clientRequests).toHaveLength(0)
|
|
405
|
+
|
|
406
|
+
const late = await runAssistantUnattended(at, deps({ now: () => new Date(t0 + 300 + ABANDON_MARGIN_MS + 1) }))
|
|
407
|
+
expect(late).toMatchObject({ id: row.id, status: 'succeeded', attempt: 2 })
|
|
408
|
+
expect((late as any).attemptsJson).toEqual([expect.objectContaining({ attempt: 1, status: 'failed', outcomeCode: 'assistant-run-abandoned' })])
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('★ an attempt that was taken over and ends late writes nothing over the next one', async () => {
|
|
412
|
+
const at = input()
|
|
413
|
+
const store = typeormRunStore(() => db.getRepository(AssistantRun))
|
|
414
|
+
const identity = { domainId: domain.id, ...at.run }
|
|
415
|
+
const t0 = Date.now()
|
|
416
|
+
const start = (at0: number) => ({ name: 'x', modeKind: at.mode.kind, runAsId: owner.id, startedAt: new Date(at0), deadlineAt: new Date(at0 + 1000) })
|
|
417
|
+
const first = await store.claim(identity, start(t0), new Date(t0))
|
|
418
|
+
const second = await store.claim(identity, start(t0 + 2000), new Date(t0 + 2000))
|
|
419
|
+
expect([first.claimed, second.claimed, second.row.attempt]).toEqual([true, true, 2])
|
|
420
|
+
|
|
421
|
+
const after = await store.finish({ id: first.row.id!, attempt: 1 }, { status: 'succeeded', draft: 'stale' })
|
|
422
|
+
expect(after).toMatchObject({ status: 'running', attempt: 2, draft: null })
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('the ledger rule by itself', () => {
|
|
426
|
+
const now = new Date('2026-11-01T07:00:00Z')
|
|
427
|
+
expect(nextStep({ status: 'succeeded' }, now)).toBe('return')
|
|
428
|
+
expect(nextStep({ status: 'running', deadlineAt: new Date(now.valueOf() + 1) }, now)).toBe('return')
|
|
429
|
+
expect(nextStep({ status: 'running', deadlineAt: new Date(now.valueOf() - 1) }, now)).toBe('retake')
|
|
430
|
+
expect(nextStep({ status: 'running', deadlineAt: null }, now)).toBe('return')
|
|
431
|
+
expect(nextStep({ status: 'failed' }, now)).toBe('retake')
|
|
432
|
+
expect(nextStep({ status: 'refused' }, now)).toBe('retake')
|
|
433
|
+
})
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
describe('no one else to run as', () => {
|
|
437
|
+
it('★ the request has one user field - a refused owner is never swapped for anyone with the privilege', async () => {
|
|
438
|
+
const run = await runAssistantUnattended(input({ runAs: locked.id }), deps())
|
|
439
|
+
expect(run.status).toBe('refused')
|
|
440
|
+
expect(gatesAsked.some(one => one.userId === admin.id)).toBe(false)
|
|
441
|
+
expect(ran).toHaveLength(0)
|
|
442
|
+
})
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
describe('§7 the outcome as a person reads it', () => {
|
|
446
|
+
it('★ every code a run can end with has its sentence, filled from its values', async () => {
|
|
447
|
+
const source = require('fs').readFileSync(require('path').join(__dirname, 'run-unattended.ts'), 'utf8') + require('fs').readFileSync(require('path').join(__dirname, 'run-as.ts'), 'utf8') + require('fs').readFileSync(require('path').join(__dirname, 'run-store.ts'), 'utf8') + require('fs').readFileSync(require('path').join(__dirname, 'run-budget.ts'), 'utf8')
|
|
448
|
+
const codes = [...new Set([...source.matchAll(/'((?:assistant-run|assistant-mode|unknown-assistant)-[a-z-]+)'/g)].map(match => match[1]))].filter(code => code !== 'assistant-run-input-invalid')
|
|
449
|
+
expect(codes.length).toBeGreaterThanOrEqual(12)
|
|
450
|
+
expect(codes.filter(code => !ASSISTANT_RUN_OUTCOME_ENGLISH[code])).toEqual([])
|
|
451
|
+
|
|
452
|
+
const { client } = scriptedModel([said('')])
|
|
453
|
+
const run = await runAssistantUnattended(input({ mode: { kind: 'office.strategy' } }), deps({ model: client }))
|
|
454
|
+
expect(assistantRunOutcomeText(run)).toBe('the assistant mode office.strategy does not run without a person.')
|
|
455
|
+
const asked: any[] = []
|
|
456
|
+
assistantRunOutcomeText(run, (key: string, options: any) => (asked.push([key, options]), 'translated'))
|
|
457
|
+
expect(asked).toEqual([['ai-assistant.run.assistant-mode-not-unattended', { kind: 'office.strategy', defaultValue: ASSISTANT_RUN_OUTCOME_ENGLISH['assistant-mode-not-unattended'] }]])
|
|
458
|
+
expect(assistantRunOutcomeText({ outcomeCode: null })).toBeUndefined()
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('★ every locale says every outcome, with the same values in it as the English', () => {
|
|
462
|
+
const { readFileSync } = require('fs')
|
|
463
|
+
const { join } = require('path')
|
|
464
|
+
const holes = (text: string) => [...String(text).matchAll(/\{(\w+)\}/g)].map(match => match[1]).sort()
|
|
465
|
+
const wrong: string[] = []
|
|
466
|
+
for (const lng of ['en', 'ko', 'ja', 'zh', 'es', 'ms']) {
|
|
467
|
+
const entries = JSON.parse(readFileSync(join(__dirname, '..', '..', '..', 'translations', `${lng}.json`), 'utf8'))
|
|
468
|
+
for (const [code, english] of Object.entries(ASSISTANT_RUN_OUTCOME_ENGLISH)) {
|
|
469
|
+
const said = entries[`ai-assistant.run.${code}`]
|
|
470
|
+
if (!said) wrong.push(`${lng} ${code}: missing`)
|
|
471
|
+
else if (JSON.stringify(holes(said)) !== JSON.stringify(holes(english as string))) wrong.push(`${lng} ${code}: ${said}`)
|
|
472
|
+
else if (lng === 'en' && said !== english) wrong.push(`en ${code}: differs from the default`)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
expect(wrong).toEqual([])
|
|
476
|
+
})
|
|
477
|
+
})
|