@frontera-sdk/cli 1.52.39 → 1.52.40

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 (225) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +48 -4
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/index-commands.ts +69 -7
  33. package/src/commands/agent/resolve.ts +2 -0
  34. package/src/commands/app/checks.ts +44 -0
  35. package/src/commands/app/deploy.ts +243 -60
  36. package/src/commands/app/deployments.ts +97 -0
  37. package/src/commands/app/dev.ts +103 -11
  38. package/src/commands/app/init.ts +36 -3
  39. package/src/commands/app/promote.ts +45 -19
  40. package/src/commands/app/pull.ts +3 -1
  41. package/src/commands/app/rollback.ts +46 -0
  42. package/src/commands/app/sdk.ts +54 -8
  43. package/src/commands/app/unpin.ts +27 -0
  44. package/src/commands/app/versions.ts +1 -1
  45. package/src/commands/automation/bindings.ts +4 -2
  46. package/src/commands/automation/build-entry.ts +4 -1
  47. package/src/commands/automation/index-commands.ts +3 -1
  48. package/src/commands/automation/pull.ts +6 -1
  49. package/src/commands/automation/run.ts +34 -9
  50. package/src/commands/blueprint/bind.ts +2 -1
  51. package/src/commands/blueprint/declarative.ts +331 -31
  52. package/src/commands/blueprint/get.ts +6 -2
  53. package/src/commands/blueprint/grants.ts +64 -10
  54. package/src/commands/blueprint/metric.ts +108 -0
  55. package/src/commands/blueprint/object-sets.ts +71 -0
  56. package/src/commands/blueprint/query.ts +155 -31
  57. package/src/commands/dataset/index-commands.ts +156 -7
  58. package/src/commands/knowledge/index-commands.ts +122 -20
  59. package/src/commands/local/check.ts +657 -44
  60. package/src/commands/local/data.ts +33 -12
  61. package/src/commands/local/dev.ts +875 -88
  62. package/src/commands/local/docs-mcp.ts +10 -18
  63. package/src/commands/local/index-commands.ts +300 -63
  64. package/src/commands/local/init.ts +18 -5
  65. package/src/commands/local/key.ts +0 -3
  66. package/src/commands/local/providers.ts +0 -7
  67. package/src/commands/local/remove.ts +116 -0
  68. package/src/commands/local/schema.ts +3 -4
  69. package/src/commands/local/sdk-refresh.ts +59 -0
  70. package/src/commands/local/snapshot.ts +100 -0
  71. package/src/commands/local/sync-request.ts +320 -13
  72. package/src/commands/local/sync.ts +102 -5
  73. package/src/commands/mcp/serve.ts +34 -0
  74. package/src/commands/registry.ts +52 -8
  75. package/src/commands/types.ts +29 -0
  76. package/src/commands/workflow/archive.ts +46 -0
  77. package/src/commands/workflow/get.ts +2 -1
  78. package/src/commands/workflow/index-commands.ts +5 -0
  79. package/src/commands/workflow/push.ts +49 -2
  80. package/src/commands/workflow/run.ts +65 -7
  81. package/src/commands/workflow/runs.ts +9 -6
  82. package/src/commands/workflow/webhook-test.ts +166 -0
  83. package/src/config.ts +43 -6
  84. package/src/dev-broker.ts +52 -0
  85. package/src/exit.ts +103 -4
  86. package/src/flag-help.ts +77 -17
  87. package/src/forge/touches.ts +15 -12
  88. package/src/function-outside-imports.ts +89 -0
  89. package/src/harness.ts +42 -24
  90. package/src/heal.ts +112 -1
  91. package/src/local/fix-words.ts +12 -0
  92. package/src/local/function-bundle-recovery.ts +775 -0
  93. package/src/local/local-actions.ts +103 -10
  94. package/src/local/local-agent-action-grants.ts +210 -0
  95. package/src/local/local-agent-checks.ts +232 -0
  96. package/src/local/local-agent-grants.ts +29 -7
  97. package/src/local/local-agent-lean-plugins.ts +68 -0
  98. package/src/local/local-agent-missing-tools.ts +68 -0
  99. package/src/local/local-agent-name.ts +27 -0
  100. package/src/local/local-app-code.ts +191 -0
  101. package/src/local/local-app-dev-files.ts +186 -0
  102. package/src/local/local-app-dev-session.ts +88 -0
  103. package/src/local/local-app-lock-install.ts +114 -0
  104. package/src/local/local-app-pulled.ts +167 -0
  105. package/src/local/local-app-restore.ts +111 -0
  106. package/src/local/local-app-runtime.ts +68 -0
  107. package/src/local/local-app-sdk-paths.ts +149 -0
  108. package/src/local/local-app-sdk.ts +509 -0
  109. package/src/local/local-applied.ts +211 -21
  110. package/src/local/local-apply-history.ts +84 -0
  111. package/src/local/local-apply-summary.ts +86 -0
  112. package/src/local/local-apply-watchdog.ts +95 -0
  113. package/src/local/local-apps.ts +180 -7
  114. package/src/local/local-blueprint-errors.ts +198 -0
  115. package/src/local/local-blueprint-outline.ts +31 -12
  116. package/src/local/local-blueprint-schemas.ts +582 -0
  117. package/src/local/local-build-inputs.ts +48 -0
  118. package/src/local/local-build-summary.ts +495 -28
  119. package/src/local/local-case-evidence.ts +120 -0
  120. package/src/local/local-case-not-run.ts +153 -0
  121. package/src/local/local-chat-apps.ts +93 -16
  122. package/src/local/local-check-inherited.ts +83 -0
  123. package/src/local/local-cities.ts +89 -0
  124. package/src/local/local-component-health.ts +119 -0
  125. package/src/local/local-control.ts +34 -7
  126. package/src/local/local-db-snapshot.ts +348 -0
  127. package/src/local/local-dev-references.ts +346 -35
  128. package/src/local/local-dev-sync.ts +136 -7
  129. package/src/local/local-docker.ts +35 -1
  130. package/src/local/local-docs-search.ts +1 -1
  131. package/src/local/local-docs.ts +281 -2
  132. package/src/local/local-empty-workflows.ts +92 -0
  133. package/src/local/local-env.ts +16 -1
  134. package/src/local/local-file-checks.ts +104 -0
  135. package/src/local/local-function-ids.ts +141 -0
  136. package/src/local/local-function-recovery.ts +141 -0
  137. package/src/local/local-function-state.ts +87 -0
  138. package/src/local/local-keys.ts +7 -5
  139. package/src/local/local-knowledge-ingestion.ts +225 -0
  140. package/src/local/local-knowledge-uploads.ts +217 -0
  141. package/src/local/local-media-placeholders.ts +182 -0
  142. package/src/local/local-media-sets.ts +90 -10
  143. package/src/local/local-model-parameters.ts +89 -0
  144. package/src/local/local-object-sets.ts +18 -6
  145. package/src/local/local-operating.ts +35 -1
  146. package/src/local/local-org-settings.ts +205 -7
  147. package/src/local/local-orphans.ts +321 -0
  148. package/src/local/local-personas.ts +296 -0
  149. package/src/local/local-portability.ts +141 -16
  150. package/src/local/local-ports.ts +99 -7
  151. package/src/local/local-project-notices.ts +234 -0
  152. package/src/local/local-project-secrets.ts +133 -0
  153. package/src/local/local-project.ts +12 -0
  154. package/src/local/local-provider-refusal.ts +66 -0
  155. package/src/local/local-request-evidence.ts +112 -0
  156. package/src/local/local-runtime-health.ts +123 -0
  157. package/src/local/local-runtime-history.ts +50 -0
  158. package/src/local/local-runtime-target.ts +42 -0
  159. package/src/local/local-sample-data.ts +141 -0
  160. package/src/local/local-sample-remake.ts +138 -0
  161. package/src/local/local-scaffold.ts +336 -53
  162. package/src/local/local-schema-check.ts +148 -0
  163. package/src/local/local-schemas.ts +742 -25
  164. package/src/local/local-secret-carry.ts +107 -0
  165. package/src/local/local-secrets.ts +38 -8
  166. package/src/local/local-seed.ts +102 -26
  167. package/src/local/local-stand-ins.ts +1 -1
  168. package/src/local/local-state.ts +43 -2
  169. package/src/local/local-studio-server.ts +170 -7
  170. package/src/local/local-supervisor.ts +44 -6
  171. package/src/local/local-sync-names.ts +2 -0
  172. package/src/local/local-sync-plan.ts +11 -0
  173. package/src/local/local-sync-push.ts +10 -7
  174. package/src/local/local-sync-refs.ts +49 -3
  175. package/src/local/local-sync-remote.ts +343 -32
  176. package/src/local/local-sync-snapshot.ts +15 -10
  177. package/src/local/local-synthetic-data.ts +2116 -99
  178. package/src/local/local-test-baseline.ts +113 -0
  179. package/src/local/local-test-cases.ts +76 -1
  180. package/src/local/local-test-runner.ts +582 -91
  181. package/src/local/local-test-runs.ts +13 -5
  182. package/src/local/local-vendored-sdk.ts +183 -0
  183. package/src/local/local-workflow-state.ts +16 -3
  184. package/src/local/local-workflow-tests.ts +482 -0
  185. package/src/local/local-workspace-grants.ts +81 -0
  186. package/src/main.ts +50 -9
  187. package/src/mcp/a11y-scan.ts +55 -0
  188. package/src/mcp/browser/ax-snapshot.ts +181 -0
  189. package/src/mcp/browser/credentials.ts +78 -0
  190. package/src/mcp/browser/frames.ts +336 -0
  191. package/src/mcp/browser/headless-session.ts +413 -0
  192. package/src/mcp/browser/headless-tab.ts +575 -0
  193. package/src/mcp/browser/keys.ts +70 -0
  194. package/src/mcp/browser/page-scripts.ts +129 -0
  195. package/src/mcp/browser/wait.ts +73 -0
  196. package/src/mcp/forge-link.ts +126 -0
  197. package/src/mcp/headless.ts +199 -0
  198. package/src/mcp/mcp-apps.ts +435 -0
  199. package/src/mcp/mcp-chat.ts +226 -0
  200. package/src/mcp/mcp-cli.ts +53 -0
  201. package/src/mcp/mcp-protocol.ts +122 -0
  202. package/src/mcp/mcp-runtime.ts +113 -0
  203. package/src/mcp/mcp-server.ts +108 -0
  204. package/src/mcp/mcp-traces.ts +409 -0
  205. package/src/mcp/page-observation.ts +193 -0
  206. package/src/mcp/tools/agent-tools.ts +700 -0
  207. package/src/mcp/tools/app-tools.ts +241 -0
  208. package/src/mcp/tools/browser-tools.ts +310 -0
  209. package/src/mcp/tools/docs-tools.ts +32 -0
  210. package/src/mcp/tools/project-tools.ts +205 -0
  211. package/src/mcp/tools/test-tools.ts +339 -0
  212. package/src/mcp/tools/workflow-tools.ts +611 -0
  213. package/src/output.ts +1 -1
  214. package/src/pack.ts +8 -3
  215. package/src/packaging.ts +1 -1
  216. package/src/project-bootstrap.ts +107 -4
  217. package/src/project-context.ts +18 -1
  218. package/src/project.ts +27 -2
  219. package/src/provenance.ts +25 -0
  220. package/src/render-evidence.ts +65 -7
  221. package/src/template.ts +14 -8
  222. package/src/templates/next-app-files.ts +35 -6
  223. package/src/templates/next-skills.ts +61 -27
  224. package/src/vendor/kit-assets.json +6 -6
  225. package/src/vendor/sdk-sources.json +24 -21
@@ -3,6 +3,13 @@ import { join, relative } from 'node:path'
3
3
 
4
4
  import { parse } from 'yaml'
5
5
 
6
+ import { contentHash, loadBuild } from './local-applied'
7
+ import { caseCouldNotRun, stopsTheRun, withDirectModel, type CaseNotRun } from './local-case-not-run'
8
+ import { directModelFor } from './local-seed'
9
+ import { allTargets } from './local-dev-sync'
10
+ import type { LocalProject } from './local-project'
11
+ import { checkAgainstSchema, describeProblems } from './local-schema-check'
12
+ import { cliSchema, MAX_TURN_WAIT_SECONDS } from './local-schemas'
6
13
  import type { LocalCredentials } from './local-state'
7
14
 
8
15
  /**
@@ -25,11 +32,31 @@ import type { LocalCredentials } from './local-state'
25
32
  export type TestCheck =
26
33
  | { type: 'contains'; value: string; text?: string }
27
34
  | { 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 }
35
+ | {
36
+ type: 'tool_called' | 'tool_not_called'
37
+ tool: string
38
+ argsSubset?: Record<string, unknown>
39
+ mode?: 'exact' | 'ignore' | 'subset' | 'superset'
40
+ text?: string
41
+ /** The calls of every turn (`all`, the default) or only of the last one, whose reply the case grades. */
42
+ turn?: 'all' | 'last'
43
+ }
44
+
45
+ /**
46
+ * One turn of a case's conversation: a person's message (sent), or an
47
+ * assistant's (kept for whoever reads the case). `waitSeconds` on a person's
48
+ * turn waits that long before sending it, so a case can keep a gap between
49
+ * turns ("turn 2 a minute after turn 1").
50
+ */
51
+ export interface CaseTurn {
52
+ user?: string
53
+ assistant?: string
54
+ waitSeconds?: number
55
+ }
29
56
 
30
57
  export interface TestCase {
31
58
  name: string
32
- conversation: Array<Record<string, string>>
59
+ conversation: CaseTurn[]
33
60
  expected?: string
34
61
  /** Pass when the reply contains each of these, instead of matching exactly. */
35
62
  expectedContains?: string[]
@@ -39,10 +66,23 @@ export interface TestCase {
39
66
  judged?: string[]
40
67
  /** Human-certified on the platform. */
41
68
  gold?: boolean
69
+ /** Guards the Agent in change requests: run, against the last applied run, before a request applies (design 2026-10-18 §6). */
70
+ gate?: boolean
71
+ /** A turn that stops on a tool approval: approve or deny it and read on, or `expect` it (the case passes by waiting). */
72
+ decision?: CaseDecision
73
+ /** Run the tools that change something for real (a decision implies it); simulated otherwise. */
74
+ realActions?: boolean
75
+ /** The conversation's variables (`customer_id: CST-00060`), set before its first message. */
76
+ variables?: Record<string, string | number | boolean>
42
77
  /** A conversation with a simulated customer, run by the local runtime's evaluation runner. */
43
78
  scenario?: Record<string, unknown>
44
79
  /** What the scenario expects: its outcome, the actions that must (or must not) run. */
45
80
  expect?: Record<string, unknown>
81
+ /**
82
+ * Where the case breaks the evals format (`frontera local schema evals`), by
83
+ * file and path; such a case fails with these and is not sent to the Agent.
84
+ */
85
+ problems?: string[]
46
86
  }
47
87
 
48
88
  /** A tool the Agent called during the case, from the chat stream. */
@@ -51,6 +91,13 @@ export interface ToolCall {
51
91
  args: unknown
52
92
  /** It ran to completion. */
53
93
  ok: boolean
94
+ /**
95
+ * It waits on a person's approval (the turn parked on it, and nobody has
96
+ * decided it): the Agent called it, so `tool_called` counts it, with the
97
+ * arguments the approval shows. A call approved and run is `ok`; one denied
98
+ * is neither.
99
+ */
100
+ parked?: boolean
54
101
  }
55
102
 
56
103
  export interface CaseResult {
@@ -64,16 +111,72 @@ export interface CaseResult {
64
111
  /** The Agent's config id and the conversation the case ran in, so Studio can open it. */
65
112
  agentId?: string
66
113
  conversationId?: string
114
+ /** When the case was sent, so its traces can be found by their time (`local-case-evidence.ts`). */
115
+ startedAt?: string
116
+ /** How many tools the Agent called, for efficiency against a baseline. */
117
+ toolCalls?: number
118
+ /**
119
+ * The waits the case kept between turns (`waitSeconds`), in seconds, in
120
+ * order: said in the run's output, since such a case takes that long.
121
+ */
122
+ waited?: Array<{ turn: number; seconds: number }>
123
+ /**
124
+ * The model provider refused the first try for its limits (a 402 or 429
125
+ * about credits, rate or in-flight requests), so the case was run once more
126
+ * after a pause: what it said the first time.
127
+ */
128
+ retried?: string
129
+ /**
130
+ * The case couldn't run: the model provider refused it (credits, a rate
131
+ * limit, a key), the turn timed out, or the local runtime did not answer.
132
+ * It is not passed, and it is not a failure of the Agent: never newly
133
+ * failing, and never recorded as the case's last outcome.
134
+ */
135
+ notRun?: CaseNotRun
67
136
  }
68
137
 
69
138
  export interface CaseFile {
70
139
  agent: string
71
140
  file: string
72
141
  cases: TestCase[]
142
+ /**
143
+ * Why the file as a whole cannot run (it is not YAML, or has no `cases` list),
144
+ * said as one failed result rather than thrown, so one bad file fails only itself.
145
+ */
146
+ problem?: string
73
147
  /** The Agent's regression dataset in the workspace: `local test --regression` runs only these. */
74
148
  regression?: boolean
75
149
  }
76
150
 
151
+ /**
152
+ * Only the cases marked `gate: true`, file by file; files left with none are
153
+ * dropped. Each case is the one the file gave, every field kept (`variables`,
154
+ * `realActions`, `decision`, `checks`): a gate run sends what any other run
155
+ * sends. A file that can't be read is kept: it may hold gate cases, and it
156
+ * fails with why rather than vanish from the run.
157
+ */
158
+ export function gatedCaseFiles(files: CaseFile[]): CaseFile[] {
159
+ return files.map((f) => (f.problem ? f : { ...f, cases: f.cases.filter((c) => c.gate === true) })).filter((f) => f.cases.length || f.problem)
160
+ }
161
+
162
+ /**
163
+ * The case files a run takes, chosen the same way by every path that runs
164
+ * them (`frontera local test`, `local check`, MCP `test_run`, a change
165
+ * request's evals, Studio): one Agent's, one file, the regression datasets,
166
+ * the gate cases. The cases themselves are always as `readCaseFile` read
167
+ * them, so no path drops a field another sends.
168
+ */
169
+ export function selectCaseFiles(root: string, scope: { agent?: string; file?: string; regression?: boolean; gate?: boolean } = {}): CaseFile[] {
170
+ const file = scope.file?.replace(/^\.\//, '')
171
+ const files = findCaseFiles(root, scope.agent)
172
+ .filter((f) => !file || f.file === file)
173
+ .filter((f) => !scope.regression || f.regression)
174
+ return scope.gate ? gatedCaseFiles(files) : files
175
+ }
176
+
177
+ /** How many results a run of these files gives: each case, or one for a file that can't be read. */
178
+ export const caseCount = (files: CaseFile[]): number => files.reduce((n, f) => n + Math.max(f.cases.length, f.problem ? 1 : 0), 0)
179
+
77
180
  export function findCaseFiles(root: string, agent?: string): CaseFile[] {
78
181
  const agentsDir = join(root, 'agents')
79
182
  if (!existsSync(agentsDir)) return []
@@ -82,25 +185,62 @@ export function findCaseFiles(root: string, agent?: string): CaseFile[] {
82
185
  if (agent && slug !== agent) continue
83
186
  const evalsDir = join(agentsDir, slug, 'evals')
84
187
  if (!existsSync(evalsDir)) continue
85
- for (const name of readdirSync(evalsDir)) {
86
- if (!/\.ya?ml$/.test(name)) continue
188
+ for (const name of readdirSync(evalsDir).sort()) {
189
+ if (!/\.ya?ml$/.test(name) || name.startsWith('.')) continue
87
190
  const path = join(evalsDir, name)
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 } : {}) })
191
+ out.push(readCaseFile(slug, relative(root, path), readFileSync(path, 'utf8')))
90
192
  }
91
193
  }
92
194
  return out
93
195
  }
94
196
 
197
+ /**
198
+ * One evals file, checked against its format before anything runs: a problem
199
+ * with the file as a whole is the file's `problem`; a case that breaks the
200
+ * format carries its `problems` (`agents/x/evals/y.yaml: cases.2.checks.0.value:
201
+ * must be text`) and fails with them instead of crashing the run.
202
+ */
203
+ export function readCaseFile(agent: string, file: string, text: string): CaseFile {
204
+ let doc: unknown
205
+ try {
206
+ doc = parse(text)
207
+ } catch (err) {
208
+ return { agent, file, cases: [], problem: `${file}: the file is not YAML: ${(err as Error).message.split('\n')[0]}` }
209
+ }
210
+ const raw = (doc ?? {}) as { cases?: unknown; regression?: unknown }
211
+ const regression = raw.regression === true ? { regression: true } : {}
212
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc) || !Array.isArray(raw.cases)) {
213
+ return { agent, file, cases: [], problem: `${file}: the file needs a \`cases:\` list (\`frontera local schema evals\`)`, ...regression }
214
+ }
215
+ const problems = checkAgainstSchema(doc, cliSchema('evals')!)
216
+ const byCase = new Map<number, string[]>()
217
+ const fileWide: string[] = []
218
+ for (const p of problems) {
219
+ const m = /^cases\.(\d+)(\.|$)/.exec(p.path)
220
+ if (m) byCase.set(Number(m[1]), [...(byCase.get(Number(m[1])) ?? []), ...describeProblems([p], file)])
221
+ else fileWide.push(...describeProblems([p], file))
222
+ }
223
+ const cases = (raw.cases as unknown[]).map((c, i) => {
224
+ const o = (c && typeof c === 'object' && !Array.isArray(c) ? c : {}) as TestCase
225
+ const name = typeof o.name === 'string' && o.name ? o.name : `case ${i + 1}`
226
+ const found = [...fileWide, ...(byCase.get(i) ?? [])]
227
+ // The evaluation runner that runs a scenario gives it no variables yet: said, not silently dropped.
228
+ if (o.scenario && o.variables && Object.keys(o.variables).length) found.push(`${file}: cases.${i} (${name}): a scenario case can’t set \`variables\` yet (the evaluation runner gives a scenario none); give the variables to a case with \`conversation\` instead`)
229
+ return { ...o, name, ...(found.length ? { problems: found } : {}) }
230
+ })
231
+ return { agent, file, cases, ...regression }
232
+ }
233
+
95
234
  function normalise(text: string): string {
96
235
  return text.replace(/\s+/g, ' ').trim()
97
236
  }
98
237
 
99
- export function judge(c: TestCase, reply: string, toolCalls: ToolCall[] = []): { passed: boolean; reason?: string } {
238
+ /** `lastTurnCalls`: the last turn's calls, for a check scoped `turn: last`; every call when not given. */
239
+ export function judge(c: TestCase, reply: string, toolCalls: ToolCall[] = [], lastTurnCalls?: ToolCall[]): { passed: boolean; reason?: string } {
100
240
  const answer = judgeAnswer(c, reply)
101
241
  if (!answer.passed) return answer
102
242
  for (const check of c.checks ?? []) {
103
- const failed = runCheck(check, reply, toolCalls)
243
+ const failed = runCheck(check, reply, toolCalls, lastTurnCalls)
104
244
  if (failed) return { passed: false, reason: failed }
105
245
  }
106
246
  return { passed: true }
@@ -129,14 +269,23 @@ function argsMatch(check: Extract<TestCheck, { tool: string }>, call: ToolCall):
129
269
  const mode = check.mode ?? 'superset'
130
270
  if (mode === 'ignore' || !check.argsSubset) return true
131
271
  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
272
+ // Every tool takes an `action` narration ("Running intake…") and strips it: not an argument the case means.
273
+ const { action: _narration, ...rest } = call.args
274
+ const args = 'action' in check.argsSubset ? call.args : rest
275
+ if (mode === 'superset') return containsAll(args, check.argsSubset)
276
+ if (mode === 'subset') return containsAll(check.argsSubset, args)
277
+ return containsAll(args, check.argsSubset) && Object.keys(args).length === Object.keys(check.argsSubset).length
135
278
  }
136
279
 
137
280
  /** Why a check failed, or null when it passed. */
138
- export function runCheck(check: TestCheck, reply: string, toolCalls: ToolCall[]): string | null {
281
+ export function runCheck(check: TestCheck, reply: string, allCalls: ToolCall[], lastTurnCalls?: ToolCall[]): string | null {
139
282
  const label = check.text ? ` (${check.text})` : ''
283
+ const lastOnly = 'turn' in check && check.turn === 'last'
284
+ const toolCalls = lastOnly && lastTurnCalls ? lastTurnCalls : allCalls
285
+ // The format says text; a number or a missing value is refused, not thrown on.
286
+ if ((check.type === 'contains' || check.type === 'regex') && (typeof check.value !== 'string' || !check.value)) {
287
+ return `the ${check.type} check's \`value\` must be text (it is ${check.value === undefined ? 'missing' : JSON.stringify(check.value)}); quote it in the file${label}`
288
+ }
140
289
  if (check.type === 'contains') return reply.toLowerCase().includes(check.value.toLowerCase()) ? null : `reply does not mention: ${check.value}${label}`
141
290
  if (check.type === 'regex') {
142
291
  if (check.value.length > 500 || NESTED_QUANTIFIER.test(check.value)) return `the pattern ${check.value} can't run safely${label}`
@@ -148,12 +297,14 @@ export function runCheck(check: TestCheck, reply: string, toolCalls: ToolCall[])
148
297
  }
149
298
  return pattern.test(reply.slice(0, 20_000)) ? null : `nothing in the reply matches /${check.value}/${label}`
150
299
  }
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
300
+ // A call that ran, or one waiting on a person's approval, proves a tool was called; any attempt counts against one that must not be.
301
+ const candidates = check.type === 'tool_called' ? toolCalls.filter((c) => c.ok || c.parked) : toolCalls
153
302
  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
303
+ const said = (c: ToolCall) => `${c.name}${c.parked ? ' (waiting on approval)' : !c.ok ? ' (did not run)' : ''}`
304
+ const made = toolCalls.length ? `calls made: ${[...new Set(toolCalls.map(said))].join(', ')}` : 'no tools were called'
305
+ const where = lastOnly ? ' in the last turn' : ''
306
+ if (check.type === 'tool_called') return matched ? null : `no matching call to ${check.tool}${where}; ${made}${label}`
307
+ return matched ? `${check.tool} was called${where}, which the case does not allow${label}` : null
157
308
  }
158
309
 
159
310
  export interface Api {
@@ -161,7 +312,7 @@ export interface Api {
161
312
  creds: LocalCredentials
162
313
  }
163
314
 
164
- async function call<T>(api: Api, path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
315
+ export async function call<T>(api: Api, path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
165
316
  const res = await fetch(new URL(path, api.serviceUrl), {
166
317
  method: init.method ?? (init.body === undefined ? 'GET' : 'POST'),
167
318
  headers: {
@@ -182,50 +333,264 @@ async function call<T>(api: Api, path: string, init: { method?: string; body?: u
182
333
  const TURN_TIMEOUT_MS = 180_000
183
334
 
184
335
  /** 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[] }> {
336
+ /** A turn that stopped on a decision a person makes: a tool waiting for approval, or a confirmation. */
337
+ export interface PendingDecision {
338
+ /** The tool that waits, when the stream named it. */
339
+ tool: string
340
+ /** Present for a tool approval: what `POST /v1/chat/approvals/:approvalId` decides. */
341
+ approvalId?: string
342
+ /** The waiting call, when the stream gave its id. */
343
+ toolCallId?: string
344
+ }
345
+
346
+ /**
347
+ * The call a `tool-approval-request` parks, marked as waiting: the call the
348
+ * stream announced under its id, or, when the request is the first the
349
+ * stream says of it, one made from the request's own tool name and
350
+ * arguments (`toolName`, `input` or `args`). Null when it names no tool.
351
+ */
352
+ export function parkCall(calls: Map<string, ToolCall>, event: { toolCallId?: string; toolName?: string; input?: unknown; args?: unknown; approvalId?: string }): (ToolCall & { id: string }) | null {
353
+ const id = event.toolCallId ?? (event.approvalId ? `approval:${event.approvalId}` : undefined)
354
+ if (!id) return null
355
+ let found = calls.get(id) as (ToolCall & { id?: string }) | undefined
356
+ if (!found) {
357
+ if (!event.toolName) return null
358
+ found = { id, name: event.toolName, args: event.input ?? event.args, ok: false }
359
+ calls.set(id, found)
360
+ } else if (found.args === undefined && (event.input ?? event.args) !== undefined) found.args = event.input ?? event.args
361
+ if (!found.ok) found.parked = true
362
+ return { ...found, id }
363
+ }
364
+
365
+ /** How a case answers a decision its turns stop on (`decision` in the evals format). */
366
+ export type CaseDecision = 'approve' | 'deny' | 'expect'
367
+
368
+ const chatHeaders = (api: Api) => ({
369
+ authorization: `Bearer ${api.creds.session}`,
370
+ 'x-org-id': api.creds.organizationId,
371
+ 'x-workspace-id': api.creds.workspaceId,
372
+ 'content-type': 'application/json',
373
+ })
374
+
375
+ /**
376
+ * Read a turn's stream into the reply and the tools called. Stops at a
377
+ * decision a person makes (a tool approval, a confirmation): the stream stays
378
+ * open while the card waits, so reading on would hang the case until it timed
379
+ * out. Counts the events read, so a resumed stream starts after them.
380
+ */
381
+ async function readTurn(body: ReadableStream<Uint8Array>, into: { reply: string; calls: Map<string, ToolCall>; events: number; seen: Set<string> }): Promise<PendingDecision | null> {
382
+ let buffer = ''
383
+ const decoder = new TextDecoder()
384
+ const reader = body.getReader()
385
+ try {
386
+ for (;;) {
387
+ const { value, done } = await reader.read()
388
+ if (done) return null
389
+ buffer += decoder.decode(value, { stream: true })
390
+ let newline: number
391
+ while ((newline = buffer.indexOf('\n')) >= 0) {
392
+ const line = buffer.slice(0, newline).trim()
393
+ buffer = buffer.slice(newline + 1)
394
+ if (!line.startsWith('data:')) continue
395
+ const payload = line.slice(5).trim()
396
+ if (payload === '[DONE]') continue
397
+ let event: { type?: string; delta?: string; errorText?: string; toolCallId?: string; toolName?: string; input?: unknown; args?: unknown; approvalId?: string; data?: Record<string, unknown> }
398
+ try {
399
+ event = JSON.parse(payload)
400
+ } catch {
401
+ continue
402
+ }
403
+ into.events += 1
404
+ if (event.type === 'text-delta' && typeof event.delta === 'string') into.reply += event.delta
405
+ if (event.type === 'tool-input-available' && event.toolCallId && event.toolName) into.calls.set(event.toolCallId, { name: event.toolName, args: event.input, ok: false })
406
+ if (event.type === 'tool-output-available' && event.toolCallId && into.calls.has(event.toolCallId)) Object.assign(into.calls.get(event.toolCallId)!, { ok: true, parked: false })
407
+ if ((event.type === 'tool-output-denied' || event.type === 'tool-output-error') && event.toolCallId && into.calls.has(event.toolCallId)) into.calls.get(event.toolCallId)!.parked = false
408
+ if (event.type === 'error') throw new Error(event.errorText ?? 'the turn failed')
409
+ // The turn is over: what follows is keepalives until the socket closes.
410
+ if (event.type === 'finish') return null
411
+ // Each decision once: a stream read on from an earlier point may say it again.
412
+ if (event.type === 'tool-approval-request' && !(event.approvalId && into.seen.has(event.approvalId))) {
413
+ if (event.approvalId) into.seen.add(event.approvalId)
414
+ const parked = parkCall(into.calls, event)
415
+ const tool = parked?.name || event.toolName || 'a tool'
416
+ return { tool, ...(typeof event.approvalId === 'string' ? { approvalId: event.approvalId } : {}), ...(parked?.id ? { toolCallId: parked.id } : {}) }
417
+ }
418
+ const card = event.type === 'data-person-confirmation' ? event.data : undefined
419
+ if (card && card.status === 'pending' && typeof card.id === 'string' && !into.seen.has(card.id)) {
420
+ into.seen.add(card.id)
421
+ return { tool: typeof card.summary === 'string' && card.summary ? `a confirmation (${card.summary})` : 'a confirmation' }
422
+ }
423
+ }
424
+ }
425
+ } finally {
426
+ // A stream parked on a decision is let go of, not drained.
427
+ await reader.cancel().catch(() => {})
428
+ }
429
+ }
430
+
431
+ /**
432
+ * One user turn: the reply, the tools called, and the decision it stopped on
433
+ * when it waits for a person. With `decide` (approve or deny) a tool approval
434
+ * is answered (`POST /v1/chat/approvals/:approvalId`) and the run's stream
435
+ * read on from where it stopped (`GET /v1/chat/:runId/stream?startIndex=N`),
436
+ * at most five times in a turn.
437
+ */
438
+ export async function sendTurn(api: Api, conversationId: string, agentName: string, text: string, decide?: 'approve' | 'deny'): Promise<{ reply: string; toolCalls: ToolCall[]; pending?: PendingDecision }> {
439
+ const signal = AbortSignal.timeout(TURN_TIMEOUT_MS)
186
440
  const res = await fetch(new URL('/v1/chat', api.serviceUrl), {
187
441
  method: 'POST',
188
- headers: {
189
- authorization: `Bearer ${api.creds.session}`,
190
- 'x-org-id': api.creds.organizationId,
191
- 'x-workspace-id': api.creds.workspaceId,
192
- 'content-type': 'application/json',
193
- },
442
+ headers: chatHeaders(api),
194
443
  body: JSON.stringify({
195
444
  conversationId,
196
445
  agentName,
197
446
  messages: [{ id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text }] }],
198
447
  }),
199
448
  // A reply that never finishes (the stream's keepalives hold it open) fails the case, not the whole run.
200
- signal: AbortSignal.timeout(TURN_TIMEOUT_MS),
449
+ signal,
201
450
  })
202
- if (!res.ok || !res.body) throw new Error(`chat answered ${res.status}: ${(await res.text()).slice(0, 200)}`)
203
- let reply = ''
204
- let buffer = ''
205
- const calls = new Map<string, ToolCall>()
206
- const decoder = new TextDecoder()
207
- for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
208
- buffer += decoder.decode(chunk, { stream: true })
209
- let newline: number
210
- while ((newline = buffer.indexOf('\n')) >= 0) {
211
- const line = buffer.slice(0, newline).trim()
212
- buffer = buffer.slice(newline + 1)
213
- if (!line.startsWith('data:')) continue
214
- const payload = line.slice(5).trim()
215
- if (payload === '[DONE]') continue
216
- try {
217
- const event = JSON.parse(payload) as { type?: string; delta?: string; errorText?: string; toolCallId?: string; toolName?: string; input?: unknown }
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
221
- if (event.type === 'error') throw new Error(event.errorText ?? 'the turn failed')
222
- } catch (err) {
223
- if (err instanceof SyntaxError) continue
224
- throw err
225
- }
226
- }
451
+ if (!res.ok || !res.body) {
452
+ const body = await res.text()
453
+ // The conversation still waits on a decision from an earlier turn.
454
+ if (res.status === 409 && /decision_pending/.test(body)) throw new Error('the conversation waits for a decision from an earlier turn; give the case `decision: approve` or `deny`')
455
+ throw new Error(`chat answered ${res.status}: ${body.slice(0, 200)}`)
456
+ }
457
+ const runId = res.headers.get('x-workflow-run-id')
458
+ const into = { reply: '', calls: new Map<string, ToolCall>(), events: 0, seen: new Set<string>() }
459
+ let pending = await readTurn(res.body, into)
460
+ for (let n = 0; pending && decide && pending.approvalId && runId && n < 5; n++) {
461
+ const decided = await fetch(new URL(`/v1/chat/approvals/${encodeURIComponent(pending.approvalId)}`, api.serviceUrl), { method: 'POST', headers: chatHeaders(api), body: JSON.stringify({ decision: decide }), signal })
462
+ if (!decided.ok) throw new Error(`deciding ${pending.tool} answered ${decided.status}: ${(await decided.text()).slice(0, 200)}`)
463
+ // Decided: no longer waiting. Approved, it runs (and its output marks it done); denied, it never runs.
464
+ const waiting = pending.toolCallId ? into.calls.get(pending.toolCallId) : undefined
465
+ if (waiting) waiting.parked = false
466
+ const resumed = await fetch(new URL(`/v1/chat/${encodeURIComponent(runId)}/stream?startIndex=${into.events}`, api.serviceUrl), { headers: chatHeaders(api), signal })
467
+ if (!resumed.ok || !resumed.body) throw new Error(`reading on after ${pending.tool} answered ${resumed.status}`)
468
+ pending = await readTurn(resumed.body, into)
469
+ }
470
+ return { reply: into.reply, toolCalls: [...into.calls.values()], ...(pending ? { pending } : {}) }
471
+ }
472
+
473
+ /**
474
+ * Whether a case runs real actions: it says so, or it answers a decision, which
475
+ * only a real action raises (a simulated one answers an empty success).
476
+ */
477
+ export function usesRealActions(c: Pick<TestCase, 'realActions' | 'decision'>): boolean {
478
+ return c.realActions === true || c.decision === 'approve' || c.decision === 'deny' || c.decision === 'expect'
479
+ }
480
+
481
+ /**
482
+ * Switch a test conversation to real actions before its first message, the
483
+ * call `agent_chat` with realActions makes. `agentId` is the Agent's own id,
484
+ * not its preview copy's. Sent only for such a case: the call without
485
+ * `simulateActions` means real actions.
486
+ */
487
+ export async function useRealActions(api: Api, agentId: string, conversationId: string, slug: string, fetcher: typeof fetch = fetch): Promise<void> {
488
+ return setUpConversation(api, agentId, conversationId, slug, { realActions: true }, fetcher)
489
+ }
490
+
491
+ /**
492
+ * Set a test conversation up before its first message, in ONE call (the
493
+ * runtime refuses a second): real actions or simulated, and the case's
494
+ * variables. Leaving `simulateActions` out means real actions, so a case
495
+ * with variables and simulated actions says `simulateActions: true`. Nothing
496
+ * is sent for a simulated case without variables.
497
+ */
498
+ export async function setUpConversation(
499
+ api: Api,
500
+ agentId: string,
501
+ conversationId: string,
502
+ slug: string,
503
+ setup: { realActions: boolean; variables?: Record<string, unknown> },
504
+ fetcher: typeof fetch = fetch,
505
+ ): Promise<void> {
506
+ const variables = setup.variables && Object.keys(setup.variables).length ? setup.variables : undefined
507
+ if (!setup.realActions && !variables) return
508
+ const res = await fetcher(new URL(`/v1/config/agents/${encodeURIComponent(agentId)}/try/conversations/${encodeURIComponent(conversationId)}`, api.serviceUrl), {
509
+ method: 'PUT',
510
+ headers: { authorization: `Bearer ${api.creds.session}`, 'x-org-id': api.creds.organizationId, 'x-workspace-id': api.creds.workspaceId, 'content-type': 'application/json' },
511
+ body: JSON.stringify({ ...(variables ? { variables } : {}), simulateActions: !setup.realActions }),
512
+ signal: AbortSignal.timeout(60_000),
513
+ })
514
+ if (res.ok) return
515
+ const text = (await res.text()).replace(/\s+/g, ' ').slice(0, 240)
516
+ const what = [setup.realActions ? 'real actions (it has `decision` or `realActions: true`)' : '', variables ? `the variables ${Object.keys(variables).join(', ')}` : ''].filter(Boolean).join(' and ')
517
+ if (res.status === 400) throw new Error(`this case sets up ${what}, which agents/${slug} refused here: ${text}`)
518
+ throw new Error(`the conversation could not be set up with ${what}: ${res.status} ${text}`)
519
+ }
520
+
521
+ /**
522
+ * Why an Agent's draft on the runtime is not what its files say, or null when
523
+ * it is: a file of it (agent.json, operating.json, bindings.json,
524
+ * lessons.yaml) changed since `local dev` applied it, never applied, or did not
525
+ * apply. Read from the build record (`.frontera/local/build.json`).
526
+ */
527
+ export function agentFilesProblem(project: LocalProject, organizationId: string | null, slug: string): string | null {
528
+ const targets = allTargets(project.root).filter((t) => (t.kind === 'agent' || t.kind === 'operating' || t.kind === 'bindings' || t.kind === 'lessons') && t.slug === slug)
529
+ // An Agent the project has no files for (made in Studio only) is tested as the runtime has it.
530
+ if (!targets.length) return null
531
+ const build = loadBuild(project, organizationId)
532
+ const again = 'run `frontera local dev --once` (or keep `frontera local dev` running), then test again'
533
+ for (const t of targets) {
534
+ const file = (t as { file: string }).file
535
+ const entry = build[t.key]
536
+ if (!entry) return `${file} is not applied to the local runtime yet: ${again}`
537
+ if (entry.status === 'failed' || entry.status === 'blocked') return `${file} did not apply (${entry.status}${entry.error ? `: ${entry.error}` : ''}), so the Agent's draft is not what its files say: fix it, ${again}`
538
+ if (entry.hash !== contentHash(project, t)) return `${file} changed since \`local dev\` applied it, so the Agent's draft is not what its files say: ${again}`
539
+ }
540
+ return null
541
+ }
542
+
543
+ /** The Agent's config id and the name a conversation addresses: its draft's preview, or the live version. */
544
+ export async function chatTarget(api: Api, slug: string, live: boolean): Promise<{ agentId: string; agentName: string }> {
545
+ const agent = await call<{ id: string }>(api, `/v1/config/agents/${encodeURIComponent(slug)}`)
546
+ if (live) return { agentId: agent.id, agentName: slug }
547
+ const preview = await call<{ previewAgentSlug: string }>(api, `/v1/config/agents/${agent.id}/test-session`, { body: {} })
548
+ return { agentId: agent.id, agentName: preview.previewAgentSlug }
549
+ }
550
+
551
+ /** Agent cases run at once by default: modest, so a provider account's limit on requests in flight holds. */
552
+ export const DEFAULT_CASE_CONCURRENCY = 2
553
+ /** How long a case waits before its one retry after the provider refused it for its limits. */
554
+ export const PROVIDER_RETRY_DELAY_MS = 8_000
555
+
556
+ /**
557
+ * Whether a failure is the model provider refusing for its limits rather than
558
+ * the Agent failing the case: a 402 or 429 about credits, rate, quota or the
559
+ * requests in flight ("This request would exceed your available credits given
560
+ * your current in-flight requests").
561
+ */
562
+ export function isProviderLimit(message: string): boolean {
563
+ return /\b(402|429)\b/.test(message) && /credit|in-flight|in flight|rate.?limit|too many requests|quota|overloaded/i.test(message)
564
+ || /\b(rate limit(ed)?|too many requests)\b/i.test(message) && /provider|model|request/i.test(message)
565
+ }
566
+
567
+ /** `--concurrency` as given: a whole number from 1 to 16, else a usage error naming it. Undefined when not given. */
568
+ export function caseConcurrency(raw: string | number | undefined): number | undefined {
569
+ if (raw === undefined || raw === '') return undefined
570
+ const n = Number(raw)
571
+ if (!Number.isInteger(n) || n < 1 || n > 16) throw new Error('--concurrency takes a whole number from 1 to 16, e.g. `--concurrency 1` to run Agent cases one at a time')
572
+ return n
573
+ }
574
+
575
+ /** Run `work` over `items`, at most `limit` at a time, keeping each result at its item's place. */
576
+ async function pool<T, R>(items: readonly T[], limit: number, work: (item: T, index: number) => Promise<R>): Promise<R[]> {
577
+ const out: R[] = new Array(items.length)
578
+ let next = 0
579
+ const lane = async () => {
580
+ for (let i = next++; i < items.length; i = next++) out[i] = await work(items[i]!, i)
581
+ }
582
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, lane))
583
+ return out
584
+ }
585
+
586
+ /** One at a time, in the order asked: cases that change records for real never run alongside each other. */
587
+ function serial(): <R>(work: () => Promise<R>) => Promise<R> {
588
+ let tail: Promise<unknown> = Promise.resolve()
589
+ return <R>(work: () => Promise<R>) => {
590
+ const run = tail.then(work, work)
591
+ tail = run.catch(() => {})
592
+ return run
227
593
  }
228
- return { reply, toolCalls: [...calls.values()] }
229
594
  }
230
595
 
231
596
  export async function runCases(args: {
@@ -234,59 +599,180 @@ export async function runCases(args: {
234
599
  files: CaseFile[]
235
600
  live: boolean
236
601
  onResult: (r: CaseResult) => void
602
+ /**
603
+ * Why an Agent's draft is not what its files say (`agentFilesProblem`), or
604
+ * null when it is: its cases fail with that instead of testing stale files.
605
+ * Not asked with `live`, which tests the published version.
606
+ */
607
+ staleDraft?: (slug: string) => string | null
608
+ /**
609
+ * How many conversation cases run at once (default `DEFAULT_CASE_CONCURRENCY`).
610
+ * Cases that change records for real (`realActions`, a `decision`) still run
611
+ * one at a time, so two never write the same records together.
612
+ */
613
+ concurrency?: number
614
+ /** The pause before a case's one retry after a provider limit (default `PROVIDER_RETRY_DELAY_MS`). */
615
+ retryDelayMs?: number
616
+ /** How a pause is waited out (tests pass a quick one). */
617
+ wait?: (ms: number) => Promise<void>
618
+ /** One conversation case, sent to the Agent (tests pass a stand-in). */
619
+ runOne?: (api: Api, target: CaseTarget, c: TestCase, opts?: ConversationCaseOptions) => Promise<CaseResult>
620
+ /** The Agent a file's cases talk to (`chatTarget`; tests pass a stand-in). */
621
+ target?: (api: Api, slug: string, live: boolean) => Promise<{ agentId: string; agentName: string }>
237
622
  }): Promise<CaseResult[]> {
238
623
  const api: Api = { serviceUrl: args.serviceUrl, creds: args.creds }
239
624
  const results: CaseResult[] = []
240
- for (const all of args.files) {
625
+ const queued: Array<{ target: CaseTarget; c: TestCase }> = []
626
+ // A credit refusal names a model the runtime offers directly from another provider whose key it holds
627
+ // (the providers the runtime applied keys for; never a key), and how to make it the default.
628
+ const told = (r: CaseResult): CaseResult => (r.notRun?.kind === 'credits' ? { ...r, notRun: withDirectModel(r.notRun, directModelFor(args.creds.modelProviders, r.notRun.provider)) } : r)
629
+ for (const whole of args.files) {
630
+ if (whole.problem) {
631
+ const r: CaseResult = { agent: whole.agent, file: whole.file, name: '(file)', passed: false, reply: '', reason: whole.problem, ms: 0 }
632
+ results.push(r)
633
+ args.onResult(r)
634
+ continue
635
+ }
636
+ // A case that breaks the format fails with where, and is not sent to the Agent.
637
+ for (const c of whole.cases.filter((c) => c.problems?.length)) {
638
+ const r: CaseResult = { agent: whole.agent, file: whole.file, name: c.name, passed: false, reply: '', reason: c.problems!.join('; '), ms: 0 }
639
+ results.push(r)
640
+ args.onResult(r)
641
+ }
642
+ const all = { ...whole, cases: whole.cases.filter((c) => !c.problems?.length) }
643
+ if (!all.cases.length) continue
644
+ // A draft that is not what the files say fails every case with why, scenarios too.
645
+ const stale = args.live ? null : (args.staleDraft?.(all.agent) ?? null)
241
646
  // 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) }
647
+ const scenarios = stale ? [] : all.cases.filter((c) => c.scenario)
648
+ const file = { ...all, cases: stale ? all.cases : all.cases.filter((c) => !c.scenario) }
244
649
  if (scenarios.length) {
245
- for (const r of await runScenarios(api, all, scenarios, args.live)) {
650
+ for (const r of (await runScenarios(api, all, scenarios, args.live)).map(told)) {
246
651
  results.push(r)
247
652
  args.onResult(r)
248
653
  }
249
654
  }
250
655
  let agentName = file.agent
251
656
  let agentId: string | undefined
252
- let setupError: string | undefined
253
- try {
254
- const agent = await call<{ id: string }>(api, `/v1/config/agents/${encodeURIComponent(file.agent)}`)
255
- agentId = agent.id
256
- if (!args.live) {
257
- const preview = await call<{ previewAgentSlug: string }>(api, `/v1/config/agents/${agent.id}/test-session`, { body: {} })
258
- agentName = preview.previewAgentSlug
259
- }
260
- } catch (err) {
261
- // An Agent that is missing or whose draft cannot be tried fails its cases, not the run.
262
- setupError = (err as Error).message
263
- }
264
- for (const c of file.cases) {
265
- const started = Date.now()
266
- let result: CaseResult
267
- let conversationId: string | undefined
657
+ let setupError: string | undefined = stale ?? undefined
658
+ if (!setupError && file.cases.length) {
268
659
  try {
269
- if (setupError) throw new Error(setupError)
270
- const session = await call<{ id: string }>(api, '/v1/sessions', { body: { agentName, workspaceId: args.creds.workspaceId } })
271
- conversationId = session.id
272
- let reply = ''
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)
279
- }
280
- const verdict = judge(c, reply, toolCalls)
281
- result = { agent: file.agent, file: file.file, name: c.name, passed: verdict.passed, reply, reason: verdict.reason, ms: Date.now() - started, agentId, conversationId }
660
+ const target = await (args.target ?? chatTarget)(api, file.agent, args.live)
661
+ agentId = target.agentId
662
+ agentName = target.agentName
282
663
  } catch (err) {
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 }
664
+ // An Agent that is missing or whose draft cannot be tried fails its cases, not the run.
665
+ setupError = (err as Error).message
284
666
  }
285
- results.push(result)
286
- args.onResult(result)
287
667
  }
668
+ const target: CaseTarget = { agent: file.agent, file: file.file, agentName, ...(agentId ? { agentId } : {}), ...(setupError ? { setupError } : {}), workspaceId: args.creds.workspaceId }
669
+ for (const c of file.cases) queued.push({ target, c })
670
+ }
671
+ // The conversation cases, a few at a time; each retried once, after a pause, when the provider refused it for its limits.
672
+ const runOne = args.runOne ?? runConversationCase
673
+ const wait = args.wait ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
674
+ const delay = args.retryDelayMs ?? PROVIDER_RETRY_DELAY_MS
675
+ const writes = serial()
676
+ // A refusal every later case would meet too (the provider account's credit or key, a runtime that
677
+ // does not answer): the cases not sent yet are not sent, and say why, instead of each failing alike.
678
+ let stopped: { notRun: CaseNotRun; by: string } | null = null
679
+ const once = async ({ target, c }: { target: CaseTarget; c: TestCase }): Promise<CaseResult> => {
680
+ if (stopped) return { agent: target.agent, file: target.file, name: c.name, passed: false, reply: '', reason: `not run: ${stopped.notRun.what} at ${stopped.by}, so the cases after it were not sent`, ms: 0, notRun: { ...stopped.notRun, skipped: true } }
681
+ const attempt = () => (usesRealActions(c) ? writes(() => runOne(api, target, c, { wait })) : runOne(api, target, c, { wait }))
682
+ const first = await attempt()
683
+ if (first.passed || !first.reason || !isProviderLimit(first.reason)) return first
684
+ await wait(delay)
685
+ const second = await attempt()
686
+ const said = first.reason.replace(/\s+/g, ' ').slice(0, 200)
687
+ return { ...second, retried: said, ...(second.passed ? {} : { reason: `${second.reason ?? 'failed'} (run twice: the provider refused the first try for its limits: ${said})` }) }
688
+ }
689
+ const ran = await pool(queued, Math.max(1, Math.floor(args.concurrency ?? DEFAULT_CASE_CONCURRENCY)), async (q) => {
690
+ const r = told(await once(q))
691
+ if (!stopped && r.notRun && !r.notRun.skipped && stopsTheRun(r.notRun)) stopped = { notRun: r.notRun, by: `${r.agent} · ${r.name}` }
692
+ args.onResult(r)
693
+ return r
694
+ })
695
+ return [...results, ...ran]
696
+ }
697
+
698
+ /** Where a file's conversation cases go: the Agent, the name a conversation addresses, and why they can't run when they can't. */
699
+ export interface CaseTarget {
700
+ agent: string
701
+ file: string
702
+ agentName: string
703
+ agentId?: string
704
+ setupError?: string
705
+ workspaceId: string
706
+ }
707
+
708
+ export interface ConversationCaseOptions {
709
+ /** How a turn's `waitSeconds` is waited out (tests pass a quick one). */
710
+ wait?: (ms: number) => Promise<void>
711
+ }
712
+
713
+ /** A turn's `waitSeconds` as the runner keeps it: a whole number of seconds from 0 to `MAX_TURN_WAIT_SECONDS`. */
714
+ export function turnWaitSeconds(turn: CaseTurn): number {
715
+ const n = Number(turn.waitSeconds)
716
+ return Number.isFinite(n) && n > 0 ? Math.min(MAX_TURN_WAIT_SECONDS, Math.floor(n)) : 0
717
+ }
718
+
719
+ /** What a case's waits say in a run's output: `waited 60 s before turn 2`. */
720
+ export function waitedWords(waited: CaseResult['waited']): string {
721
+ return waited?.length ? `waited ${waited.map((w) => `${w.seconds} s before turn ${w.turn}`).join(', ')}` : ''
722
+ }
723
+
724
+ /** One conversation case: a fresh conversation, its turns, its decision, and the verdict. */
725
+ export async function runConversationCase(api: Api, target: CaseTarget, c: TestCase, opts: ConversationCaseOptions = {}): Promise<CaseResult> {
726
+ const started = Date.now()
727
+ const wait = opts.wait ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
728
+ const gaps: NonNullable<CaseResult['waited']> = []
729
+ const waitedField = () => (gaps.length ? { waited: gaps } : {})
730
+ const { agentId } = target
731
+ let conversationId: string | undefined
732
+ try {
733
+ if (target.setupError) throw new Error(target.setupError)
734
+ const session = await call<{ id: string }>(api, '/v1/sessions', { body: { agentName: target.agentName, workspaceId: target.workspaceId } })
735
+ conversationId = session.id
736
+ // Before the first message; never sent for a simulated case (without the flag it means real).
737
+ // One setup call: real actions and the case's variables together.
738
+ if (agentId) await setUpConversation(api, agentId, session.id, target.agent, { realActions: usesRealActions(c), ...(isRecord(c.variables) ? { variables: c.variables } : {}) })
739
+ let reply = ''
740
+ const toolCalls: ToolCall[] = []
741
+ let lastTurnCalls: ToolCall[] = []
742
+ let waited: PendingDecision | undefined
743
+ const decide = c.decision === 'approve' || c.decision === 'deny' ? c.decision : undefined
744
+ let userTurn = 0
745
+ for (const turn of c.conversation ?? []) {
746
+ if (typeof turn.user !== 'string') continue
747
+ userTurn++
748
+ // A gap the case keeps before this turn (`waitSeconds`), at most five minutes.
749
+ const seconds = turnWaitSeconds(turn)
750
+ if (seconds) {
751
+ await wait(seconds * 1000)
752
+ gaps.push({ turn: userTurn, seconds })
753
+ }
754
+ const answer = await sendTurn(api, session.id, target.agentName, turn.user, decide)
755
+ reply = answer.reply
756
+ toolCalls.push(...answer.toolCalls)
757
+ lastTurnCalls = answer.toolCalls
758
+ // A turn parked on a decision ends the case there: a later turn would be refused.
759
+ if (answer.pending) {
760
+ waited = answer.pending
761
+ break
762
+ }
763
+ }
764
+ // Waiting for a person is a failure unless the case expects it (`decision: expect`).
765
+ const verdict = waited && c.decision !== 'expect'
766
+ ? { passed: false, reason: waited.approvalId ? `turn waits on an approval for ${waited.tool}; decide it (\`decision: approve\` or \`deny\`) or mark the case \`decision: expect\`` : `turn waits on ${waited.tool}; mark the case \`decision: expect\`` }
767
+ : !waited && c.decision === 'expect'
768
+ ? { passed: false, reason: 'the case expects a decision, and no turn waited for one' }
769
+ : judge(c, reply, toolCalls, lastTurnCalls)
770
+ return { agent: target.agent, file: target.file, name: c.name, passed: verdict.passed, reply, reason: verdict.reason, ms: Date.now() - started, startedAt: new Date(started).toISOString(), agentId, conversationId, toolCalls: toolCalls.length, ...waitedField() }
771
+ } catch (err) {
772
+ const reason = (err as Error).message
773
+ const notRun = caseCouldNotRun(reason)
774
+ return { agent: target.agent, file: target.file, name: c.name, passed: false, reply: '', reason, ms: Date.now() - started, startedAt: new Date(started).toISOString(), agentId, conversationId, ...waitedField(), ...(notRun ? { notRun } : {}) }
288
775
  }
289
- return results
290
776
  }
291
777
 
292
778
  const SCENARIO_POLL_MS = 3_000
@@ -309,7 +795,10 @@ export function scenarioDataset(file: CaseFile): string {
309
795
  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
796
  const started = Date.now()
311
797
  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 }))
798
+ const fail = (reason: string, agentId?: string): CaseResult[] => {
799
+ const notRun = caseCouldNotRun(reason)
800
+ return cases.map((c) => ({ agent: file.agent, file: file.file, name: c.name, passed: false, reply: '', reason, ms: Date.now() - started, agentId, ...(notRun ? { notRun } : {}) }))
801
+ }
313
802
  let agentId: string | undefined
314
803
  try {
315
804
  const agent = await call<{ id: string }>(api, `/v1/config/agents/${encodeURIComponent(file.agent)}`)
@@ -341,7 +830,9 @@ export async function runScenarios(api: Api, file: CaseFile, cases: TestCase[],
341
830
  const reply = typeof at?.output === 'string' ? at.output : ''
342
831
  const passed = outcome === 'pass'
343
832
  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 }
833
+ // An item the provider refused never ran: its error says so.
834
+ const notRun = !passed && at?.error ? caseCouldNotRun(at.error) : null
835
+ return { agent: file.agent, file: file.file, name: c.name, passed, reply, ...(reason ? { reason } : {}), ms: Date.now() - started, agentId, ...(notRun ? { notRun } : {}) }
345
836
  })
346
837
  } catch (err) {
347
838
  return fail((err as Error).message, agentId)