@frontera-sdk/cli 1.52.27 → 1.52.29
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/package.json +5 -4
- package/src/commands/local/data.ts +60 -0
- package/src/commands/local/dev.ts +361 -18
- package/src/commands/local/index-commands.ts +11 -3
- package/src/commands/local/init.ts +88 -7
- package/src/commands/local/sync-request.ts +750 -0
- package/src/commands/local/sync.ts +941 -0
- package/src/commands/skill/bundle-commands.ts +9 -6
- package/src/errors.ts +4 -1
- package/src/exit.ts +3 -0
- package/src/flag-help.ts +48 -0
- package/src/local/local-actions.ts +306 -0
- package/src/local/local-adapt.ts +216 -0
- package/src/local/local-agent-files.ts +9 -2
- package/src/local/local-agent-grants.ts +125 -0
- package/src/local/local-agent-name.ts +71 -0
- package/src/local/local-applied.ts +334 -3
- package/src/local/local-apps.ts +156 -0
- package/src/local/local-blueprint-outline.ts +465 -0
- package/src/local/local-brand.ts +123 -0
- package/src/local/local-build-summary.ts +106 -0
- package/src/local/local-chat-apps.ts +170 -0
- package/src/local/local-dev-references.ts +1132 -0
- package/src/local/local-dev-sync.ts +171 -7
- package/src/local/local-docs.ts +53 -5
- package/src/local/local-env.ts +2 -0
- package/src/local/local-function-draft.ts +33 -0
- package/src/local/local-function-shared.ts +65 -0
- package/src/local/local-function-state.ts +34 -0
- package/src/local/local-knowledge-documents.ts +302 -0
- package/src/local/local-lessons.ts +278 -0
- package/src/local/local-library.ts +124 -0
- package/src/local/local-media-sets.ts +31 -0
- package/src/local/local-object-sets.ts +181 -0
- package/src/local/local-objects.ts +159 -0
- package/src/local/local-operating.ts +9 -5
- package/src/local/local-org-settings.ts +118 -0
- package/src/local/local-portability.ts +346 -0
- package/src/local/local-project.ts +6 -2
- package/src/local/local-quality-checks.ts +171 -0
- package/src/local/local-scaffold.ts +14 -11
- package/src/local/local-schemas.ts +33 -2
- package/src/local/local-secrets.ts +306 -0
- package/src/local/local-stand-ins.ts +442 -0
- package/src/local/local-studio-server.ts +23 -2
- package/src/local/local-sync-base.ts +237 -0
- package/src/local/local-sync-client.ts +93 -0
- package/src/local/local-sync-link.ts +122 -0
- package/src/local/local-sync-merge.ts +395 -0
- package/src/local/local-sync-names.ts +169 -0
- package/src/local/local-sync-plan.ts +717 -0
- package/src/local/local-sync-push.ts +986 -0
- package/src/local/local-sync-refs.ts +221 -0
- package/src/local/local-sync-remote.ts +1482 -0
- package/src/local/local-sync-snapshot.ts +526 -0
- package/src/local/local-synthetic-data.ts +374 -0
- package/src/local/local-test-runner.ts +172 -14
- package/src/local/local-themes.ts +201 -0
- package/src/local/local-workflow-state.ts +131 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { base, en, Faker } from '@faker-js/faker'
|
|
5
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Sample rows for a project's Blueprint, so a Blueprint pulled from a
|
|
9
|
+
* workspace (which brings no records) can be explored, queried and used by
|
|
10
|
+
* Agents without copying customer data to a laptop.
|
|
11
|
+
*
|
|
12
|
+
* Each object type gets a CSV under `datasets/` with the definition beside
|
|
13
|
+
* it. Values fit each property: its type, its name (an `email` gets an email,
|
|
14
|
+
* an `amount` money) and the values its description lists ("open, pending or
|
|
15
|
+
* closed"). Links stay consistent: a property another type's records are
|
|
16
|
+
* linked by gets a value from those records, so every relation resolves.
|
|
17
|
+
* The same seed gives the same rows.
|
|
18
|
+
*
|
|
19
|
+
* `data/generate.ts` in the project, when present, adjusts it: rows per type
|
|
20
|
+
* and a function per property, for scenarios ("five tickets breaching SLA").
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
interface Property {
|
|
24
|
+
apiName: string
|
|
25
|
+
displayName?: string
|
|
26
|
+
description?: string
|
|
27
|
+
valueType?: string
|
|
28
|
+
required?: boolean
|
|
29
|
+
propertyType?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ObjectType {
|
|
33
|
+
apiName: string
|
|
34
|
+
displayName?: string
|
|
35
|
+
pluralDisplayName?: string
|
|
36
|
+
primaryKey?: string
|
|
37
|
+
properties?: Property[]
|
|
38
|
+
backing?: { dataset?: string; mapping?: Record<string, string> }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface LinkType {
|
|
42
|
+
apiName: string
|
|
43
|
+
cardinality?: string
|
|
44
|
+
from?: { objectType?: string; property?: string }
|
|
45
|
+
to?: { objectType?: string; property?: string }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What `data/generate.ts` may return. */
|
|
49
|
+
export interface Scenario {
|
|
50
|
+
rows?: number
|
|
51
|
+
types?: Record<string, { rows?: number; fields?: Record<string, (row: Record<string, unknown>, index: number) => unknown> }>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface GeneratedDataset {
|
|
55
|
+
objectType: string
|
|
56
|
+
dataset: string
|
|
57
|
+
rows: number
|
|
58
|
+
csvPath: string
|
|
59
|
+
definitionPath: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SyntheticOptions {
|
|
63
|
+
rows?: number
|
|
64
|
+
seed?: number
|
|
65
|
+
/** Dates are spread over the months before this day; fixed by default so a seed gives the same rows. */
|
|
66
|
+
today?: Date
|
|
67
|
+
scenario?: Scenario
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readTree<T>(dir: string): Array<{ file: string; doc: T }> {
|
|
71
|
+
if (!existsSync(dir)) return []
|
|
72
|
+
return readdirSync(dir)
|
|
73
|
+
.filter((f) => /\.(ya?ml|json)$/.test(f) && !f.startsWith('.'))
|
|
74
|
+
.sort()
|
|
75
|
+
.map((f) => {
|
|
76
|
+
const text = readFileSync(join(dir, f), 'utf8')
|
|
77
|
+
return { file: f, doc: (f.endsWith('.json') ? JSON.parse(text) : parseYaml(text)) as T }
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function snake(name: string): string {
|
|
82
|
+
return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[^A-Za-z0-9]+/g, '_').toLowerCase().replace(/^_|_$/g, '')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function plural(name: string): string {
|
|
86
|
+
if (/[^aeiou]y$/.test(name)) return `${name.slice(0, -1)}ies`
|
|
87
|
+
if (/(s|x|ch|sh)$/.test(name)) return `${name}es`
|
|
88
|
+
return `${name}s`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The dataset an object type reads, and its column per property. */
|
|
92
|
+
export function backingOf(type: ObjectType): { dataset: string; column: (property: string) => string } {
|
|
93
|
+
const dataset = type.backing?.dataset || plural(snake(type.apiName))
|
|
94
|
+
const mapping = type.backing?.mapping ?? {}
|
|
95
|
+
return { dataset, column: (p) => mapping[p] ?? snake(p) }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** "open, pending, resolved or closed" → the four values, when a description lists them. */
|
|
99
|
+
export function listedValues(description: string | undefined): string[] | null {
|
|
100
|
+
if (!description) return null
|
|
101
|
+
const text = description.replace(/^[^:]*:\s*/, '').trim()
|
|
102
|
+
if (!/,|\bor\b/.test(text) || text.length > 160) return null
|
|
103
|
+
const parts = text.split(/\s*,\s*|\s+or\s+|\s+and\s+/).map((p) => p.trim().replace(/^["'`]|["'`.]$/g, ''))
|
|
104
|
+
if (parts.length < 2 || parts.some((p) => !/^[A-Za-z0-9_-]{1,32}$/.test(p))) return null
|
|
105
|
+
return [...new Set(parts)]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A short, readable key prefix from the type's name: Ticket → TCK, Customer → CUS. */
|
|
109
|
+
function keyPrefix(apiName: string): string {
|
|
110
|
+
const upper = apiName.toUpperCase().replace(/[^A-Z]/g, '')
|
|
111
|
+
const consonants = upper[0]! + upper.slice(1).replace(/[AEIOU]/g, '')
|
|
112
|
+
return (consonants.length >= 3 ? consonants : upper).slice(0, 3).padEnd(3, 'X')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface Reference {
|
|
116
|
+
/** The type whose records this property points at. */
|
|
117
|
+
objectType: string
|
|
118
|
+
property: string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Which property of which type points at which other type's key, from the link types. */
|
|
122
|
+
export function referencesFrom(links: LinkType[]): Map<string, Map<string, Reference>> {
|
|
123
|
+
const out = new Map<string, Map<string, Reference>>()
|
|
124
|
+
const add = (type: string, property: string, ref: Reference) => {
|
|
125
|
+
if (!out.has(type)) out.set(type, new Map())
|
|
126
|
+
out.get(type)!.set(property, ref)
|
|
127
|
+
}
|
|
128
|
+
for (const link of links) {
|
|
129
|
+
const from = link.from
|
|
130
|
+
const to = link.to
|
|
131
|
+
if (!from?.objectType || !from.property || !to?.objectType || !to.property) continue
|
|
132
|
+
if (link.cardinality === 'many_to_one') add(from.objectType, from.property, { objectType: to.objectType, property: to.property })
|
|
133
|
+
else if (link.cardinality === 'many_to_many') continue
|
|
134
|
+
else add(to.objectType, to.property, { objectType: from.objectType, property: from.property })
|
|
135
|
+
}
|
|
136
|
+
return out
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Types in an order where every referenced type comes before the types that point at it. */
|
|
140
|
+
function dependencyOrder(types: ObjectType[], refs: Map<string, Map<string, Reference>>): ObjectType[] {
|
|
141
|
+
const byName = new Map(types.map((t) => [t.apiName, t]))
|
|
142
|
+
const done = new Set<string>()
|
|
143
|
+
const out: ObjectType[] = []
|
|
144
|
+
const visit = (name: string, trail: Set<string>) => {
|
|
145
|
+
if (done.has(name) || trail.has(name) || !byName.has(name)) return
|
|
146
|
+
trail.add(name)
|
|
147
|
+
for (const ref of refs.get(name)?.values() ?? []) if (ref.objectType !== name) visit(ref.objectType, trail)
|
|
148
|
+
trail.delete(name)
|
|
149
|
+
done.add(name)
|
|
150
|
+
out.push(byName.get(name)!)
|
|
151
|
+
}
|
|
152
|
+
for (const t of types) visit(t.apiName, new Set())
|
|
153
|
+
return out
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function words(name: string): string {
|
|
157
|
+
return name.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A value for one property, from its type, its name and its description. */
|
|
161
|
+
function valueFor(faker: Faker, type: ObjectType, p: Property, row: Record<string, unknown>, state: { lastTime: number | null; today: Date }): unknown {
|
|
162
|
+
const name = words(p.apiName)
|
|
163
|
+
const listed = listedValues(p.description)
|
|
164
|
+
if (listed && (p.valueType ?? 'string') === 'string') return faker.helpers.arrayElement(listed)
|
|
165
|
+
switch (p.valueType ?? 'string') {
|
|
166
|
+
case 'boolean':
|
|
167
|
+
return faker.datatype.boolean({ probability: /^(is|has) /.test(name) ? 0.35 : 0.5 })
|
|
168
|
+
case 'number': {
|
|
169
|
+
if (/csat|rating|stars|score/.test(name)) return faker.number.int({ min: 1, max: 5 })
|
|
170
|
+
if (/percent|rate|ratio|share/.test(name)) return faker.number.float({ min: 0, max: 100, fractionDigits: 1 })
|
|
171
|
+
if (/hours?\b|sla/.test(name)) return faker.helpers.arrayElement([4, 8, 24, 48, 72])
|
|
172
|
+
if (/days?\b/.test(name)) return faker.number.int({ min: 1, max: 30 })
|
|
173
|
+
if (/quantity|qty|count|units|items/.test(name)) return faker.number.int({ min: 1, max: 6 })
|
|
174
|
+
if (/age\b/.test(name)) return faker.number.int({ min: 18, max: 80 })
|
|
175
|
+
if (/price|amount|total|revenue|cost|value|fee|refund|balance|spend|discount/.test(name)) return Number(faker.commerce.price({ min: 5, max: 900 }))
|
|
176
|
+
if (/weight|kg/.test(name)) return faker.number.float({ min: 0.1, max: 25, fractionDigits: 2 })
|
|
177
|
+
if (/lat/.test(name)) return faker.location.latitude()
|
|
178
|
+
if (/lon|lng/.test(name)) return faker.location.longitude()
|
|
179
|
+
return faker.number.int({ min: 1, max: 1000 })
|
|
180
|
+
}
|
|
181
|
+
case 'date':
|
|
182
|
+
return faker.date.past({ years: 2, refDate: state.today }).toISOString().slice(0, 10)
|
|
183
|
+
case 'timestamp': {
|
|
184
|
+
// Times in a row follow one another (opened, then answered, then resolved).
|
|
185
|
+
const at = state.lastTime === null
|
|
186
|
+
? faker.date.recent({ days: 120, refDate: state.today }).getTime()
|
|
187
|
+
: state.lastTime + faker.number.int({ min: 20, max: 72 * 60 }) * 60_000
|
|
188
|
+
const clamped = Math.min(at, state.today.getTime())
|
|
189
|
+
state.lastTime = clamped
|
|
190
|
+
return new Date(clamped).toISOString().replace(/\.\d{3}Z$/, 'Z')
|
|
191
|
+
}
|
|
192
|
+
case 'json':
|
|
193
|
+
return JSON.stringify({})
|
|
194
|
+
case 'geopoint':
|
|
195
|
+
return JSON.stringify({ lat: faker.location.latitude(), lon: faker.location.longitude() })
|
|
196
|
+
case 'media_reference':
|
|
197
|
+
return ''
|
|
198
|
+
default:
|
|
199
|
+
break
|
|
200
|
+
}
|
|
201
|
+
if (/e ?mail/.test(name)) return faker.internet.email({ provider: 'example.com' }).toLowerCase()
|
|
202
|
+
if (/phone|mobile/.test(name)) return faker.phone.number({ style: 'international' })
|
|
203
|
+
if (/first name|given name/.test(name)) return faker.person.firstName()
|
|
204
|
+
if (/last name|surname|family name/.test(name)) return faker.person.lastName()
|
|
205
|
+
if (/(^|\b)(name|full name|customer name|contact)$/.test(name) && /customer|person|user|contact|employee|member|agent/i.test(type.apiName + name)) return faker.person.fullName()
|
|
206
|
+
if (/company|organi[sz]ation|account name|merchant|vendor|supplier/.test(name)) return faker.company.name()
|
|
207
|
+
if (/product|item name/.test(name)) return faker.commerce.productName()
|
|
208
|
+
if (/sku|code|reference/.test(name)) return faker.string.alphanumeric({ length: 8, casing: 'upper' })
|
|
209
|
+
if (/city/.test(name)) return faker.location.city()
|
|
210
|
+
if (/country/.test(name)) return faker.location.countryCode()
|
|
211
|
+
if (/postal|zip/.test(name)) return faker.location.zipCode()
|
|
212
|
+
if (/address|street/.test(name)) return faker.location.streetAddress()
|
|
213
|
+
if (/currency/.test(name)) return 'USD'
|
|
214
|
+
if (/url|website|link/.test(name)) return faker.internet.url()
|
|
215
|
+
if (/carrier|courier/.test(name)) return faker.helpers.arrayElement(['DHL', 'UPS', 'FedEx', 'Royal Mail', 'PostNL'])
|
|
216
|
+
if (/assignee|owner|agent|rep/.test(name)) return `agent-${faker.person.firstName().toLowerCase()}`
|
|
217
|
+
if (/segment|tier|plan/.test(name)) return faker.helpers.arrayElement(['standard', 'plus', 'enterprise'])
|
|
218
|
+
// English words, not lorem ipsum: sample rows are read by people and by Agents.
|
|
219
|
+
if (/subject|title|summary|headline/.test(name)) return phrase(faker)
|
|
220
|
+
if (/description|notes?|body|comment|message|reason|details?/.test(name)) return `${phrase(faker)}. ${faker.hacker.phrase()}`
|
|
221
|
+
if (/name/.test(name)) return faker.commerce.productName()
|
|
222
|
+
if (/status|state|stage/.test(name)) return faker.helpers.arrayElement(['open', 'in_progress', 'done'])
|
|
223
|
+
if (/category|type|kind/.test(name)) return faker.helpers.arrayElement(['general', 'billing', 'delivery', 'product'])
|
|
224
|
+
return faker.word.noun()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** "Replace damaged charger": a short line that reads like a title. */
|
|
228
|
+
function phrase(faker: Faker): string {
|
|
229
|
+
const verb = faker.word.verb()
|
|
230
|
+
return `${verb[0]!.toUpperCase()}${verb.slice(1)} ${faker.word.adjective()} ${faker.word.noun()}`
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function csvCell(value: unknown): string {
|
|
234
|
+
if (value === null || value === undefined) return ''
|
|
235
|
+
const text = typeof value === 'string' ? value : String(value)
|
|
236
|
+
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Generate a CSV and a dataset definition for every object type in the
|
|
241
|
+
* project's Blueprint tree. Returns what was written.
|
|
242
|
+
*/
|
|
243
|
+
export function generateSyntheticData(root: string, opts: SyntheticOptions = {}): GeneratedDataset[] {
|
|
244
|
+
const types = readTree<ObjectType>(join(root, 'blueprint', 'object-types')).map((f) => f.doc).filter((t) => t?.apiName)
|
|
245
|
+
const links = readTree<LinkType>(join(root, 'blueprint', 'link-types')).map((f) => f.doc).filter((l) => l?.apiName)
|
|
246
|
+
if (!types.length) return []
|
|
247
|
+
const faker = new Faker({ locale: [en, base] })
|
|
248
|
+
faker.seed(opts.seed ?? 7)
|
|
249
|
+
const today = opts.today ?? new Date('2026-10-01T00:00:00Z')
|
|
250
|
+
const refs = referencesFrom(links)
|
|
251
|
+
const keys = new Map<string, unknown[]>()
|
|
252
|
+
const out: GeneratedDataset[] = []
|
|
253
|
+
const baseRows = Math.max(1, Math.min(opts.scenario?.rows ?? opts.rows ?? 200, 100_000))
|
|
254
|
+
mkdirSync(join(root, 'datasets'), { recursive: true })
|
|
255
|
+
|
|
256
|
+
for (const type of dependencyOrder(types, refs)) {
|
|
257
|
+
const custom = opts.scenario?.types?.[type.apiName]
|
|
258
|
+
// Types others point at are the fewer ones (customers, products); the rest scale with them.
|
|
259
|
+
const referenced = [...refs.values()].some((m) => [...m.values()].some((r) => r.objectType === type.apiName))
|
|
260
|
+
const count = custom?.rows ?? (referenced ? Math.max(5, Math.round(baseRows / 2)) : baseRows)
|
|
261
|
+
const properties = type.properties ?? []
|
|
262
|
+
const pk = type.primaryKey ?? properties[0]?.apiName ?? 'id'
|
|
263
|
+
const { dataset, column } = backingOf(type)
|
|
264
|
+
const prefix = keyPrefix(type.apiName)
|
|
265
|
+
const ownRefs = refs.get(type.apiName) ?? new Map<string, Reference>()
|
|
266
|
+
const rows: Array<Record<string, unknown>> = []
|
|
267
|
+
for (let i = 0; i < count; i++) {
|
|
268
|
+
const row: Record<string, unknown> = {}
|
|
269
|
+
const state = { lastTime: null as number | null, today }
|
|
270
|
+
for (const p of properties) {
|
|
271
|
+
const fn = custom?.fields?.[p.apiName]
|
|
272
|
+
if (fn) {
|
|
273
|
+
row[p.apiName] = fn(row, i)
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
if (p.apiName === pk) {
|
|
277
|
+
row[p.apiName] = (p.valueType ?? 'string') === 'number' ? i + 1 : `${prefix}-${String(i + 1).padStart(5, '0')}`
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
const ref = ownRefs.get(p.apiName)
|
|
281
|
+
if (ref) {
|
|
282
|
+
const pool = keys.get(`${ref.objectType}.${ref.property}`) ?? []
|
|
283
|
+
row[p.apiName] = pool.length && (p.required !== false || faker.datatype.boolean({ probability: 0.85 })) ? faker.helpers.arrayElement(pool) : ''
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
// Optional values are sometimes empty, as real records are.
|
|
287
|
+
if (p.required === false && faker.datatype.boolean({ probability: 0.2 })) {
|
|
288
|
+
row[p.apiName] = ''
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
row[p.apiName] = valueFor(faker, type, p, row, state)
|
|
292
|
+
}
|
|
293
|
+
rows.push(row)
|
|
294
|
+
}
|
|
295
|
+
for (const p of properties) keys.set(`${type.apiName}.${p.apiName}`, rows.map((r) => r[p.apiName]).filter((v) => v !== '' && v !== undefined))
|
|
296
|
+
|
|
297
|
+
const columns = properties.map((p) => column(p.apiName))
|
|
298
|
+
const csv = [columns.join(','), ...rows.map((r) => properties.map((p) => csvCell(r[p.apiName])).join(','))].join('\n') + '\n'
|
|
299
|
+
const csvPath = join('datasets', `${dataset}.csv`)
|
|
300
|
+
writeFileSync(join(root, csvPath), csv)
|
|
301
|
+
const definitionPath = join('datasets', `${dataset}.json`)
|
|
302
|
+
const definition = {
|
|
303
|
+
apiName: dataset,
|
|
304
|
+
displayName: type.pluralDisplayName ?? plural(type.displayName ?? type.apiName),
|
|
305
|
+
description: `Sample ${(type.pluralDisplayName ?? plural(type.apiName)).toLowerCase()} made by \`frontera local data --synthetic\`: not real records.`,
|
|
306
|
+
kind: 'file',
|
|
307
|
+
path: `./${dataset}.csv`,
|
|
308
|
+
includedColumns: columns,
|
|
309
|
+
keyColumns: [column(pk)],
|
|
310
|
+
deterministicKeyConfirmed: true,
|
|
311
|
+
}
|
|
312
|
+
writeFileSync(join(root, definitionPath), `${JSON.stringify(definition, null, 2)}\n`)
|
|
313
|
+
out.push({ objectType: type.apiName, dataset, rows: rows.length, csvPath, definitionPath })
|
|
314
|
+
}
|
|
315
|
+
return out
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Give each object type without a dataset the one sample data writes, so the
|
|
320
|
+
* Blueprint reads its rows. A type pulled with its own backing keeps it.
|
|
321
|
+
* Returns the files changed.
|
|
322
|
+
*/
|
|
323
|
+
export function addMissingBackings(root: string): string[] {
|
|
324
|
+
const dir = join(root, 'blueprint', 'object-types')
|
|
325
|
+
const changed: string[] = []
|
|
326
|
+
for (const { file, doc } of readTree<ObjectType>(dir)) {
|
|
327
|
+
if (!doc?.apiName || doc.backing?.dataset) continue
|
|
328
|
+
const { dataset, column } = backingOf(doc)
|
|
329
|
+
const next = { ...doc, backing: { dataset, mapping: Object.fromEntries((doc.properties ?? []).map((p) => [p.apiName, column(p.apiName)])) } }
|
|
330
|
+
const path = join(dir, file)
|
|
331
|
+
writeFileSync(path, file.endsWith('.json') ? `${JSON.stringify(next, null, 2)}\n` : stringifyYaml(next, { lineWidth: 0 }))
|
|
332
|
+
changed.push(join('blueprint', 'object-types', file))
|
|
333
|
+
}
|
|
334
|
+
return changed
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The scenario template written the first time, for the FDE or a coding agent to edit. */
|
|
338
|
+
export const SCENARIO_TEMPLATE = `/**
|
|
339
|
+
* Adjusts the sample data \`frontera local data --synthetic\` writes into
|
|
340
|
+
* datasets/. Return rows per object type and, per property, a function that
|
|
341
|
+
* gives its value; anything left out is generated from the property's type,
|
|
342
|
+
* name and description. \`faker\` is seeded, so the same seed gives the same rows.
|
|
343
|
+
*
|
|
344
|
+
* frontera local data --synthetic --seed 7
|
|
345
|
+
*
|
|
346
|
+
* \`faker\` is the CLI's own (https://fakerjs.dev/api/), so the project
|
|
347
|
+
* installs nothing for it.
|
|
348
|
+
*/
|
|
349
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
350
|
+
export default function scenario({ faker }: { faker: any }) {
|
|
351
|
+
return {
|
|
352
|
+
// rows: 200,
|
|
353
|
+
types: {
|
|
354
|
+
// Ticket: {
|
|
355
|
+
// rows: 300,
|
|
356
|
+
// fields: {
|
|
357
|
+
// priority: () => faker.helpers.weightedArrayElement([{ value: 'urgent', weight: 1 }, { value: 'normal', weight: 6 }]),
|
|
358
|
+
// },
|
|
359
|
+
// },
|
|
360
|
+
},
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
`
|
|
364
|
+
|
|
365
|
+
/** The project's scenario, when it has `data/generate.ts`; null when it has none. */
|
|
366
|
+
export async function loadScenario(root: string, seed: number): Promise<Scenario | null> {
|
|
367
|
+
const file = join(root, 'data', 'generate.ts')
|
|
368
|
+
if (!existsSync(file)) return null
|
|
369
|
+
const mod = (await import(`${file}?t=${Date.now()}`)) as { default?: (ctx: { faker: Faker }) => Scenario }
|
|
370
|
+
if (typeof mod.default !== 'function') return null
|
|
371
|
+
const faker = new Faker({ locale: [en, base] })
|
|
372
|
+
faker.seed(seed + 1)
|
|
373
|
+
return mod.default({ faker })
|
|
374
|
+
}
|
|
@@ -12,15 +12,45 @@ import type { LocalCredentials } from './local-state'
|
|
|
12
12
|
* should end with. The runner opens a fresh conversation with the Agent's
|
|
13
13
|
* draft (or the live version), sends the person's turns in order through the
|
|
14
14
|
* same chat endpoint the platform uses, and compares the final reply with the
|
|
15
|
-
* expectation
|
|
15
|
+
* expectation, then runs the case's `checks` against the reply and the tools
|
|
16
|
+
* the Agent called, as the platform's expectation grading does.
|
|
17
|
+
*
|
|
18
|
+
* A scenario case (a conversation with a simulated customer) runs on the
|
|
19
|
+
* local runtime's own evaluation runner: the file's scenarios are imported
|
|
20
|
+
* into a dataset there and run against the Agent, and each comes back with
|
|
21
|
+
* the runner's verdict.
|
|
16
22
|
*/
|
|
17
23
|
|
|
24
|
+
/** A check graded in code, as the platform grades an item's `expectations` (`expectation-grading.ts`). */
|
|
25
|
+
export type TestCheck =
|
|
26
|
+
| { type: 'contains'; value: string; text?: string }
|
|
27
|
+
| { type: 'regex'; value: string; flags?: string; text?: string }
|
|
28
|
+
| { type: 'tool_called' | 'tool_not_called'; tool: string; argsSubset?: Record<string, unknown>; mode?: 'exact' | 'ignore' | 'subset' | 'superset'; text?: string }
|
|
29
|
+
|
|
18
30
|
export interface TestCase {
|
|
19
31
|
name: string
|
|
20
32
|
conversation: Array<Record<string, string>>
|
|
21
33
|
expected?: string
|
|
22
34
|
/** Pass when the reply contains each of these, instead of matching exactly. */
|
|
23
35
|
expectedContains?: string[]
|
|
36
|
+
/** Checks on the reply and the tools called; every one must pass too. */
|
|
37
|
+
checks?: TestCheck[]
|
|
38
|
+
/** Expectations only the platform's judge grades, in words: kept, not graded here. */
|
|
39
|
+
judged?: string[]
|
|
40
|
+
/** Human-certified on the platform. */
|
|
41
|
+
gold?: boolean
|
|
42
|
+
/** A conversation with a simulated customer, run by the local runtime's evaluation runner. */
|
|
43
|
+
scenario?: Record<string, unknown>
|
|
44
|
+
/** What the scenario expects: its outcome, the actions that must (or must not) run. */
|
|
45
|
+
expect?: Record<string, unknown>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A tool the Agent called during the case, from the chat stream. */
|
|
49
|
+
export interface ToolCall {
|
|
50
|
+
name: string
|
|
51
|
+
args: unknown
|
|
52
|
+
/** It ran to completion. */
|
|
53
|
+
ok: boolean
|
|
24
54
|
}
|
|
25
55
|
|
|
26
56
|
export interface CaseResult {
|
|
@@ -40,6 +70,8 @@ export interface CaseFile {
|
|
|
40
70
|
agent: string
|
|
41
71
|
file: string
|
|
42
72
|
cases: TestCase[]
|
|
73
|
+
/** The Agent's regression dataset in the workspace: `local test --regression` runs only these. */
|
|
74
|
+
regression?: boolean
|
|
43
75
|
}
|
|
44
76
|
|
|
45
77
|
export function findCaseFiles(root: string, agent?: string): CaseFile[] {
|
|
@@ -53,8 +85,8 @@ export function findCaseFiles(root: string, agent?: string): CaseFile[] {
|
|
|
53
85
|
for (const name of readdirSync(evalsDir)) {
|
|
54
86
|
if (!/\.ya?ml$/.test(name)) continue
|
|
55
87
|
const path = join(evalsDir, name)
|
|
56
|
-
const doc = parse(readFileSync(path, 'utf8')) as { cases?: TestCase[] } | null
|
|
57
|
-
out.push({ agent: slug, file: relative(root, path), cases: doc?.cases ?? [] })
|
|
88
|
+
const doc = parse(readFileSync(path, 'utf8')) as { cases?: TestCase[]; regression?: unknown } | null
|
|
89
|
+
out.push({ agent: slug, file: relative(root, path), cases: doc?.cases ?? [], ...(doc?.regression === true ? { regression: true } : {}) })
|
|
58
90
|
}
|
|
59
91
|
}
|
|
60
92
|
return out
|
|
@@ -64,16 +96,67 @@ function normalise(text: string): string {
|
|
|
64
96
|
return text.replace(/\s+/g, ' ').trim()
|
|
65
97
|
}
|
|
66
98
|
|
|
67
|
-
export function judge(c: TestCase, reply: string): { passed: boolean; reason?: string } {
|
|
99
|
+
export function judge(c: TestCase, reply: string, toolCalls: ToolCall[] = []): { passed: boolean; reason?: string } {
|
|
100
|
+
const answer = judgeAnswer(c, reply)
|
|
101
|
+
if (!answer.passed) return answer
|
|
102
|
+
for (const check of c.checks ?? []) {
|
|
103
|
+
const failed = runCheck(check, reply, toolCalls)
|
|
104
|
+
if (failed) return { passed: false, reason: failed }
|
|
105
|
+
}
|
|
106
|
+
return { passed: true }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function judgeAnswer(c: TestCase, reply: string): { passed: boolean; reason?: string } {
|
|
68
110
|
if (c.expectedContains?.length) {
|
|
69
111
|
const missing = c.expectedContains.filter((s) => !normalise(reply).toLowerCase().includes(normalise(s).toLowerCase()))
|
|
70
112
|
return missing.length ? { passed: false, reason: `reply does not mention: ${missing.join(', ')}` } : { passed: true }
|
|
71
113
|
}
|
|
72
|
-
|
|
114
|
+
// Checks alone are an expectation.
|
|
115
|
+
if (c.expected === undefined) return c.checks?.length ? { passed: true } : { passed: false, reason: 'the case has no `expected`, `expectedContains` or `checks`' }
|
|
73
116
|
return normalise(reply) === normalise(c.expected) ? { passed: true } : { passed: false, reason: 'reply differs from `expected`' }
|
|
74
117
|
}
|
|
75
118
|
|
|
76
|
-
|
|
119
|
+
/** A quantified group itself quantified: the pattern shape that backtracks without end, refused as the platform refuses it. */
|
|
120
|
+
const NESTED_QUANTIFIER = /\([^)]*[*+][^)]*\)\s*[*+{]/
|
|
121
|
+
|
|
122
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
123
|
+
|
|
124
|
+
const deepEqual = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b) || (isRecord(a) && isRecord(b) && Object.keys(a).length === Object.keys(b).length && Object.keys(a).every((k) => deepEqual(a[k], b[k])))
|
|
125
|
+
|
|
126
|
+
const containsAll = (haystack: Record<string, unknown>, needles: Record<string, unknown>) => Object.entries(needles).every(([k, v]) => k in haystack && deepEqual(haystack[k], v))
|
|
127
|
+
|
|
128
|
+
function argsMatch(check: Extract<TestCheck, { tool: string }>, call: ToolCall): boolean {
|
|
129
|
+
const mode = check.mode ?? 'superset'
|
|
130
|
+
if (mode === 'ignore' || !check.argsSubset) return true
|
|
131
|
+
if (!isRecord(call.args)) return false
|
|
132
|
+
if (mode === 'superset') return containsAll(call.args, check.argsSubset)
|
|
133
|
+
if (mode === 'subset') return containsAll(check.argsSubset, call.args)
|
|
134
|
+
return containsAll(call.args, check.argsSubset) && Object.keys(call.args).length === Object.keys(check.argsSubset).length
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Why a check failed, or null when it passed. */
|
|
138
|
+
export function runCheck(check: TestCheck, reply: string, toolCalls: ToolCall[]): string | null {
|
|
139
|
+
const label = check.text ? ` (${check.text})` : ''
|
|
140
|
+
if (check.type === 'contains') return reply.toLowerCase().includes(check.value.toLowerCase()) ? null : `reply does not mention: ${check.value}${label}`
|
|
141
|
+
if (check.type === 'regex') {
|
|
142
|
+
if (check.value.length > 500 || NESTED_QUANTIFIER.test(check.value)) return `the pattern ${check.value} can't run safely${label}`
|
|
143
|
+
let pattern: RegExp
|
|
144
|
+
try {
|
|
145
|
+
pattern = new RegExp(check.value, check.flags ?? 'i')
|
|
146
|
+
} catch {
|
|
147
|
+
return `the pattern ${check.value} does not compile${label}`
|
|
148
|
+
}
|
|
149
|
+
return pattern.test(reply.slice(0, 20_000)) ? null : `nothing in the reply matches /${check.value}/${label}`
|
|
150
|
+
}
|
|
151
|
+
// A call that ran proves a tool was called; any attempt counts against one that must not be.
|
|
152
|
+
const candidates = check.type === 'tool_called' ? toolCalls.filter((c) => c.ok) : toolCalls
|
|
153
|
+
const matched = candidates.some((c) => c.name === check.tool && argsMatch(check, c))
|
|
154
|
+
const made = toolCalls.length ? `calls made: ${[...new Set(toolCalls.map((c) => c.name))].join(', ')}` : 'no tools were called'
|
|
155
|
+
if (check.type === 'tool_called') return matched ? null : `no matching call to ${check.tool}; ${made}${label}`
|
|
156
|
+
return matched ? `${check.tool} was called, which the case does not allow${label}` : null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface Api {
|
|
77
160
|
serviceUrl: string
|
|
78
161
|
creds: LocalCredentials
|
|
79
162
|
}
|
|
@@ -98,8 +181,8 @@ async function call<T>(api: Api, path: string, init: { method?: string; body?: u
|
|
|
98
181
|
|
|
99
182
|
const TURN_TIMEOUT_MS = 180_000
|
|
100
183
|
|
|
101
|
-
/** Send one person turn and collect the streamed reply's text. */
|
|
102
|
-
async function sendTurn(api: Api, conversationId: string, agentName: string, text: string): Promise<string> {
|
|
184
|
+
/** Send one person turn and collect the streamed reply's text and the tools it called. */
|
|
185
|
+
async function sendTurn(api: Api, conversationId: string, agentName: string, text: string): Promise<{ reply: string; toolCalls: ToolCall[] }> {
|
|
103
186
|
const res = await fetch(new URL('/v1/chat', api.serviceUrl), {
|
|
104
187
|
method: 'POST',
|
|
105
188
|
headers: {
|
|
@@ -119,6 +202,7 @@ async function sendTurn(api: Api, conversationId: string, agentName: string, tex
|
|
|
119
202
|
if (!res.ok || !res.body) throw new Error(`chat answered ${res.status}: ${(await res.text()).slice(0, 200)}`)
|
|
120
203
|
let reply = ''
|
|
121
204
|
let buffer = ''
|
|
205
|
+
const calls = new Map<string, ToolCall>()
|
|
122
206
|
const decoder = new TextDecoder()
|
|
123
207
|
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
|
|
124
208
|
buffer += decoder.decode(chunk, { stream: true })
|
|
@@ -130,8 +214,10 @@ async function sendTurn(api: Api, conversationId: string, agentName: string, tex
|
|
|
130
214
|
const payload = line.slice(5).trim()
|
|
131
215
|
if (payload === '[DONE]') continue
|
|
132
216
|
try {
|
|
133
|
-
const event = JSON.parse(payload) as { type?: string; delta?: string; errorText?: string }
|
|
217
|
+
const event = JSON.parse(payload) as { type?: string; delta?: string; errorText?: string; toolCallId?: string; toolName?: string; input?: unknown }
|
|
134
218
|
if (event.type === 'text-delta' && typeof event.delta === 'string') reply += event.delta
|
|
219
|
+
if (event.type === 'tool-input-available' && event.toolCallId && event.toolName) calls.set(event.toolCallId, { name: event.toolName, args: event.input, ok: false })
|
|
220
|
+
if (event.type === 'tool-output-available' && event.toolCallId && calls.has(event.toolCallId)) calls.get(event.toolCallId)!.ok = true
|
|
135
221
|
if (event.type === 'error') throw new Error(event.errorText ?? 'the turn failed')
|
|
136
222
|
} catch (err) {
|
|
137
223
|
if (err instanceof SyntaxError) continue
|
|
@@ -139,7 +225,7 @@ async function sendTurn(api: Api, conversationId: string, agentName: string, tex
|
|
|
139
225
|
}
|
|
140
226
|
}
|
|
141
227
|
}
|
|
142
|
-
return reply
|
|
228
|
+
return { reply, toolCalls: [...calls.values()] }
|
|
143
229
|
}
|
|
144
230
|
|
|
145
231
|
export async function runCases(args: {
|
|
@@ -151,7 +237,16 @@ export async function runCases(args: {
|
|
|
151
237
|
}): Promise<CaseResult[]> {
|
|
152
238
|
const api: Api = { serviceUrl: args.serviceUrl, creds: args.creds }
|
|
153
239
|
const results: CaseResult[] = []
|
|
154
|
-
for (const
|
|
240
|
+
for (const all of args.files) {
|
|
241
|
+
// Scenarios run on the runtime's evaluation runner, the rest as conversations here.
|
|
242
|
+
const scenarios = all.cases.filter((c) => c.scenario)
|
|
243
|
+
const file = { ...all, cases: all.cases.filter((c) => !c.scenario) }
|
|
244
|
+
if (scenarios.length) {
|
|
245
|
+
for (const r of await runScenarios(api, all, scenarios, args.live)) {
|
|
246
|
+
results.push(r)
|
|
247
|
+
args.onResult(r)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
155
250
|
let agentName = file.agent
|
|
156
251
|
let agentId: string | undefined
|
|
157
252
|
let setupError: string | undefined
|
|
@@ -175,10 +270,14 @@ export async function runCases(args: {
|
|
|
175
270
|
const session = await call<{ id: string }>(api, '/v1/sessions', { body: { agentName, workspaceId: args.creds.workspaceId } })
|
|
176
271
|
conversationId = session.id
|
|
177
272
|
let reply = ''
|
|
178
|
-
|
|
179
|
-
|
|
273
|
+
const toolCalls: ToolCall[] = []
|
|
274
|
+
for (const turn of c.conversation ?? []) {
|
|
275
|
+
if (typeof turn.user !== 'string') continue
|
|
276
|
+
const answer = await sendTurn(api, session.id, agentName, turn.user)
|
|
277
|
+
reply = answer.reply
|
|
278
|
+
toolCalls.push(...answer.toolCalls)
|
|
180
279
|
}
|
|
181
|
-
const verdict = judge(c, reply)
|
|
280
|
+
const verdict = judge(c, reply, toolCalls)
|
|
182
281
|
result = { agent: file.agent, file: file.file, name: c.name, passed: verdict.passed, reply, reason: verdict.reason, ms: Date.now() - started, agentId, conversationId }
|
|
183
282
|
} catch (err) {
|
|
184
283
|
result = { agent: file.agent, file: file.file, name: c.name, passed: false, reply: '', reason: (err as Error).message, ms: Date.now() - started, agentId, conversationId }
|
|
@@ -189,3 +288,62 @@ export async function runCases(args: {
|
|
|
189
288
|
}
|
|
190
289
|
return results
|
|
191
290
|
}
|
|
291
|
+
|
|
292
|
+
const SCENARIO_POLL_MS = 3_000
|
|
293
|
+
const OUTCOME_WORDS: Record<string, string> = { fail: 'failed', error: 'errored', split: 'passed and failed equally often', ungraded: 'was not graded', skipped: 'was skipped', pending: 'did not finish' }
|
|
294
|
+
const SCENARIO_TIMEOUT_MS = 15 * 60_000
|
|
295
|
+
|
|
296
|
+
/** The dataset a case file's scenarios are imported into on the local runtime. */
|
|
297
|
+
export function scenarioDataset(file: CaseFile): string {
|
|
298
|
+
const stem = file.file.split(/[\\/]/).pop()!.replace(/\.ya?ml$/, '')
|
|
299
|
+
return `local-test-${file.agent}-${stem}`.toLowerCase().replace(/[^a-z0-9-]+/g, '-').slice(0, 120)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* A file's scenario cases, run by the local runtime's evaluation runner: the
|
|
304
|
+
* scenarios are imported into the file's dataset there (by name, so running
|
|
305
|
+
* again updates them), run against the Agent's draft (`live`: its published
|
|
306
|
+
* version) with no judges (a scenario is graded on what the Agent did), and
|
|
307
|
+
* each case reads its item's verdict.
|
|
308
|
+
*/
|
|
309
|
+
export async function runScenarios(api: Api, file: CaseFile, cases: TestCase[], live: boolean, wait: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms))): Promise<CaseResult[]> {
|
|
310
|
+
const started = Date.now()
|
|
311
|
+
const dataset = scenarioDataset(file)
|
|
312
|
+
const fail = (reason: string, agentId?: string): CaseResult[] => cases.map((c) => ({ agent: file.agent, file: file.file, name: c.name, passed: false, reply: '', reason, ms: Date.now() - started, agentId }))
|
|
313
|
+
let agentId: string | undefined
|
|
314
|
+
try {
|
|
315
|
+
const agent = await call<{ id: string }>(api, `/v1/config/agents/${encodeURIComponent(file.agent)}`)
|
|
316
|
+
agentId = agent.id
|
|
317
|
+
// Made once; it exists on every later run.
|
|
318
|
+
await call(api, '/v1/eval/datasets', { body: { name: dataset, description: `Scenarios from ${file.file} (frontera local test)` } }).catch(() => null)
|
|
319
|
+
await call(api, `/v1/eval/datasets/${encodeURIComponent(dataset)}/scenarios/import`, {
|
|
320
|
+
body: { scenarios: cases.map((c) => ({ name: c.name, scenario: c.scenario, expect: c.expect ?? {} })) },
|
|
321
|
+
})
|
|
322
|
+
const draft = live ? null : await call<{ revision?: number }>(api, `/v1/config/agents/${agent.id}/draft-v2`).catch(() => null)
|
|
323
|
+
const run = await call<{ runId: string }>(api, `/v1/eval/datasets/${encodeURIComponent(dataset)}/runs`, {
|
|
324
|
+
body: { agentId: agent.id, judgeSlugs: [], runName: 'frontera local test', ...(draft?.revision ? { draftRevision: draft.revision } : {}) },
|
|
325
|
+
})
|
|
326
|
+
type Run = { status?: string; items?: Array<{ datasetItemId: string; attempt: number; input?: { query?: string } | null; output?: unknown; error?: string | null }>; rollups?: Array<{ datasetItemId: string; outcome: string }> }
|
|
327
|
+
let state: Run = {}
|
|
328
|
+
while (Date.now() - started < SCENARIO_TIMEOUT_MS) {
|
|
329
|
+
state = await call<Run>(api, `/v1/eval/datasets/${encodeURIComponent(dataset)}/runs/${run.runId}`)
|
|
330
|
+
if (state.status && !['queued', 'running'].includes(state.status)) break
|
|
331
|
+
await wait(SCENARIO_POLL_MS)
|
|
332
|
+
}
|
|
333
|
+
if (!state.status || ['queued', 'running'].includes(state.status)) return fail('the scenario run did not finish in time', agentId)
|
|
334
|
+
const outcomes = new Map((state.rollups ?? []).map((r) => [r.datasetItemId, r.outcome]))
|
|
335
|
+
const firstAttempts = (state.items ?? []).filter((i) => i.attempt === 0)
|
|
336
|
+
return cases.map((c) => {
|
|
337
|
+
// An item is matched by its opening, which the run records as the item's query.
|
|
338
|
+
const opening = typeof c.scenario?.opening === 'string' ? c.scenario.opening.trim() : ''
|
|
339
|
+
const at = firstAttempts.find((i) => (i.input?.query ?? '').trim() === opening)
|
|
340
|
+
const outcome = at ? outcomes.get(at.datasetItemId) : undefined
|
|
341
|
+
const reply = typeof at?.output === 'string' ? at.output : ''
|
|
342
|
+
const passed = outcome === 'pass'
|
|
343
|
+
const reason = passed ? undefined : !at ? 'the run has no result for this scenario' : at.error ? at.error : `the scenario ${OUTCOME_WORDS[outcome ?? ''] ?? 'did not finish'}`
|
|
344
|
+
return { agent: file.agent, file: file.file, name: c.name, passed, reply, ...(reason ? { reason } : {}), ms: Date.now() - started, agentId }
|
|
345
|
+
})
|
|
346
|
+
} catch (err) {
|
|
347
|
+
return fail((err as Error).message, agentId)
|
|
348
|
+
}
|
|
349
|
+
}
|