@frontera-sdk/cli 1.52.28 → 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.
Files changed (59) hide show
  1. package/package.json +5 -4
  2. package/src/commands/local/data.ts +60 -0
  3. package/src/commands/local/dev.ts +361 -18
  4. package/src/commands/local/index-commands.ts +11 -3
  5. package/src/commands/local/init.ts +88 -7
  6. package/src/commands/local/sync-request.ts +750 -0
  7. package/src/commands/local/sync.ts +941 -0
  8. package/src/commands/skill/bundle-commands.ts +9 -6
  9. package/src/errors.ts +4 -1
  10. package/src/exit.ts +3 -0
  11. package/src/flag-help.ts +48 -0
  12. package/src/local/local-actions.ts +306 -0
  13. package/src/local/local-adapt.ts +216 -0
  14. package/src/local/local-agent-files.ts +9 -2
  15. package/src/local/local-agent-grants.ts +125 -0
  16. package/src/local/local-agent-name.ts +71 -0
  17. package/src/local/local-applied.ts +334 -3
  18. package/src/local/local-apps.ts +156 -0
  19. package/src/local/local-blueprint-outline.ts +465 -0
  20. package/src/local/local-brand.ts +123 -0
  21. package/src/local/local-build-summary.ts +106 -0
  22. package/src/local/local-chat-apps.ts +170 -0
  23. package/src/local/local-dev-references.ts +1132 -0
  24. package/src/local/local-dev-sync.ts +171 -7
  25. package/src/local/local-docs.ts +53 -5
  26. package/src/local/local-env.ts +2 -0
  27. package/src/local/local-function-draft.ts +33 -0
  28. package/src/local/local-function-shared.ts +65 -0
  29. package/src/local/local-function-state.ts +34 -0
  30. package/src/local/local-knowledge-documents.ts +302 -0
  31. package/src/local/local-lessons.ts +278 -0
  32. package/src/local/local-library.ts +124 -0
  33. package/src/local/local-media-sets.ts +31 -0
  34. package/src/local/local-object-sets.ts +181 -0
  35. package/src/local/local-objects.ts +159 -0
  36. package/src/local/local-operating.ts +9 -5
  37. package/src/local/local-org-settings.ts +118 -0
  38. package/src/local/local-portability.ts +346 -0
  39. package/src/local/local-project.ts +6 -2
  40. package/src/local/local-quality-checks.ts +171 -0
  41. package/src/local/local-scaffold.ts +14 -11
  42. package/src/local/local-schemas.ts +33 -2
  43. package/src/local/local-secrets.ts +306 -0
  44. package/src/local/local-stand-ins.ts +442 -0
  45. package/src/local/local-studio-server.ts +23 -2
  46. package/src/local/local-sync-base.ts +237 -0
  47. package/src/local/local-sync-client.ts +93 -0
  48. package/src/local/local-sync-link.ts +122 -0
  49. package/src/local/local-sync-merge.ts +395 -0
  50. package/src/local/local-sync-names.ts +169 -0
  51. package/src/local/local-sync-plan.ts +717 -0
  52. package/src/local/local-sync-push.ts +986 -0
  53. package/src/local/local-sync-refs.ts +221 -0
  54. package/src/local/local-sync-remote.ts +1482 -0
  55. package/src/local/local-sync-snapshot.ts +526 -0
  56. package/src/local/local-synthetic-data.ts +374 -0
  57. package/src/local/local-test-runner.ts +172 -14
  58. package/src/local/local-themes.ts +201 -0
  59. 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
- if (c.expected === undefined) return { passed: false, reason: 'the case has no `expected` or `expectedContains`' }
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
- interface Api {
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 file of args.files) {
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
- for (const turn of c.conversation) {
179
- if (typeof turn.user === 'string') reply = await sendTurn(api, session.id, agentName, turn.user)
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
+ }