primitive-admin 1.2.0-alpha.2 → 1.2.0-alpha.4

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 (57) hide show
  1. package/dist/src/commands/agent-sessions.js +4 -0
  2. package/dist/src/commands/agent-sessions.js.map +1 -1
  3. package/dist/src/commands/databases.js +13 -38
  4. package/dist/src/commands/databases.js.map +1 -1
  5. package/dist/src/commands/documents.d.ts +1 -1
  6. package/dist/src/commands/documents.js +70 -33
  7. package/dist/src/commands/documents.js.map +1 -1
  8. package/dist/src/commands/prompts.d.ts +7 -0
  9. package/dist/src/commands/prompts.js +44 -0
  10. package/dist/src/commands/prompts.js.map +1 -1
  11. package/dist/src/commands/sync.d.ts +12 -29
  12. package/dist/src/commands/sync.js +128 -138
  13. package/dist/src/commands/sync.js.map +1 -1
  14. package/dist/src/lib/aggregate-output.d.ts +25 -0
  15. package/dist/src/lib/aggregate-output.js +53 -0
  16. package/dist/src/lib/aggregate-output.js.map +1 -0
  17. package/dist/src/lib/api-client.d.ts +29 -16
  18. package/dist/src/lib/api-client.js +30 -28
  19. package/dist/src/lib/api-client.js.map +1 -1
  20. package/dist/src/lib/config-object-descriptor.js +14 -5
  21. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  22. package/dist/src/lib/db-codegen/dbGenerator.js +5 -16
  23. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
  24. package/dist/src/lib/db-codegen/dbNaming.d.ts +3 -12
  25. package/dist/src/lib/db-codegen/dbNaming.js +3 -16
  26. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
  27. package/dist/src/lib/db-codegen/dbTemplates.d.ts +7 -56
  28. package/dist/src/lib/db-codegen/dbTemplates.js +25 -125
  29. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  30. package/dist/src/lib/db-codegen/dbTypeIR.d.ts +1 -1
  31. package/dist/src/lib/function-sync.d.ts +3 -16
  32. package/dist/src/lib/function-sync.js +14 -18
  33. package/dist/src/lib/function-sync.js.map +1 -1
  34. package/dist/src/lib/generated-config-surfaces.d.ts +172 -47
  35. package/dist/src/lib/generated-config-surfaces.js +599 -90
  36. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  37. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  38. package/dist/src/lib/generated-sdk-types.js +1 -1
  39. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  40. package/dist/src/lib/prompt-schema-codegen.js +5 -2
  41. package/dist/src/lib/prompt-schema-codegen.js.map +1 -1
  42. package/dist/src/lib/swift-codegen/dbGenerator.d.ts +4 -3
  43. package/dist/src/lib/swift-codegen/dbGenerator.js +14 -117
  44. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -1
  45. package/dist/src/lib/swift-codegen/generator.d.ts +5 -5
  46. package/dist/src/lib/swift-codegen/generator.js +8 -13
  47. package/dist/src/lib/swift-codegen/generator.js.map +1 -1
  48. package/dist/src/lib/workflow-codegen/generator.d.ts +7 -7
  49. package/dist/src/lib/workflow-codegen/generator.js +16 -23
  50. package/dist/src/lib/workflow-codegen/generator.js.map +1 -1
  51. package/dist/src/lib/workflow-codegen/invokerIR.d.ts +6 -12
  52. package/dist/src/lib/workflow-codegen/invokerIR.js +4 -5
  53. package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -1
  54. package/dist/src/lib/workflow-toml-validator.d.ts +2 -0
  55. package/dist/src/lib/workflow-toml-validator.js +13 -0
  56. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  57. package/package.json +2 -2
@@ -2386,6 +2386,21 @@ export const RETIRED_CONFIG_KEYS = {
2386
2386
  "`admin-only` (app admins; `personal-uploads` is the per-uploader " +
2387
2387
  "preset).",
2388
2388
  },
2389
+ // #4283 — #4116 made the bucket author its rule set by name and kept the
2390
+ // id spelling as a deprecated key. Its own sentence rather than the type
2391
+ // configs' shared one, which says a pulled file already spells the name:
2392
+ // a bucket file pulled before #4116 does not. The `request` half is never
2393
+ // surfaced, for the reason given at `RULE_SET_ID_RETIRED`.
2394
+ ruleSetId: {
2395
+ toml: "`ruleSetId` is no longer authored in TOML (#4283). A rule-set id " +
2396
+ "names a row in one app and resolves to nothing in another, so the " +
2397
+ "reference is authored by name: replace this line with " +
2398
+ '`ruleSetName = "<name>"`, where the name is the `name` the rule ' +
2399
+ "set's own `rule-sets/<key>.toml` declares, and push again, or " +
2400
+ "`config pull` rewrites the file.",
2401
+ request: "`ruleSetId` is the live request key on this surface; only its TOML " +
2402
+ "spelling is retired (#4283). Send the rule set's id.",
2403
+ },
2389
2404
  },
2390
2405
  // #2645 — pausing moved off `state` onto its own field, so `state` stopped
2391
2406
  // being author-writable rather than becoming a second way to say "paused".
@@ -2423,8 +2438,8 @@ export const RETIRED_CONFIG_KEYS = {
2423
2438
  },
2424
2439
  },
2425
2440
  // #3997 — keyed by each table's TOML prefix. Only `database-type-config`
2426
- // uses `[type]`, so that entry cannot reach another surface; the blob
2427
- // bucket's `[bucket] ruleSetId` is its live key and is not listed.
2441
+ // uses `[type]`, so that entry cannot reach another surface. The blob
2442
+ // bucket's `[bucket] ruleSetId` has its own entry under `bucket` (#4283).
2428
2443
  type: { ruleSetId: RULE_SET_ID_RETIRED },
2429
2444
  groupTypeConfig: { ruleSetId: RULE_SET_ID_RETIRED },
2430
2445
  collectionTypeConfig: { ruleSetId: RULE_SET_ID_RETIRED },
@@ -3336,7 +3351,8 @@ export function parseFunctionManifest(value) {
3336
3351
  // ── src/config-surface/function-retired-keys.ts ──────────────────────────
3337
3352
  /**
3338
3353
  * The runtime keys a `functions/<key>.toml` no longer decides anything with —
3339
- * #3482, project `server-functions` phase 4.
3354
+ * #3482 retired them, and #3188 (project `server-functions` phase 5) refuses
3355
+ * them.
3340
3356
  *
3341
3357
  * The sponsor settled it on 2026-09-15 (the intent, "Mode vocabulary?",
3342
3358
  * amended again): the config says NOTHING about how a function runs. A
@@ -3345,44 +3361,50 @@ export function parseFunctionManifest(value) {
3345
3361
  * and a function that must not run under one runtime tests for it in code with
3346
3362
  * `assertRuntime`.
3347
3363
  *
3348
- * ── Why these keys are accepted, not refused ──────────────────────────────
3364
+ * ── Accepted through phase 4, refused from phase 5 ────────────────────────
3349
3365
  *
3350
- * Every tree pushed since #3454 that declares a cron trigger carries
3351
- * `mode = …` ON THE ENTRY, because `cron-trigger-mode-required` demanded it;
3352
- * plenty carry a top-level `mode` or the older `durable`. Removing the keys
3353
- * from the accepted set outright would refuse those trees with an unknown-key
3354
- * error one day after the platform insisted on them, which is the opposite of
3355
- * principle 5 inside a non-breaking phase.
3366
+ * Every tree pushed since #3454 that declares a cron trigger carried
3367
+ * `mode = …` ON THE ENTRY, because `cron-trigger-mode-required` demanded it,
3368
+ * so #3482 accepted the keys and warned rather than refusing them one day
3369
+ * after the platform insisted on them. Phase 5 is the declared breaking phase
3370
+ * that ends the grace: `mode`, `durable` and a cron entry's `mode` are refused
3371
+ * with one sentence per key, naming the key, saying the caller picks the
3372
+ * runtime, and saying to delete the line. `config pull` still strips them
3373
+ * (`stripRetiredFunctionKeys` in the CLI reads this scanner), so a tree pulled
3374
+ * from a version pushed before the retirement pushes again unchanged.
3356
3375
  *
3357
- * So through phase 4 they are ACCEPTED and IGNORED, with one warning per key
3358
- * naming it and the line to delete; from phase 5 (#3188) they are refused with
3359
- * the rest of the retirement matrix. The value is not validated — there is
3360
- * nothing left to validate it against, and refusing a bad value for a key
3361
- * nobody reads would be a refusal about spelling alone.
3376
+ * One rule id, one handler: `refusedFunctionKeys` is declared `preflight` on
3377
+ * the `function` surface and the CLI's push preflight runs it before the first
3378
+ * request. The value is not validated — the key's PRESENCE is the refusal.
3362
3379
  *
3363
- * Pure: no models, no tenant context, no `env` (see `types.ts` §Purity). Both
3364
- * the CLI preflight (vendored by `cli/scripts/gen-config-surfaces.mjs`) and the
3365
- * admin config-version route read this one scanner, so the warning an author
3366
- * sees from `config push` is the sentence the API answers with.
3380
+ * Pure: no models, no tenant context, no `env` (see `types.ts` §Purity). The
3381
+ * CLI preflight reaches this module through the vendored copy
3382
+ * (`cli/scripts/gen-config-surfaces.mjs`), so the sentence an author reads from
3383
+ * `config push` is the one this module writes.
3367
3384
  */
3368
3385
  /** The two `[function]` keys that no longer say anything (#3482). */
3369
3386
  export const RETIRED_FUNCTION_RUNTIME_KEYS = ["mode", "durable"];
3387
+ /**
3388
+ * The rule ids this module owns, declared on the `function` surface (#3374's
3389
+ * anchor). One id covers the three spellings: they are one rule — the config
3390
+ * does not choose the runtime — met in three places.
3391
+ */
3392
+ export const RETIRED_RUNTIME_KEY_RULE_IDS = ["retired-runtime-key"];
3393
+ /** The one refusal constructor (#3373's anchor). */
3394
+ function refuseRuntimeKey(ruleId, path, message) {
3395
+ return { ruleId, path, message };
3396
+ }
3370
3397
  const RUNTIME_IS_THE_CALLERS = "a function is a function, and the caller picks the runtime at each call — " +
3371
3398
  "`invoke` runs it inside the request, `start` runs it as a task";
3372
- const FROM_PHASE_5 = "Remove the line; from phase 5 it is refused.";
3373
3399
  /**
3374
- * Every retired runtime key a `[function]` table still carries.
3400
+ * Every retired runtime key a `[function]` table carries.
3375
3401
  *
3376
3402
  * Reads the table the way the author wrote it: the two top-level keys, and a
3377
- * `mode` on each `[[function.triggers.cron]]` entry. A cron entry's sentence
3378
- * says the extra thing its author needs to hear — that the schedule no longer
3379
- * chooses, because every fire starts a task run (#3334's runner branch is
3380
- * gone).
3381
- *
3382
- * Order is the order an author reads their file in: the top-level keys first,
3383
- * then the cron entries in declaration order.
3403
+ * `mode` on each `[[function.triggers.cron]]` entry. Order is the order an
3404
+ * author reads their file in: the top-level keys first, then the cron entries
3405
+ * in declaration order.
3384
3406
  */
3385
- export function ignoredFunctionKeys(functionTable) {
3407
+ function retiredKeySites(functionTable) {
3386
3408
  const table = functionTable;
3387
3409
  if (!table || typeof table !== "object" || Array.isArray(table))
3388
3410
  return [];
@@ -3390,10 +3412,7 @@ export function ignoredFunctionKeys(functionTable) {
3390
3412
  for (const key of RETIRED_FUNCTION_RUNTIME_KEYS) {
3391
3413
  if (table[key] === undefined)
3392
3414
  continue;
3393
- found.push({
3394
- path: key,
3395
- message: `\`${key}\` is ignored (#3482): ${RUNTIME_IS_THE_CALLERS}. ${FROM_PHASE_5}`,
3396
- });
3415
+ found.push({ path: key, cronEntry: null, key });
3397
3416
  }
3398
3417
  const triggers = table.triggers;
3399
3418
  const rawCron = triggers && typeof triggers === "object" && !Array.isArray(triggers)
@@ -3411,19 +3430,38 @@ export function ignoredFunctionKeys(functionTable) {
3411
3430
  return;
3412
3431
  const rawName = entry.name;
3413
3432
  const name = typeof rawName === "string" && rawName ? rawName : `#${index + 1}`;
3414
- found.push({
3415
- path: `triggers.cron.${name}.mode`,
3416
- message: `\`mode\` on cron trigger '${name}' is ignored (#3482): every cron ` +
3417
- `fire starts a task run, and ${RUNTIME_IS_THE_CALLERS}. ${FROM_PHASE_5}`,
3418
- });
3433
+ found.push({ path: `triggers.cron.${name}.mode`, cronEntry: name, key: "mode" });
3419
3434
  });
3420
3435
  return found;
3421
3436
  }
3437
+ /**
3438
+ * Every retired runtime key the table carries, each as the refusal the author
3439
+ * reads. A cron entry's sentence says the extra thing its author needs to hear
3440
+ * — that the schedule no longer chooses, because every fire starts a task run.
3441
+ */
3442
+ export function refusedFunctionKeys(functionTable) {
3443
+ return retiredKeySites(functionTable).map((site) => refuseRuntimeKey("retired-runtime-key", site.path, site.cronEntry === null
3444
+ ? `\`${site.key}\` is not a key of a function: ${RUNTIME_IS_THE_CALLERS}. ` +
3445
+ "Delete the line."
3446
+ : `\`mode\` on cron trigger '${site.cronEntry}' is not a key of a ` +
3447
+ `function: every cron fire starts a task run, and ${RUNTIME_IS_THE_CALLERS}. ` +
3448
+ "Delete the line."));
3449
+ }
3450
+ /**
3451
+ * The same refusals, rendered for one file — the shape `config push` and
3452
+ * `config diff` print.
3453
+ *
3454
+ * `label` is what the reader can act on: `functions/<key>.toml` from the CLI,
3455
+ * and the function's key from the API, which has no file to name.
3456
+ */
3457
+ export function refusedFunctionKeyMessages(label, functionTable) {
3458
+ return refusedFunctionKeys(functionTable).map((refusal) => `${label}: ${refusal.message}`);
3459
+ }
3422
3460
  /**
3423
3461
  * The database types a `[function]` table's retired trigger block names —
3424
3462
  * #3483.
3425
3463
  *
3426
- * Beside `ignoredFunctionKeys` because it answers the same question about the
3464
+ * Beside `refusedFunctionKeys` because it answers the same question about the
3427
3465
  * same table, for the same reader: `config pull` writes a version's AUTHORED
3428
3466
  * bytes, and a version pushed before the removal carries the block. Without
3429
3467
  * this the pulled tree would be refused on its next push — a tree an author
@@ -3484,16 +3522,6 @@ function retiredDatabaseTriggerValue(functionTable) {
3484
3522
  }
3485
3523
  return triggers.database;
3486
3524
  }
3487
- /**
3488
- * The same scan, rendered for one file — the shape `config push` and
3489
- * `config diff` print and the admin route answers with.
3490
- *
3491
- * `label` is what the reader can act on: `functions/<key>.toml` from the CLI,
3492
- * and the function's key from the API, which has no file to name.
3493
- */
3494
- export function ignoredFunctionKeyWarnings(label, functionTable) {
3495
- return ignoredFunctionKeys(functionTable).map((ignored) => `${label}: ${ignored.message}`);
3496
- }
3497
3525
  export const EMPTY_TRIGGER_DECLARATION = {
3498
3526
  webhook: null,
3499
3527
  cron: [],
@@ -4339,6 +4367,10 @@ export const WORKFLOW_SURFACE = {
4339
4367
  // Accepted on BOTH modes — #807's deferred second PATCH is an update
4340
4368
  // call. The CLI's create-only send is a sequencing invariant owned by
4341
4369
  // `applyWorkflowBody`, not a statement about what the server accepts.
4370
+ // #3188 retired the synchronous run route this flag opted into; the
4371
+ // field still round-trips (pull, diff, push) until the workflow
4372
+ // surface itself retires, and `config push` notes a `true` as
4373
+ // ignored. Its step-kind rule went with the route.
4342
4374
  {
4343
4375
  field: "syncCallable",
4344
4376
  tomlKey: "syncCallable",
@@ -4594,8 +4626,6 @@ export const WORKFLOW_SURFACE = {
4594
4626
  workflowApplyRule("access-rule-required", "validateWorkflowIdentityConfig", "On create, a caller-mode workflow must state its `accessRule` (`\"true\"` " +
4595
4627
  "for any app member), so a locked workflow is a decision rather than a " +
4596
4628
  "default."),
4597
- workflowApplyRule("sync-callable-step-kind", "validateSyncCallableSteps", "A `syncCallable = true` workflow may not carry a step kind that only " +
4598
- "the durable runtime can execute (`delay`, `event.wait`)."),
4599
4629
  ],
4600
4630
  };
4601
4631
  const FIELD_RANGES = [
@@ -5697,6 +5727,9 @@ export const AGENT_ERROR_CODES = [
5697
5727
  "AGENT_TOOL_ARGUMENTS_INVALID",
5698
5728
  "AGENT_ROUND_TRUNCATED",
5699
5729
  "AGENT_ROUND_EMPTY",
5730
+ // #4262 — a single-round test case whose seeded history, context or
5731
+ // assertions cannot be a round of this agent (`agent-test-case.ts`).
5732
+ "AGENT_TEST_CASE_INVALID",
5700
5733
  ];
5701
5734
  /**
5702
5735
  * The platform maximums. An agent may lower `maxSteps`, `waitLimitSeconds`
@@ -5721,6 +5754,20 @@ export const AGENT_NAME = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
5721
5754
  export const AGENT_ANSWERED_BY = ["initiator", "participants"];
5722
5755
  export const AGENT_TOOL_INVOKE = ["request", "task"];
5723
5756
  export const AGENT_TOOL_HISTORY_SCOPES = ["session", "turn"];
5757
+ /**
5758
+ * The result the platform records for a call to a client tool with
5759
+ * `endsTurn = true`: the call is final when its response is committed, and
5760
+ * later rounds read this as its tool result.
5761
+ */
5762
+ export const AGENT_TERMINAL_CALL_OUTPUT = Object.freeze({
5763
+ delivered: true,
5764
+ });
5765
+ /** The schema of `AGENT_TERMINAL_CALL_OUTPUT`: a terminal client tool's output type. */
5766
+ export const AGENT_TERMINAL_CALL_OUTPUT_SCHEMA = Object.freeze({
5767
+ type: "object",
5768
+ properties: { delivered: { type: "boolean" } },
5769
+ required: ["delivered"],
5770
+ });
5724
5771
  const AGENT_TOP_LEVEL_KEYS = [
5725
5772
  "answeredBy",
5726
5773
  "maxSteps",
@@ -5742,6 +5789,7 @@ const AGENT_TOOL_KEYS = [
5742
5789
  "approval",
5743
5790
  "statusText",
5744
5791
  "historyScope",
5792
+ "endsTurn",
5745
5793
  "inputSchema",
5746
5794
  "outputSchema",
5747
5795
  ];
@@ -5870,8 +5918,22 @@ function checkAgentTool(tool, index, names, out) {
5870
5918
  out.push(agentInvalid(`${path}.invoke`, `must be one of ${AGENT_TOOL_INVOKE.join(" | ")}`));
5871
5919
  }
5872
5920
  }
5921
+ const terminal = tool.endsTurn === true;
5922
+ if (tool.endsTurn !== undefined && typeof tool.endsTurn !== "boolean") {
5923
+ out.push(agentInvalid(`${path}.endsTurn`, "must be true or false"));
5924
+ }
5925
+ if (terminal && tool.approval === true) {
5926
+ out.push(agentInvalid(`${path}.endsTurn`, "cannot be set with approval = true: a terminal call never pauses"));
5927
+ }
5873
5928
  for (const key of ["inputSchema", "outputSchema"]) {
5874
5929
  const schemaPath = `${path}.${key}`;
5930
+ if (isClient && terminal && key === "outputSchema") {
5931
+ if (tool[key] !== undefined) {
5932
+ out.push(agentInvalid(schemaPath, "is not declared on a client tool with endsTurn = true: the " +
5933
+ "platform records a terminal client call's result — remove it"));
5934
+ }
5935
+ continue;
5936
+ }
5875
5937
  if (isClient) {
5876
5938
  if (tool[key] === undefined) {
5877
5939
  out.push(agentInvalid(schemaPath, "is required on a client tool"));
@@ -6146,17 +6208,14 @@ export function agentRoundNotAgentRefusal(promptKey) {
6146
6208
  "agent rounds. Run it with ctx.prompts.run, or name an agent prompt");
6147
6209
  }
6148
6210
  /**
6149
- * The refusal of a prompt test case that involves an agent (§D1: prompt test
6150
- * cases are refused for the kind in v1; multi-turn tests are deferred).
6151
- * `subject` is a case written ON the agent, `evaluator` a case that names the
6152
- * agent as its judge.
6211
+ * The refusal of a prompt test case that names an agent as its judge (§D1: an
6212
+ * evaluator runs single-shot, which an agent never does). A case written ON an
6213
+ * agent is a single-round test since #4262 (`agent-test-case.ts`), so
6214
+ * `evaluator` is the one role left.
6153
6215
  */
6154
- export function agentTestCaseRefusal(promptKey, role) {
6155
- return agentRefusal("AGENT_TEST_CASE_NOT_SUPPORTED", role === "subject"
6156
- ? `Prompt "${promptKey}" is an agent (kind = "agent"); prompt test ` +
6157
- "cases are not supported for agents yet. Test its tools as functions"
6158
- : `Prompt "${promptKey}" is an agent (kind = "agent") and cannot be an ` +
6159
- "evaluator: an evaluator is run single-shot. Name a chat prompt");
6216
+ export function agentTestCaseRefusal(promptKey, _role = "evaluator") {
6217
+ return agentRefusal("AGENT_TEST_CASE_NOT_SUPPORTED", `Prompt "${promptKey}" is an agent (kind = "agent") and cannot be an ` +
6218
+ "evaluator: an evaluator is run single-shot. Name a chat prompt");
6160
6219
  }
6161
6220
  /** A deep copy, so a reader's defaults never reach the caller's value. */
6162
6221
  function agentCopy(value) {
@@ -6165,7 +6224,7 @@ function agentCopy(value) {
6165
6224
  /**
6166
6225
  * The declaration with every default applied: `answeredBy = "initiator"`, the
6167
6226
  * platform maximums for the limits, and per tool `invoke = "request"` (server
6168
- * tools), `approval = false` and `historyScope = "session"`.
6227
+ * tools), `approval = false`, `historyScope = "session"` and `endsTurn = false`.
6169
6228
  *
6170
6229
  * The ONLY accessor for defaults — the stored value never carries them
6171
6230
  * (DSO-3798-001). Takes a value `resolveAgentDeclaration` accepted (or `null`)
@@ -6185,6 +6244,7 @@ export function normalizeAgentDeclaration(declaration) {
6185
6244
  ...(tool.runs === "client" ? {} : { invoke: tool.invoke ?? "request" }),
6186
6245
  approval: tool.approval ?? false,
6187
6246
  historyScope: tool.historyScope ?? "session",
6247
+ endsTurn: tool.endsTurn ?? false,
6188
6248
  })),
6189
6249
  events: source.events ?? [],
6190
6250
  };
@@ -6902,6 +6962,365 @@ export function agentSchemaTranslationRefusals(input) {
6902
6962
  }
6903
6963
  return out;
6904
6964
  }
6965
+ // ── src/config-surface/agent-test-case.ts ────────────────────────────────
6966
+ /**
6967
+ * What an agent test case may say — issue #4262 (single-round prompt tests
6968
+ * from a seeded history; design-docs/agents.md §D1).
6969
+ *
6970
+ * A test case on an agent prompt carries one `[test.agent]` block: a seeded
6971
+ * `history` (JSON text), the round's turn `context`, `timezone` and `locale`,
6972
+ * and two assertions on the round's calls (`expectedToolCall`,
6973
+ * `expectedNoToolCall`). On the wire and in storage those are six flat
6974
+ * `BlockTestCase` fields, `agentHistory` … `agentExpectedNoToolCall`.
6975
+ *
6976
+ * This module is the whole FILE-DECIDABLE rule for them: the history's shape,
6977
+ * its turns, its call and result pairing, where its events sit, the
6978
+ * assertions' shapes, and — when the caller passes the agent's declaration —
6979
+ * that the event kinds and the asserted tool are declared. The admin test-case
6980
+ * routes call it, and so does the CLI's push preflight through the vendored
6981
+ * artifact, so a refusal at push and a refusal at the route cannot disagree.
6982
+ * What needs app state or a schema validator (the input, context and event
6983
+ * schemas, the send route's zone and tag rules) is `agent-test-round.ts`'s.
6984
+ *
6985
+ * Every refusal carries `AGENT_TEST_CASE_INVALID` and the caller gets every
6986
+ * offending item at once, as the declaration resolver does.
6987
+ *
6988
+ * Pure and dependency-free apart from the agent declaration's types, like
6989
+ * every module under `src/config-surface/`.
6990
+ */
6991
+ /** The six wire and stored fields, in the order the surface declares them. */
6992
+ export const AGENT_TEST_CASE_FIELDS = [
6993
+ "agentHistory",
6994
+ "agentContext",
6995
+ "agentTimezone",
6996
+ "agentLocale",
6997
+ "agentExpectedToolCall",
6998
+ "agentExpectedNoToolCall",
6999
+ ];
7000
+ function agentTestRefusal(message) {
7001
+ return { code: "AGENT_TEST_CASE_INVALID", message: `[test.agent] ${message}` };
7002
+ }
7003
+ function agentTestIsObject(value) {
7004
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7005
+ }
7006
+ function agentTestIsText(value) {
7007
+ return typeof value === "string" && value.trim() !== "";
7008
+ }
7009
+ /** `null` and `undefined` are absence; so is `""` for a text field. */
7010
+ function agentTestIsUnset(value) {
7011
+ if (value === null || value === undefined)
7012
+ return true;
7013
+ return typeof value === "string" && value.trim() === "";
7014
+ }
7015
+ /** A JSON field: the value as given, or the JSON text a sidecar carries parsed. */
7016
+ function agentTestJsonValue(value) {
7017
+ if (typeof value !== "string")
7018
+ return { ok: true, value };
7019
+ try {
7020
+ return { ok: true, value: JSON.parse(value) };
7021
+ }
7022
+ catch {
7023
+ return { ok: false };
7024
+ }
7025
+ }
7026
+ /** The names a declaration declares, or `null` when the caller passed none. */
7027
+ function agentTestDeclaredNames(declaration, given) {
7028
+ if (!given)
7029
+ return null;
7030
+ let block = declaration;
7031
+ if (typeof block === "string") {
7032
+ try {
7033
+ block = JSON.parse(block);
7034
+ }
7035
+ catch {
7036
+ block = null;
7037
+ }
7038
+ }
7039
+ const names = (list) => new Set((Array.isArray(list) ? list : [])
7040
+ .map((item) => (agentTestIsObject(item) ? item.name : undefined))
7041
+ .filter((name) => typeof name === "string"));
7042
+ const source = agentTestIsObject(block) ? block : {};
7043
+ return { tools: names(source.tools), events: names(source.events) };
7044
+ }
7045
+ const AGENT_TEST_ENTRY_KEYS = {
7046
+ user: ["role", "text"],
7047
+ assistant: ["role", "text", "reasoning", "calls"],
7048
+ tool: ["role", "callId", "output", "error"],
7049
+ event: ["role", "kind", "refersTo", "data"],
7050
+ };
7051
+ /**
7052
+ * The history's entries and every refusal of their shape. A user entry
7053
+ * starts a turn and the last one's turn is the current one; every call is
7054
+ * answered exactly once, by a tool entry that follows its assistant entry;
7055
+ * an event precedes the current turn's user entry (a live event reaches the
7056
+ * model only in a later turn's history).
7057
+ */
7058
+ function agentTestReadHistory(raw, names, out) {
7059
+ if (agentTestIsUnset(raw)) {
7060
+ out.push(agentTestRefusal("history is required on an agent case: a JSON array of user, assistant, tool and event entries, oldest first"));
7061
+ return null;
7062
+ }
7063
+ if (typeof raw !== "string") {
7064
+ out.push(agentTestRefusal("history must be JSON text (a JSON array written as one string)"));
7065
+ return null;
7066
+ }
7067
+ let parsed;
7068
+ try {
7069
+ parsed = JSON.parse(raw);
7070
+ }
7071
+ catch (error) {
7072
+ out.push(agentTestRefusal(`history is not valid JSON (${error?.message ?? "parse failed"})`));
7073
+ return null;
7074
+ }
7075
+ if (!Array.isArray(parsed)) {
7076
+ out.push(agentTestRefusal("history must be a JSON array of entries"));
7077
+ return null;
7078
+ }
7079
+ const entries = [];
7080
+ const calls = new Map();
7081
+ /** The calls of the last assistant entry, which tool entries may answer. */
7082
+ let open = new Set();
7083
+ let seenUser = false;
7084
+ const lastUser = parsed.reduce((at, entry, index) => (agentTestIsObject(entry) && entry.role === "user" ? index : at), -1);
7085
+ if (lastUser === -1) {
7086
+ out.push(agentTestRefusal("history needs at least one user entry; the last one is the round's current turn"));
7087
+ }
7088
+ parsed.forEach((entry, index) => {
7089
+ const at = `history[${index}]`;
7090
+ if (!agentTestIsObject(entry)) {
7091
+ out.push(agentTestRefusal(`${at} must be an object with a role`));
7092
+ return;
7093
+ }
7094
+ // An own-property read: a role such as "constructor" or "__proto__" must
7095
+ // not resolve to an inherited member of the table.
7096
+ const allowed = typeof entry.role === "string" && Object.prototype.hasOwnProperty.call(AGENT_TEST_ENTRY_KEYS, entry.role)
7097
+ ? AGENT_TEST_ENTRY_KEYS[entry.role]
7098
+ : undefined;
7099
+ if (!allowed) {
7100
+ out.push(agentTestRefusal(`${at}.role must be "user", "assistant", "tool" or "event"; found ${JSON.stringify(entry.role)}`));
7101
+ return;
7102
+ }
7103
+ const unknown = Object.keys(entry).filter((key) => !allowed.includes(key));
7104
+ if (unknown.length > 0) {
7105
+ out.push(agentTestRefusal(`${at} has ${unknown.map((k) => `"${k}"`).join(", ")}, which a ${entry.role} entry does not carry; allowed: ${allowed.join(", ")}`));
7106
+ }
7107
+ if (entry.role === "user") {
7108
+ if (typeof entry.text !== "string" || entry.text === "") {
7109
+ out.push(agentTestRefusal(`${at}.text must be a non-empty string`));
7110
+ return;
7111
+ }
7112
+ open = new Set();
7113
+ seenUser = true;
7114
+ entries.push({ role: "user", text: entry.text });
7115
+ return;
7116
+ }
7117
+ if (entry.role === "assistant") {
7118
+ if (!seenUser) {
7119
+ out.push(agentTestRefusal(`${at} is an assistant entry before the first user entry; a turn starts with a user entry`));
7120
+ }
7121
+ const hasText = entry.text !== undefined;
7122
+ const hasCalls = entry.calls !== undefined;
7123
+ if (hasText && typeof entry.text !== "string") {
7124
+ out.push(agentTestRefusal(`${at}.text must be a string`));
7125
+ }
7126
+ if (entry.reasoning !== undefined && typeof entry.reasoning !== "string") {
7127
+ out.push(agentTestRefusal(`${at}.reasoning must be a string`));
7128
+ }
7129
+ const read = [];
7130
+ if (hasCalls) {
7131
+ if (!Array.isArray(entry.calls) || entry.calls.length === 0) {
7132
+ out.push(agentTestRefusal(`${at}.calls must be a non-empty array of { callId, name, arguments }`));
7133
+ }
7134
+ else {
7135
+ entry.calls.forEach((call, callIndex) => {
7136
+ const callAt = `${at}.calls[${callIndex}]`;
7137
+ if (!agentTestIsObject(call) ||
7138
+ !agentTestIsText(call.callId) ||
7139
+ !agentTestIsText(call.name) ||
7140
+ !("arguments" in call) ||
7141
+ Object.keys(call).some((key) => !["callId", "name", "arguments"].includes(key))) {
7142
+ out.push(agentTestRefusal(`${callAt} must be { callId, name, arguments }`));
7143
+ return;
7144
+ }
7145
+ if (calls.has(call.callId)) {
7146
+ out.push(agentTestRefusal(`${callAt}: call id "${call.callId}" is used twice; call ids are unique across the history`));
7147
+ return;
7148
+ }
7149
+ calls.set(call.callId, { answered: false });
7150
+ read.push({ callId: call.callId, name: call.name, arguments: call.arguments });
7151
+ });
7152
+ }
7153
+ }
7154
+ if (!(typeof entry.text === "string" && entry.text !== "") && read.length === 0 && !hasCalls) {
7155
+ out.push(agentTestRefusal(`${at} needs text or calls`));
7156
+ }
7157
+ open = new Set(read.map((call) => call.callId));
7158
+ entries.push({
7159
+ role: "assistant",
7160
+ ...(typeof entry.text === "string" ? { text: entry.text } : {}),
7161
+ ...(typeof entry.reasoning === "string" ? { reasoning: entry.reasoning } : {}),
7162
+ ...(read.length > 0 ? { calls: read } : {}),
7163
+ });
7164
+ return;
7165
+ }
7166
+ if (entry.role === "tool") {
7167
+ if (!agentTestIsText(entry.callId)) {
7168
+ out.push(agentTestRefusal(`${at}.callId must name a call of an earlier assistant entry`));
7169
+ return;
7170
+ }
7171
+ const hasOutput = "output" in entry;
7172
+ const hasError = "error" in entry;
7173
+ if (hasOutput === hasError) {
7174
+ out.push(agentTestRefusal(`${at} must carry exactly one of output or error`));
7175
+ return;
7176
+ }
7177
+ const call = calls.get(entry.callId);
7178
+ if (!call) {
7179
+ out.push(agentTestRefusal(`${at} answers call "${entry.callId}", which no earlier assistant entry makes`));
7180
+ return;
7181
+ }
7182
+ if (call.answered) {
7183
+ out.push(agentTestRefusal(`${at} is a second result for call "${entry.callId}"; every call has exactly one`));
7184
+ return;
7185
+ }
7186
+ if (!open.has(entry.callId)) {
7187
+ out.push(agentTestRefusal(`${at} answers call "${entry.callId}", which is not a call of the assistant entry just before it; ` +
7188
+ "a result follows its call"));
7189
+ return;
7190
+ }
7191
+ call.answered = true;
7192
+ if (hasError) {
7193
+ const error = entry.error;
7194
+ if (!agentTestIsObject(error) || !agentTestIsText(error.code) || typeof error.message !== "string") {
7195
+ out.push(agentTestRefusal(`${at}.error must be { code, message }`));
7196
+ return;
7197
+ }
7198
+ entries.push({ role: "tool", callId: entry.callId, error: { code: error.code, message: error.message } });
7199
+ return;
7200
+ }
7201
+ entries.push({ role: "tool", callId: entry.callId, output: entry.output });
7202
+ return;
7203
+ }
7204
+ // An event.
7205
+ if (!agentTestIsText(entry.kind)) {
7206
+ out.push(agentTestRefusal(`${at}.kind must name an event kind the agent declares`));
7207
+ return;
7208
+ }
7209
+ if (names && !names.events.has(entry.kind)) {
7210
+ out.push(agentTestRefusal(`${at} has event kind "${entry.kind}", which the agent does not declare` +
7211
+ (names.events.size > 0 ? ` (declared: ${[...names.events].join(", ")})` : "")));
7212
+ }
7213
+ if (entry.refersTo !== undefined && (!agentTestIsText(entry.refersTo) || !calls.has(entry.refersTo))) {
7214
+ out.push(agentTestRefusal(`${at}.refersTo names call ${JSON.stringify(entry.refersTo)}, which no earlier assistant entry makes`));
7215
+ }
7216
+ if (lastUser !== -1 && index > lastUser) {
7217
+ out.push(agentTestRefusal(`${at} is an event after the current turn's user entry; a live event reaches the model only in a ` +
7218
+ "later turn, so place it before the last user entry"));
7219
+ }
7220
+ entries.push({
7221
+ role: "event",
7222
+ kind: entry.kind,
7223
+ ...(typeof entry.refersTo === "string" ? { refersTo: entry.refersTo } : {}),
7224
+ data: entry.data ?? null,
7225
+ });
7226
+ });
7227
+ for (const [callId, call] of calls) {
7228
+ if (!call.answered) {
7229
+ out.push(agentTestRefusal(`call "${callId}" has no result; write one tool entry for it (an unanswered call as an error, ` +
7230
+ 'e.g. { "code": "superseded", "message": "…" })'));
7231
+ }
7232
+ }
7233
+ const last = parsed[parsed.length - 1];
7234
+ if (agentTestIsObject(last) && last.role === "assistant") {
7235
+ out.push(agentTestRefusal("history ends with an assistant entry; a round starts from a user entry or a tool result"));
7236
+ }
7237
+ return entries;
7238
+ }
7239
+ function agentTestReadExpectedToolCall(raw, names, out) {
7240
+ if (agentTestIsUnset(raw))
7241
+ return null;
7242
+ const parsed = agentTestJsonValue(raw);
7243
+ const value = parsed.ok ? parsed.value : undefined;
7244
+ if (!agentTestIsObject(value) ||
7245
+ !agentTestIsText(value.name) ||
7246
+ Object.keys(value).some((key) => key !== "name" && key !== "arguments")) {
7247
+ out.push(agentTestRefusal("expectedToolCall must be { name, arguments? } (arguments is matched as a subset)"));
7248
+ return null;
7249
+ }
7250
+ if (names && !names.tools.has(value.name)) {
7251
+ out.push(agentTestRefusal(`expectedToolCall names tool "${value.name}", which the agent does not declare` +
7252
+ (names.tools.size > 0 ? ` (declared: ${[...names.tools].join(", ")})` : "")));
7253
+ }
7254
+ return { name: value.name, ...("arguments" in value ? { arguments: value.arguments } : {}) };
7255
+ }
7256
+ /**
7257
+ * The one rule: is this case's agent block something a round can be built
7258
+ * from? `{ ok: true, testCase }` carries it read (`null` on a case whose
7259
+ * prompt is not an agent). On a chat or decisions case, every agent key
7260
+ * holding a non-null value is refused by name; an explicit `null` is unset on
7261
+ * any case, which is how a full-state update clears one.
7262
+ */
7263
+ export function resolveAgentTestCase(input) {
7264
+ const { fields } = input;
7265
+ const kind = agentTestIsUnset(input.kind) ? "chat" : String(input.kind).trim();
7266
+ const refusals = [];
7267
+ if (kind !== "agent") {
7268
+ const owner = kind === "chat" || kind === "decisions" ? `a ${kind} prompt` : `a ${kind}`;
7269
+ for (const key of AGENT_TEST_CASE_FIELDS) {
7270
+ const value = fields[key];
7271
+ if (value !== null && value !== undefined) {
7272
+ refusals.push(agentTestRefusal(`${key} is set on a case of ${owner}; only an agent prompt's case carries the agent block. ` +
7273
+ "Remove it, or send it as null"));
7274
+ }
7275
+ }
7276
+ return refusals.length > 0 ? { ok: false, refusals } : { ok: true, testCase: null };
7277
+ }
7278
+ const names = agentTestDeclaredNames(input.declaration, "declaration" in input);
7279
+ const history = agentTestReadHistory(fields.agentHistory, names, refusals);
7280
+ let context = null;
7281
+ if (!agentTestIsUnset(fields.agentContext)) {
7282
+ const parsed = agentTestJsonValue(fields.agentContext);
7283
+ if (parsed.ok && agentTestIsObject(parsed.value))
7284
+ context = parsed.value;
7285
+ else
7286
+ refusals.push(agentTestRefusal("context must be a JSON object (turn.* for the round)"));
7287
+ }
7288
+ const text = (key, label, example) => {
7289
+ const value = fields[key];
7290
+ if (value === null || value === undefined)
7291
+ return null;
7292
+ if (typeof value !== "string" || value === "") {
7293
+ refusals.push(agentTestRefusal(`${label} must be ${example}`));
7294
+ return null;
7295
+ }
7296
+ return value;
7297
+ };
7298
+ const timezone = text("agentTimezone", "timezone", 'an IANA time zone name such as "America/New_York"');
7299
+ const locale = text("agentLocale", "locale", 'a BCP 47 language tag such as "en-US"');
7300
+ const expectedToolCall = agentTestReadExpectedToolCall(fields.agentExpectedToolCall, names, refusals);
7301
+ let expectedNoToolCall = false;
7302
+ const noCall = fields.agentExpectedNoToolCall;
7303
+ if (noCall !== null && noCall !== undefined) {
7304
+ if (typeof noCall !== "boolean")
7305
+ refusals.push(agentTestRefusal("expectedNoToolCall must be true or false"));
7306
+ else
7307
+ expectedNoToolCall = noCall;
7308
+ }
7309
+ if (expectedToolCall && expectedNoToolCall) {
7310
+ refusals.push(agentTestRefusal("sets both expectedToolCall and expectedNoToolCall = true; a round cannot both call a tool and call none"));
7311
+ }
7312
+ const subset = fields.expectedJsonSubset;
7313
+ if (!agentTestIsUnset(subset) && !(agentTestIsObject(subset) && Object.keys(subset).length === 0) && subset !== "{}") {
7314
+ refusals.push(agentTestRefusal("expectedJsonSubset is not used on an agent case: an agent's answer is text, and anything structured is a " +
7315
+ "tool call. Assert it with expectedToolCall"));
7316
+ }
7317
+ if (refusals.length > 0 || !history)
7318
+ return { ok: false, refusals };
7319
+ return {
7320
+ ok: true,
7321
+ testCase: { history, context, timezone, locale, expectedToolCall, expectedNoToolCall },
7322
+ };
7323
+ }
6905
7324
  // ── src/config-surface/prompt.ts ─────────────────────────────────────────
6906
7325
  /**
6907
7326
  * The `prompt` configuration object's definition (issue #2644, phase 2).
@@ -8246,8 +8665,8 @@ export const CRON_TRIGGER_SURFACE = {
8246
8665
  * halves of the access model (#1020), and the only access keys: at most one may
8247
8666
  * change per request, which is the controller's rule and stays there. The rule
8248
8667
  * set is authored by name (`ruleSetName`) and sent as `ruleSetId`, as the type
8249
- * configs do (#4116); the id spelling in TOML is deprecated. The pre-#1020
8250
- * `accessPolicy` spelling is a retired key (#3996, `retired-keys.ts`).
8668
+ * configs do (#4116). The id spelling in TOML (#4283) and the pre-#1020
8669
+ * `accessPolicy` spelling (#3996) are retired keys (`retired-keys.ts`).
8251
8670
  */
8252
8671
  const BLOB_BUCKET_HANDLER = "src/app-api/controllers/blob-buckets-controller.ts";
8253
8672
  export const BLOB_BUCKET_SURFACE = {
@@ -8303,7 +8722,7 @@ export const BLOB_BUCKET_SURFACE = {
8303
8722
  // Authored as a name, sent as an id (#4116), so a tree copied into
8304
8723
  // another app binds that app's rule set. Existence-checked against
8305
8724
  // AccessRuleSet; mutually exclusive with a preset change in the same
8306
- // request.
8725
+ // request. The TOML spelling of the id is a retired key (#4283).
8307
8726
  validation: handledBy(BLOB_BUCKET_HANDLER, "BlobBucketsController", "apply"),
8308
8727
  },
8309
8728
  ],
@@ -8353,14 +8772,6 @@ export const BLOB_BUCKET_SURFACE = {
8353
8772
  note: "The bucket key. `config pull` writes it for readability; push takes " +
8354
8773
  "the identity from the file name and attaches `bucketKey` itself.",
8355
8774
  },
8356
- ruleSetId: {
8357
- kind: "deprecated",
8358
- note: "The id spelling of the rule-set reference (#4116). An id names a " +
8359
- "rule set in one app, so a copied tree cannot resolve it; " +
8360
- "`ruleSetName` is the authored key. Still accepted on push, with " +
8361
- "an advisory line, and `config pull` rewrites the file to " +
8362
- "`ruleSetName`. Retiring it is a later change of its own.",
8363
- },
8364
8775
  },
8365
8776
  responseOnlyKeys: {},
8366
8777
  },
@@ -9219,7 +9630,7 @@ export const TRANSFORM_SURFACE = {
9219
9630
  * config version (`ServerFunctionConfig`), but it is ONE table in the file: an
9220
9631
  * author does not write a version, they write a function and push it. The
9221
9632
  * definition therefore declares the header's field surface and lists the
9222
- * version-carried keys (`entry`, `mode`, `limits`) in `tomlOnlyKeys` beside
9633
+ * version-carried keys (`entry`, `limits`) in `tomlOnlyKeys` beside
9223
9634
  * `key` — they are authored, they are not fields of the header model, and they
9224
9635
  * travel on the config-version route rather than in the header body. Declaring
9225
9636
  * a second table over `ServerFunctionConfig` at the same `tomlPath` is not
@@ -9307,6 +9718,20 @@ const TRIGGER_DECLARATION_RULES = functionRules("preflight", "src/config-surface
9307
9718
  ["cron-trigger-expression-required", "A cron entry carries a `cron` expression."],
9308
9719
  ["cron-trigger-overlap-policy", "`overlapPolicy` is `skip` or `allow`."],
9309
9720
  ]);
9721
+ /**
9722
+ * The retired runtime keys (#3188). `mode`, `durable` and a cron entry's
9723
+ * `mode` were accepted with a warning through phase 4 (#3482); phase 5 refuses
9724
+ * them, first in the preflight's order, so an author reads them in the order
9725
+ * their file carries them.
9726
+ */
9727
+ const RETIRED_RUNTIME_KEY_RULES = functionRules("preflight", "src/config-surface/function-retired-keys.ts", "refusedFunctionKeys", [
9728
+ [
9729
+ "retired-runtime-key",
9730
+ "`mode`, `durable` and a cron entry's `mode` are refused: the caller " +
9731
+ "picks the runtime at each call (`invoke` or `start`) and every cron " +
9732
+ "fire starts a task run, so the file has nothing to say. Delete the line.",
9733
+ ],
9734
+ ]);
9310
9735
  /**
9311
9736
  * The file-decidable half of the webhook `signingSecret` rule (#3320). The
9312
9737
  * SAME handler under the SAME ids is declared `apply` on the standalone
@@ -9508,24 +9933,21 @@ export const SERVER_FUNCTION_SURFACE = {
9508
9933
  },
9509
9934
  mode: {
9510
9935
  kind: "structural",
9511
- note: "RETIRED (#3482), and still ACCEPTED through phase 4. A function " +
9512
- "is a function: the CALLER picks the runtime at each call, with " +
9513
- "`invoke` running it inside the request and `start` running it as " +
9514
- "a task, cron fires always starting a task run and webhook " +
9515
- "deliveries always running as a request. A file that still " +
9516
- "carries the key pushes, with a warning naming it as ignored and " +
9517
- "every other key applied, and `config pull` removes it; from " +
9518
- "phase 5 (#3188) it is refused as an unknown key. Nothing reads " +
9519
- "it, and its value is not validated. A function that must not run " +
9520
- "under one runtime says so in code with `assertRuntime`.",
9936
+ note: "RETIRED (#3482) and REFUSED from phase 5 (#3188) by the " +
9937
+ "`retired-runtime-key` rule, whose sentence names the key and " +
9938
+ "says to delete the line. A function is a function: the CALLER " +
9939
+ "picks the runtime at each call, with `invoke` running it inside " +
9940
+ "the request and `start` running it as a task, cron fires always " +
9941
+ "starting a task run and webhook deliveries always running as a " +
9942
+ "request. Listed here so the refusal is the rule's sentence rather " +
9943
+ "than the generic unknown-key one; `config pull` removes it from a " +
9944
+ "version pushed before the retirement.",
9521
9945
  },
9522
9946
  durable: {
9523
9947
  kind: "structural",
9524
- note: "RETIRED (#3482), and still ACCEPTED through phase 4 — the same " +
9525
- "as `mode`, whose alias it was. It named one runner for the whole " +
9526
- "function, and there is no such thing any more. A file that " +
9527
- "carries it pushes with a warning, `config pull` removes it, and " +
9528
- "phase 5 refuses it.",
9948
+ note: "RETIRED (#3482) and REFUSED from phase 5 (#3188) — the same as " +
9949
+ "`mode`, whose alias it was, by the same rule. It named one runner " +
9950
+ "for the whole function, and there is no such thing any more.",
9529
9951
  },
9530
9952
  limits: {
9531
9953
  kind: "structural",
@@ -9563,11 +9985,12 @@ export const SERVER_FUNCTION_SURFACE = {
9563
9985
  tomlDocumentKeys: {},
9564
9986
  // ── Cross-field rules (#3374) ─────────────────────────────────────────────
9565
9987
  //
9566
- // Single-field refusals inside these modules (`mode`'s value, a webhook
9988
+ // Single-field refusals inside these modules (a retired `mode` key, a webhook
9567
9989
  // block's `verificationScheme`) are declared here too: this is the only
9568
9990
  // id-bearing slot until #3375 gives `ConfigField.validation` a stage, and a
9569
9991
  // module's list must equal its declarations in both directions.
9570
9992
  crossFieldRules: [
9993
+ ...RETIRED_RUNTIME_KEY_RULES,
9571
9994
  ...TRIGGER_DECLARATION_RULES,
9572
9995
  ...SIGNING_SECRET_RULES,
9573
9996
  // #3376 — after the signing-secret group, mirroring the order
@@ -9601,8 +10024,23 @@ export const SERVER_FUNCTION_SURFACE = {
9601
10024
  * WIRE shape is the parsed value. That asymmetry is the handler's, not the
9602
10025
  * definition's: `parseTestCaseToml` parses on push and the admin API
9603
10026
  * re-stringifies on store.
10027
+ *
10028
+ * #4262 — an agent prompt's case carries one more block, `[test.agent]`: the
10029
+ * seeded history its single round starts from, the round's turn context, zone
10030
+ * and tag, and two assertions on the round's calls. Six flat fields on the
10031
+ * wire, each `whenSet`, so a chat case's sidecar never grows an empty block.
10032
+ * `history` is typed `string` — JSON text in TOML, on the wire and in storage —
10033
+ * because `isEmptyForEmit` reads every array as empty for a `json` field, so a
10034
+ * `whenSet` array would never pull and would compare equal to anything.
9604
10035
  */
9605
10036
  const TEST_CASE_HANDLER = "src/admin-api.ts";
10037
+ /**
10038
+ * #4262 — the agent block's one rule, vendored: the admin routes call it on
10039
+ * create and update, the CLI's preflight on an agent prompt's sidecar. Its
10040
+ * schema half (input, context and event schemas) needs the app's agent, so the
10041
+ * stage is `apply`.
10042
+ */
10043
+ const AGENT_TEST_CASE_RULE = handledBy("src/config-surface/agent-test-case.ts", "resolveAgentTestCase", "apply");
9606
10044
  /**
9607
10045
  * ── Why this module's CLI handler is declared `apply` (#3375) ─────────────
9608
10046
  *
@@ -9704,7 +10142,72 @@ export const TEST_CASE_SURFACE = {
9704
10142
  // JSON object, deep-partial-matched against the parsed output.
9705
10143
  validation: handledBy(CLI_SYNC, "parseTestCaseToml", "apply"),
9706
10144
  },
10145
+ // #4262 — `[test.agent]`, an agent prompt's case only.
10146
+ {
10147
+ field: "agentHistory",
10148
+ tomlKey: "history",
10149
+ tomlGroup: "agent",
10150
+ // JSON text of the seeded history, stored as authored.
10151
+ type: "string",
10152
+ emit: "whenSet",
10153
+ writableOn: BOTH,
10154
+ validation: AGENT_TEST_CASE_RULE,
10155
+ },
10156
+ {
10157
+ field: "agentContext",
10158
+ tomlKey: "context",
10159
+ tomlGroup: "agent",
10160
+ // `turn.*` for the round; TOML carries the JSON text.
10161
+ type: "json",
10162
+ emit: "whenSet",
10163
+ writableOn: BOTH,
10164
+ validation: AGENT_TEST_CASE_RULE,
10165
+ },
10166
+ {
10167
+ field: "agentTimezone",
10168
+ tomlKey: "timezone",
10169
+ tomlGroup: "agent",
10170
+ type: "string",
10171
+ emit: "whenSet",
10172
+ writableOn: BOTH,
10173
+ validation: AGENT_TEST_CASE_RULE,
10174
+ },
10175
+ {
10176
+ field: "agentLocale",
10177
+ tomlKey: "locale",
10178
+ tomlGroup: "agent",
10179
+ type: "string",
10180
+ emit: "whenSet",
10181
+ writableOn: BOTH,
10182
+ validation: AGENT_TEST_CASE_RULE,
10183
+ },
10184
+ {
10185
+ field: "agentExpectedToolCall",
10186
+ tomlKey: "expectedToolCall",
10187
+ tomlGroup: "agent",
10188
+ // `{ name, arguments? }`; TOML carries the JSON text.
10189
+ type: "json",
10190
+ emit: "whenSet",
10191
+ writableOn: BOTH,
10192
+ validation: AGENT_TEST_CASE_RULE,
10193
+ },
10194
+ {
10195
+ field: "agentExpectedNoToolCall",
10196
+ tomlKey: "expectedNoToolCall",
10197
+ tomlGroup: "agent",
10198
+ type: "boolean",
10199
+ emit: "whenSet",
10200
+ writableOn: BOTH,
10201
+ validation: AGENT_TEST_CASE_RULE,
10202
+ },
9707
10203
  ],
10204
+ tomlGroups: {
10205
+ agent: {
10206
+ note: "#4262 — `[test.agent]`: the seeded history, turn context and " +
10207
+ "tool-call assertions of a single-round test on an agent prompt. " +
10208
+ "Refused on any other case.",
10209
+ },
10210
+ },
9708
10211
  notExposed: {
9709
10212
  testCaseId: {
9710
10213
  kind: "server-owned",
@@ -10561,6 +11064,12 @@ export const GENERATED_CONFIG_MODEL_FIELDS = {
10561
11064
  "evaluatorPromptId",
10562
11065
  "evaluatorConfigId",
10563
11066
  "attachments",
11067
+ "agentHistory",
11068
+ "agentContext",
11069
+ "agentTimezone",
11070
+ "agentLocale",
11071
+ "agentExpectedToolCall",
11072
+ "agentExpectedNoToolCall",
10564
11073
  "createdBy",
10565
11074
  "createdAt",
10566
11075
  "modifiedAt",