@newheap/platform-ai-chat 0.18.0 → 0.20.0

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.
@@ -1,6 +1,6 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { InjectionToken, inject, signal, Injectable, computed, DestroyRef, makeEnvironmentProviders } from '@angular/core';
3
- import { NH_ASSISTANT_UPLOAD_HEADER, ɵnhAssistantMediaTypeOf as _nhAssistantMediaTypeOf, NH_ASSISTANT_MAX_PROPOSAL_LOOKUP, NH_ASSISTANT_CONFIG, applyNhAssistantEvent, normalizeNhAssistantClientContext, applyNhAssistantApprovalDecision, NH_ASSISTANT_FETCH, NH_ASSISTANT_UPLOAD, NhAssistantLiveService, NH_ASSISTANT_MCP_AUTHORIZATION_WINDOW } from '@newheap/platform-ai-chat';
3
+ import { NH_ASSISTANT_AGENT_PACKAGE_MAX_AGENTS, NH_ASSISTANT_AGENT_PACKAGE_FORMAT_VERSION, NH_ASSISTANT_AGENT_PACKAGE_FORMAT, NH_ASSISTANT_PACKAGE_LOCK_ALL, NH_ASSISTANT_PACKAGE_LOCK_FIELDS, NH_ASSISTANT_AGENT_PACKAGE_MAX_BYTES, NH_ASSISTANT_UPLOAD_HEADER, ɵnhAssistantMediaTypeOf as _nhAssistantMediaTypeOf, NH_ASSISTANT_MAX_PROPOSAL_LOOKUP, NH_ASSISTANT_CONFIG, applyNhAssistantEvent, normalizeNhAssistantClientContext, applyNhAssistantApprovalDecision, applyNhAssistantQuestionAnswer, NH_ASSISTANT_FETCH, NH_ASSISTANT_UPLOAD, NhAssistantLiveService, NH_ASSISTANT_MCP_AUTHORIZATION_WINDOW } from '@newheap/platform-ai-chat';
4
4
  import { Observable, Subject } from 'rxjs';
5
5
 
6
6
  /**
@@ -25,6 +25,8 @@ const statusTable = {
25
25
  'assistant-run-filter-invalid',
26
26
  'assistant-board-scope-invalid',
27
27
  'assistant-feedback-invalid',
28
+ 'assistant-question-invalid',
29
+ 'assistant-question-answer-invalid',
28
30
  'assistant-trigger-filter-not-found',
29
31
  'assistant-replay-not-shadow',
30
32
  'assistant-evaluation-not-variant',
@@ -55,6 +57,7 @@ const statusTable = {
55
57
  'assistant-context-unavailable',
56
58
  'assistant-owner-required',
57
59
  'assistant-approval-forbidden',
60
+ 'assistant-question-forbidden',
58
61
  'assistant-turn-forbidden',
59
62
  'assistant-subject-access-denied',
60
63
  'assistant-approval-policy-denied',
@@ -68,7 +71,10 @@ const statusTable = {
68
71
  'assistant-disabled',
69
72
  'assistant-conversation-not-found',
70
73
  'assistant-agent-not-found',
74
+ 'assistant-metadata-entry-not-found',
75
+ 'assistant-instructions-asset-not-found',
71
76
  'assistant-approval-not-found',
77
+ 'assistant-question-not-found',
72
78
  'assistant-share-link-invalid',
73
79
  'assistant-participant-not-found',
74
80
  'assistant-directory-unavailable',
@@ -84,6 +90,8 @@ const statusTable = {
84
90
  'assistant-artifact-not-found',
85
91
  'assistant-trigger-not-found',
86
92
  'assistant-consultation-not-found',
93
+ 'assistant-handoff-not-found',
94
+ 'assistant-package-not-linked',
87
95
  'assistant-not-found',
88
96
  'assistant-context-not-found',
89
97
  'assistant-mcp-server-not-found',
@@ -107,6 +115,7 @@ const statusTable = {
107
115
  'assistant-agent-does-not-work-on',
108
116
  'assistant-moved-to-shadow',
109
117
  'assistant-shadow-variant',
118
+ 'assistant-not-shadow-variant',
110
119
  'assistant-service-account-not-used',
111
120
  'assistant-consult-route-not-allowed',
112
121
  'assistant-consult-proposal-refused',
@@ -114,6 +123,9 @@ const statusTable = {
114
123
  'assistant-consult-proposal-moved',
115
124
  'assistant-consultation-in-code',
116
125
  'assistant-consultations-not-allowed',
126
+ 'assistant-handoffs-not-allowed',
127
+ 'assistant-handoff-in-code',
128
+ 'assistant-shadow-variants-not-allowed',
117
129
  'assistant-skill-in-code',
118
130
  'assistant-trigger-in-code',
119
131
  'assistant-skill-in-use',
@@ -126,11 +138,18 @@ const statusTable = {
126
138
  'assistant-memory-conflict',
127
139
  'assistant-replay-active',
128
140
  'assistant-feedback-not-accepted',
141
+ 'assistant-question-not-pending',
142
+ 'assistant-question-expired',
143
+ 'assistant-question-cancelled',
129
144
  'assistant-trigger-rate-limited',
130
145
  'assistant-version-conflict',
131
146
  'assistant-agent-exists',
132
147
  'assistant-code-agent-not-deletable',
133
148
  'assistant-agent-not-code',
149
+ 'assistant-metadata-read-only',
150
+ 'assistant-package-refused',
151
+ 'assistant-package-locked',
152
+ 'assistant-agent-instructions-unavailable',
134
153
  'assistant-mcp-server-exists',
135
154
  'assistant-mcp-headless-not-allowed',
136
155
  'assistant-mcp-consent-required',
@@ -144,6 +163,7 @@ const statusTable = {
144
163
  ],
145
164
  413: [
146
165
  'assistant-webhook-payload-too-large',
166
+ 'assistant-package-too-large',
147
167
  'assistant-file-too-large'
148
168
  ],
149
169
  415: [
@@ -169,6 +189,7 @@ const statusTable = {
169
189
  'assistant-proposal-check-failed',
170
190
  'assistant-trigger-rolled-back',
171
191
  'assistant-mcp-secret-unavailable',
192
+ 'assistant-instructions-asset-unavailable',
172
193
  'assistant-file-unavailable',
173
194
  'assistant-file-upload-failed'
174
195
  ]
@@ -215,6 +236,22 @@ function mockFailure(code, errors) {
215
236
  function mockValidationFailure(field) {
216
237
  return mockFailure('assistant-validation', field ? { [field]: ['invalid'] } : undefined);
217
238
  }
239
+ /**
240
+ * Copies the stored state of a mock area: the returned function puts every map back as it was, so
241
+ * an agent package import that one part refuses leaves nothing behind, as the server's one
242
+ * transaction does, and a preview tries the import without keeping it.
243
+ */
244
+ function mockSnapshot(...maps) {
245
+ const copies = maps.map(map => structuredClone(map));
246
+ return () => {
247
+ maps.forEach((map, index) => {
248
+ map.clear();
249
+ for (const [key, value] of copies[index]) {
250
+ map.set(key, value);
251
+ }
252
+ });
253
+ };
254
+ }
218
255
  /** A stable, non-cryptographic fingerprint; the mock only needs change detection. */
219
256
  /**
220
257
  * The warnings of an agent or skill that classifies its model calls with `classification` on
@@ -397,8 +434,9 @@ class NhAssistantMockAdminAgents {
397
434
  const agents = admin.agents
398
435
  ?? scenario.agents.map(agent => ({ ...codeAgentFromSummary(agent), source: 'code' }));
399
436
  // How an agent runs, in code or as data, belongs to its run configuration, not to the agent.
400
- for (const { source, skills, shadowOf, shadow, serviceAccount, worksOnDeclared, knownSubjectTypes, consultations, consultationDescriptions, codeRunSettings: _, runConfiguration: __, consultableAsData: ___, dataConsultations: ____, ...input } of agents) {
401
- const agentShadow = shadowOf !== undefined || shadow === true;
437
+ for (const { source, skills, shadowOf, shadow, serviceAccount, worksOnDeclared, knownSubjectTypes, consultations, consultationDescriptions, codeRunSettings: _, runConfiguration: __, consultableAsData: ___, dataConsultations: ____, consultationTools: _____, codeHandOffs: ______, ...input } of agents) {
438
+ const variant = typeof shadowOf === 'string';
439
+ const agentShadow = variant || shadow === true;
402
440
  const initial = this.toAgent(input, source, 1, false);
403
441
  this.agents.set(input.id, {
404
442
  agent: {
@@ -407,7 +445,7 @@ class NhAssistantMockAdminAgents {
407
445
  ...(source === 'code' ? { ...(mockWorksOnFromInput(input) ?? mockUndeclaredWorksOn), worksOnDeclared: worksOnDeclared ?? mockWorksOnFromInput(input) !== null } : {}),
408
446
  knownSubjectTypes: knownSubjectTypes ?? [],
409
447
  shadowOf: shadowOf ?? null,
410
- shadow: shadowState(agentShadow, shadowOf !== undefined || shadow === true, null, shadowOf !== undefined),
448
+ shadow: shadowState(agentShadow, variant || shadow === true, null, variant),
411
449
  serviceAccount: serviceAccount === undefined
412
450
  ? null
413
451
  : { actorId: serviceAccount, configuredActorId: serviceAccount, overridden: false, changedAt: null, changedBy: null, reason: null },
@@ -425,7 +463,7 @@ class NhAssistantMockAdminAgents {
425
463
  dailyTokens: null,
426
464
  cancelledRuns: 0,
427
465
  startedRuns: 0,
428
- shadow: shadowState(agentShadow || skill.shadow === true, skill.shadow === true, null, shadowOf !== undefined)
466
+ shadow: shadowState(agentShadow || skill.shadow === true, skill.shadow === true, null, variant)
429
467
  }))
430
468
  },
431
469
  codeDefinition: source === 'code' ? structuredClone(input) : null,
@@ -439,6 +477,18 @@ class NhAssistantMockAdminAgents {
439
477
  find(agentId) {
440
478
  return this.agents.get(agentId)?.agent;
441
479
  }
480
+ /**
481
+ * A copy of the agents and their replays; the returned function puts it back, for an import of an
482
+ * agent package that fails as a whole and for its preview.
483
+ */
484
+ snapshot() {
485
+ return mockSnapshot(this.agents, this.replays);
486
+ }
487
+ /** The definition in code of an agent in code, as `reset` restores it; `null` for an agent created here. */
488
+ codeDefinition(agentId) {
489
+ const definition = this.agents.get(agentId)?.codeDefinition;
490
+ return definition ? structuredClone(definition) : null;
491
+ }
442
492
  has(agentId) {
443
493
  return this.agents.has(agentId);
444
494
  }
@@ -446,6 +496,53 @@ class NhAssistantMockAdminAgents {
446
496
  all() {
447
497
  return [...this.agents.values()].map(stored => stored.agent);
448
498
  }
499
+ /**
500
+ * Makes the next version of an agent, as a change of the metadata of an agent created here does on
501
+ * the server; nothing else of the agent changes.
502
+ */
503
+ nextVersion(agentId) {
504
+ const stored = this.agents.get(agentId);
505
+ if (!stored) {
506
+ throw new Error(`The mock has no agent '${agentId}'.`);
507
+ }
508
+ stored.agent = { ...stored.agent, version: stored.agent.version + 1, updatedAt: this.now() };
509
+ return stored.agent;
510
+ }
511
+ /**
512
+ * Copies a data shadow variant's definition onto its live agent as the live agent's next version,
513
+ * as a promotion does on the server before it deletes the variant: the live agent keeps its name,
514
+ * MCP servers, state and shadow mode; an agent in code takes only the fields it can override, an
515
+ * agent created here also the variant's profile, classification, what it works on and its asset.
516
+ */
517
+ promote(variantId, liveId) {
518
+ const variant = this.agents.get(variantId);
519
+ const live = this.agents.get(liveId);
520
+ if (!variant || !live) {
521
+ throw new Error(`The mock has no agent '${variant ? liveId : variantId}'.`);
522
+ }
523
+ const from = variant.agent;
524
+ const runtime = live.agent.source === 'admin';
525
+ const definition = {
526
+ id: live.agent.id,
527
+ displayName: live.agent.displayName,
528
+ description: from.description,
529
+ instructions: runtime && from.instructionsAsset ? '' : from.instructions,
530
+ toolSelectors: [...from.toolSelectors],
531
+ mcpServerIds: [...live.agent.mcpServerIds],
532
+ requiredPolicy: from.requiredPolicy,
533
+ autonomy: from.autonomy,
534
+ ...(runtime
535
+ ? {
536
+ ...pickWorksOn(from),
537
+ profile: from.profile,
538
+ dataClassification: from.dataClassification ?? null,
539
+ instructionsAsset: from.instructionsAsset ?? null
540
+ }
541
+ : {})
542
+ };
543
+ live.agent = this.toAgent(definition, live.agent.source, live.agent.version + 1, live.agent.source === 'code', live.agent);
544
+ return live.agent;
545
+ }
449
546
  /** Removes a deleted MCP server from the agents it was assigned to. */
450
547
  removeServer(serverId) {
451
548
  for (const agent of this.agents.values()) {
@@ -480,7 +577,7 @@ class NhAssistantMockAdminAgents {
480
577
  agentsEndpoint(method, id, action, body, length) {
481
578
  if (id === undefined) {
482
579
  if (method === 'GET') {
483
- return { status: 200, body: [...this.agents.values()].map(stored => stored.agent) };
580
+ return { status: 200, body: [...this.agents.values()].map(stored => this.withInstructions(stored.agent)) };
484
581
  }
485
582
  if (method !== 'POST') {
486
583
  return { status: 405 };
@@ -493,7 +590,21 @@ class NhAssistantMockAdminAgents {
493
590
  if (this.agents.has(input.id)) {
494
591
  return mockFailure('assistant-agent-exists');
495
592
  }
496
- const agent = this.toAgent(input, 'admin', 1, false);
593
+ const shadowOf = typeof input.shadowOf === 'string' && input.shadowOf.trim().length > 0 ? input.shadowOf.trim() : null;
594
+ // Without the application's ceiling the mock creates no data variants, as the server refuses them.
595
+ const refused = shadowOf === null
596
+ ? null
597
+ : this.rules.shadowVariantRefusal
598
+ ? this.rules.shadowVariantRefusal({ ...input, shadowOf })
599
+ : mockFailure('assistant-shadow-variants-not-allowed');
600
+ if (refused) {
601
+ return refused;
602
+ }
603
+ const created = this.toAgent(input, 'admin', 1, false);
604
+ // A data shadow variant always works in shadow and is never offered in the chat.
605
+ const agent = shadowOf === null
606
+ ? created
607
+ : { ...created, shadowOf, offeredInChat: false, shadow: shadowState(true, false, null, true) };
497
608
  this.agents.set(agent.id, { agent, codeDefinition: null, consultations: noSwitches(null) });
498
609
  return { status: 201, body: agent };
499
610
  }
@@ -549,7 +660,9 @@ class NhAssistantMockAdminAgents {
549
660
  return mockFailure('assistant-version-conflict');
550
661
  }
551
662
  const { expectedVersion, ...definition } = input;
552
- stored.agent = this.toAgent(definition, stored.agent.source, stored.agent.version + 1, stored.agent.source === 'code', stored.agent);
663
+ const updated = this.toAgent(definition, stored.agent.source, stored.agent.version + 1, stored.agent.source === 'code', stored.agent);
664
+ // A data shadow variant keeps its live agent and stays out of the chat.
665
+ stored.agent = stored.agent.source === 'admin' && stored.agent.shadowOf ? { ...updated, offeredInChat: false } : updated;
553
666
  return { status: 200, body: stored.agent };
554
667
  }
555
668
  if (method === 'DELETE') {
@@ -966,6 +1079,22 @@ class NhAssistantMockAdminAgents {
966
1079
  if (typeof input?.instructions === 'string' && input.instructions.length > mockMaxInstructions) {
967
1080
  return mockFailure('assistant-instructions-too-long');
968
1081
  }
1082
+ // Only an agent created here takes its instructions from an asset, never next to inline text.
1083
+ if (input?.instructionsAsset) {
1084
+ const errors = {};
1085
+ if (source !== 'admin' || !this.rules.instructionAssets) {
1086
+ errors['instructionsAsset'] = ['not-allowed'];
1087
+ }
1088
+ else {
1089
+ this.rules.instructionAssets().check(input.instructionsAsset, errors);
1090
+ }
1091
+ if (typeof input.instructions === 'string' && input.instructions.trim().length > 0) {
1092
+ errors['instructions'] = ['not-allowed'];
1093
+ }
1094
+ if (Object.keys(errors).length > 0) {
1095
+ return mockFailure('assistant-validation', errors);
1096
+ }
1097
+ }
969
1098
  if (!this.validAgent(input)) {
970
1099
  return mockValidationFailure();
971
1100
  }
@@ -1010,8 +1139,14 @@ class NhAssistantMockAdminAgents {
1010
1139
  */
1011
1140
  toAgent(input, source, version, isOverridden, previous) {
1012
1141
  // Only a new agent starts off, created that way here or with StartsOff() in code.
1013
- const { isEnabled, startsOff, profile, dataClassification, ...definition } = structuredClone(input);
1142
+ // Metadata is not part of the definition; its values live with the mock's agent metadata.
1143
+ const { isEnabled, startsOff, startsInShadow, profile, dataClassification, metadata, instructionsAsset, ...definition } = structuredClone(input);
1014
1144
  const createdOff = previous === undefined && startsOff === true;
1145
+ // Only a new agent created here starts in shadow, as an override that applies once it has run settings.
1146
+ const createdInShadow = previous === undefined && source === 'admin' && startsInShadow === true;
1147
+ // The agent shows the asset's text read-only, or none while it does not resolve.
1148
+ const reference = source === 'admin' && instructionsAsset ? instructionsAsset : null;
1149
+ const assetProblem = reference ? this.instructionsProblemOf(reference) : null;
1015
1150
  const chosenProfile = source === 'admin' && typeof profile === 'string' && profile.trim().length > 0 ? profile.trim() : null;
1016
1151
  const classification = chosenClassification(dataClassification);
1017
1152
  return this.withWarnings({
@@ -1026,7 +1161,10 @@ class NhAssistantMockAdminAgents {
1026
1161
  version,
1027
1162
  source,
1028
1163
  isOverridden,
1029
- instructionsHash: mockHashText(input.instructions),
1164
+ instructions: reference ? this.rules.instructionAssets?.().textOf(reference) ?? '' : definition.instructions,
1165
+ instructionsHash: reference ? reference.hash : mockHashText(input.instructions),
1166
+ instructionsAsset: reference,
1167
+ instructionsProblem: assetProblem,
1030
1168
  updatedAt: this.now(),
1031
1169
  runState: previous?.runState ?? (createdOff ? 'off' : 'active'),
1032
1170
  stateChangedAt: previous?.stateChangedAt ?? (createdOff && source === 'admin' ? this.now() : null),
@@ -1035,7 +1173,9 @@ class NhAssistantMockAdminAgents {
1035
1173
  waitingInputs: previous?.waitingInputs ?? 0,
1036
1174
  skills: previous?.skills ?? [],
1037
1175
  shadowOf: previous?.shadowOf ?? null,
1038
- shadow: previous?.shadow ?? shadowState(false, false, null, false),
1176
+ shadow: previous?.shadow ?? (createdInShadow
1177
+ ? { ...shadowState(true, false, true, false), changedAt: this.now(), changedBy: 'current-user' }
1178
+ : shadowState(false, false, null, false)),
1039
1179
  serviceAccount: previous?.serviceAccount ?? null,
1040
1180
  offBeforeStop: previous?.offBeforeStop ?? false,
1041
1181
  // Code decides for its agents; an agent created here keeps its choice when it is omitted.
@@ -1049,6 +1189,26 @@ class NhAssistantMockAdminAgents {
1049
1189
  knownSubjectTypes: previous?.knownSubjectTypes ?? []
1050
1190
  });
1051
1191
  }
1192
+ /** Why the asset an agent created here names does not resolve now, or `null` when it does. */
1193
+ instructionsProblemOf(reference) {
1194
+ const assets = this.rules.instructionAssets?.();
1195
+ return assets ? assets.problemOf(reference) : 'no-asset-source';
1196
+ }
1197
+ /**
1198
+ * The agent as a read finds it now: the text of the asset it names and the problem when that no
1199
+ * longer resolves, as the server checks it at every load.
1200
+ */
1201
+ withInstructions(agent) {
1202
+ if (!agent.instructionsAsset) {
1203
+ return agent;
1204
+ }
1205
+ const problem = this.instructionsProblemOf(agent.instructionsAsset);
1206
+ return {
1207
+ ...agent,
1208
+ instructions: problem === null ? this.rules.instructionAssets?.().textOf(agent.instructionsAsset) ?? '' : '',
1209
+ instructionsProblem: problem
1210
+ };
1211
+ }
1052
1212
  /** The agent with the warnings the server reports for it, from its profile and classification. */
1053
1213
  withWarnings(agent) {
1054
1214
  return {
@@ -2335,6 +2495,325 @@ function kebab(value) {
2335
2495
  .toLowerCase();
2336
2496
  }
2337
2497
 
2498
+ const maxHandOffs = 20;
2499
+ const maxSources = 20;
2500
+ const maxDescription$3 = 500;
2501
+ const maxPerDay = 100;
2502
+ const defaultPerDay = 5;
2503
+ const defaultVariants = 3;
2504
+ /**
2505
+ * In-memory hand-offs defined as data and shadow variants of the mock API (S9), within the scenario's
2506
+ * `handOffsAsData` and `shadowVariantsAsData` as the ceilings in code: saving checks a hand-off as the
2507
+ * server checks it on every save and load, and `GET` reports a stored hand-off that no longer fits,
2508
+ * for example after its source lost its run settings, out of effect with its problem. A data variant
2509
+ * is created with `POST admin/agents` and `shadowOf`, which `shadowVariantRefusal` checks.
2510
+ */
2511
+ class NhAssistantMockHandOffs {
2512
+ constructor(scenario, agents, now) {
2513
+ this.scenario = scenario;
2514
+ this.agents = agents;
2515
+ this.now = now;
2516
+ this.stored = new Map();
2517
+ }
2518
+ /**
2519
+ * A copy of the hand-offs defined as data; the returned function puts it back, for an import of
2520
+ * an agent package that fails as a whole and for its preview.
2521
+ */
2522
+ snapshot() {
2523
+ return mockSnapshot(this.stored);
2524
+ }
2525
+ /**
2526
+ * `GET admin/agents/{id}/hand-offs`, `PUT` and `DELETE admin/agents/{id}/hand-offs/{subjectType}`
2527
+ * and `GET admin/agents/{id}/shadow-variants`; `segments` start after the agent id. `null` for
2528
+ * another route.
2529
+ */
2530
+ handle(method, agentId, segments, body, query) {
2531
+ const [area, subjectType] = segments;
2532
+ if (area === 'shadow-variants' && segments.length === 1) {
2533
+ const agent = this.agents.find(agentId);
2534
+ if (!agent) {
2535
+ return mockFailure('assistant-agent-not-found');
2536
+ }
2537
+ return method === 'GET' ? { status: 200, body: this.variantsOf(agent) } : { status: 405 };
2538
+ }
2539
+ if (area !== 'hand-offs' || segments.length > 2) {
2540
+ return null;
2541
+ }
2542
+ const agent = this.agents.find(agentId);
2543
+ if (!agent) {
2544
+ return mockFailure('assistant-agent-not-found');
2545
+ }
2546
+ if (subjectType === undefined) {
2547
+ return method === 'GET' ? { status: 200, body: this.handOffsOf(agent) } : { status: 405 };
2548
+ }
2549
+ if (method === 'PUT') {
2550
+ return this.save(agent, subjectType, body);
2551
+ }
2552
+ if (method === 'DELETE') {
2553
+ return this.remove(agent, subjectType, query);
2554
+ }
2555
+ return { status: 405 };
2556
+ }
2557
+ /** Whether the agent accepts a hand-off defined as data, which keeps its run settings in use. */
2558
+ acceptsDataHandOffs(agentId) {
2559
+ return [...this.stored.values()].some(handOff => handOff.agentId === agentId);
2560
+ }
2561
+ /**
2562
+ * Why `input` may not create a data shadow variant, as `POST admin/agents` with `shadowOf` answers,
2563
+ * or `null`.
2564
+ */
2565
+ shadowVariantRefusal(input) {
2566
+ const liveId = input.shadowOf ?? null;
2567
+ if (liveId === null) {
2568
+ return null;
2569
+ }
2570
+ if (liveId === input.id) {
2571
+ return mockFailure('assistant-validation', { shadowOf: ['invalid'] });
2572
+ }
2573
+ const live = this.agents.find(liveId);
2574
+ if (!this.allowsVariantsOf(liveId, live)) {
2575
+ return mockFailure('assistant-shadow-variants-not-allowed');
2576
+ }
2577
+ if (!live || !this.agents.isConfigured(live)) {
2578
+ return mockFailure('assistant-validation', { shadowOf: ['not-found'] });
2579
+ }
2580
+ if (!isLive(live)) {
2581
+ return mockFailure('assistant-validation', { shadowOf: ['invalid'] });
2582
+ }
2583
+ return this.dataVariantsOf(liveId).length >= this.maxVariants()
2584
+ ? mockFailure('assistant-configuration-limit')
2585
+ : null;
2586
+ }
2587
+ handOffsOf(agent) {
2588
+ return {
2589
+ agentId: agent.id,
2590
+ codeHandOffs: this.codeHandOffsOf(agent.id),
2591
+ dataHandOffs: [...this.stored.values()]
2592
+ .filter(item => item.agentId === agent.id)
2593
+ .sort((first, second) => first.subjectType.localeCompare(second.subjectType))
2594
+ .map(({ agentId, ...item }) => {
2595
+ const problem = this.problemOf(agent, item.subjectType, item.sources, item.maxPerSubjectPerDay);
2596
+ return { ...item, inEffect: problem === null, problem };
2597
+ }),
2598
+ ceiling: this.ceilingOf(agent),
2599
+ configured: this.agents.isConfigured(agent)
2600
+ };
2601
+ }
2602
+ save(agent, subjectType, request) {
2603
+ const ceiling = this.ceilingOf(agent);
2604
+ if (!ceiling) {
2605
+ return mockFailure('assistant-handoffs-not-allowed');
2606
+ }
2607
+ if (this.codeHandOffsOf(agent.id).some(item => item.subjectType === subjectType)) {
2608
+ return mockFailure('assistant-handoff-in-code');
2609
+ }
2610
+ if (!this.agents.isConfigured(agent)) {
2611
+ return mockFailure('assistant-agent-not-configured');
2612
+ }
2613
+ const sources = Array.isArray(request?.sources) ? request.sources.map(source => String(source).trim()) : [];
2614
+ const description = typeof request?.description === 'string' && request.description.trim().length > 0 ? request.description.trim() : null;
2615
+ const limit = request?.maxPerSubjectPerDay ?? Math.min(defaultPerDay, ceiling.maxPerSubjectPerDay);
2616
+ const reason = typeof request?.reason === 'string' && request.reason.trim().length > 0 ? request.reason.trim() : null;
2617
+ const errors = {};
2618
+ if (sources.length === 0) {
2619
+ errors['sources'] = ['required'];
2620
+ }
2621
+ else if (sources.length > maxSources) {
2622
+ errors['sources'] = ['too-long'];
2623
+ }
2624
+ else if (sources.some(source => !mockDashCase.test(source)) || new Set(sources).size !== sources.length || sources.includes(agent.id)) {
2625
+ errors['sources'] = ['invalid'];
2626
+ }
2627
+ if (description !== null && description.length > maxDescription$3) {
2628
+ errors['description'] = ['too-long'];
2629
+ }
2630
+ if (!Number.isInteger(limit) || limit < 1 || limit > maxPerDay) {
2631
+ errors['maxPerSubjectPerDay'] = ['invalid'];
2632
+ }
2633
+ if (reason !== null && reason.length > mockMaxStateReason) {
2634
+ errors['reason'] = ['too-long'];
2635
+ }
2636
+ if (Object.keys(errors).length > 0) {
2637
+ return mockFailure('assistant-validation', errors);
2638
+ }
2639
+ // The same check as every load, refused here instead of left out of effect.
2640
+ const problem = this.problemOf(agent, subjectType, sources, limit);
2641
+ switch (problem) {
2642
+ case null:
2643
+ break;
2644
+ case 'outside-ceiling':
2645
+ return mockFailure('assistant-validation', ceiling.subjectTypes.some(item => item.subjectType === subjectType)
2646
+ ? { sources: ['not-allowed'] }
2647
+ : { subjectType: ['not-allowed'] });
2648
+ case 'unknown-source':
2649
+ return mockFailure('assistant-validation', { sources: ['not-found'] });
2650
+ case 'not-worked-on':
2651
+ return mockFailure('assistant-validation', { subjectType: ['invalid'] });
2652
+ case 'limit-above-ceiling':
2653
+ return mockFailure('assistant-validation', { maxPerSubjectPerDay: ['not-allowed'] });
2654
+ default:
2655
+ return mockFailure('assistant-validation', { sources: ['invalid'] });
2656
+ }
2657
+ const key = `${agent.id}/${subjectType}`;
2658
+ const existing = this.stored.get(key);
2659
+ if ((existing?.version ?? null) !== (request?.expectedVersion ?? null)) {
2660
+ return mockFailure('assistant-version-conflict');
2661
+ }
2662
+ if (!existing && [...this.stored.values()].filter(item => item.agentId === agent.id).length >= maxHandOffs) {
2663
+ return mockFailure('assistant-configuration-limit');
2664
+ }
2665
+ const now = this.now();
2666
+ const saved = {
2667
+ agentId: agent.id,
2668
+ subjectType,
2669
+ version: (existing?.version ?? 0) + 1,
2670
+ sources,
2671
+ description,
2672
+ notifySource: request?.notifySource === true,
2673
+ maxPerSubjectPerDay: limit,
2674
+ toolId: `assistant.handoff.${agent.id}.${subjectType}`,
2675
+ changedAt: now,
2676
+ changedBy: 'current-user',
2677
+ reason,
2678
+ inEffect: true,
2679
+ problem: null,
2680
+ createdAt: existing?.createdAt ?? now,
2681
+ createdBy: existing?.createdBy ?? 'current-user'
2682
+ };
2683
+ this.stored.set(key, saved);
2684
+ const { agentId, ...body } = saved;
2685
+ return { status: 200, body };
2686
+ }
2687
+ remove(agent, subjectType, query) {
2688
+ if (this.codeHandOffsOf(agent.id).some(item => item.subjectType === subjectType)) {
2689
+ return mockFailure('assistant-handoff-in-code');
2690
+ }
2691
+ const key = `${agent.id}/${subjectType}`;
2692
+ const existing = this.stored.get(key);
2693
+ if (!existing) {
2694
+ return mockFailure('assistant-handoff-not-found');
2695
+ }
2696
+ const expected = query.get('expectedVersion');
2697
+ if (expected !== null && Number(expected) !== existing.version) {
2698
+ return mockFailure('assistant-version-conflict');
2699
+ }
2700
+ this.stored.delete(key);
2701
+ return { status: 204 };
2702
+ }
2703
+ /**
2704
+ * The problem a hand-off would have, as the server checks it on save and on every load: a ceiling
2705
+ * that connects the agent about the type, a type it works on, sources with a run configuration
2706
+ * that the ceiling allows, and a limit within the ceiling's.
2707
+ */
2708
+ problemOf(agent, subjectType, sources, limit) {
2709
+ const rules = this.rulesFor(agent, subjectType);
2710
+ if (!this.scenario.handOffsAsData) {
2711
+ return 'outside-ceiling';
2712
+ }
2713
+ if (!this.agents.isConfigured(agent)) {
2714
+ return 'unknown-agent';
2715
+ }
2716
+ if (this.codeHandOffsOf(agent.id).some(item => item.subjectType === subjectType)) {
2717
+ return 'conflicts-with-code';
2718
+ }
2719
+ if (!mockWorksOnCovers(agent, subjectType)) {
2720
+ return 'not-worked-on';
2721
+ }
2722
+ const known = sources.map(source => this.agents.find(source));
2723
+ if (known.some(source => !source || !this.agents.isConfigured(source))) {
2724
+ return 'unknown-source';
2725
+ }
2726
+ if (known.some(source => !rules.some(rule => this.allowsSource(rule, source)))) {
2727
+ return 'outside-ceiling';
2728
+ }
2729
+ return limit > this.maxPerDay() ? 'limit-above-ceiling' : null;
2730
+ }
2731
+ ceilingOf(agent) {
2732
+ const rules = (this.scenario.handOffsAsData?.rules ?? []).filter(rule => this.allowsTarget(rule, agent));
2733
+ if (rules.length === 0) {
2734
+ return null;
2735
+ }
2736
+ const configured = this.agents.all().filter(candidate => candidate.id !== agent.id && this.agents.isConfigured(candidate));
2737
+ const types = [...new Set(rules.map(rule => rule.subjectType))].sort();
2738
+ return {
2739
+ subjectTypes: types.map(subjectType => {
2740
+ const forType = rules.filter(rule => rule.subjectType === subjectType);
2741
+ return {
2742
+ subjectType,
2743
+ sources: configured.filter(candidate => forType.some(rule => this.allowsSource(rule, candidate))).map(candidate => candidate.id).sort(),
2744
+ sourcesCreatedInAdministration: forType.some(rule => rule.fromAgentsCreatedInAdministration === true)
2745
+ };
2746
+ }),
2747
+ maxPerSubjectPerDay: this.maxPerDay(),
2748
+ maxHandOffs,
2749
+ maxSources,
2750
+ maxDescriptionLength: maxDescription$3
2751
+ };
2752
+ }
2753
+ rulesFor(agent, subjectType) {
2754
+ return (this.scenario.handOffsAsData?.rules ?? []).filter(rule => rule.subjectType === subjectType && this.allowsTarget(rule, agent));
2755
+ }
2756
+ allowsTarget(rule, agent) {
2757
+ return (rule.to ?? []).includes(agent.id) || (rule.toAgentsCreatedInAdministration === true && agent.source === 'admin');
2758
+ }
2759
+ allowsSource(rule, agent) {
2760
+ return (rule.from ?? []).includes(agent.id) || (rule.fromAgentsCreatedInAdministration === true && agent.source === 'admin');
2761
+ }
2762
+ maxPerDay() {
2763
+ return this.scenario.handOffsAsData?.maxPerSubjectPerDay ?? maxPerDay;
2764
+ }
2765
+ codeHandOffsOf(agentId) {
2766
+ const agent = (this.scenario.agents ?? []).find(item => item.id === agentId && item.source === 'code');
2767
+ return (agent?.codeHandOffs ?? []).map(item => ({
2768
+ subjectType: item.subjectType,
2769
+ sources: [...item.sources],
2770
+ description: item.description ?? null,
2771
+ notifySource: item.notifySource ?? false,
2772
+ maxPerSubjectPerDay: item.maxPerSubjectPerDay ?? defaultPerDay,
2773
+ toolId: `assistant.handoff.${agentId}.${item.subjectType}`
2774
+ }));
2775
+ }
2776
+ variantsOf(live) {
2777
+ const all = this.agents.all();
2778
+ const code = all.filter(agent => agent.source === 'code' && agent.shadowOf === live.id);
2779
+ const data = this.dataVariantsOf(live.id);
2780
+ const allowed = this.allowsVariantsOf(live.id, live) && this.agents.isConfigured(live) && isLive(live);
2781
+ return {
2782
+ agentId: live.id,
2783
+ allowsDataVariants: allowed,
2784
+ maxDataVariants: allowed ? this.maxVariants() : 0,
2785
+ variants: [
2786
+ ...code.map(agent => ({
2787
+ agentId: agent.id, displayName: agent.displayName, source: 'code', hasRunSettings: true, inEffect: true, problem: null, version: agent.version
2788
+ })),
2789
+ ...data.map(agent => {
2790
+ const hasRunSettings = this.agents.isConfigured(agent);
2791
+ return {
2792
+ agentId: agent.id, displayName: agent.displayName, source: 'data', hasRunSettings, inEffect: hasRunSettings, problem: null, version: agent.version
2793
+ };
2794
+ })
2795
+ ]
2796
+ };
2797
+ }
2798
+ dataVariantsOf(liveId) {
2799
+ return this.agents.all()
2800
+ .filter(agent => agent.source === 'admin' && agent.shadowOf === liveId)
2801
+ .sort((first, second) => first.id.localeCompare(second.id));
2802
+ }
2803
+ allowsVariantsOf(liveId, live) {
2804
+ const ceiling = this.scenario.shadowVariantsAsData;
2805
+ return ceiling !== undefined
2806
+ && ((ceiling.of ?? []).includes(liveId) || (ceiling.ofAgentsCreatedInAdministration === true && (live?.source ?? 'admin') === 'admin'));
2807
+ }
2808
+ maxVariants() {
2809
+ return this.scenario.shadowVariantsAsData?.maxPerAgent ?? defaultVariants;
2810
+ }
2811
+ }
2812
+ /** A live agent: no shadow variant and not in shadow by its code. */
2813
+ function isLive(agent) {
2814
+ return !agent.shadowOf && !(agent.source === 'code' && agent.shadow?.configured === true);
2815
+ }
2816
+
2338
2817
  /** The bounds of `AllowConsultation`, within which a ceiling may only lower the maxima. */
2339
2818
  const bounds = {
2340
2819
  toolCalls: { min: 0, max: 32, initial: 4 },
@@ -2349,14 +2828,19 @@ const roles = ['reader', 'participant', 'approver'];
2349
2828
  const allRoutes = ['asker-decides', 'subject-agent-rights', 'subject-asker-rights'];
2350
2829
  const classifications = ['public', 'internal', 'confidential', 'restricted'];
2351
2830
  const subjectTypePattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
2831
+ /** A tool id or a prefix ending in `.*`, as an agent's tool selectors are. */
2832
+ const selectorPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)*(?:\.\*)?$/;
2352
2833
  /**
2353
2834
  * Consultations of the mock API's agents: those in code from the scenario, read-only, and those
2354
2835
  * defined as data, saved and removed as on the server within the ceilings in code
2355
2836
  * (`consultationsAsData` for the application, `consultableAsData` for an agent in code). A save
2356
2837
  * checks a consultation as the server does and refuses with the same codes and field errors; every
2357
2838
  * read checks it again, so a consultation that no longer fits, for example after the run settings
2358
- * changed whom the agent answers to, stays with its problems. Data consultations in effect generate
2359
- * their consult and request tools in the tool list and count for the agent's route switches.
2839
+ * changed whom the agent answers to or the agent's own tools no longer cover its tool selection,
2840
+ * stays with its problems. Data consultations in effect generate their consult and request tools in
2841
+ * the tool list and count for the agent's route switches. A new consultation starts off when its
2842
+ * request or the ceiling says so (`startsOff`); one that is off is checked as every read checks it but
2843
+ * offers nothing until `POST .../state` switches it on.
2360
2844
  */
2361
2845
  class NhAssistantMockConsultations {
2362
2846
  constructor(application, agents, rules, now) {
@@ -2365,6 +2849,7 @@ class NhAssistantMockConsultations {
2365
2849
  this.stored = new Map();
2366
2850
  this.codeAllowed = new Map();
2367
2851
  this.codeDescriptions = new Map();
2852
+ this.codeTools = new Map();
2368
2853
  this.agentCeilings = new Map();
2369
2854
  this.applicationCeiling = application ? ceilingOf(application) : null;
2370
2855
  for (const agent of agents) {
@@ -2374,6 +2859,7 @@ class NhAssistantMockConsultations {
2374
2859
  if (agent.consultations !== undefined || agent.consultationDescriptions !== undefined) {
2375
2860
  this.codeAllowed.set(agent.id, agent.consultations ?? {});
2376
2861
  this.codeDescriptions.set(agent.id, agent.consultationDescriptions ?? {});
2862
+ this.codeTools.set(agent.id, agent.consultationTools ?? {});
2377
2863
  }
2378
2864
  if (agent.consultableAsData !== undefined) {
2379
2865
  this.agentCeilings.set(agent.id, agent.consultableAsData);
@@ -2405,7 +2891,8 @@ class NhAssistantMockConsultations {
2405
2891
  maxToolCalls: Math.min(application.maxToolCalls, narrowed.maxToolCalls),
2406
2892
  maxTimeoutSeconds: Math.min(application.maxTimeoutSeconds, narrowed.maxTimeoutSeconds),
2407
2893
  maxAnswerChars: Math.min(application.maxAnswerChars, narrowed.maxAnswerChars),
2408
- maxHistoryMessages: Math.min(application.maxHistoryMessages, narrowed.maxHistoryMessages)
2894
+ maxHistoryMessages: Math.min(application.maxHistoryMessages, narrowed.maxHistoryMessages),
2895
+ ...(application.startsOff === true || narrowed.startsOff === true ? { startsOff: true } : {})
2409
2896
  };
2410
2897
  }
2411
2898
  /** The agent's consultations in code, by subject type. */
@@ -2415,6 +2902,7 @@ class NhAssistantMockConsultations {
2415
2902
  return [];
2416
2903
  }
2417
2904
  const descriptions = this.codeDescriptions.get(agent.id) ?? {};
2905
+ const tools = this.codeTools.get(agent.id) ?? {};
2418
2906
  const types = new Set([...Object.values(allowed).flat(), ...Object.keys(descriptions)]);
2419
2907
  return [...types].sort().map(subjectType => {
2420
2908
  const consultToolId = consultToolOf(agent.id, subjectType);
@@ -2426,7 +2914,8 @@ class NhAssistantMockConsultations {
2426
2914
  exposure: ['local', 'agent'],
2427
2915
  consultToolId,
2428
2916
  requestToolId: null,
2429
- askingAgents: this.askersOf(agent.id, consultToolId)
2917
+ askingAgents: this.askersOf(agent.id, consultToolId),
2918
+ toolSelectors: tools[subjectType] ? [...tools[subjectType]] : null
2430
2919
  };
2431
2920
  });
2432
2921
  }
@@ -2474,8 +2963,18 @@ class NhAssistantMockConsultations {
2474
2963
  }
2475
2964
  return tools;
2476
2965
  }
2477
- /** `PUT` and `DELETE admin/agents/{id}/consultations/{subjectType}`. */
2478
- handle(method, agent, subjectType, body, query) {
2966
+ /**
2967
+ * A copy of the consultations defined as data; the returned function puts it back, for an import
2968
+ * of an agent package that fails as a whole and for its preview.
2969
+ */
2970
+ snapshot() {
2971
+ return mockSnapshot(this.stored);
2972
+ }
2973
+ /** `PUT` and `DELETE admin/agents/{id}/consultations/{subjectType}`, and `POST .../state`. */
2974
+ handle(method, agent, subjectType, body, query, action) {
2975
+ if (action === 'state') {
2976
+ return method === 'POST' ? this.setState(agent, subjectType, body) : { status: 405 };
2977
+ }
2479
2978
  if (method === 'DELETE') {
2480
2979
  return this.remove(agent, subjectType, query);
2481
2980
  }
@@ -2483,7 +2982,7 @@ class NhAssistantMockConsultations {
2483
2982
  }
2484
2983
  save(agent, subjectType, body) {
2485
2984
  const request = (body ?? {});
2486
- const reason = text$1(request.reason);
2985
+ const reason = text$2(request.reason);
2487
2986
  if (reason !== null && reason.length > mockMaxStateReason) {
2488
2987
  return mockFailure('assistant-validation', { reason: ['too-long'] });
2489
2988
  }
@@ -2498,12 +2997,16 @@ class NhAssistantMockConsultations {
2498
2997
  return mockFailure('assistant-consultation-in-code');
2499
2998
  }
2500
2999
  // A new consultation starts with the defaults of code, capped by the ceiling.
2501
- const description = text$1(request.description);
2502
- const instructions = text$1(request.instructions);
2503
- const minimumRole = (text$1(request.minimumRole) ?? 'reader');
2504
- const proposalRole = (text$1(request.proposalRole) ?? 'participant');
3000
+ const description = text$2(request.description);
3001
+ const instructions = text$2(request.instructions);
3002
+ const minimumRole = (text$2(request.minimumRole) ?? 'reader');
3003
+ const proposalRole = (text$2(request.proposalRole) ?? 'participant');
2505
3004
  const routes = (request.routes ?? []).map(route => (typeof route === 'string' ? route.trim() : ''));
2506
- const classification = text$1(request.dataClassification);
3005
+ // No selection, or an empty one, offers every tool of the agent.
3006
+ const toolSelectors = Array.isArray(request.toolSelectors) && request.toolSelectors.length > 0
3007
+ ? request.toolSelectors.map(selector => (typeof selector === 'string' ? selector.trim() : ''))
3008
+ : null;
3009
+ const classification = text$2(request.dataClassification);
2507
3010
  const limits = {
2508
3011
  maxToolCalls: request.maxToolCalls ?? Math.min(bounds.toolCalls.initial, ceiling.maxToolCalls),
2509
3012
  timeoutSeconds: request.timeoutSeconds ?? Math.min(bounds.timeoutSeconds.initial, ceiling.maxTimeoutSeconds),
@@ -2535,6 +3038,9 @@ class NhAssistantMockConsultations {
2535
3038
  if (!validRoutes(routes)) {
2536
3039
  add('routes', 'invalid');
2537
3040
  }
3041
+ if (toolSelectors !== null && !validSelectors(toolSelectors)) {
3042
+ add('toolSelectors', 'invalid');
3043
+ }
2538
3044
  if (!within(limits.maxToolCalls, bounds.toolCalls)
2539
3045
  || !within(limits.timeoutSeconds, bounds.timeoutSeconds)
2540
3046
  || !within(limits.maxAnswerChars, bounds.answerChars)
@@ -2544,6 +3050,10 @@ class NhAssistantMockConsultations {
2544
3050
  if (classification !== null && !classifications.includes(classification)) {
2545
3051
  add('dataClassification', 'invalid');
2546
3052
  }
3053
+ // Only a new consultation starts off; one that exists keeps whether it is on.
3054
+ if (request.startsOff === true && request.expectedVersion != null) {
3055
+ add('startsOff', 'invalid');
3056
+ }
2547
3057
  if (Object.keys(errors).length > 0) {
2548
3058
  return mockFailure('assistant-validation', errors);
2549
3059
  }
@@ -2556,14 +3066,16 @@ class NhAssistantMockConsultations {
2556
3066
  description: description,
2557
3067
  instructions,
2558
3068
  minimumRole,
2559
- askerPolicy: text$1(request.askerPolicy),
3069
+ askerPolicy: text$2(request.askerPolicy),
2560
3070
  routes,
2561
3071
  proposalRole,
2562
- proposalPolicy: text$1(request.proposalPolicy),
3072
+ proposalPolicy: text$2(request.proposalPolicy),
3073
+ toolSelectors,
2563
3074
  ...limits,
2564
3075
  allowRequests: request.allowRequests === true,
2565
3076
  exposeToMcp: request.exposeToMcp === true,
2566
3077
  dataClassification: classification,
3078
+ off: existing ? existing.off : request.startsOff === true || ceiling.startsOff === true,
2567
3079
  createdAt: existing?.createdAt ?? now,
2568
3080
  createdBy: existing?.createdBy ?? 'current-user',
2569
3081
  changedAt: now,
@@ -2584,6 +3096,31 @@ class NhAssistantMockConsultations {
2584
3096
  this.stored.set(key$2(agent.id, subjectType), row);
2585
3097
  return { status: 200, body: this.toConsultation(agent, row) };
2586
3098
  }
3099
+ /** `POST admin/agents/{id}/consultations/{subjectType}/state`: on or off, without a new version. */
3100
+ setState(agent, subjectType, body) {
3101
+ const request = body;
3102
+ if (request?.state !== 'on' && request?.state !== 'off') {
3103
+ return mockFailure('assistant-validation', { state: [request?.state === undefined ? 'required' : 'invalid'] });
3104
+ }
3105
+ if (typeof request.reason === 'string' && request.reason.trim().length > mockMaxStateReason) {
3106
+ return mockFailure('assistant-validation', { reason: ['too-long'] });
3107
+ }
3108
+ if (this.inCode(agent.id, subjectType)) {
3109
+ return mockFailure('assistant-consultation-in-code');
3110
+ }
3111
+ const existing = this.stored.get(key$2(agent.id, subjectType));
3112
+ if (!existing) {
3113
+ return mockFailure('assistant-consultation-not-found');
3114
+ }
3115
+ const off = request.state === 'off';
3116
+ if (existing.off !== off) {
3117
+ existing.off = off;
3118
+ existing.changedAt = this.now();
3119
+ existing.changedBy = 'current-user';
3120
+ existing.reason = typeof request.reason === 'string' && request.reason.trim().length > 0 ? request.reason.trim() : null;
3121
+ }
3122
+ return { status: 200, body: this.toConsultation(agent, existing) };
3123
+ }
2587
3124
  remove(agent, subjectType, query) {
2588
3125
  const reason = query.get('reason');
2589
3126
  if (reason !== null && reason.trim().length > mockMaxStateReason) {
@@ -2610,7 +3147,7 @@ class NhAssistantMockConsultations {
2610
3147
  * no longer fit and with limits capped at the ceiling, each with a problem.
2611
3148
  */
2612
3149
  check(agent, row) {
2613
- const out = (problem) => ({ inEffect: false, problems: [problem], routes: [], allowRequests: false });
3150
+ const out = (problem) => ({ inEffect: false, problems: [problem], routes: [], allowRequests: false, toolSelectors: [] });
2614
3151
  const ceiling = this.ceilingOf(agent);
2615
3152
  if (!ceiling) {
2616
3153
  return out('not-consultable');
@@ -2676,7 +3213,19 @@ class NhAssistantMockConsultations {
2676
3213
  || row.maxHistoryMessages > ceiling.maxHistoryMessages) {
2677
3214
  problems.push('limit-above-ceiling');
2678
3215
  }
2679
- return { inEffect: true, problems, routes: kept, allowRequests };
3216
+ // The selection only narrows the agent's own tools; one they no longer cover drops, and a
3217
+ // selection with none left offers no tools, never every tool of the agent.
3218
+ let toolSelectors = null;
3219
+ if (row.toolSelectors !== null) {
3220
+ toolSelectors = row.toolSelectors.filter(selector => agent.toolSelectors.some(own => selectorCovers(own, selector)));
3221
+ if (toolSelectors.length < row.toolSelectors.length) {
3222
+ problems.push('tools-not-allowed');
3223
+ }
3224
+ }
3225
+ // Off, it is checked as every read checks it, and offers nothing until it is switched on.
3226
+ return row.off
3227
+ ? { inEffect: false, problems, routes: [], allowRequests: false, toolSelectors: [] }
3228
+ : { inEffect: true, problems, routes: kept, allowRequests, toolSelectors };
2680
3229
  }
2681
3230
  namesOnlyTheInitiator(policy) {
2682
3231
  const summary = this.rules.approvalPolicySummaries().find(item => item.name === policy);
@@ -2689,6 +3238,8 @@ class NhAssistantMockConsultations {
2689
3238
  return {
2690
3239
  ...stored,
2691
3240
  routes: [...row.routes],
3241
+ toolSelectors: row.toolSelectors ? [...row.toolSelectors] : null,
3242
+ toolSelectorsInEffect: check.toolSelectors ? [...check.toolSelectors] : null,
2692
3243
  instructionsHash: row.instructions === null ? null : mockHashText(row.instructions),
2693
3244
  inEffect: check.inEffect,
2694
3245
  problem: check.problems[0] ?? null,
@@ -2728,7 +3279,9 @@ function ceilingOf(ceiling) {
2728
3279
  maxAnswerChars: ceiling.maxAnswerChars ?? bounds.answerChars.max,
2729
3280
  maxHistoryMessages: ceiling.maxHistoryMessages ?? bounds.history.max,
2730
3281
  maxConsultations,
2731
- maxInstructionsLength: maxInstructions$1
3282
+ maxInstructionsLength: maxInstructions$1,
3283
+ // Reported only when the application lets new consultations start off, as older servers omit it.
3284
+ ...(ceiling.startsOff === true ? { startsOff: true } : {})
2732
3285
  };
2733
3286
  }
2734
3287
  /** The field error of each problem a consultation would have, as the server maps them on save. */
@@ -2750,7 +3303,8 @@ function fieldFailure(problems) {
2750
3303
  'no-approver': ['proposalPolicy', 'invalid'],
2751
3304
  'limit-above-ceiling': ['limits', 'not-allowed'],
2752
3305
  'requests-not-allowed': ['allowRequests', 'not-allowed'],
2753
- 'mcp-not-allowed': ['exposeToMcp', 'not-allowed']
3306
+ 'mcp-not-allowed': ['exposeToMcp', 'not-allowed'],
3307
+ 'tools-not-allowed': ['toolSelectors', 'not-allowed']
2754
3308
  };
2755
3309
  const errors = {};
2756
3310
  for (const problem of problems) {
@@ -2765,6 +3319,15 @@ function validRoutes(routes) {
2765
3319
  && new Set(routes).size === routes.length
2766
3320
  && !(routes.includes('subject-agent-rights') && routes.includes('subject-asker-rights'));
2767
3321
  }
3322
+ /** Valid selectors, each once. */
3323
+ function validSelectors(selectors) {
3324
+ return selectors.every(selector => selector.length <= 128 && selectorPattern.test(selector))
3325
+ && new Set(selectors).size === selectors.length;
3326
+ }
3327
+ /** The agent's selector covers a consultation's selector: the same tool, or a prefix that includes it. */
3328
+ function selectorCovers(agentSelector, selector) {
3329
+ return agentSelector === selector || (agentSelector.endsWith('.*') && selector.startsWith(agentSelector.slice(0, -1)));
3330
+ }
2768
3331
  function within(value, range) {
2769
3332
  return Number.isInteger(value) && value >= range.min && value <= range.max;
2770
3333
  }
@@ -2782,11 +3345,116 @@ function covers$1(selector, toolId) {
2782
3345
  function key$2(agentId, subjectType) {
2783
3346
  return `${agentId}/${subjectType}`;
2784
3347
  }
2785
- function text$1(value) {
3348
+ function text$2(value) {
2786
3349
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
2787
3350
  }
2788
3351
 
2789
- const dashCase$4 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
3352
+ /**
3353
+ * The application's text assets of the mock API, as its `INhAiTextAssetSource` would supply them:
3354
+ * `GET admin/instruction-assets` lists them without content and `GET admin/instruction-assets/{id}/versions/{version}`
3355
+ * reads one. A skill version or an agent created in the administration that names one is checked as
3356
+ * the server checks it: the asset must exist with the hash the request names. `setAsset` and
3357
+ * `removeAsset` change the assets afterwards, so a reference that no longer resolves shows its problem
3358
+ * (`asset-changed`, `asset-missing`), as after the server's next load. Without `instructionAssets` in
3359
+ * the scenario the application offers none and the run catalog says so.
3360
+ */
3361
+ class NhAssistantMockInstructionAssets {
3362
+ constructor(assets) {
3363
+ this.assets = new Map();
3364
+ this.supported = assets !== undefined;
3365
+ for (const asset of assets ?? []) {
3366
+ this.assets.set(keyOf$1(asset.assetId, asset.version), { ...asset });
3367
+ }
3368
+ }
3369
+ /** Publishes or changes one version of an asset. */
3370
+ setAsset(asset) {
3371
+ this.assets.set(keyOf$1(asset.assetId, asset.version), { ...asset });
3372
+ }
3373
+ /** Removes one version of an asset. */
3374
+ removeAsset(assetId, version) {
3375
+ this.assets.delete(keyOf$1(assetId, version));
3376
+ }
3377
+ /** `admin/instruction-assets[/{id}/versions/{version}]`; `segments` start after `instruction-assets`. */
3378
+ handle(method, segments) {
3379
+ if (method !== 'GET') {
3380
+ return { status: 405 };
3381
+ }
3382
+ if (segments.length === 0) {
3383
+ const list = {
3384
+ supported: this.supported,
3385
+ assets: [...this.assets.values()].map(asset => ({
3386
+ assetId: asset.assetId,
3387
+ version: asset.version,
3388
+ hash: mockHashText(asset.content),
3389
+ replacedByAssetId: asset.replacedByAssetId ?? null,
3390
+ deprecatedAt: asset.deprecatedAt ?? null
3391
+ }))
3392
+ };
3393
+ return { status: 200, body: list };
3394
+ }
3395
+ const [assetId, versions, version] = segments;
3396
+ const asset = segments.length === 3 && versions === 'versions' ? this.assets.get(keyOf$1(assetId, Number(version))) : undefined;
3397
+ if (!this.supported || !asset) {
3398
+ return mockFailure('assistant-instructions-asset-not-found');
3399
+ }
3400
+ const text = {
3401
+ assetId: asset.assetId,
3402
+ version: asset.version,
3403
+ hash: mockHashText(asset.content),
3404
+ content: asset.content,
3405
+ problem: null
3406
+ };
3407
+ return { status: 200, body: text };
3408
+ }
3409
+ /**
3410
+ * Adds the field errors of a reference a save names, as the server does: `instructionsAsset`
3411
+ * `not-allowed` without a source, `invalid` for a malformed reference, `not-found` for a missing
3412
+ * asset, and `instructionsAsset.hash` `invalid` for another hash.
3413
+ */
3414
+ check(reference, errors) {
3415
+ if (!this.supported) {
3416
+ errors['instructionsAsset'] = ['not-allowed'];
3417
+ return;
3418
+ }
3419
+ if (typeof reference.assetId !== 'string' || !mockDashCase.test(reference.assetId) || !Number.isInteger(reference.version) || reference.version < 1) {
3420
+ errors['instructionsAsset'] = ['invalid'];
3421
+ return;
3422
+ }
3423
+ const problem = this.problemOf(reference);
3424
+ if (problem === 'asset-missing') {
3425
+ errors['instructionsAsset'] = ['not-found'];
3426
+ }
3427
+ else if (problem === 'asset-changed') {
3428
+ errors['instructionsAsset.hash'] = ['invalid'];
3429
+ }
3430
+ else if (problem !== null) {
3431
+ errors['instructionsAsset'] = ['invalid'];
3432
+ }
3433
+ }
3434
+ /** Why the reference does not resolve to its hash now, or `null` when it does. */
3435
+ problemOf(reference) {
3436
+ if (!this.supported) {
3437
+ return 'no-asset-source';
3438
+ }
3439
+ const asset = this.assets.get(keyOf$1(reference.assetId, reference.version));
3440
+ if (!asset) {
3441
+ return 'asset-missing';
3442
+ }
3443
+ if (asset.content.trim().length === 0 || asset.content.length > mockMaxInstructions) {
3444
+ return 'asset-invalid';
3445
+ }
3446
+ return mockHashText(asset.content) === reference.hash ? null : 'asset-changed';
3447
+ }
3448
+ /** The text the reference resolves to, or `null` when it does not resolve. */
3449
+ textOf(reference) {
3450
+ return this.problemOf(reference) === null ? this.assets.get(keyOf$1(reference.assetId, reference.version)).content : null;
3451
+ }
3452
+ }
3453
+ function keyOf$1(assetId, version) {
3454
+ return `${assetId}@${version}`;
3455
+ }
3456
+
3457
+ const dashCase$5 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
2790
3458
  const dataClassifications = ['public', 'internal', 'confidential', 'restricted'];
2791
3459
  const accountabilities = ['agent', 'subject-responsible', 'service-account'];
2792
3460
  const conflicts = ['coalesce', 'queue', 'supersede', 'ignore'];
@@ -2811,17 +3479,22 @@ const fileLimitFields = ['maxFileBytes', 'maxFilePages', 'maxFilesPerTurn', 'max
2811
3479
  * checks the names against the scenario's run catalog as the server checks them against the code;
2812
3480
  * new skills and triggers start in shadow. A schedule without a subject set runs for the whole
2813
3481
  * application and fits only an agent that works on it; a stored trigger that no longer fits what its
2814
- * agent works on stays, out of effect, with the problem `not-worked-on`. The mock runs no agents, so
2815
- * a comparison counts nothing.
3482
+ * agent works on stays, out of effect, with the problem `not-worked-on`. Triggers have append-only
3483
+ * versions as skills do: a change applies at once or, with `apply` `shadow`, becomes the candidate next
3484
+ * to the live version, which the administrator promotes or discards. The mock runs no agents, so a
3485
+ * comparison counts nothing.
2816
3486
  */
2817
3487
  class NhAssistantMockRunConfiguration {
2818
- constructor(scenario, agents, now, accounts = new NhAssistantMockAccounts(scenario)) {
3488
+ constructor(scenario, agents, now, accounts = new NhAssistantMockAccounts(scenario), instructionAssets = new NhAssistantMockInstructionAssets(scenario.instructionAssets)) {
2819
3489
  this.agents = agents;
2820
3490
  this.now = now;
2821
3491
  this.accounts = accounts;
3492
+ this.instructionAssets = instructionAssets;
2822
3493
  this.settings = new Map();
2823
3494
  this.skills = new Map();
2824
3495
  this.triggers = new Map();
3496
+ /** Every version of every data trigger by trigger id, live and candidate included, oldest first. */
3497
+ this.triggerVersions = new Map();
2825
3498
  const approvalPolicies = scenario.runCatalog?.approvalPolicies ?? scenario.policies ?? [];
2826
3499
  this.consultations = new NhAssistantMockConsultations(scenario.consultationsAsData, scenario.agents ?? [], {
2827
3500
  find: agentId => this.agents.find(agentId),
@@ -2854,7 +3527,8 @@ class NhAssistantMockRunConfiguration {
2854
3527
  divisions: scenario.runCatalog?.divisions ?? false,
2855
3528
  // The platform's default file limits, unless the scenario's application chose its own.
2856
3529
  fileLimits: scenario.runCatalog?.fileLimits ?? defaultFileLimits,
2857
- consultationCeiling: this.consultations.applicationCeiling
3530
+ consultationCeiling: this.consultations.applicationCeiling,
3531
+ instructionAssets: instructionAssets.supported
2858
3532
  };
2859
3533
  this.codeTriggers = (scenario.codeTriggers ?? []).map(trigger => ({
2860
3534
  ...trigger,
@@ -2878,6 +3552,18 @@ class NhAssistantMockRunConfiguration {
2878
3552
  get profileNames() {
2879
3553
  return [...this.catalog.profiles];
2880
3554
  }
3555
+ /**
3556
+ * A copy of the run settings, skills, triggers and consultations; the returned function puts it
3557
+ * back, for an import of an agent package that fails as a whole and for its preview.
3558
+ */
3559
+ snapshot() {
3560
+ const restoreOwn = mockSnapshot(this.settings, this.skills, this.triggers, this.triggerVersions);
3561
+ const restoreConsultations = this.consultations.snapshot();
3562
+ return () => {
3563
+ restoreOwn();
3564
+ restoreConsultations();
3565
+ };
3566
+ }
2881
3567
  /**
2882
3568
  * The agent's consultations defined as data in effect with their routes in effect, which count for
2883
3569
  * its route switches.
@@ -2895,8 +3581,8 @@ class NhAssistantMockRunConfiguration {
2895
3581
  }
2896
3582
  /**
2897
3583
  * `admin/agents/{id}/configuration`, `run-settings`, `skills/{skillId}[/...]`,
2898
- * `triggers/{triggerId}[/shadow]` and `consultations/{subjectType}`; `segments` start after the
2899
- * agent id.
3584
+ * `triggers/{triggerId}[/shadow|/versions|/candidate/...]` and `consultations/{subjectType}[/state]`;
3585
+ * `segments` start after the agent id.
2900
3586
  */
2901
3587
  handle(method, agentId, segments, body, query) {
2902
3588
  const agent = this.agents.find(agentId);
@@ -2919,14 +3605,22 @@ class NhAssistantMockRunConfiguration {
2919
3605
  }
2920
3606
  return segments.length === 2 ? this.skill(method, agent, id, body, query) : null;
2921
3607
  }
3608
+ if (area === 'triggers' && id !== undefined && (segments.length === 3 && action === 'versions' || segments.length === 4 && action === 'candidate')) {
3609
+ if (!agent) {
3610
+ return mockFailure('assistant-agent-not-found');
3611
+ }
3612
+ return action === 'versions'
3613
+ ? method === 'GET' ? { status: 200, body: this.triggerHistory(agent.id, id) } : { status: 405 }
3614
+ : this.triggerCandidate(method, agent, id, child, body);
3615
+ }
2922
3616
  if (area === 'triggers' && id !== undefined && (segments.length === 2 || (segments.length === 3 && action === 'shadow'))) {
2923
3617
  if (!agent) {
2924
3618
  return mockFailure('assistant-agent-not-found');
2925
3619
  }
2926
3620
  return action === 'shadow' ? this.triggerShadow(method, agent, id, body) : this.trigger(method, agent, id, body, query);
2927
3621
  }
2928
- if (area === 'consultations' && id !== undefined && segments.length === 2) {
2929
- return agent ? this.consultations.handle(method, agent, id, body, query) : mockFailure('assistant-agent-not-found');
3622
+ if (area === 'consultations' && id !== undefined && (segments.length === 2 || (segments.length === 3 && action === 'state'))) {
3623
+ return agent ? this.consultations.handle(method, agent, id, body, query, action) : mockFailure('assistant-agent-not-found');
2930
3624
  }
2931
3625
  return null;
2932
3626
  }
@@ -2937,7 +3631,9 @@ class NhAssistantMockRunConfiguration {
2937
3631
  .filter(trigger => this.ownerOf(trigger) === agent.id)
2938
3632
  .map(trigger => {
2939
3633
  const problem = this.triggerProblem(agent, trigger);
2940
- return problem ? { ...withoutOwner(trigger), inEffect: false, problem } : withoutOwner(trigger);
3634
+ const live = problem ? { ...withoutOwner(trigger), inEffect: false, problem } : withoutOwner(trigger);
3635
+ const candidate = this.candidateOf(trigger.triggerId);
3636
+ return { ...live, candidate: candidate ? this.candidateDto(agent, candidate) : null };
2941
3637
  });
2942
3638
  return {
2943
3639
  agentId: agent.id,
@@ -2949,9 +3645,16 @@ class NhAssistantMockRunConfiguration {
2949
3645
  codeTriggers: this.codeTriggers.filter(trigger => trigger.agentId === agent.id).map(({ agentId, ...trigger }) => trigger),
2950
3646
  dataTriggers,
2951
3647
  problems: [
3648
+ ...dataSkills
3649
+ .flatMap(skill => [{ kind: 'skill', skill }, ...(skill.candidate ? [{ kind: 'candidate', skill: skill.candidate }] : [])])
3650
+ .filter(entry => entry.skill.instructionsAsset && entry.skill.problem !== null)
3651
+ .map(entry => ({ agentId: agent.id, kind: entry.kind, id: entry.skill.skillId, code: entry.skill.problem })),
2952
3652
  ...dataTriggers
2953
3653
  .filter(trigger => trigger.problem !== null)
2954
3654
  .map(trigger => ({ agentId: agent.id, kind: 'trigger', id: trigger.triggerId, code: trigger.problem })),
3655
+ ...dataTriggers
3656
+ .filter(trigger => trigger.candidate && trigger.candidate.problem !== null)
3657
+ .map(trigger => ({ agentId: agent.id, kind: 'trigger-candidate', id: trigger.triggerId, code: trigger.candidate.problem })),
2955
3658
  ...this.consultations.problems(agent)
2956
3659
  ],
2957
3660
  source: agent.source,
@@ -3008,7 +3711,8 @@ class NhAssistantMockRunConfiguration {
3008
3711
  }
3009
3712
  if (this.liveSkills(agent.id).length > 0
3010
3713
  || [...this.triggers.values()].some(trigger => this.ownerOf(trigger) === agent.id)
3011
- || this.consultations.has(agent.id)) {
3714
+ || this.consultations.has(agent.id)
3715
+ || this.agents.acceptsDataHandOffs?.(agent.id)) {
3012
3716
  return mockFailure('assistant-run-settings-in-use');
3013
3717
  }
3014
3718
  this.settings.delete(agent.id);
@@ -3018,7 +3722,7 @@ class NhAssistantMockRunConfiguration {
3018
3722
  return { status: 405 };
3019
3723
  }
3020
3724
  const request = (body ?? {});
3021
- const serviceAccount = text(request.serviceAccountActorId);
3725
+ const serviceAccount = text$1(request.serviceAccountActorId);
3022
3726
  const errors = {};
3023
3727
  if (!accountabilities.includes(request.accountability)) {
3024
3728
  errors['accountability'] = [request.accountability ? 'invalid' : 'required'];
@@ -3061,18 +3765,18 @@ class NhAssistantMockRunConfiguration {
3061
3765
  delegateFromInitiator: request.delegateFromInitiator ?? false,
3062
3766
  allowGatewayReads: request.allowGatewayReads ?? false,
3063
3767
  elevatedReads: request.elevatedReads ?? 'restricted',
3064
- approvalPolicy: text(request.approvalPolicy),
3768
+ approvalPolicy: text$1(request.approvalPolicy),
3065
3769
  retryCount: request.retryCount ?? 3,
3066
3770
  dailyToolCalls: request.dailyToolCalls ?? null,
3067
3771
  dailyTokens: request.dailyTokens ?? null,
3068
3772
  maxConcurrentRuns: request.maxConcurrentRuns ?? 4,
3069
3773
  maxRunsPerSubjectPerDay: request.maxRunsPerSubjectPerDay ?? 10,
3070
3774
  maxTokensPerRun: request.maxTokensPerRun ?? null,
3071
- triageProfile: text(request.triageProfile),
3072
- ruleHandler: text(request.ruleHandler),
3775
+ triageProfile: text$1(request.triageProfile),
3776
+ ruleHandler: text$1(request.ruleHandler),
3073
3777
  changedAt: this.now(),
3074
3778
  changedBy: 'current-user',
3075
- reason: text(request.reason),
3779
+ reason: text$1(request.reason),
3076
3780
  inEffect: true,
3077
3781
  problem: null,
3078
3782
  acceptsFiles: request.acceptsFiles ?? false,
@@ -3110,9 +3814,9 @@ class NhAssistantMockRunConfiguration {
3110
3814
  return { status: 405 };
3111
3815
  }
3112
3816
  const request = (body ?? {});
3113
- const description = text(request.description);
3817
+ const description = text$1(request.description);
3114
3818
  const errors = {};
3115
- if (!dashCase$4.test(skillId)) {
3819
+ if (!dashCase$5.test(skillId)) {
3116
3820
  errors['skillId'] = ['invalid'];
3117
3821
  }
3118
3822
  if (description === null) {
@@ -3124,6 +3828,14 @@ class NhAssistantMockRunConfiguration {
3124
3828
  if ((request.instructions ?? '').length > maxInstructions) {
3125
3829
  errors['instructions'] = ['too-long'];
3126
3830
  }
3831
+ // A version takes its instructions from an asset of the application or has text of its own.
3832
+ const instructionsAsset = request.instructionsAsset ?? null;
3833
+ if (instructionsAsset !== null) {
3834
+ if (text$1(request.instructions) !== null) {
3835
+ errors['instructions'] = ['not-allowed'];
3836
+ }
3837
+ this.instructionAssets.check(instructionsAsset, errors);
3838
+ }
3127
3839
  if ((request.tools ?? []).some(tool => !agent.toolSelectors.some(selector => covers(selector, tool)))) {
3128
3840
  errors['tools'] = ['invalid'];
3129
3841
  }
@@ -3140,7 +3852,7 @@ class NhAssistantMockRunConfiguration {
3140
3852
  if (request.startsOff === true && (request.expectedVersion ?? null) !== null) {
3141
3853
  errors['startsOff'] = ['invalid'];
3142
3854
  }
3143
- const classification = text(request.dataClassification);
3855
+ const classification = text$1(request.dataClassification);
3144
3856
  if (classification !== null && !dataClassifications.includes(classification)) {
3145
3857
  errors['dataClassification'] = ['invalid'];
3146
3858
  }
@@ -3163,27 +3875,30 @@ class NhAssistantMockRunConfiguration {
3163
3875
  version.status = 'superseded';
3164
3876
  }
3165
3877
  }
3166
- const instructions = text(request.instructions);
3878
+ const instructions = instructionsAsset === null ? text$1(request.instructions) : null;
3167
3879
  const saved = {
3168
3880
  skillId,
3169
3881
  version: versions.reduce((latest, version) => Math.max(latest, version.version), 0) + 1,
3170
3882
  status: candidate ? 'candidate' : 'live',
3171
3883
  description: description,
3172
3884
  instructions,
3173
- instructionsHash: instructions === null ? null : hashText(instructions),
3885
+ instructionsHash: instructionsAsset?.hash ?? (instructions === null ? null : hashText(instructions)),
3886
+ instructionsAsset: instructionsAsset === null
3887
+ ? null
3888
+ : { assetId: instructionsAsset.assetId, version: instructionsAsset.version, hash: instructionsAsset.hash },
3174
3889
  tools: [...new Set(request.tools ?? [])],
3175
- profile: text(request.profile),
3890
+ profile: text$1(request.profile),
3176
3891
  dataClassification: classification,
3177
3892
  acceptsFiles: request.acceptsFiles ?? false,
3178
3893
  ...storedFileLimits(request),
3179
- ruleHandler: text(request.ruleHandler),
3894
+ ruleHandler: text$1(request.ruleHandler),
3180
3895
  maxTokensPerRun: request.maxTokensPerRun ?? null,
3181
3896
  dailyToolCalls: request.dailyToolCalls ?? null,
3182
3897
  dailyTokens: request.dailyTokens ?? null,
3183
- approvalPolicy: text(request.approvalPolicy),
3898
+ approvalPolicy: text$1(request.approvalPolicy),
3184
3899
  changedAt: this.now(),
3185
3900
  changedBy: 'current-user',
3186
- reason: text(request.reason),
3901
+ reason: text$1(request.reason),
3187
3902
  inEffect: true,
3188
3903
  problem: null
3189
3904
  };
@@ -3248,7 +3963,8 @@ class NhAssistantMockRunConfiguration {
3248
3963
  instructionsHash: version.instructionsHash,
3249
3964
  createdAt: version.changedAt,
3250
3965
  createdBy: version.changedBy,
3251
- reason: version.reason
3966
+ reason: version.reason,
3967
+ instructionsAsset: version.instructionsAsset ?? null
3252
3968
  }));
3253
3969
  }
3254
3970
  trigger(method, agent, triggerId, body, query) {
@@ -3269,6 +3985,10 @@ class NhAssistantMockRunConfiguration {
3269
3985
  if (!versionMatches(query, existing.version)) {
3270
3986
  return mockFailure('assistant-version-conflict');
3271
3987
  }
3988
+ // Out of effect; its versions stay as history.
3989
+ for (const version of this.triggerVersions.get(triggerId) ?? []) {
3990
+ version.status = version.status === 'live' ? 'superseded' : version.status === 'candidate' ? 'discarded' : version.status;
3991
+ }
3272
3992
  this.triggers.delete(triggerId);
3273
3993
  return { status: 204 };
3274
3994
  }
@@ -3278,7 +3998,7 @@ class NhAssistantMockRunConfiguration {
3278
3998
  const request = (body ?? {});
3279
3999
  const schedule = request.source === 'schedule';
3280
4000
  const errors = {};
3281
- if (!dashCase$4.test(triggerId)) {
4001
+ if (!dashCase$5.test(triggerId)) {
3282
4002
  errors['triggerId'] = ['invalid'];
3283
4003
  }
3284
4004
  if (request.source !== 'event' && request.source !== 'schedule') {
@@ -3293,16 +4013,16 @@ class NhAssistantMockRunConfiguration {
3293
4013
  if (request.source === 'event') {
3294
4014
  this.requireKnown(errors, 'event', request.event, this.catalog.triggerEvents.map(item => item.name), true);
3295
4015
  // An event that names its object type up front fits only an agent that works on it.
3296
- const subjectType = this.catalog.triggerEvents.find(item => item.name === text(request.event))?.subjectType;
4016
+ const subjectType = this.catalog.triggerEvents.find(item => item.name === text$1(request.event))?.subjectType;
3297
4017
  if (!errors['event'] && subjectType && !mockWorksOnCovers(agent, subjectType)) {
3298
4018
  errors['event'] = ['invalid'];
3299
4019
  }
3300
4020
  }
3301
4021
  if (schedule) {
3302
- if ((text(request.cron) ?? '').split(' ').filter(field => field.length > 0).length < 5) {
3303
- errors['cron'] = [text(request.cron) === null ? 'required' : 'invalid'];
4022
+ if ((text$1(request.cron) ?? '').split(' ').filter(field => field.length > 0).length < 5) {
4023
+ errors['cron'] = [text$1(request.cron) === null ? 'required' : 'invalid'];
3304
4024
  }
3305
- if (text(request.subjectSet) !== null) {
4025
+ if (text$1(request.subjectSet) !== null) {
3306
4026
  this.requireKnown(errors, 'subjectSet', request.subjectSet, this.catalog.subjectSets.map(item => item.name));
3307
4027
  }
3308
4028
  else if (!mockWorksOnCovers(agent, mockSystemSubjectType)) {
@@ -3310,7 +4030,7 @@ class NhAssistantMockRunConfiguration {
3310
4030
  // that works on it may do.
3311
4031
  errors['subjectSet'] = ['required'];
3312
4032
  }
3313
- const message = text(request.message);
4033
+ const message = text$1(request.message);
3314
4034
  if (message === null) {
3315
4035
  errors['message'] = ['required'];
3316
4036
  }
@@ -3318,7 +4038,7 @@ class NhAssistantMockRunConfiguration {
3318
4038
  errors['message'] = ['too-long'];
3319
4039
  }
3320
4040
  }
3321
- const skill = text(request.skill);
4041
+ const skill = text$1(request.skill);
3322
4042
  if (skill !== null && !this.skillIds(agent).includes(skill)) {
3323
4043
  errors['skill'] = ['not-found'];
3324
4044
  }
@@ -3327,11 +4047,16 @@ class NhAssistantMockRunConfiguration {
3327
4047
  if (filters.length > maxTriggerFilters || filters.some(name => name.length > 64 || !filterName.test(name)) || (schedule && filters.length > 0)) {
3328
4048
  errors['filters'] = ['invalid'];
3329
4049
  }
4050
+ // A new trigger has no live version to evaluate a candidate next to.
4051
+ if ((request.apply !== undefined && request.apply !== 'live' && request.apply !== 'shadow')
4052
+ || (request.apply === 'shadow' && (request.expectedVersion ?? null) === null)) {
4053
+ errors['apply'] = ['invalid'];
4054
+ }
3330
4055
  if (Object.keys(errors).length > 0) {
3331
4056
  return mockFailure('assistant-validation', errors);
3332
4057
  }
3333
4058
  if (request.source === 'event') {
3334
- const offered = (this.catalog.triggerEvents.find(item => item.name === text(request.event))?.filters ?? []).map(filter => filter.name);
4059
+ const offered = (this.catalog.triggerEvents.find(item => item.name === text$1(request.event))?.filters ?? []).map(filter => filter.name);
3335
4060
  if (filters.some(name => !offered.includes(name))) {
3336
4061
  return mockFailure('assistant-trigger-filter-not-found', { filters: ['not-found'] });
3337
4062
  }
@@ -3339,34 +4064,139 @@ class NhAssistantMockRunConfiguration {
3339
4064
  if ((request.expectedVersion ?? null) !== (existing?.version ?? null)) {
3340
4065
  return mockFailure('assistant-version-conflict');
3341
4066
  }
4067
+ const candidate = request.apply === 'shadow';
4068
+ const history = this.triggerVersions.get(triggerId) ?? [];
3342
4069
  const saved = {
3343
4070
  triggerId,
3344
4071
  skillId: skill,
3345
4072
  source: request.source,
3346
- event: schedule ? null : text(request.event),
3347
- cron: schedule ? text(request.cron) : null,
3348
- timeZone: schedule ? text(request.timeZone) : null,
3349
- subjectSet: schedule ? text(request.subjectSet) : null,
3350
- message: schedule ? text(request.message) : null,
4073
+ event: schedule ? null : text$1(request.event),
4074
+ cron: schedule ? text$1(request.cron) : null,
4075
+ timeZone: schedule ? text$1(request.timeZone) : null,
4076
+ subjectSet: schedule ? text$1(request.subjectSet) : null,
4077
+ message: schedule ? text$1(request.message) : null,
3351
4078
  conflict: request.conflict ?? (schedule ? 'ignore' : 'coalesce'),
3352
4079
  debounceSeconds: schedule ? 0 : request.debounceSeconds ?? 120,
3353
- shadow: existing?.shadow ?? true,
3354
- version: (existing?.version ?? 0) + 1,
4080
+ // A candidate always fires in shadow; a new version keeps the trigger's mode.
4081
+ shadow: candidate || (existing?.shadow ?? true),
4082
+ version: history.reduce((latest, version) => Math.max(latest, version.version), existing?.version ?? 0) + 1,
3355
4083
  changedAt: this.now(),
3356
4084
  changedBy: 'current-user',
3357
- reason: text(request.reason),
4085
+ reason: text$1(request.reason),
3358
4086
  inEffect: true,
3359
4087
  problem: null,
3360
- filters: schedule ? [] : filters
4088
+ filters: schedule ? [] : filters,
4089
+ createdAt: this.now(),
4090
+ createdBy: 'current-user'
3361
4091
  };
3362
- this.triggers.set(triggerId, Object.assign(saved, { [ownerKey]: agent.id }));
3363
- return { status: 200, body: withoutOwner(saved) };
3364
- }
3365
- triggerShadow(method, agent, triggerId, body) {
3366
- if (method !== 'POST') {
3367
- return { status: 405 };
3368
- }
3369
- const existing = this.triggers.get(triggerId);
4092
+ for (const version of history) {
4093
+ if (version.status === 'candidate') {
4094
+ version.status = 'discarded';
4095
+ }
4096
+ else if (version.status === 'live' && !candidate) {
4097
+ version.status = 'superseded';
4098
+ }
4099
+ }
4100
+ if (existing && history.length === 0) {
4101
+ // A trigger of the scenario's preset starts its history with its live version.
4102
+ history.push({ ...withoutOwner(existing), status: candidate ? 'live' : 'superseded', agentId: agent.id });
4103
+ }
4104
+ this.triggerVersions.set(triggerId, [...history, { ...withoutOwner(saved), status: candidate ? 'candidate' : 'live', agentId: agent.id }]);
4105
+ if (candidate) {
4106
+ return { status: 200, body: this.candidateDto(agent, this.candidateOf(triggerId)) };
4107
+ }
4108
+ this.triggers.set(triggerId, Object.assign(saved, { [ownerKey]: agent.id }));
4109
+ return { status: 200, body: withoutOwner(saved) };
4110
+ }
4111
+ /** `admin/agents/{id}/triggers/{triggerId}/candidate/{promote|discard|comparison}`. */
4112
+ triggerCandidate(method, agent, triggerId, action, body) {
4113
+ const live = this.triggers.get(triggerId);
4114
+ const candidate = live && this.ownerOf(live) === agent.id ? this.candidateOf(triggerId) : undefined;
4115
+ if (action === 'comparison' && method === 'GET') {
4116
+ if (!candidate || !live) {
4117
+ return mockFailure('assistant-trigger-candidate-not-found');
4118
+ }
4119
+ const comparison = {
4120
+ agentId: agent.id,
4121
+ triggerId,
4122
+ from: candidate.createdAt ?? candidate.changedAt,
4123
+ to: this.now(),
4124
+ live: emptyStats(live.version),
4125
+ candidate: emptyStats(candidate.version),
4126
+ pairedEvents: 0,
4127
+ liveOnlyEvents: 0,
4128
+ candidateOnlyEvents: 0,
4129
+ tools: []
4130
+ };
4131
+ return { status: 200, body: comparison };
4132
+ }
4133
+ if ((action !== 'promote' && action !== 'discard') || method !== 'POST') {
4134
+ return { status: 405 };
4135
+ }
4136
+ const request = body;
4137
+ if (typeof request?.version !== 'number') {
4138
+ return mockFailure('assistant-validation', { version: ['required'] });
4139
+ }
4140
+ if (!candidate || !live) {
4141
+ return mockFailure('assistant-trigger-candidate-not-found');
4142
+ }
4143
+ if (candidate.version !== request.version) {
4144
+ return mockFailure('assistant-version-conflict');
4145
+ }
4146
+ if (action === 'discard') {
4147
+ candidate.status = 'discarded';
4148
+ return { status: 204 };
4149
+ }
4150
+ for (const version of this.triggerVersions.get(triggerId) ?? []) {
4151
+ if (version.status === 'live') {
4152
+ version.status = 'superseded';
4153
+ }
4154
+ }
4155
+ candidate.status = 'live';
4156
+ // The promoted version keeps working in the trigger's mode.
4157
+ const { status, agentId, ...definition } = candidate;
4158
+ const promoted = { ...definition, shadow: live.shadow, changedAt: this.now(), changedBy: 'current-user' };
4159
+ candidate.shadow = live.shadow;
4160
+ this.triggers.set(triggerId, Object.assign(promoted, { [ownerKey]: agent.id }));
4161
+ return { status: 200, body: withoutOwner(promoted) };
4162
+ }
4163
+ /** Every version of a data trigger of the agent, newest first, with its definition. */
4164
+ triggerHistory(agentId, triggerId) {
4165
+ return [...(this.triggerVersions.get(triggerId) ?? [])]
4166
+ .filter(version => version.agentId === agentId)
4167
+ .sort((left, right) => right.version - left.version)
4168
+ .map(version => ({
4169
+ version: version.version,
4170
+ status: version.status,
4171
+ skillId: version.skillId,
4172
+ source: version.source,
4173
+ event: version.event,
4174
+ filters: [...(version.filters ?? [])],
4175
+ cron: version.cron,
4176
+ timeZone: version.timeZone,
4177
+ subjectSet: version.subjectSet,
4178
+ message: version.message,
4179
+ conflict: version.conflict,
4180
+ debounceSeconds: version.debounceSeconds,
4181
+ createdAt: version.createdAt ?? version.changedAt,
4182
+ createdBy: version.createdBy ?? version.changedBy,
4183
+ reason: version.reason
4184
+ }));
4185
+ }
4186
+ candidateOf(triggerId) {
4187
+ return (this.triggerVersions.get(triggerId) ?? []).find(version => version.status === 'candidate');
4188
+ }
4189
+ /** A candidate as the server shows it: always in shadow, with its own problem. */
4190
+ candidateDto(agent, candidate) {
4191
+ const { status, agentId, ...definition } = candidate;
4192
+ const problem = this.triggerProblem(agent, definition);
4193
+ return { ...definition, shadow: true, inEffect: problem === null, problem };
4194
+ }
4195
+ triggerShadow(method, agent, triggerId, body) {
4196
+ if (method !== 'POST') {
4197
+ return { status: 405 };
4198
+ }
4199
+ const existing = this.triggers.get(triggerId);
3370
4200
  if (!existing || this.ownerOf(existing) !== agent.id) {
3371
4201
  return mockFailure('assistant-trigger-not-found');
3372
4202
  }
@@ -3383,6 +4213,54 @@ class NhAssistantMockRunConfiguration {
3383
4213
  existing.reason = typeof request.reason === 'string' && request.reason.trim().length > 0 ? request.reason.trim() : null;
3384
4214
  return { status: 200, body: withoutOwner(existing) };
3385
4215
  }
4216
+ /**
4217
+ * The ids of the variant's data skills that would move to `live` and the failure of one that cannot,
4218
+ * as a promotion with `moveSkills` checks them before anything changes: a skill the live agent has
4219
+ * in code stays (`assistant-skill-in-code`).
4220
+ */
4221
+ skillsToMove(variantId, live) {
4222
+ const skillIds = this.liveSkills(variantId).map(skill => skill.skillId);
4223
+ const inCode = (live.skills ?? []).some(skill => skillIds.includes(skill.skillId) && !this.skills.has(key$1(live.id, skill.skillId)));
4224
+ return { skillIds, failure: inCode ? mockFailure('assistant-skill-in-code') : null };
4225
+ }
4226
+ /**
4227
+ * Moves the variant's data skills to `live` as a promotion does: each becomes the next live version
4228
+ * of the live agent's skill with the same id, or a new skill, which starts in shadow. Answers the ids
4229
+ * that moved, in order, or the failure of the first save.
4230
+ */
4231
+ moveSkills(variantId, live, reason) {
4232
+ const moved = [];
4233
+ for (const skill of this.liveSkills(variantId)) {
4234
+ const current = (this.skills.get(key$1(live.id, skill.skillId)) ?? []).find(version => version.status === 'live');
4235
+ const request = {
4236
+ description: skill.description,
4237
+ instructions: skill.instructionsAsset ? null : skill.instructions,
4238
+ tools: [...skill.tools],
4239
+ profile: skill.profile,
4240
+ ruleHandler: skill.ruleHandler,
4241
+ maxTokensPerRun: skill.maxTokensPerRun,
4242
+ dailyToolCalls: skill.dailyToolCalls,
4243
+ dailyTokens: skill.dailyTokens,
4244
+ approvalPolicy: skill.approvalPolicy,
4245
+ expectedVersion: current?.version ?? null,
4246
+ reason,
4247
+ ...(current ? { apply: 'live' } : {}),
4248
+ dataClassification: skill.dataClassification ?? null,
4249
+ acceptsFiles: skill.acceptsFiles ?? false,
4250
+ maxFileBytes: skill.maxFileBytes ?? null,
4251
+ maxFilePages: skill.maxFilePages ?? null,
4252
+ maxFilesPerTurn: skill.maxFilesPerTurn ?? null,
4253
+ maxFileTokensPerTurn: skill.maxFileTokensPerTurn ?? null,
4254
+ instructionsAsset: skill.instructionsAsset ?? null
4255
+ };
4256
+ const saved = this.skill('PUT', live, skill.skillId, request, new URLSearchParams());
4257
+ if (saved.status !== 200) {
4258
+ return { moved, failure: saved };
4259
+ }
4260
+ moved.push(skill.skillId);
4261
+ }
4262
+ return { moved, failure: null };
4263
+ }
3386
4264
  /** An agent in code has a run configuration; one created in the administration once it has run settings. */
3387
4265
  isConfigured(agent) {
3388
4266
  return agent.source === 'code' || this.settings.has(agent.id);
@@ -3413,8 +4291,11 @@ class NhAssistantMockRunConfiguration {
3413
4291
  * that its profile, or else its agent's, does not permit.
3414
4292
  */
3415
4293
  withWarnings(agent, skill) {
4294
+ // A version whose asset no longer resolves to its hash is out of effect, as after the server's load.
4295
+ const assetProblem = skill.instructionsAsset ? this.instructionAssets.problemOf(skill.instructionsAsset) : null;
3416
4296
  return {
3417
4297
  ...skill,
4298
+ ...(assetProblem !== null ? { inEffect: false, problem: assetProblem } : {}),
3418
4299
  warnings: mockClassificationWarnings(this.profileClassifications, skill.profile ?? agent?.profile, skill.dataClassification ?? agent?.dataClassification)
3419
4300
  };
3420
4301
  }
@@ -3422,7 +4303,7 @@ class NhAssistantMockRunConfiguration {
3422
4303
  return trigger[ownerKey];
3423
4304
  }
3424
4305
  requireKnown(errors, field, value, known, required = false) {
3425
- const name = text(value);
4306
+ const name = text$1(value);
3426
4307
  if (name === null) {
3427
4308
  if (required) {
3428
4309
  errors[field] = ['required'];
@@ -3470,7 +4351,7 @@ function withoutOwner(trigger) {
3470
4351
  function emptyStats(version) {
3471
4352
  return { version, runs: 0, completed: 0, failed: 0, proposals: 0, toolCalls: 0, tokens: 0 };
3472
4353
  }
3473
- function text(value) {
4354
+ function text$1(value) {
3474
4355
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
3475
4356
  }
3476
4357
  function inRange(value, min, max) {
@@ -3527,20 +4408,29 @@ class NhAssistantMockAdmin {
3527
4408
  this.canAdminister = admin.canAdminister ?? true;
3528
4409
  this.preferences = { ...defaultPreferences, ...scenario.preferences };
3529
4410
  this.contextVersions.push({ version: 1, hash: mockHashText(admin.context ?? ''), updatedAt: now(), updatedBy: null, text: admin.context ?? '' });
4411
+ this.instructionAssets = new NhAssistantMockInstructionAssets(admin.instructionAssets);
3530
4412
  this.agents = new NhAssistantMockAdminAgents(scenario, now, {
3531
4413
  hasServer: serverId => this.mcp.hasServer(serverId),
3532
4414
  agentPolicyNames: () => this.runConfiguration.agentPolicyNames,
3533
4415
  divisionsListed: () => this.runConfiguration.divisionsListed,
3534
4416
  profileNames: () => this.runConfiguration.profileNames,
3535
- dataConsultations: agentId => this.runConfiguration.consultationsInEffect(agentId)
4417
+ dataConsultations: agentId => this.runConfiguration.consultationsInEffect(agentId),
4418
+ shadowVariantRefusal: input => this.handOffs.shadowVariantRefusal(input),
4419
+ instructionAssets: () => this.instructionAssets
3536
4420
  });
3537
4421
  this.accounts = new NhAssistantMockAccounts(admin);
3538
4422
  this.runConfiguration = new NhAssistantMockRunConfiguration(admin, {
3539
4423
  find: agentId => this.agents.find(agentId),
3540
4424
  all: () => this.agents.all(),
3541
4425
  knownSubjectTypes: () => [...(scenario.subjectTypes ?? []).map(type => type.id), mockSystemSubjectType],
3542
- setDataSkill: (agentId, skillId, description, startsOff) => this.agents.setDataSkill(agentId, skillId, description, startsOff)
3543
- }, now, this.accounts);
4426
+ setDataSkill: (agentId, skillId, description, startsOff) => this.agents.setDataSkill(agentId, skillId, description, startsOff),
4427
+ acceptsDataHandOffs: agentId => this.handOffs.acceptsDataHandOffs(agentId)
4428
+ }, now, this.accounts, this.instructionAssets);
4429
+ this.handOffs = new NhAssistantMockHandOffs(admin, {
4430
+ find: agentId => this.agents.find(agentId),
4431
+ all: () => this.agents.all(),
4432
+ isConfigured: agent => this.runConfiguration.isConfigured(agent)
4433
+ }, now);
3544
4434
  this.mcp = new NhAssistantMockAdminMcp(scenario, now, {
3545
4435
  agents: () => this.agents.all(),
3546
4436
  chatAgentIds: () => this.agents.chatAgents().map(agent => agent.id),
@@ -3593,6 +4483,30 @@ class NhAssistantMockAdmin {
3593
4483
  allAgents() {
3594
4484
  return this.agents.all();
3595
4485
  }
4486
+ /** One agent of the administration, or `undefined`. */
4487
+ findAgent(agentId) {
4488
+ return this.agents.find(agentId);
4489
+ }
4490
+ /** The definition in code of an agent in code; `null` for an agent created here. */
4491
+ codeDefinition(agentId) {
4492
+ return this.agents.codeDefinition(agentId);
4493
+ }
4494
+ /**
4495
+ * A copy of the agents and their run configuration, consultations and hand-offs; the returned
4496
+ * function puts it back, for an import of an agent package that fails as a whole and for its preview.
4497
+ */
4498
+ snapshot() {
4499
+ const restores = [this.agents.snapshot(), this.runConfiguration.snapshot(), this.handOffs.snapshot()];
4500
+ return () => {
4501
+ for (const restore of restores) {
4502
+ restore();
4503
+ }
4504
+ };
4505
+ }
4506
+ /** Makes the next version of an agent, as a change of its metadata does. */
4507
+ nextAgentVersion(agentId) {
4508
+ return this.agents.nextVersion(agentId);
4509
+ }
3596
4510
  handlePreferences(method, body) {
3597
4511
  if (method === 'GET') {
3598
4512
  return { status: 200, body: this.preferences };
@@ -3635,6 +4549,8 @@ class NhAssistantMockAdmin {
3635
4549
  return method === 'GET' && segments.length === 2 ? { status: 200, body: this.toolCatalog() } : mockFailure('assistant-not-found');
3636
4550
  case 'run-catalog':
3637
4551
  return segments.length === 2 ? this.runConfiguration.runCatalog(method) : mockFailure('assistant-not-found');
4552
+ case 'instruction-assets':
4553
+ return this.instructionAssets.handle(method, segments.slice(2));
3638
4554
  case 'accounts':
3639
4555
  return segments.length === 2 ? this.accounts.search(method, query) : mockFailure('assistant-not-found');
3640
4556
  case 'agents':
@@ -3647,6 +4563,15 @@ class NhAssistantMockAdmin {
3647
4563
  if (id !== undefined && (action === 'replay' || action === 'replays') && segments.length === 4) {
3648
4564
  return this.agents.agentReplay(method, id, action, body);
3649
4565
  }
4566
+ if (id !== undefined && action === 'promote' && segments.length === 4) {
4567
+ return this.promote(method, id, body);
4568
+ }
4569
+ if (id !== undefined && (action === 'hand-offs' || action === 'shadow-variants')) {
4570
+ const handed = this.handOffs.handle(method, id, segments.slice(3), body, query);
4571
+ if (handed) {
4572
+ return handed;
4573
+ }
4574
+ }
3650
4575
  if (id !== undefined && action !== undefined) {
3651
4576
  const configured = this.runConfiguration.handle(method, id, segments.slice(3), body, query);
3652
4577
  if (configured) {
@@ -3668,6 +4593,56 @@ class NhAssistantMockAdmin {
3668
4593
  return mockFailure('assistant-not-found');
3669
4594
  }
3670
4595
  }
4596
+ /**
4597
+ * `POST admin/agents/{id}/promote`: a data shadow variant's definition becomes its live agent's next
4598
+ * version, its skills move along with `moveSkills`, and the variant is deleted, as on the server. A
4599
+ * stale version of either agent answers `409`; a skill the live agent has in code stops the promotion
4600
+ * before anything changes.
4601
+ */
4602
+ promote(method, variantId, body) {
4603
+ if (method !== 'POST') {
4604
+ return { status: 405 };
4605
+ }
4606
+ const variant = this.agents.find(variantId);
4607
+ if (!variant) {
4608
+ return mockFailure('assistant-agent-not-found');
4609
+ }
4610
+ if (variant.source !== 'admin' || !variant.shadowOf) {
4611
+ return mockFailure('assistant-not-shadow-variant');
4612
+ }
4613
+ const request = body;
4614
+ if (typeof request?.expectedVersion !== 'number') {
4615
+ return mockFailure('assistant-validation', { expectedVersion: ['required'] });
4616
+ }
4617
+ if (typeof request.reason === 'string' && request.reason.length > 500) {
4618
+ return mockFailure('assistant-validation', { reason: ['too-long'] });
4619
+ }
4620
+ const live = this.agents.find(variant.shadowOf);
4621
+ if (!live) {
4622
+ return mockFailure('assistant-agent-not-found');
4623
+ }
4624
+ if (request.expectedVersion !== live.version
4625
+ || (typeof request.expectedVariantVersion === 'number' && request.expectedVariantVersion !== variant.version)) {
4626
+ return mockFailure('assistant-version-conflict');
4627
+ }
4628
+ if (request.moveSkills === true) {
4629
+ const refused = this.runConfiguration.skillsToMove(variant.id, live).failure;
4630
+ if (refused) {
4631
+ return refused;
4632
+ }
4633
+ }
4634
+ // In the server's order: the definition, the skills, then the variant leaves.
4635
+ const agent = this.agents.promote(variant.id, live.id);
4636
+ const moved = request.moveSkills === true
4637
+ ? this.runConfiguration.moveSkills(variant.id, agent, typeof request.reason === 'string' ? request.reason.trim() || null : null)
4638
+ : { moved: [], failure: null };
4639
+ if (moved.failure) {
4640
+ return moved.failure;
4641
+ }
4642
+ this.agents.agentsEndpoint('DELETE', variant.id, undefined, undefined, 3);
4643
+ const promotion = { agent: this.agents.find(agent.id), variantId: variant.id, movedSkills: moved.moved };
4644
+ return { status: 200, body: promotion };
4645
+ }
3671
4646
  context(method, sub, body) {
3672
4647
  const current = this.contextVersions[this.contextVersions.length - 1];
3673
4648
  if (sub === 'versions' && method === 'GET') {
@@ -3726,33 +4701,1356 @@ class NhAssistantMockAdmin {
3726
4701
  }
3727
4702
  }
3728
4703
  }
3729
- for (const agent of agents) {
3730
- for (const { subjectType, ...consultation } of agent.dataConsultations ?? []) {
3731
- presetStep(agent.id, `consultation about '${subjectType}'`, this.runConfiguration.handle('PUT', agent.id, ['consultations', subjectType], consultation, query));
3732
- }
4704
+ for (const agent of agents) {
4705
+ for (const { subjectType, ...consultation } of agent.dataConsultations ?? []) {
4706
+ presetStep(agent.id, `consultation about '${subjectType}'`, this.runConfiguration.handle('PUT', agent.id, ['consultations', subjectType], consultation, query));
4707
+ }
4708
+ }
4709
+ }
4710
+ /**
4711
+ * The scenario's tools, with the object type they name as the server reports it, the generated tools
4712
+ * of the consultations defined as data in effect, read live as the server reads its catalogs, and
4713
+ * the enabled MCP tools.
4714
+ */
4715
+ toolCatalog() {
4716
+ return [
4717
+ ...(this.scenario.admin?.tools ?? []).map(tool => ({ ...tool, subjectType: tool.subjectType ?? null })),
4718
+ ...this.runConfiguration.consultationTools(),
4719
+ ...this.mcp.catalogEntries()
4720
+ ];
4721
+ }
4722
+ }
4723
+ /** Fails the scenario when the mock refused a part of a preset run configuration, with the mock's answer. */
4724
+ function presetStep(agentId, part, result) {
4725
+ if (!result || result.status >= 300) {
4726
+ throw new Error(`The mock refused the ${part} of agent '${agentId}' in the scenario's run configuration: ${JSON.stringify(result?.body ?? null)}.`);
4727
+ }
4728
+ }
4729
+ function toContext(version) {
4730
+ return { text: version.text, version: version.version, hash: version.hash, updatedAt: version.updatedAt, updatedBy: version.updatedBy };
4731
+ }
4732
+
4733
+ /** The longest package id. */
4734
+ const maxPackageId = 128;
4735
+ /** The longest package description. */
4736
+ const maxPackageDescription = 500;
4737
+ /** The header of the export that lists the references to agents outside the selection. */
4738
+ const warningsHeader = 'X-NewHeap-Package-Warnings';
4739
+ /** The order in which an import writes the parts of its agents. */
4740
+ const phaseOrder = ['agent', 'runSettings', 'skill', 'trigger', 'consultation', 'handOff', 'metadata'];
4741
+ /** The properties of the package format each lockable field stands for. */
4742
+ const lockProperties = {
4743
+ agent: {
4744
+ instructions: ['instructions', 'instructionsAsset'],
4745
+ worksOn: ['worksOnSubjectTypes', 'worksOnAllSubjects', 'worksOnApplication', 'perDivision', 'runPolicy', 'viewPolicy']
4746
+ },
4747
+ runSettings: {
4748
+ dailyBudget: ['dailyToolCalls', 'dailyTokens'],
4749
+ files: ['acceptsFiles', 'maxFileBytes', 'maxFilePages', 'maxFilesPerTurn', 'maxFileTokensPerTurn']
4750
+ },
4751
+ skill: {
4752
+ instructions: ['instructions', 'instructionsAsset'],
4753
+ dailyBudget: ['dailyToolCalls', 'dailyTokens'],
4754
+ files: ['acceptsFiles', 'maxFileBytes', 'maxFilePages', 'maxFilesPerTurn', 'maxFileTokensPerTurn']
4755
+ },
4756
+ trigger: {},
4757
+ consultation: {
4758
+ limits: ['maxToolCalls', 'timeoutSeconds', 'maxAnswerChars', 'maxHistoryMessages']
4759
+ },
4760
+ handOff: {},
4761
+ metadata: {}
4762
+ };
4763
+ /** The fields of the definition an administrator overrides on an agent in code, and its overlay carries. */
4764
+ const codeAgentFields = ['displayName', 'description', 'instructions', 'toolSelectors', 'requiredPolicy', 'autonomy'];
4765
+ /** The properties the strict format reads per object, required ones first. */
4766
+ const formatShapes = {
4767
+ root: { required: ['format', 'formatVersion', 'package', 'agents'], optional: [] },
4768
+ package: { required: ['id', 'version'], optional: ['description'] },
4769
+ agent: {
4770
+ required: ['id', 'source'],
4771
+ optional: ['definition', 'runSettings', 'skills', 'triggers', 'consultations', 'handOffs', 'metadata', 'groups', 'locks', 'hashes']
4772
+ },
4773
+ definition: {
4774
+ required: [],
4775
+ optional: [
4776
+ 'displayName', 'description', 'instructions', 'instructionsAsset', 'toolSelectors', 'requiredPolicy', 'autonomy', 'offeredInChat',
4777
+ 'worksOnSubjectTypes', 'worksOnAllSubjects', 'worksOnApplication', 'perDivision', 'runPolicy', 'viewPolicy', 'profile',
4778
+ 'dataClassification', 'startsOff', 'startsInShadow', 'shadowOf'
4779
+ ]
4780
+ },
4781
+ runSettings: {
4782
+ required: ['accountability'],
4783
+ optional: [
4784
+ 'delegateFromInitiator', 'allowGatewayReads', 'elevatedReads', 'approvalPolicy', 'retryCount', 'dailyToolCalls', 'dailyTokens',
4785
+ 'maxConcurrentRuns', 'maxRunsPerSubjectPerDay', 'maxTokensPerRun', 'triageProfile', 'ruleHandler', 'acceptsFiles', 'maxFileBytes',
4786
+ 'maxFilePages', 'maxFilesPerTurn', 'maxFileTokensPerTurn'
4787
+ ]
4788
+ },
4789
+ skill: {
4790
+ required: ['id', 'description'],
4791
+ optional: [
4792
+ 'instructions', 'instructionsAsset', 'tools', 'profile', 'ruleHandler', 'maxTokensPerRun', 'dailyToolCalls', 'dailyTokens',
4793
+ 'approvalPolicy', 'dataClassification', 'acceptsFiles', 'maxFileBytes', 'maxFilePages', 'maxFilesPerTurn', 'maxFileTokensPerTurn',
4794
+ 'startsOff'
4795
+ ]
4796
+ },
4797
+ trigger: {
4798
+ required: ['id', 'source'],
4799
+ optional: ['skill', 'event', 'filters', 'cron', 'timeZone', 'subjectSet', 'message', 'conflict', 'debounceSeconds']
4800
+ },
4801
+ consultation: {
4802
+ required: ['subjectType', 'description'],
4803
+ optional: [
4804
+ 'instructions', 'minimumRole', 'askerPolicy', 'routes', 'proposalRole', 'proposalPolicy', 'toolSelectors', 'maxToolCalls',
4805
+ 'timeoutSeconds', 'maxAnswerChars', 'maxHistoryMessages', 'allowRequests', 'exposeToMcp', 'dataClassification', 'startsOff'
4806
+ ]
4807
+ },
4808
+ handOff: { required: ['subjectType', 'sources'], optional: ['description', 'notifySource', 'maxPerSubjectPerDay'] },
4809
+ instructionsAsset: { required: ['assetId', 'version', 'hash'], optional: [] }
4810
+ };
4811
+ /**
4812
+ * Agent packages of the mock API (S7-2), with the rules of the server: an export of exactly the chosen
4813
+ * agents with the references to agents outside them, a preview per part (`created`, `changed`,
4814
+ * `unchanged`, `kept-local`, `left-behind`, `refused`) after one check of the whole package against
4815
+ * the host as it would be after the import, and an import that applies every part through the mock's
4816
+ * own endpoints in dependency order, or nothing: a refused part answers `409 assistant-package-refused`
4817
+ * with the plain field error `parts: invalid`, and the preview says why. Linked parts carry their link
4818
+ * in every read; every save path refuses a change of a locked field with `{field}: locked`, and removing
4819
+ * a locked part or deleting or resetting a linked agent answers `409 assistant-package-locked`.
4820
+ * Unlinking makes a part local: the next upgrade keeps it as `kept-local` unless the import relinks it.
4821
+ * An import takes over an agent the host has with a package agent's id and no package of its own when
4822
+ * `relink` names that whole agent; otherwise the agent refuses the package (`assistant-agent-exists`).
4823
+ * Hashes are the SHA-256 of each part's canonical JSON; a hash a package gives is not checked.
4824
+ */
4825
+ class NhAssistantMockAgentPackages {
4826
+ constructor(context) {
4827
+ this.context = context;
4828
+ this.links = new Map();
4829
+ this.installed = new Map();
4830
+ }
4831
+ /**
4832
+ * Imports the scenario's packages in order, as an administrator would; a package the import refuses
4833
+ * fails the scenario at once.
4834
+ */
4835
+ importPresets(presets) {
4836
+ for (const preset of presets) {
4837
+ const result = this.importPackage({ package: preset.package, options: { serviceAccounts: preset.serviceAccounts ?? null } }, true);
4838
+ if (result.status !== 200) {
4839
+ throw new Error(`The mock refused the scenario's agent package '${preset.package?.package?.id}': ${JSON.stringify(result.body ?? null)}.`);
4840
+ }
4841
+ }
4842
+ }
4843
+ /**
4844
+ * `POST admin/agent-packages/export`, `.../preview` and `.../import`, and `POST
4845
+ * admin/agents/{id}/package/unlink`; `null` for another route.
4846
+ */
4847
+ handle(method, segments, body) {
4848
+ if (segments[0] !== 'admin') {
4849
+ return null;
4850
+ }
4851
+ if (segments[1] === 'agent-packages' && segments.length === 3) {
4852
+ if (method !== 'POST') {
4853
+ return { status: 405 };
4854
+ }
4855
+ switch (segments[2]) {
4856
+ case 'export':
4857
+ return this.exportPackage(body);
4858
+ case 'preview':
4859
+ return this.importPackage(body, false);
4860
+ case 'import':
4861
+ return this.importPackage(body, true);
4862
+ default:
4863
+ return mockFailure('assistant-not-found');
4864
+ }
4865
+ }
4866
+ if (segments[1] === 'agents' && segments[3] === 'package' && segments[4] === 'unlink' && segments.length === 5) {
4867
+ return method === 'POST' ? this.unlink(segments[2], body) : { status: 405 };
4868
+ }
4869
+ return null;
4870
+ }
4871
+ /**
4872
+ * Runs a save of the administration under the package locks: a save may keep a locked field's value
4873
+ * or take the package's, never another; such a change is undone and refused with `{field}: locked`.
4874
+ * Removing a part with locks and deleting a linked agent answer `409 assistant-package-locked`, as
4875
+ * does a reset that would give a locked field of the definition another value; removing a linked
4876
+ * part without locks drops its link. Other requests run as they are.
4877
+ */
4878
+ guard(method, segments, run) {
4879
+ const target = guardedPart(method, segments);
4880
+ if (!target) {
4881
+ return run();
4882
+ }
4883
+ if (target.part === null) {
4884
+ return this.isLinked(target.agentId) ? mockFailure('assistant-package-locked') : run();
4885
+ }
4886
+ const reset = target.reset === true;
4887
+ const link = this.links.get(target.agentId)?.parts.get(target.part);
4888
+ if (!link) {
4889
+ return run();
4890
+ }
4891
+ if (target.removal) {
4892
+ if (link.lockedFields.length > 0) {
4893
+ return mockFailure('assistant-package-locked');
4894
+ }
4895
+ const removed = run();
4896
+ if (removed.status < 300) {
4897
+ this.links.get(target.agentId)?.parts.delete(target.part);
4898
+ }
4899
+ return removed;
4900
+ }
4901
+ if (link.lockedFields.length === 0) {
4902
+ return run();
4903
+ }
4904
+ const kind = kindOf(target.part);
4905
+ const before = this.lockView(target.agentId, target.part);
4906
+ const restore = this.context.snapshot();
4907
+ const result = run();
4908
+ if (result.status >= 300) {
4909
+ return result;
4910
+ }
4911
+ const changed = lockedChanges(kind, link.lockedFields, before, this.lockView(target.agentId, target.part), link.view);
4912
+ if (changed.length > 0) {
4913
+ restore();
4914
+ return reset ? mockFailure('assistant-package-locked') : mockFailure('assistant-validation', Object.fromEntries(changed.map(field => [field, ['locked']])));
4915
+ }
4916
+ return result;
4917
+ }
4918
+ /**
4919
+ * Adds the package links to an administration answer: the agent's package on agents, the part's link
4920
+ * on run settings, skills, triggers, consultations, hand-offs and metadata entries.
4921
+ */
4922
+ decorate(method, segments, result) {
4923
+ if (segments[0] !== 'admin' || segments[1] !== 'agents' || result.status >= 300 || result.body === undefined || this.links.size === 0) {
4924
+ return result;
4925
+ }
4926
+ const [, , agentId, area, id, action] = segments;
4927
+ const body = result.body;
4928
+ if (agentId === undefined) {
4929
+ if (Array.isArray(body)) {
4930
+ return { ...result, body: body.map(agent => this.withAgentPackage(agent)) };
4931
+ }
4932
+ return { ...result, body: this.withAgentPackage(body) };
4933
+ }
4934
+ if (area === undefined || (area === 'reset' && segments.length === 4)) {
4935
+ return { ...result, body: this.withAgentPackage(body) };
4936
+ }
4937
+ if (method === 'DELETE' && area !== 'metadata') {
4938
+ return result;
4939
+ }
4940
+ switch (area) {
4941
+ case 'configuration': {
4942
+ const configuration = body;
4943
+ return {
4944
+ ...result,
4945
+ body: {
4946
+ ...configuration,
4947
+ runSettings: configuration.runSettings ? this.withLink(agentId, 'runSettings', configuration.runSettings) : configuration.runSettings,
4948
+ dataSkills: configuration.dataSkills.map(skill => this.withLink(agentId, `skill:${skill.skillId}`, skill)),
4949
+ dataTriggers: configuration.dataTriggers.map(trigger => this.withLink(agentId, `trigger:${trigger.triggerId}`, trigger)),
4950
+ dataConsultations: configuration.dataConsultations?.map(item => this.withLink(agentId, `consultation:${item.subjectType}`, item))
4951
+ }
4952
+ };
4953
+ }
4954
+ case 'run-settings':
4955
+ return { ...result, body: this.withLink(agentId, 'runSettings', body) };
4956
+ case 'skills':
4957
+ return id !== undefined && (segments.length === 5 || (action === 'candidate' && segments[6] === 'promote'))
4958
+ ? { ...result, body: this.withLink(agentId, `skill:${id}`, body) }
4959
+ : result;
4960
+ case 'triggers':
4961
+ return id !== undefined && (segments.length === 5 || (action === 'candidate' && segments[6] === 'promote') || action === 'shadow')
4962
+ ? { ...result, body: this.withLink(agentId, `trigger:${id}`, body) }
4963
+ : result;
4964
+ case 'consultations':
4965
+ return id !== undefined ? { ...result, body: this.withLink(agentId, `consultation:${id}`, body) } : result;
4966
+ case 'hand-offs': {
4967
+ if (id !== undefined) {
4968
+ return { ...result, body: this.withLink(agentId, `handOff:${id}`, body) };
4969
+ }
4970
+ const handOffs = body;
4971
+ return { ...result, body: { ...handOffs, dataHandOffs: handOffs.dataHandOffs.map(item => this.withLink(agentId, `handOff:${item.subjectType}`, item)) } };
4972
+ }
4973
+ case 'metadata':
4974
+ if (id !== undefined) {
4975
+ return { ...result, body: this.withLink(agentId, `metadata:${id}`, body) };
4976
+ }
4977
+ return { ...result, body: body.map(entry => this.withLink(agentId, `metadata:${entry.name}`, entry)) };
4978
+ default:
4979
+ return result;
4980
+ }
4981
+ }
4982
+ /** A copy of the links and the imported packages; the returned function puts them back. */
4983
+ snapshot() {
4984
+ return mockSnapshot(this.links, this.installed);
4985
+ }
4986
+ /** Forgets the links of an agent that was deleted, as the server removes them with it. */
4987
+ forget(agentId) {
4988
+ this.links.delete(agentId);
4989
+ }
4990
+ exportPackage(body) {
4991
+ const request = (body ?? {});
4992
+ const agentIds = request.agentIds;
4993
+ if (!Array.isArray(agentIds) || agentIds.length === 0 || !agentIds.every(id => typeof id === 'string')) {
4994
+ return mockFailure('assistant-validation', { agentIds: ['required'] });
4995
+ }
4996
+ if (agentIds.length > NH_ASSISTANT_AGENT_PACKAGE_MAX_AGENTS) {
4997
+ return mockFailure('assistant-validation', { agentIds: ['too-long'] });
4998
+ }
4999
+ if (new Set(agentIds).size !== agentIds.length) {
5000
+ return mockFailure('assistant-validation', { agentIds: ['invalid'] });
5001
+ }
5002
+ const reads = [];
5003
+ for (const agentId of agentIds) {
5004
+ const read = this.read(agentId);
5005
+ if (!read) {
5006
+ return mockFailure('assistant-agent-not-found');
5007
+ }
5008
+ reads.push(read);
5009
+ }
5010
+ const options = request.options ?? {};
5011
+ const errors = {};
5012
+ const linked = this.links.get(reads[0].agent.id);
5013
+ const packageId = text(options.packageId) ?? linked?.packageId ?? reads[0].agent.id;
5014
+ if (!mockDashCase.test(packageId) || packageId.length > maxPackageId) {
5015
+ errors['packageId'] = ['invalid'];
5016
+ }
5017
+ const installed = this.installed.get(packageId);
5018
+ const version = options.version ?? (installed ? installed.version + 1 : 1);
5019
+ if (!Number.isInteger(version) || version < 1) {
5020
+ errors['version'] = ['invalid'];
5021
+ }
5022
+ const description = text(options.description);
5023
+ if (description !== null && description.length > maxPackageDescription) {
5024
+ errors['description'] = ['too-long'];
5025
+ }
5026
+ const agents = reads.map(read => this.packageAgentOf(read));
5027
+ for (const [agentId, locks] of Object.entries(options.locks ?? {})) {
5028
+ const agent = agents.find(item => item.id === agentId);
5029
+ for (const [part, fields] of Object.entries(locks ?? {})) {
5030
+ // A lock of an agent or part the export does not hold, or of a field its kind does not lock.
5031
+ const problem = agent ? lockProblem(agent, part, fields) : 'not-found';
5032
+ if (problem) {
5033
+ errors['locks'] = ['invalid'];
5034
+ }
5035
+ else if (agent && fields.length > 0) {
5036
+ agent.locks = { ...(agent.locks ?? {}), [part]: [...fields] };
5037
+ }
5038
+ }
5039
+ }
5040
+ if (Object.keys(errors).length > 0) {
5041
+ return mockFailure('assistant-validation', errors);
5042
+ }
5043
+ for (const agent of agents) {
5044
+ agent.hashes = Object.fromEntries([...partsOf(agent)].map(([part, value]) => [part, mockSha256(canonicalJson(value))]));
5045
+ }
5046
+ const agentPackage = {
5047
+ format: NH_ASSISTANT_AGENT_PACKAGE_FORMAT,
5048
+ formatVersion: NH_ASSISTANT_AGENT_PACKAGE_FORMAT_VERSION,
5049
+ package: { id: packageId, version, ...(description !== null ? { description } : {}) },
5050
+ agents
5051
+ };
5052
+ return {
5053
+ status: 200,
5054
+ text: JSON.stringify(withoutNulls(agentPackage), null, 2),
5055
+ headers: {
5056
+ 'content-type': 'application/json',
5057
+ 'content-disposition': `attachment; filename="agent-package-${packageId}.json"`,
5058
+ [warningsHeader]: JSON.stringify(this.warningsOf(reads))
5059
+ }
5060
+ };
5061
+ }
5062
+ /** Every reference of the exported agents to agents outside the selection. */
5063
+ warningsOf(reads) {
5064
+ const selected = new Set(reads.map(read => read.agent.id));
5065
+ const everyone = this.agents();
5066
+ const warnings = [];
5067
+ const outside = (agentId, part, code, referencedAgentId) => {
5068
+ if (!selected.has(referencedAgentId)) {
5069
+ warnings.push({ agentId, part, code, referencedAgentId });
5070
+ }
5071
+ };
5072
+ for (const { agent, configuration, handOffs } of reads) {
5073
+ if (agent.shadowOf) {
5074
+ outside(agent.id, 'agent', 'shadow-of', agent.shadowOf);
5075
+ }
5076
+ for (const variant of everyone.filter(item => item.shadowOf === agent.id)) {
5077
+ outside(agent.id, 'agent', 'shadow-variant', variant.id);
5078
+ }
5079
+ for (const handOff of handOffs?.dataHandOffs ?? []) {
5080
+ for (const source of handOff.sources) {
5081
+ outside(agent.id, `handOff:${handOff.subjectType}`, 'hand-off-source', source);
5082
+ }
5083
+ }
5084
+ for (const consultation of configuration?.dataConsultations ?? []) {
5085
+ for (const asker of consultation.askingAgents) {
5086
+ outside(agent.id, `consultation:${consultation.subjectType}`, 'consultation-asker', asker);
5087
+ }
5088
+ }
5089
+ }
5090
+ return warnings;
5091
+ }
5092
+ /**
5093
+ * `POST admin/agent-packages/preview` (`keep` false) and `.../import`: the package read strictly, its
5094
+ * parts applied in dependency order on a copy of the host, and, for an import without a refused part,
5095
+ * applied for real with the links of its parts. An import with a refused part answers
5096
+ * `assistant-package-refused` with `parts: invalid`, as the server does; the preview names each reason.
5097
+ */
5098
+ importPackage(body, keep) {
5099
+ const request = (body ?? {});
5100
+ const read = readPackage(request.package);
5101
+ if ('status' in read) {
5102
+ return read;
5103
+ }
5104
+ const agentPackage = read.package;
5105
+ const options = request.options ?? {};
5106
+ const apply = options.apply ?? 'live';
5107
+ if (apply !== 'live' && apply !== 'shadow') {
5108
+ return mockFailure('assistant-validation', { apply: ['invalid'] });
5109
+ }
5110
+ // The accounts and the parts to relink name agents of the package, as the server checks them.
5111
+ const ids = new Set(agentPackage.agents.map(agent => agent.id));
5112
+ const optionErrors = {};
5113
+ if (Object.entries(options.serviceAccounts ?? {}).some(([agentId, actorId]) => !ids.has(agentId) || typeof actorId !== 'string' || actorId.trim().length === 0 || actorId.trim().length > 256)) {
5114
+ optionErrors['serviceAccounts'] = ['invalid'];
5115
+ }
5116
+ if ((options.relink ?? []).some(item => !item || !ids.has(item.agentId)
5117
+ || (item.part !== null && item.part !== undefined && (typeof item.part !== 'string' || !validPartKey(item.part))))) {
5118
+ optionErrors['relink'] = ['invalid'];
5119
+ }
5120
+ if (Object.keys(optionErrors).length > 0) {
5121
+ return mockFailure('assistant-validation', optionErrors);
5122
+ }
5123
+ // The check tries every part on a copy of the host, in live mode, and puts the host back.
5124
+ const restoreHost = this.context.snapshot();
5125
+ const restoreOwn = this.snapshot();
5126
+ const planned = this.apply(agentPackage, options, 'live', null);
5127
+ restoreHost();
5128
+ restoreOwn();
5129
+ const preview = {
5130
+ packageId: agentPackage.package.id,
5131
+ packageVersion: agentPackage.package.version,
5132
+ installedVersion: this.installed.get(agentPackage.package.id)?.version ?? null,
5133
+ importable: planned.every(part => part.outcome !== 'refused'),
5134
+ parts: planned.map(publicPart)
5135
+ };
5136
+ if (!keep) {
5137
+ return { status: 200, body: preview };
5138
+ }
5139
+ if (!preview.importable) {
5140
+ return mockFailure('assistant-package-refused', refusedErrors);
5141
+ }
5142
+ // The import applies the package for real, with the chosen mode, and links its parts.
5143
+ const restoreAll = [this.context.snapshot(), this.snapshot()];
5144
+ const applied = this.apply(agentPackage, options, apply, planned);
5145
+ if (applied.some(part => part.outcome === 'refused')) {
5146
+ for (const restore of restoreAll) {
5147
+ restore();
5148
+ }
5149
+ return mockFailure('assistant-package-refused', refusedErrors);
5150
+ }
5151
+ this.link(agentPackage, options, planned);
5152
+ return {
5153
+ status: 200,
5154
+ body: {
5155
+ packageId: agentPackage.package.id,
5156
+ packageVersion: agentPackage.package.version,
5157
+ hash: mockSha256(canonicalJson(agentPackage)),
5158
+ parts: applied.map(publicPart)
5159
+ }
5160
+ };
5161
+ }
5162
+ /**
5163
+ * Applies every agent and part of the package to the host in dependency order: agents that are no
5164
+ * shadow variant of another agent of the package first, each phase for all of them, then the
5165
+ * variants; reports per part what happened, `refused` with the endpoint's code or field errors.
5166
+ * Parts an administrator unlinked stay as they are (`kept-local`) unless `relink` names them; agents
5167
+ * and parts an earlier version of the package linked that it no longer holds are `left-behind`.
5168
+ */
5169
+ apply(agentPackage, options, mode, plan) {
5170
+ const packageId = agentPackage.package.id;
5171
+ const ids = new Set(agentPackage.agents.map(agent => agent.id));
5172
+ const live = agentPackage.agents.filter(agent => !agent.definition?.shadowOf || !ids.has(agent.definition.shadowOf));
5173
+ const variants = agentPackage.agents.filter(agent => !live.includes(agent));
5174
+ const results = new Map();
5175
+ const refusedAgents = new Set();
5176
+ for (const group of [live, variants]) {
5177
+ for (const kind of phaseOrder) {
5178
+ for (const agent of group) {
5179
+ if (refusedAgents.has(agent.id)) {
5180
+ continue;
5181
+ }
5182
+ for (const [part, value] of partsOf(agent)) {
5183
+ if (kindOf(part) !== kind) {
5184
+ continue;
5185
+ }
5186
+ const planned = plan?.find(item => item.agentId === agent.id && item.part === part);
5187
+ // The import writes only what the check found new or changed.
5188
+ const outcome = planned && planned.outcome !== 'created' && planned.outcome !== 'changed'
5189
+ ? planned
5190
+ : this.applyPart(agentPackage, agent, part, value, options, mode);
5191
+ results.set(agent.id, [...(results.get(agent.id) ?? []), outcome]);
5192
+ if (kind === 'agent' && outcome.outcome === 'refused') {
5193
+ refusedAgents.add(agent.id);
5194
+ }
5195
+ }
5196
+ // Groups go with the agent: they are no part, and stay free once imported.
5197
+ if (kind === 'agent' && !refusedAgents.has(agent.id) && agent.groups && !agent.definition?.shadowOf) {
5198
+ const groups = this.context.call('PUT', `admin/agents/${encodeURIComponent(agent.id)}/groups`, { groups: agent.groups });
5199
+ if (groups.status >= 300) {
5200
+ const refusal = refusalOf(agent.id, 'agent', groups, agent.locks?.['agent'] ?? []);
5201
+ results.set(agent.id, [refusal, ...(results.get(agent.id) ?? []).filter(item => item.part !== 'agent')]);
5202
+ refusedAgents.add(agent.id);
5203
+ }
5204
+ }
5205
+ }
5206
+ }
5207
+ }
5208
+ // What an earlier version of the package linked and this one no longer holds stays, unlinked.
5209
+ const ordered = agentPackage.agents.flatMap(agent => results.get(agent.id) ?? []);
5210
+ for (const [agentId, link] of this.links) {
5211
+ if (link.packageId !== packageId) {
5212
+ continue;
5213
+ }
5214
+ if (!ids.has(agentId)) {
5215
+ ordered.push(leftBehind(agentId, 'agent'));
5216
+ continue;
5217
+ }
5218
+ const held = new Set(partsOf(agentPackage.agents.find(agent => agent.id === agentId)).keys());
5219
+ for (const part of link.parts.keys()) {
5220
+ if (!held.has(part)) {
5221
+ ordered.push(leftBehind(agentId, part));
5222
+ }
5223
+ }
5224
+ }
5225
+ return ordered;
5226
+ }
5227
+ /** Applies one part through its endpoint and reports what happened to it. */
5228
+ applyPart(agentPackage, agent, part, value, options, mode) {
5229
+ const lockedFields = agent.locks?.[part] ?? [];
5230
+ const link = this.links.get(agent.id);
5231
+ const linkedHere = link?.packageId === agentPackage.package.id;
5232
+ const relinked = (options.relink ?? []).some(item => item.agentId === agent.id && (item.part === null || item.part === undefined || item.part === part));
5233
+ const lockProblemCode = lockProblem(agent, part, lockedFields);
5234
+ if (lockProblemCode) {
5235
+ return { ...base(agent.id, part, lockedFields), outcome: 'refused', errors: { locks: [lockProblemCode] } };
5236
+ }
5237
+ const host = this.read(agent.id);
5238
+ if (part === 'agent') {
5239
+ // A relink of the whole agent lets the import take over an agent the host has with its id.
5240
+ const takenOver = (options.relink ?? []).some(item => item.agentId === agent.id && (item.part === null || item.part === undefined));
5241
+ const otherPackage = !!link && !linkedHere && link.parts.size > 0;
5242
+ const refusal = agentRefusal(agent, host?.agent ?? null, linkedHere || takenOver, otherPackage);
5243
+ if (refusal) {
5244
+ return { ...base(agent.id, part, lockedFields), outcome: 'refused', code: refusal };
5245
+ }
5246
+ }
5247
+ if (linkedHere && !relinked && (link.local.has(part) || link.local.has(NH_ASSISTANT_PACKAGE_LOCK_ALL))) {
5248
+ return { ...base(agent.id, part, lockedFields), outcome: 'kept-local' };
5249
+ }
5250
+ const before = host ? this.hostHash(host, part) : null;
5251
+ const partLink = linkedHere ? link.parts.get(part) : undefined;
5252
+ const result = this.write(agent, part, value, host, options, mode);
5253
+ if (result.status >= 300) {
5254
+ return refusalOf(agent.id, part, result, lockedFields);
5255
+ }
5256
+ const after = this.read(agent.id);
5257
+ const hashAfter = after ? this.hostHash(after, part) : null;
5258
+ // In shadow a changed skill or trigger is saved as the candidate next to its live version.
5259
+ const kind = kindOf(part);
5260
+ const candidateVersion = mode === 'shadow' && before !== null && (kind === 'skill' || kind === 'trigger')
5261
+ ? result.body?.version ?? null
5262
+ : null;
5263
+ let outcome = 'changed';
5264
+ if (before === null) {
5265
+ outcome = 'created';
5266
+ }
5267
+ else if (candidateVersion === null && before === hashAfter) {
5268
+ outcome = 'unchanged';
5269
+ }
5270
+ return {
5271
+ ...base(agent.id, part, lockedFields),
5272
+ outcome,
5273
+ changedLocally: !!partLink && before !== partLink.hash,
5274
+ candidateVersion,
5275
+ hashAfter,
5276
+ viewAfter: this.lockView(agent.id, part)
5277
+ };
5278
+ }
5279
+ /** Writes one part through the endpoint a save of it uses. */
5280
+ write(agent, part, value, host, options, mode) {
5281
+ const kind = kindOf(part);
5282
+ const key = keyOf(part);
5283
+ const agentPath = `admin/agents/${encodeURIComponent(agent.id)}`;
5284
+ const item = value;
5285
+ switch (kind) {
5286
+ case 'agent':
5287
+ return this.writeDefinition(agent, host);
5288
+ case 'runSettings': {
5289
+ const settings = item;
5290
+ const account = settings.accountability === 'service-account' ? options.serviceAccounts?.[agent.id] ?? null : null;
5291
+ return this.context.call('PUT', `${agentPath}/run-settings`, {
5292
+ ...settings,
5293
+ serviceAccountActorId: account,
5294
+ expectedVersion: host?.configuration?.runSettings?.version ?? null
5295
+ });
5296
+ }
5297
+ case 'skill': {
5298
+ const { id: _, startsOff, ...skill } = item;
5299
+ const current = host?.configuration?.dataSkills.find(existing => existing.skillId === key);
5300
+ return this.context.call('PUT', `${agentPath}/skills/${encodeURIComponent(key)}`, {
5301
+ ...skill,
5302
+ tools: skill['tools'] ?? [],
5303
+ expectedVersion: current?.version ?? null,
5304
+ ...(current ? { apply: mode } : startsOff === true ? { startsOff: true } : {})
5305
+ });
5306
+ }
5307
+ case 'trigger': {
5308
+ const { id: _, ...trigger } = item;
5309
+ const current = host?.configuration?.dataTriggers.find(existing => existing.triggerId === key);
5310
+ return this.context.call('PUT', `${agentPath}/triggers/${encodeURIComponent(key)}`, {
5311
+ ...trigger,
5312
+ expectedVersion: current?.version ?? null,
5313
+ ...(current ? { apply: mode } : {})
5314
+ });
5315
+ }
5316
+ case 'consultation': {
5317
+ const { subjectType: _, startsOff, ...consultation } = item;
5318
+ const current = host?.configuration?.dataConsultations?.find(existing => existing.subjectType === key);
5319
+ return this.context.call('PUT', `${agentPath}/consultations/${encodeURIComponent(key)}`, {
5320
+ ...consultation,
5321
+ routes: consultation['routes'] ?? [],
5322
+ expectedVersion: current?.version ?? null,
5323
+ ...(!current && startsOff === true ? { startsOff: true } : {})
5324
+ });
5325
+ }
5326
+ case 'handOff': {
5327
+ const { subjectType: _, ...handOff } = item;
5328
+ const current = host?.handOffs?.dataHandOffs.find(existing => existing.subjectType === key);
5329
+ return this.context.call('PUT', `${agentPath}/hand-offs/${encodeURIComponent(key)}`, {
5330
+ ...handOff,
5331
+ expectedVersion: current?.version ?? null
5332
+ });
5333
+ }
5334
+ case 'metadata': {
5335
+ const entry = this.read(agent.id)?.metadata.find(existing => existing.name === key);
5336
+ return entry
5337
+ ? this.context.call('PUT', `${agentPath}/metadata/${encodeURIComponent(key)}`, { value, expectedVersion: entry.version })
5338
+ : mockFailure('assistant-metadata-entry-not-found');
5339
+ }
5340
+ }
5341
+ }
5342
+ /**
5343
+ * Writes the definition: creates an agent created here when its id is free, or makes its next
5344
+ * version; overrides an agent in code with the overlay, or restores its code without one.
5345
+ */
5346
+ writeDefinition(agent, host) {
5347
+ const definition = agent.definition ?? {};
5348
+ if (agent.source === 'code') {
5349
+ const code = this.context.codeDefinition(agent.id);
5350
+ const overlay = codeAgentFields.filter(field => definition[field] !== undefined && definition[field] !== null);
5351
+ if (overlay.length === 0) {
5352
+ return host?.agent.isOverridden
5353
+ ? this.context.call('POST', `admin/agents/${encodeURIComponent(agent.id)}/reset`)
5354
+ : { status: 200, body: host?.agent };
5355
+ }
5356
+ return this.context.call('PUT', `admin/agents/${encodeURIComponent(agent.id)}`, {
5357
+ ...code,
5358
+ ...Object.fromEntries(overlay.map(field => [field, definition[field]])),
5359
+ id: agent.id,
5360
+ mcpServerIds: host?.agent.mcpServerIds ?? [],
5361
+ expectedVersion: host?.agent.version ?? 1
5362
+ });
5363
+ }
5364
+ const worksOn = ['worksOnSubjectTypes', 'worksOnAllSubjects', 'worksOnApplication', 'perDivision', 'runPolicy', 'viewPolicy'];
5365
+ const input = {
5366
+ id: agent.id,
5367
+ displayName: definition.displayName ?? '',
5368
+ description: definition.description ?? '',
5369
+ instructions: definition.instructions ?? '',
5370
+ ...(definition.instructionsAsset ? { instructionsAsset: definition.instructionsAsset } : {}),
5371
+ toolSelectors: definition.toolSelectors ?? [],
5372
+ mcpServerIds: host?.agent.mcpServerIds ?? [],
5373
+ requiredPolicy: definition.requiredPolicy ?? null,
5374
+ autonomy: definition.autonomy ?? 'explain',
5375
+ ...(definition.offeredInChat !== undefined && definition.offeredInChat !== null ? { offeredInChat: definition.offeredInChat } : {}),
5376
+ ...(worksOn.some(field => definition[field] !== undefined && definition[field] !== null)
5377
+ ? Object.fromEntries(worksOn.map(field => [field, definition[field] ?? null]))
5378
+ : {}),
5379
+ ...(definition.profile ? { profile: definition.profile } : {}),
5380
+ ...(definition.dataClassification ? { dataClassification: definition.dataClassification } : {})
5381
+ };
5382
+ if (!host) {
5383
+ return this.context.call('POST', 'admin/agents', {
5384
+ ...input,
5385
+ ...(definition.startsOff ? { startsOff: true } : {}),
5386
+ ...(definition.startsInShadow ? { startsInShadow: true } : {}),
5387
+ ...(definition.shadowOf ? { shadowOf: definition.shadowOf } : {})
5388
+ });
5389
+ }
5390
+ return this.context.call('PUT', `admin/agents/${encodeURIComponent(agent.id)}`, { ...input, expectedVersion: host.agent.version });
5391
+ }
5392
+ /**
5393
+ * Links the parts of an import: each part the import applied or found unchanged, with its locks and
5394
+ * the hash of the host's part once the package's version is in effect; parts that stayed local stay
5395
+ * so, relinked ones are linked again, and what the package left behind is unlinked.
5396
+ */
5397
+ link(agentPackage, options, planned) {
5398
+ const packageId = agentPackage.package.id;
5399
+ const version = agentPackage.package.version;
5400
+ const now = this.context.now();
5401
+ for (const agent of agentPackage.agents) {
5402
+ const previous = this.links.get(agent.id);
5403
+ const kept = previous?.packageId === packageId ? previous : undefined;
5404
+ const relinked = (options.relink ?? []).filter(item => item.agentId === agent.id);
5405
+ const local = new Set([...(kept?.local ?? [])].filter(part => !relinked.some(item => item.part === null || item.part === undefined || item.part === part)));
5406
+ if (relinked.some(item => item.part === null || item.part === undefined)) {
5407
+ local.clear();
5408
+ }
5409
+ const parts = new Map();
5410
+ for (const item of planned.filter(entry => entry.agentId === agent.id)) {
5411
+ if (item.outcome === 'kept-local' || item.outcome === 'left-behind' || item.outcome === 'refused') {
5412
+ continue;
5413
+ }
5414
+ const previousLink = kept?.parts.get(item.part);
5415
+ parts.set(item.part, {
5416
+ lockedFields: [...item.lockedFields],
5417
+ hash: item.hashAfter ?? previousLink?.hash ?? '',
5418
+ view: item.viewAfter ?? previousLink?.view ?? null,
5419
+ packageVersion: version,
5420
+ linkedAt: item.outcome === 'unchanged' && previousLink ? previousLink.linkedAt : now,
5421
+ linkedBy: item.outcome === 'unchanged' && previousLink ? previousLink.linkedBy : 'current-user'
5422
+ });
5423
+ }
5424
+ this.links.set(agent.id, { packageId, packageVersion: version, parts, local });
5425
+ }
5426
+ for (const item of planned.filter(entry => entry.outcome === 'left-behind' && entry.part === 'agent')) {
5427
+ this.links.delete(item.agentId);
5428
+ }
5429
+ this.installed.set(packageId, { version, agents: agentPackage.agents.map(agent => agent.id) });
5430
+ }
5431
+ /** `POST admin/agents/{id}/package/unlink`: one part, or without one the whole agent, becomes local. */
5432
+ unlink(agentId, body) {
5433
+ if (!this.read(agentId)) {
5434
+ return mockFailure('assistant-agent-not-found');
5435
+ }
5436
+ const request = (body ?? {});
5437
+ const part = request.part === undefined || request.part === null ? null : request.part;
5438
+ if (part !== null && (typeof part !== 'string' || !validPartKey(part))) {
5439
+ return mockFailure('assistant-validation', { part: ['invalid'] });
5440
+ }
5441
+ if (request.reason !== undefined && request.reason !== null && (typeof request.reason !== 'string' || request.reason.length > mockMaxStateReason)) {
5442
+ return mockFailure('assistant-validation', { reason: ['invalid'] });
5443
+ }
5444
+ const link = this.links.get(agentId);
5445
+ if (!link || link.parts.size === 0 || (part !== null && !link.parts.has(part))) {
5446
+ return mockFailure('assistant-package-not-linked');
5447
+ }
5448
+ const unlinked = part === null ? [...link.parts.keys()] : [part];
5449
+ for (const key of unlinked) {
5450
+ link.parts.delete(key);
5451
+ link.local.add(key);
5452
+ }
5453
+ if (part === null) {
5454
+ link.local.add(NH_ASSISTANT_PACKAGE_LOCK_ALL);
5455
+ }
5456
+ return { status: 200, body: { agentId, packageId: link.packageId, parts: unlinked.sort() } };
5457
+ }
5458
+ /** Whether the agent still has a linked part, so it cannot be deleted or reset. */
5459
+ isLinked(agentId) {
5460
+ return (this.links.get(agentId)?.parts.size ?? 0) > 0;
5461
+ }
5462
+ /** The agent with the package it belongs to, while a part of it is linked. */
5463
+ withAgentPackage(agent) {
5464
+ const link = this.links.get(agent?.id);
5465
+ if (!link || link.parts.size === 0) {
5466
+ return agent;
5467
+ }
5468
+ const read = this.read(agent.id);
5469
+ const value = {
5470
+ packageId: link.packageId,
5471
+ packageVersion: link.packageVersion,
5472
+ parts: [...link.parts.keys()].sort().map(part => this.partLinkOf(agent.id, part, link, read))
5473
+ };
5474
+ return { ...agent, package: value };
5475
+ }
5476
+ /** The item with the link of its part, or as it is when the part is not linked. */
5477
+ withLink(agentId, part, item) {
5478
+ const link = this.links.get(agentId);
5479
+ const value = link ? this.partLinkOf(agentId, part, link, null) : null;
5480
+ return value ? { ...item, package: value } : item;
5481
+ }
5482
+ partLinkOf(agentId, part, link, read) {
5483
+ const partLink = link.parts.get(part);
5484
+ if (!partLink) {
5485
+ return null;
5486
+ }
5487
+ const host = read ?? this.read(agentId);
5488
+ return {
5489
+ part,
5490
+ packageId: link.packageId,
5491
+ packageVersion: partLink.packageVersion,
5492
+ lockedFields: [...partLink.lockedFields],
5493
+ hash: partLink.hash,
5494
+ changedLocally: host ? this.hostHash(host, part) !== partLink.hash : false,
5495
+ linkedAt: partLink.linkedAt,
5496
+ linkedBy: partLink.linkedBy
5497
+ };
5498
+ }
5499
+ /** What the mock has of one agent, read through its own endpoints; `null` for an unknown agent. */
5500
+ read(agentId) {
5501
+ const agent = this.agents().find(item => item.id === agentId);
5502
+ if (!agent) {
5503
+ return null;
5504
+ }
5505
+ const path = `admin/agents/${encodeURIComponent(agentId)}`;
5506
+ const configuration = this.context.call('GET', `${path}/configuration`);
5507
+ const handOffs = this.context.call('GET', `${path}/hand-offs`);
5508
+ const metadata = this.context.call('GET', `${path}/metadata`);
5509
+ return {
5510
+ agent,
5511
+ configuration: configuration.status === 200 ? configuration.body : null,
5512
+ handOffs: handOffs.status === 200 ? handOffs.body : null,
5513
+ metadata: metadata.status === 200 ? metadata.body : []
5514
+ };
5515
+ }
5516
+ agents() {
5517
+ const result = this.context.call('GET', 'admin/agents');
5518
+ return result.status === 200 ? result.body : [];
5519
+ }
5520
+ /** The SHA-256 of the canonical JSON of the host's part as a package holds it; `null` without the part. */
5521
+ hostHash(read, part) {
5522
+ const value = partsOf(this.packageAgentOf(read)).get(part);
5523
+ return value === undefined ? null : mockSha256(canonicalJson(value));
5524
+ }
5525
+ /**
5526
+ * What a save may not change of a part: the agent's definition as it is in effect (for an agent in
5527
+ * code with its code's values), or the part as a package holds it.
5528
+ */
5529
+ lockView(agentId, part) {
5530
+ const read = this.read(agentId);
5531
+ if (!read) {
5532
+ return null;
5533
+ }
5534
+ if (part === 'agent') {
5535
+ return agentDefinitionOf(read.agent);
5536
+ }
5537
+ const value = partsOf(this.packageAgentOf(read)).get(part);
5538
+ return value === undefined ? null : part.startsWith('metadata:') ? { value } : value;
5539
+ }
5540
+ /** The agent as a package holds it, without locks and hashes. */
5541
+ packageAgentOf(read) {
5542
+ const { agent, configuration, handOffs, metadata } = read;
5543
+ const inCode = agent.source === 'code';
5544
+ const definition = inCode ? this.overlayOf(agent) : agentDefinitionOf(agent);
5545
+ const values = metadata.filter(entry => entry.source === (inCode ? 'override' : 'data'));
5546
+ // The projections are the format's JSON; the format's types name the same properties.
5547
+ const packageAgent = {
5548
+ id: agent.id,
5549
+ source: agent.source,
5550
+ ...(definition && Object.keys(definition).length > 0 ? { definition } : {}),
5551
+ ...(!inCode && configuration?.runSettings ? { runSettings: runSettingsOf(configuration.runSettings) } : {}),
5552
+ ...nonEmpty('skills', (configuration?.dataSkills ?? []).map(skillOf)),
5553
+ ...nonEmpty('triggers', (configuration?.dataTriggers ?? []).map(triggerOf)),
5554
+ ...nonEmpty('consultations', (configuration?.dataConsultations ?? []).map(consultationOf)),
5555
+ ...nonEmpty('handOffs', (handOffs?.dataHandOffs ?? []).map(handOff => ({
5556
+ subjectType: handOff.subjectType,
5557
+ sources: [...handOff.sources],
5558
+ description: handOff.description,
5559
+ notifySource: handOff.notifySource,
5560
+ maxPerSubjectPerDay: handOff.maxPerSubjectPerDay
5561
+ })))
5562
+ };
5563
+ if (values.length > 0) {
5564
+ packageAgent['metadata'] = Object.fromEntries(values.map(entry => [entry.name, entry.value]));
5565
+ }
5566
+ if (!agent.shadowOf && (agent.groups?.length ?? 0) > 0) {
5567
+ packageAgent['groups'] = [...agent.groups];
5568
+ }
5569
+ return packageAgent;
5570
+ }
5571
+ /** The overrides of an agent in code: the fields of its definition that differ from its code. */
5572
+ overlayOf(agent) {
5573
+ const code = this.context.codeDefinition(agent.id);
5574
+ if (!code || !agent.isOverridden) {
5575
+ return {};
5576
+ }
5577
+ const definition = agent;
5578
+ const inCode = code;
5579
+ return Object.fromEntries(codeAgentFields
5580
+ .filter(field => !sameValue(definition[field], inCode[field]))
5581
+ .map(field => [field, definition[field]]));
5582
+ }
5583
+ }
5584
+ /** The part of the administration a request saves or removes, or the whole agent it deletes or resets. */
5585
+ function guardedPart(method, segments) {
5586
+ if (segments[0] !== 'admin' || segments[1] !== 'agents' || segments[2] === undefined) {
5587
+ return null;
5588
+ }
5589
+ const [, , agentId, area, id, action, child] = segments;
5590
+ if (segments.length === 3) {
5591
+ return method === 'PUT' ? { agentId, part: 'agent', removal: false } : method === 'DELETE' ? { agentId, part: null, removal: true } : null;
5592
+ }
5593
+ // A reset restores the code's definition, which the locks keep as the package has it.
5594
+ if (area === 'reset' && segments.length === 4) {
5595
+ return method === 'POST' ? { agentId, part: 'agent', removal: false, reset: true } : null;
5596
+ }
5597
+ if (area === 'run-settings' && segments.length === 4) {
5598
+ return method === 'PUT' || method === 'DELETE' ? { agentId, part: 'runSettings', removal: method === 'DELETE' } : null;
5599
+ }
5600
+ const kinds = {
5601
+ skills: 'skill',
5602
+ triggers: 'trigger',
5603
+ consultations: 'consultation',
5604
+ 'hand-offs': 'handOff',
5605
+ metadata: 'metadata'
5606
+ };
5607
+ const kind = area === undefined ? undefined : kinds[area];
5608
+ if (kind === undefined || id === undefined) {
5609
+ return null;
5610
+ }
5611
+ const part = `${kind}:${id}`;
5612
+ if (segments.length === 5) {
5613
+ return method === 'PUT' || method === 'DELETE' ? { agentId, part, removal: method === 'DELETE' } : null;
5614
+ }
5615
+ // Promoting a candidate makes it the live version, which the locks keep as the package has it.
5616
+ if ((kind === 'skill' || kind === 'trigger') && action === 'candidate' && child === 'promote' && segments.length === 7 && method === 'POST') {
5617
+ return { agentId, part, removal: false };
5618
+ }
5619
+ return null;
5620
+ }
5621
+ /**
5622
+ * The locked fields a save changed to something else than the package's value: promoting the
5623
+ * candidate an import made of the package's version is no change of a locked field.
5624
+ */
5625
+ function lockedChanges(kind, locked, before, after, packaged) {
5626
+ const fields = locked.includes(NH_ASSISTANT_PACKAGE_LOCK_ALL) ? NH_ASSISTANT_PACKAGE_LOCK_FIELDS[kind] : locked;
5627
+ return fields.filter(field => {
5628
+ const properties = lockProperties[kind][field] ?? [field];
5629
+ const changed = properties.some(property => !sameValue(before?.[property], after?.[property]));
5630
+ const asPackaged = packaged !== null && properties.every(property => sameValue(after?.[property], packaged[property]));
5631
+ return changed && !asPackaged;
5632
+ });
5633
+ }
5634
+ /** Why a lock of a package agent is invalid: `not-found` for a part it does not hold, `invalid` fields. */
5635
+ function lockProblem(agent, part, fields) {
5636
+ if (fields.length === 0) {
5637
+ return null;
5638
+ }
5639
+ if (!partsOf(agent).has(part)) {
5640
+ return 'not-found';
5641
+ }
5642
+ const kind = kindOf(part);
5643
+ const allowed = kind === 'agent' && agent.source === 'code'
5644
+ ? NH_ASSISTANT_PACKAGE_LOCK_FIELDS.agent.slice(0, codeAgentFields.length)
5645
+ : NH_ASSISTANT_PACKAGE_LOCK_FIELDS[kind];
5646
+ const whole = fields.length === 1 && fields[0] === NH_ASSISTANT_PACKAGE_LOCK_ALL;
5647
+ return whole || fields.every(field => allowed.includes(field)) ? null : 'invalid';
5648
+ }
5649
+ /**
5650
+ * Why the agent of a package may not be imported onto the host's agent with its id, or `null`: an
5651
+ * agent of another package, an agent in code for an agent created here, or an agent created here that
5652
+ * the package neither linked nor takes over (`relink` naming the whole agent).
5653
+ */
5654
+ function agentRefusal(agent, host, linkedToPackage, otherPackage) {
5655
+ if (otherPackage) {
5656
+ return 'assistant-agent-exists';
5657
+ }
5658
+ if (agent.source === 'code') {
5659
+ if (!host) {
5660
+ return 'assistant-agent-not-found';
5661
+ }
5662
+ return host.source === 'code' ? null : 'assistant-agent-not-code';
5663
+ }
5664
+ if (host && (host.source === 'code' || !linkedToPackage)) {
5665
+ return 'assistant-agent-exists';
5666
+ }
5667
+ return null;
5668
+ }
5669
+ /** The parts of a package agent by part key, in the format's order. */
5670
+ function partsOf(agent) {
5671
+ const parts = new Map();
5672
+ if (agent.definition) {
5673
+ parts.set('agent', agent.definition);
5674
+ }
5675
+ if (agent.runSettings) {
5676
+ parts.set('runSettings', agent.runSettings);
5677
+ }
5678
+ for (const skill of agent.skills ?? []) {
5679
+ parts.set(`skill:${skill.id}`, skill);
5680
+ }
5681
+ for (const trigger of agent.triggers ?? []) {
5682
+ parts.set(`trigger:${trigger.id}`, trigger);
5683
+ }
5684
+ for (const consultation of agent.consultations ?? []) {
5685
+ parts.set(`consultation:${consultation.subjectType}`, consultation);
5686
+ }
5687
+ for (const handOff of agent.handOffs ?? []) {
5688
+ parts.set(`handOff:${handOff.subjectType}`, handOff);
5689
+ }
5690
+ for (const [name, value] of Object.entries(agent.metadata ?? {})) {
5691
+ parts.set(`metadata:${name}`, value);
5692
+ }
5693
+ return parts;
5694
+ }
5695
+ function kindOf(part) {
5696
+ const colon = part.indexOf(':');
5697
+ return (colon < 0 ? part : part.slice(0, colon));
5698
+ }
5699
+ function keyOf(part) {
5700
+ const colon = part.indexOf(':');
5701
+ return colon < 0 ? '' : part.slice(colon + 1);
5702
+ }
5703
+ /** Whether a part key names a known kind, with a dash-case key for the kinds that take one. */
5704
+ function validPartKey(part) {
5705
+ if (part === 'agent' || part === 'runSettings') {
5706
+ return true;
5707
+ }
5708
+ const kind = kindOf(part);
5709
+ return ['skill', 'trigger', 'consultation', 'handOff', 'metadata'].includes(kind) && mockDashCase.test(keyOf(part));
5710
+ }
5711
+ /** The definition of an agent as a package holds an agent created here. */
5712
+ function agentDefinitionOf(agent) {
5713
+ return {
5714
+ displayName: agent.displayName,
5715
+ description: agent.description,
5716
+ instructions: agent.instructionsAsset ? null : agent.instructions,
5717
+ instructionsAsset: agent.instructionsAsset ?? null,
5718
+ toolSelectors: [...agent.toolSelectors],
5719
+ requiredPolicy: agent.requiredPolicy,
5720
+ autonomy: agent.autonomy,
5721
+ offeredInChat: agent.offeredInChat ?? true,
5722
+ worksOnSubjectTypes: agent.worksOnSubjectTypes ?? null,
5723
+ worksOnAllSubjects: agent.worksOnAllSubjects ?? null,
5724
+ worksOnApplication: agent.worksOnApplication ?? null,
5725
+ perDivision: agent.perDivision ?? null,
5726
+ runPolicy: agent.runPolicy ?? null,
5727
+ viewPolicy: agent.viewPolicy ?? null,
5728
+ profile: agent.profile ?? null,
5729
+ dataClassification: agent.dataClassification ?? null,
5730
+ shadowOf: agent.shadowOf ?? null
5731
+ };
5732
+ }
5733
+ function runSettingsOf(settings) {
5734
+ return {
5735
+ accountability: settings.accountability,
5736
+ delegateFromInitiator: settings.delegateFromInitiator,
5737
+ allowGatewayReads: settings.allowGatewayReads,
5738
+ elevatedReads: settings.elevatedReads,
5739
+ approvalPolicy: settings.approvalPolicy,
5740
+ retryCount: settings.retryCount,
5741
+ dailyToolCalls: settings.dailyToolCalls,
5742
+ dailyTokens: settings.dailyTokens,
5743
+ maxConcurrentRuns: settings.maxConcurrentRuns,
5744
+ maxRunsPerSubjectPerDay: settings.maxRunsPerSubjectPerDay,
5745
+ maxTokensPerRun: settings.maxTokensPerRun,
5746
+ triageProfile: settings.triageProfile,
5747
+ ruleHandler: settings.ruleHandler,
5748
+ acceptsFiles: settings.acceptsFiles ?? false,
5749
+ maxFileBytes: settings.maxFileBytes ?? null,
5750
+ maxFilePages: settings.maxFilePages ?? null,
5751
+ maxFilesPerTurn: settings.maxFilesPerTurn ?? null,
5752
+ maxFileTokensPerTurn: settings.maxFileTokensPerTurn ?? null
5753
+ };
5754
+ }
5755
+ function skillOf(skill) {
5756
+ return {
5757
+ id: skill.skillId,
5758
+ description: skill.description,
5759
+ instructions: skill.instructionsAsset ? null : skill.instructions,
5760
+ instructionsAsset: skill.instructionsAsset ?? null,
5761
+ tools: [...skill.tools],
5762
+ profile: skill.profile,
5763
+ ruleHandler: skill.ruleHandler,
5764
+ maxTokensPerRun: skill.maxTokensPerRun,
5765
+ dailyToolCalls: skill.dailyToolCalls,
5766
+ dailyTokens: skill.dailyTokens,
5767
+ approvalPolicy: skill.approvalPolicy,
5768
+ dataClassification: skill.dataClassification ?? null,
5769
+ acceptsFiles: skill.acceptsFiles ?? false,
5770
+ maxFileBytes: skill.maxFileBytes ?? null,
5771
+ maxFilePages: skill.maxFilePages ?? null,
5772
+ maxFilesPerTurn: skill.maxFilesPerTurn ?? null,
5773
+ maxFileTokensPerTurn: skill.maxFileTokensPerTurn ?? null
5774
+ };
5775
+ }
5776
+ function triggerOf(trigger) {
5777
+ return {
5778
+ id: trigger.triggerId,
5779
+ source: trigger.source,
5780
+ skill: trigger.skillId,
5781
+ event: trigger.event,
5782
+ filters: [...(trigger.filters ?? [])],
5783
+ cron: trigger.cron,
5784
+ timeZone: trigger.timeZone,
5785
+ subjectSet: trigger.subjectSet,
5786
+ message: trigger.message,
5787
+ conflict: trigger.conflict,
5788
+ debounceSeconds: trigger.debounceSeconds
5789
+ };
5790
+ }
5791
+ function consultationOf(consultation) {
5792
+ return {
5793
+ subjectType: consultation.subjectType,
5794
+ description: consultation.description,
5795
+ instructions: consultation.instructions,
5796
+ minimumRole: consultation.minimumRole,
5797
+ askerPolicy: consultation.askerPolicy,
5798
+ routes: [...consultation.routes],
5799
+ proposalRole: consultation.proposalRole,
5800
+ proposalPolicy: consultation.proposalPolicy,
5801
+ toolSelectors: consultation.toolSelectors ?? null,
5802
+ maxToolCalls: consultation.maxToolCalls,
5803
+ timeoutSeconds: consultation.timeoutSeconds,
5804
+ maxAnswerChars: consultation.maxAnswerChars,
5805
+ maxHistoryMessages: consultation.maxHistoryMessages,
5806
+ allowRequests: consultation.allowRequests,
5807
+ exposeToMcp: consultation.exposeToMcp,
5808
+ dataClassification: consultation.dataClassification
5809
+ };
5810
+ }
5811
+ /** `{ [name]: items }` for a list with items, otherwise nothing, as the format leaves empty lists out. */
5812
+ function nonEmpty(name, items) {
5813
+ return items.length > 0 ? { [name]: items } : {};
5814
+ }
5815
+ /**
5816
+ * Reads a package strictly, as the server does: `assistant-package-too-large` above 1 MB or 20
5817
+ * agents; `assistant-package-format-unsupported` for another format, a newer format version, an
5818
+ * unknown property, a missing required one or an agent id given twice.
5819
+ */
5820
+ function readPackage(value) {
5821
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
5822
+ return mockFailure('assistant-package-format-unsupported');
5823
+ }
5824
+ if (new TextEncoder().encode(JSON.stringify(value)).length > NH_ASSISTANT_AGENT_PACKAGE_MAX_BYTES) {
5825
+ return mockFailure('assistant-package-too-large');
5826
+ }
5827
+ const root = value;
5828
+ if (root['format'] !== NH_ASSISTANT_AGENT_PACKAGE_FORMAT
5829
+ || typeof root['formatVersion'] !== 'number'
5830
+ || !Number.isInteger(root['formatVersion'])
5831
+ || root['formatVersion'] < 1
5832
+ || root['formatVersion'] > NH_ASSISTANT_AGENT_PACKAGE_FORMAT_VERSION) {
5833
+ return mockFailure('assistant-package-format-unsupported');
5834
+ }
5835
+ const agents = root['agents'];
5836
+ if (!fits(root, 'root') || !fits(root['package'], 'package') || !Array.isArray(agents)) {
5837
+ return mockFailure('assistant-package-format-unsupported');
5838
+ }
5839
+ if (agents.length > NH_ASSISTANT_AGENT_PACKAGE_MAX_AGENTS) {
5840
+ return mockFailure('assistant-package-too-large');
5841
+ }
5842
+ const info = root['package'];
5843
+ const ids = new Set();
5844
+ for (const item of agents) {
5845
+ if (!fitsAgent(item)) {
5846
+ return mockFailure('assistant-package-format-unsupported');
5847
+ }
5848
+ const id = item['id'];
5849
+ if (ids.has(id)) {
5850
+ return mockFailure('assistant-package-format-unsupported');
5851
+ }
5852
+ ids.add(id);
5853
+ }
5854
+ if (agents.length === 0) {
5855
+ return mockFailure('assistant-package-format-unsupported');
5856
+ }
5857
+ const errors = {};
5858
+ if (typeof info['id'] !== 'string' || !mockDashCase.test(info['id']) || info['id'].length > maxPackageId) {
5859
+ errors['package.id'] = ['invalid'];
5860
+ }
5861
+ if (typeof info['version'] !== 'number' || !Number.isInteger(info['version']) || info['version'] < 1) {
5862
+ errors['package.version'] = ['invalid'];
5863
+ }
5864
+ if (info['description'] !== undefined && info['description'] !== null
5865
+ && (typeof info['description'] !== 'string' || info['description'].length > maxPackageDescription)) {
5866
+ errors['package.description'] = ['too-long'];
5867
+ }
5868
+ return Object.keys(errors).length > 0 ? mockFailure('assistant-validation', errors) : { package: structuredClone(value) };
5869
+ }
5870
+ function fitsAgent(value) {
5871
+ if (!fits(value, 'agent')) {
5872
+ return false;
5873
+ }
5874
+ const agent = value;
5875
+ if (typeof agent['id'] !== 'string' || (agent['source'] !== 'admin' && agent['source'] !== 'code')) {
5876
+ return false;
5877
+ }
5878
+ if (agent['source'] === 'admin' && (agent['definition'] === undefined || agent['definition'] === null)) {
5879
+ return false;
5880
+ }
5881
+ const definition = agent['definition'];
5882
+ if (definition !== undefined && definition !== null
5883
+ && (!fits(definition, 'definition') || (definition['instructionsAsset'] && !fits(definition['instructionsAsset'], 'instructionsAsset')))) {
5884
+ return false;
5885
+ }
5886
+ if (agent['runSettings'] !== undefined && agent['runSettings'] !== null && !fits(agent['runSettings'], 'runSettings')) {
5887
+ return false;
5888
+ }
5889
+ const lists = [['skills', 'skill'], ['triggers', 'trigger'], ['consultations', 'consultation'], ['handOffs', 'handOff']];
5890
+ for (const [list, shape] of lists) {
5891
+ const items = agent[list];
5892
+ if (items !== undefined && items !== null && (!Array.isArray(items) || !items.every(item => fits(item, shape)))) {
5893
+ return false;
5894
+ }
5895
+ }
5896
+ for (const map of ['metadata', 'locks', 'hashes']) {
5897
+ const item = agent[map];
5898
+ if (item !== undefined && item !== null && (typeof item !== 'object' || Array.isArray(item))) {
5899
+ return false;
3733
5900
  }
3734
5901
  }
3735
- /**
3736
- * The scenario's tools, with the object type they name as the server reports it, the generated tools
3737
- * of the consultations defined as data in effect, read live as the server reads its catalogs, and
3738
- * the enabled MCP tools.
3739
- */
3740
- toolCatalog() {
3741
- return [
3742
- ...(this.scenario.admin?.tools ?? []).map(tool => ({ ...tool, subjectType: tool.subjectType ?? null })),
3743
- ...this.runConfiguration.consultationTools(),
3744
- ...this.mcp.catalogEntries()
3745
- ];
5902
+ const groups = agent['groups'];
5903
+ return groups === undefined || groups === null || (Array.isArray(groups) && groups.every(group => typeof group === 'string'));
5904
+ }
5905
+ /** Whether an object has every required property of its shape and none it does not know. */
5906
+ function fits(value, shape) {
5907
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
5908
+ return false;
3746
5909
  }
5910
+ const { required, optional } = formatShapes[shape];
5911
+ const record = value;
5912
+ return required.every(name => record[name] !== undefined && record[name] !== null)
5913
+ && Object.keys(record).every(name => required.includes(name) || optional.includes(name));
3747
5914
  }
3748
- /** Fails the scenario when the mock refused a part of a preset run configuration, with the mock's answer. */
3749
- function presetStep(agentId, part, result) {
3750
- if (!result || result.status >= 300) {
3751
- throw new Error(`The mock refused the ${part} of agent '${agentId}' in the scenario's run configuration: ${JSON.stringify(result?.body ?? null)}.`);
5915
+ function base(agentId, part, lockedFields) {
5916
+ return {
5917
+ agentId,
5918
+ part,
5919
+ outcome: 'unchanged',
5920
+ changedLocally: false,
5921
+ code: null,
5922
+ errors: null,
5923
+ lockedFields: [...lockedFields],
5924
+ candidateVersion: null,
5925
+ hashAfter: null,
5926
+ viewAfter: null
5927
+ };
5928
+ }
5929
+ function leftBehind(agentId, part) {
5930
+ return { ...base(agentId, part, []), outcome: 'left-behind' };
5931
+ }
5932
+ /** A refused part with the endpoint's field errors, or its code without them. */
5933
+ function refusalOf(agentId, part, result, lockedFields) {
5934
+ const body = (result.body ?? {});
5935
+ const errors = body.errors && Object.keys(body.errors).length > 0 ? body.errors : null;
5936
+ return {
5937
+ ...base(agentId, part, lockedFields),
5938
+ outcome: 'refused',
5939
+ code: errors ? null : body.code ?? 'assistant-validation',
5940
+ errors
5941
+ };
5942
+ }
5943
+ /**
5944
+ * The field errors of `assistant-package-refused`, as the server answers them: one plain field, since
5945
+ * the preview's part outcomes say why each part was refused.
5946
+ */
5947
+ const refusedErrors = { parts: ['invalid'] };
5948
+ function publicPart({ hashAfter: _, viewAfter: __, ...part }) {
5949
+ return part;
5950
+ }
5951
+ /** A trimmed text, or `null` for none. */
5952
+ function text(value) {
5953
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
5954
+ }
5955
+ /** Whether two values are the same as a package holds them: `null` and missing alike. */
5956
+ function sameValue(left, right) {
5957
+ return canonicalJson(left ?? null) === canonicalJson(right ?? null);
5958
+ }
5959
+ /** A copy of a value without `null` and `undefined` properties, as the format writes it. */
5960
+ function withoutNulls(value) {
5961
+ if (Array.isArray(value)) {
5962
+ return value.map(item => withoutNulls(item));
3752
5963
  }
5964
+ if (value && typeof value === 'object') {
5965
+ return Object.fromEntries(Object.entries(value)
5966
+ .filter(([, item]) => item !== null && item !== undefined)
5967
+ .map(([key, item]) => [key, withoutNulls(item)]));
5968
+ }
5969
+ return value;
3753
5970
  }
3754
- function toContext(version) {
3755
- return { text: version.text, version: version.version, hash: version.hash, updatedAt: version.updatedAt, updatedBy: version.updatedBy };
5971
+ /**
5972
+ * The canonical JSON of a value, as the format hashes a part: object properties ordered by name
5973
+ * (ordinal), properties with a `null` value left out, no white space.
5974
+ */
5975
+ function canonicalJson(value) {
5976
+ if (value === null || value === undefined) {
5977
+ return 'null';
5978
+ }
5979
+ if (Array.isArray(value)) {
5980
+ return `[${value.map(item => canonicalJson(item)).join(',')}]`;
5981
+ }
5982
+ if (typeof value === 'object') {
5983
+ const entries = Object.entries(value)
5984
+ .filter(([, item]) => item !== null && item !== undefined)
5985
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
5986
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}`;
5987
+ }
5988
+ return JSON.stringify(value);
5989
+ }
5990
+ const sha256Constants = new Uint32Array([
5991
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
5992
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
5993
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
5994
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
5995
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
5996
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
5997
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
5998
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
5999
+ ]);
6000
+ /**
6001
+ * The SHA-256 of the UTF-8 bytes of a text, lowercase hex, computed synchronously so an import can
6002
+ * hash its parts between two of its writes.
6003
+ */
6004
+ function mockSha256(input) {
6005
+ const bytes = new TextEncoder().encode(input);
6006
+ const length = ((bytes.length + 9 + 63) >> 6) << 6;
6007
+ const data = new Uint8Array(length);
6008
+ data.set(bytes);
6009
+ data[bytes.length] = 0x80;
6010
+ const view = new DataView(data.buffer);
6011
+ view.setUint32(length - 8, Math.floor(bytes.length / 0x20000000));
6012
+ view.setUint32(length - 4, (bytes.length << 3) >>> 0);
6013
+ const hash = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
6014
+ const words = new Uint32Array(64);
6015
+ const rotate = (value, bits) => (value >>> bits) | (value << (32 - bits));
6016
+ for (let offset = 0; offset < length; offset += 64) {
6017
+ for (let index = 0; index < 16; index++) {
6018
+ words[index] = view.getUint32(offset + index * 4);
6019
+ }
6020
+ for (let index = 16; index < 64; index++) {
6021
+ const low = words[index - 15];
6022
+ const high = words[index - 2];
6023
+ const s0 = rotate(low, 7) ^ rotate(low, 18) ^ (low >>> 3);
6024
+ const s1 = rotate(high, 17) ^ rotate(high, 19) ^ (high >>> 10);
6025
+ words[index] = (words[index - 16] + s0 + words[index - 7] + s1) >>> 0;
6026
+ }
6027
+ let [a, b, c, d, e, f, g, h] = hash;
6028
+ for (let index = 0; index < 64; index++) {
6029
+ const s1 = rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25);
6030
+ const choice = (e & f) ^ (~e & g);
6031
+ const first = (h + s1 + choice + sha256Constants[index] + words[index]) >>> 0;
6032
+ const s0 = rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22);
6033
+ const majority = (a & b) ^ (a & c) ^ (b & c);
6034
+ const second = (s0 + majority) >>> 0;
6035
+ h = g;
6036
+ g = f;
6037
+ f = e;
6038
+ e = (d + first) >>> 0;
6039
+ d = c;
6040
+ c = b;
6041
+ b = a;
6042
+ a = (first + second) >>> 0;
6043
+ }
6044
+ hash[0] = (hash[0] + a) >>> 0;
6045
+ hash[1] = (hash[1] + b) >>> 0;
6046
+ hash[2] = (hash[2] + c) >>> 0;
6047
+ hash[3] = (hash[3] + d) >>> 0;
6048
+ hash[4] = (hash[4] + e) >>> 0;
6049
+ hash[5] = (hash[5] + f) >>> 0;
6050
+ hash[6] = (hash[6] + g) >>> 0;
6051
+ hash[7] = (hash[7] + h) >>> 0;
6052
+ }
6053
+ return [...hash].map(word => word.toString(16).padStart(8, '0')).join('');
3756
6054
  }
3757
6055
 
3758
6056
  /** The longest file name a reference takes, as on the server. */
@@ -3777,7 +6075,7 @@ const binarySignatures = [
3777
6075
  ];
3778
6076
  /** How files of the platform's types start; other accepted types are the application's to check. */
3779
6077
  const signatures = {
3780
- 'application/pdf': head => indexOf(head, [0x25, 0x50, 0x44, 0x46, 0x2d]) >= 0,
6078
+ 'application/pdf': head => indexOf$1(head, [0x25, 0x50, 0x44, 0x46, 0x2d]) >= 0,
3781
6079
  'image/png': head => startsWith(head, pngSignature),
3782
6080
  'image/jpeg': head => startsWith(head, jpegSignature),
3783
6081
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document': head => startsWith(head, zipSignature),
@@ -3936,7 +6234,7 @@ function refusalCode(code) {
3936
6234
  function startsWith(head, signature) {
3937
6235
  return head.length >= signature.length && signature.every((byte, index) => head[index] === byte);
3938
6236
  }
3939
- function indexOf(head, signature) {
6237
+ function indexOf$1(head, signature) {
3940
6238
  for (let start = 0; start + signature.length <= head.length; start++) {
3941
6239
  if (signature.every((byte, index) => head[start + index] === byte)) {
3942
6240
  return start;
@@ -3945,7 +6243,7 @@ function indexOf(head, signature) {
3945
6243
  return -1;
3946
6244
  }
3947
6245
 
3948
- const dashCase$3 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
6246
+ const dashCase$4 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
3949
6247
  const minQueryLength = 2;
3950
6248
  const maxQueryLength = 100;
3951
6249
  const defaultLimit = 20;
@@ -3972,7 +6270,7 @@ class NhAssistantMockSubjects {
3972
6270
  this.openProposals = openProposals;
3973
6271
  const ids = new Set();
3974
6272
  for (const type of types) {
3975
- if (!dashCase$3.test(type.id) || type.id.length > 100) {
6273
+ if (!dashCase$4.test(type.id) || type.id.length > 100) {
3976
6274
  throw new Error(`Mock subject type '${type.id}' is not a dash-case id of at most 100 characters.`);
3977
6275
  }
3978
6276
  if (type.id.startsWith('nh-')) {
@@ -4128,7 +6426,7 @@ class NhAssistantMockSubjects {
4128
6426
  * subject not found.
4129
6427
  */
4130
6428
  work(typeId, subjectId, language) {
4131
- if (!dashCase$3.test(typeId) || typeId.length > 100 || subjectId.trim().length === 0 || subjectId.length > 200 || subjectId.trim() !== subjectId) {
6429
+ if (!dashCase$4.test(typeId) || typeId.length > 100 || subjectId.trim().length === 0 || subjectId.length > 200 || subjectId.trim() !== subjectId) {
4132
6430
  return mockFailure('assistant-subject-invalid');
4133
6431
  }
4134
6432
  if (typeId === systemType$1) {
@@ -4245,7 +6543,7 @@ function isValidLinkTemplate(template) {
4245
6543
  return pathStart > 0 && template.indexOf(placeholder) > pathStart;
4246
6544
  }
4247
6545
 
4248
- const dashCase$2 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
6546
+ const dashCase$3 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
4249
6547
  const maxMessage = 8_000;
4250
6548
  const maxSubjectId = 200;
4251
6549
  const maxAgentId = 128;
@@ -4963,7 +7261,7 @@ function key(agentId, subject) {
4963
7261
  return `${agentId}|${subject.subjectType}|${subject.subjectId}`;
4964
7262
  }
4965
7263
  function validSubject(type, id) {
4966
- return dashCase$2.test(type) && type.length <= 100 && id.trim().length > 0 && id.length <= maxSubjectId && id.trim() === id;
7264
+ return dashCase$3.test(type) && type.length <= 100 && id.trim().length > 0 && id.length <= maxSubjectId && id.trim() === id;
4967
7265
  }
4968
7266
  /** The type the agent works on, and for the whole application its scope: app-wide or per division. */
4969
7267
  function worksOn(agent, subject) {
@@ -5033,7 +7331,7 @@ function readFilter(query, withAgent) {
5033
7331
  errors['agentId'] = ['invalid'];
5034
7332
  }
5035
7333
  const subjectType = query.get('subjectType');
5036
- if (subjectType !== null && (!dashCase$2.test(subjectType) || subjectType.length > 100)) {
7334
+ if (subjectType !== null && (!dashCase$3.test(subjectType) || subjectType.length > 100)) {
5037
7335
  errors['subjectType'] = ['invalid'];
5038
7336
  }
5039
7337
  const subjectId = query.get('subjectId');
@@ -5063,7 +7361,7 @@ function readFilter(query, withAgent) {
5063
7361
  return { filter: { agentId, subjectType, subjectId, status, from, to, page, itemsPerPage } };
5064
7362
  }
5065
7363
 
5066
- const dashCase$1 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
7364
+ const dashCase$2 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
5067
7365
  const language = /^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/;
5068
7366
  const maxGroupId = 64;
5069
7367
  const maxGroups = 100;
@@ -5125,6 +7423,13 @@ class NhAssistantMockBoards {
5125
7423
  }
5126
7424
  return { agentId, groups: [...groups].sort(), codeGroups };
5127
7425
  }
7426
+ /**
7427
+ * A copy of the administrators' group choices; the returned function puts it back, for an import
7428
+ * of an agent package that fails as a whole and for its preview.
7429
+ */
7430
+ snapshot() {
7431
+ return mockSnapshot(this.choices);
7432
+ }
5128
7433
  /** Forgets the choices about an agent that was deleted. */
5129
7434
  forget(agentId) {
5130
7435
  this.choices.delete(agentId);
@@ -5307,7 +7612,7 @@ function validate(groups) {
5307
7612
  }
5308
7613
  const seen = new Set();
5309
7614
  for (const group of groups) {
5310
- if (!dashCase$1.test(group.id) || group.id.length > maxGroupId) {
7615
+ if (!dashCase$2.test(group.id) || group.id.length > maxGroupId) {
5311
7616
  throw new Error(`The mock agent group '${group.id}' is not a dash-case id of at most ${maxGroupId} characters.`);
5312
7617
  }
5313
7618
  if (seen.has(group.id)) {
@@ -5333,6 +7638,239 @@ function checkTexts(groupId, kind, texts, fallback, max) {
5333
7638
  }
5334
7639
  }
5335
7640
 
7641
+ const defaultMaxBytes = 4_096;
7642
+ const dashCase$1 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7643
+ /**
7644
+ * Agent metadata of the mock API (MD1 to MD3), as the server serves it: `admin.agentMetadata` of the
7645
+ * scenario are the entries registered in code, and `metadata` of each scenario agent its values, in
7646
+ * code for an agent in code and stored at the first version for an agent created as data.
7647
+ * `GET admin/agents/{id}/metadata`, `PUT` and `DELETE admin/agents/{id}/metadata/{name}` answer with
7648
+ * the server's checks: an entry of an agent created here makes the agent's next version, an editable
7649
+ * entry of an agent in code is an override with a version of its own, and a read-only one is refused.
7650
+ * Values of `POST` and `PUT admin/agents` are not taken: the agent editor saves each entry through its
7651
+ * slot. Values live as long as the mock.
7652
+ */
7653
+ class NhAssistantMockAgentMetadata {
7654
+ constructor(scenario, context, now) {
7655
+ this.context = context;
7656
+ this.now = now;
7657
+ /** The values in code, by agent and entry. */
7658
+ this.code = new Map();
7659
+ /** The stored values, by agent and entry. */
7660
+ this.stored = new Map();
7661
+ this.entries = [...(scenario.admin?.agentMetadata ?? [])].sort((left, right) => left.name.localeCompare(right.name));
7662
+ for (const entry of this.entries) {
7663
+ if (!dashCase$1.test(entry.name) || entry.name.length > 64 || entry.name.startsWith('nh-')) {
7664
+ throw new Error(`The mock's agent metadata entry '${entry.name}' is not a valid name.`);
7665
+ }
7666
+ }
7667
+ for (const agent of scenario.admin?.agents ?? []) {
7668
+ for (const [name, value] of Object.entries(agent.metadata ?? {})) {
7669
+ const entry = this.entries.find(item => item.name === name);
7670
+ // Startup refuses a value that a save would refuse.
7671
+ const problems = entry ? this.problemsOf(entry, value) : { '': ['not-found'] };
7672
+ if (!entry || Object.keys(problems).length > 0) {
7673
+ throw new Error(`The mock agent '${agent.id}' sets metadata '${name}' that the entries refuse.`);
7674
+ }
7675
+ if (agent.source === 'code') {
7676
+ this.valuesOf(this.code, agent.id).set(name, structuredClone(value));
7677
+ }
7678
+ else {
7679
+ this.valuesOf(this.stored, agent.id).set(name, {
7680
+ value: structuredClone(value),
7681
+ version: 1,
7682
+ changedAt: this.now(),
7683
+ changedBy: 'current-user'
7684
+ });
7685
+ }
7686
+ }
7687
+ }
7688
+ }
7689
+ /** The run catalog's `agentMetadata`. */
7690
+ catalog() {
7691
+ return this.entries.map(entry => ({
7692
+ name: entry.name,
7693
+ editable: entry.editable === true,
7694
+ maxBytes: entry.maxBytes ?? defaultMaxBytes,
7695
+ indexed: entry.indexBy !== undefined || entry.indexByEach !== undefined,
7696
+ indexedByEach: entry.indexByEach !== undefined
7697
+ }));
7698
+ }
7699
+ /**
7700
+ * A copy of the stored values; the returned function puts it back, for an import of an agent
7701
+ * package that fails as a whole and for its preview.
7702
+ */
7703
+ snapshot() {
7704
+ return mockSnapshot(this.stored);
7705
+ }
7706
+ /** Forgets the values of an agent that was deleted, as the server removes them with it. */
7707
+ forget(agentId) {
7708
+ this.stored.delete(agentId);
7709
+ }
7710
+ /** `GET admin/agents/{id}/metadata`, `PUT` and `DELETE admin/agents/{id}/metadata/{name}`. */
7711
+ handle(method, agentId, name, query, body) {
7712
+ if (!this.context.administers()) {
7713
+ return mockFailure('assistant-forbidden');
7714
+ }
7715
+ if (name === undefined) {
7716
+ if (method !== 'GET') {
7717
+ return { status: 405 };
7718
+ }
7719
+ const agent = this.context.agent(agentId);
7720
+ return agent
7721
+ ? { status: 200, body: this.entries.map(entry => this.view(agent, entry)) }
7722
+ : mockFailure('assistant-agent-not-found');
7723
+ }
7724
+ if (method !== 'PUT' && method !== 'DELETE') {
7725
+ return { status: 405 };
7726
+ }
7727
+ const entry = this.entries.find(item => item.name === name);
7728
+ if (!entry) {
7729
+ return mockFailure('assistant-metadata-entry-not-found');
7730
+ }
7731
+ const agent = this.context.agent(agentId);
7732
+ if (!agent) {
7733
+ return mockFailure('assistant-agent-not-found');
7734
+ }
7735
+ if (agent.source === 'code' && entry.editable !== true) {
7736
+ return mockFailure('assistant-metadata-read-only');
7737
+ }
7738
+ return method === 'PUT'
7739
+ ? this.save(agent, entry, body)
7740
+ : this.remove(agent, entry, query.get('expectedVersion'));
7741
+ }
7742
+ save(agent, entry, request) {
7743
+ if (!request || typeof request !== 'object') {
7744
+ return mockFailure('assistant-validation');
7745
+ }
7746
+ const errors = {};
7747
+ for (const [field, codes] of Object.entries(this.problemsOf(entry, request.value))) {
7748
+ errors[field === '' ? 'value' : `value.${field}`] = codes;
7749
+ }
7750
+ const expectedVersion = typeof request.expectedVersion === 'number' ? request.expectedVersion : null;
7751
+ if (agent.source !== 'code' && expectedVersion === null) {
7752
+ errors['expectedVersion'] = ['required'];
7753
+ }
7754
+ if (Object.keys(errors).length > 0) {
7755
+ return mockFailure('assistant-validation', errors);
7756
+ }
7757
+ const values = this.valuesOf(this.stored, agent.id);
7758
+ const current = values.get(entry.name);
7759
+ let version;
7760
+ if (agent.source === 'code') {
7761
+ if (expectedVersion !== (current?.version ?? null)) {
7762
+ return mockFailure('assistant-version-conflict');
7763
+ }
7764
+ version = (current?.version ?? 0) + 1;
7765
+ }
7766
+ else {
7767
+ if (expectedVersion !== agent.version) {
7768
+ return mockFailure('assistant-version-conflict');
7769
+ }
7770
+ version = this.context.nextVersion(agent.id).version;
7771
+ }
7772
+ values.set(entry.name, { value: structuredClone(request.value), version, changedAt: this.now(), changedBy: 'current-user' });
7773
+ return { status: 200, body: this.view(this.context.agent(agent.id), entry) };
7774
+ }
7775
+ remove(agent, entry, expected) {
7776
+ const values = this.valuesOf(this.stored, agent.id);
7777
+ const current = values.get(entry.name);
7778
+ if (!current) {
7779
+ return { status: 200, body: this.view(agent, entry) };
7780
+ }
7781
+ if (expected === null || !/^\d+$/.test(expected)) {
7782
+ return mockFailure('assistant-validation', { expectedVersion: ['required'] });
7783
+ }
7784
+ const expectedVersion = Number(expected);
7785
+ if (expectedVersion !== (agent.source === 'code' ? current.version : agent.version)) {
7786
+ return mockFailure('assistant-version-conflict');
7787
+ }
7788
+ if (agent.source !== 'code') {
7789
+ this.context.nextVersion(agent.id);
7790
+ }
7791
+ values.delete(entry.name);
7792
+ return { status: 200, body: this.view(this.context.agent(agent.id), entry) };
7793
+ }
7794
+ /** The entry of the agent in effect, as `GET admin/agents/{id}/metadata` lists it. */
7795
+ view(agent, entry) {
7796
+ const stored = this.stored.get(agent.id)?.get(entry.name);
7797
+ const code = this.code.get(agent.id)?.get(entry.name);
7798
+ const inCode = agent.source === 'code';
7799
+ const value = stored?.value ?? (inCode ? code : undefined);
7800
+ const source = stored
7801
+ ? (inCode ? 'override' : 'data')
7802
+ : (inCode && code !== undefined ? 'code' : null);
7803
+ return {
7804
+ name: entry.name,
7805
+ editable: !inCode || entry.editable === true,
7806
+ maxBytes: entry.maxBytes ?? defaultMaxBytes,
7807
+ source,
7808
+ value: value === undefined ? null : structuredClone(value),
7809
+ version: inCode ? stored?.version ?? null : agent.version,
7810
+ agentVersion: agent.version,
7811
+ codeValue: inCode && code !== undefined ? structuredClone(code) : null,
7812
+ indexed: entry.indexBy !== undefined || entry.indexByEach !== undefined,
7813
+ indexValue: value !== undefined ? indexOf(entry, value) : null,
7814
+ indexedByEach: entry.indexByEach !== undefined,
7815
+ indexValues: value !== undefined ? indexValuesOf(entry, value) : [],
7816
+ changedAt: stored?.changedAt ?? null,
7817
+ changedBy: stored?.changedBy ?? null
7818
+ };
7819
+ }
7820
+ /** The problems of a value, by field path, `''` for the whole value, as the server checks it. */
7821
+ problemsOf(entry, value) {
7822
+ if (value === null || value === undefined) {
7823
+ return { '': ['required'] };
7824
+ }
7825
+ if (typeof value !== 'object' || Array.isArray(value)
7826
+ || (entry.fields && Object.keys(value).some(field => !entry.fields.includes(field)))) {
7827
+ return { '': ['invalid'] };
7828
+ }
7829
+ if (new TextEncoder().encode(JSON.stringify(value)).length > (entry.maxBytes ?? defaultMaxBytes)) {
7830
+ return { '': ['too-long'] };
7831
+ }
7832
+ const problems = { ...(entry.validate?.(value) ?? {}) };
7833
+ const index = indexOf(entry, value);
7834
+ if (index !== null && index.length > 256) {
7835
+ problems[''] = [...(problems[''] ?? []), 'too-long'];
7836
+ }
7837
+ return Object.fromEntries(Object.entries(problems).filter(([, codes]) => codes.length > 0));
7838
+ }
7839
+ valuesOf(map, agentId) {
7840
+ let values = map.get(agentId);
7841
+ if (!values) {
7842
+ values = new Map();
7843
+ map.set(agentId, values);
7844
+ }
7845
+ return values;
7846
+ }
7847
+ }
7848
+ /** The searchable value of a value, trimmed and in lowercase, as the server stores it. */
7849
+ function indexOf(entry, value) {
7850
+ if (entry.indexBy === undefined) {
7851
+ return null;
7852
+ }
7853
+ const text = value[entry.indexBy];
7854
+ const trimmed = typeof text === 'string' ? text.trim() : '';
7855
+ return trimmed.length > 0 ? trimmed.toLowerCase() : null;
7856
+ }
7857
+ /**
7858
+ * The searchable values of an `IndexByEach` entry's value as the server stores them: trimmed, in
7859
+ * lowercase, without empty values or repeats, in ordinal order.
7860
+ */
7861
+ function indexValuesOf(entry, value) {
7862
+ const texts = entry.indexByEach === undefined ? undefined : value[entry.indexByEach];
7863
+ if (!Array.isArray(texts)) {
7864
+ return [];
7865
+ }
7866
+ const values = texts
7867
+ .map(text => (typeof text === 'string' ? text.trim().toLowerCase() : ''))
7868
+ .filter(text => text.length > 0);
7869
+ return [...new Set(values)].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
7870
+ }
7871
+
7872
+ /** How long the application may take to report after a hand-off in the mock. */
7873
+ const reportWithinMs = 4 * 60 * 60 * 1000;
5336
7874
  const dashCase = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
5337
7875
  const maxReason = 500;
5338
7876
  const maxItemsPerPage = 100;
@@ -5357,7 +7895,10 @@ const fourEyesCode = 'assistant-four-eyes-required';
5357
7895
  * answers `404` and deciding it `404 assistant-approval-not-found`. A consultation's proposal that the
5358
7896
  * person who asked decides in their own chat (`asker-decides`) waits inline and is refused here with
5359
7897
  * `assistant-consult-asker-decides`; one that runs with the agent's own rights refuses its asker with
5360
- * `assistant-four-eyes-required`.
7898
+ * `assistant-four-eyes-required`. A proposal of a tool the application executes (`executedByApplication`)
7899
+ * waits as `awaiting-application` once approved, and `POST approvals/{id}/resolve` resolves an
7900
+ * `execution-unknown` proposal as the server does: `hand-to-application` for one the application
7901
+ * executes, `re-execute` for any other, `mark-resolved` for both.
5361
7902
  */
5362
7903
  class NhAssistantMockProposals {
5363
7904
  constructor(scenario = [], actorId, now, mayDecideApplication = () => true, decided = () => undefined, mayWorkIn = () => true) {
@@ -5383,6 +7924,9 @@ class NhAssistantMockProposals {
5383
7924
  const route = item.consultationRoute ?? null;
5384
7925
  const refusal = consultationRefusal(route, item.initiatorActorId ?? null, actorId());
5385
7926
  const canDecide = item.canDecide ?? refusal === null;
7927
+ const executedByApplication = item.executedByApplication ?? false;
7928
+ const handedOff = executedByApplication && (item.status === 'awaiting-application' || item.status === 'execution-unknown');
7929
+ const madeAt = item.createdAt ?? new Date(created).toISOString();
5386
7930
  this.proposals.set(item.approvalId, {
5387
7931
  proposal: {
5388
7932
  approvalId: item.approvalId,
@@ -5410,8 +7954,11 @@ class NhAssistantMockProposals {
5410
7954
  initiatorActorId: item.initiatorActorId ?? null,
5411
7955
  decidedByActorId: null,
5412
7956
  decidedAt: null,
5413
- executionResultCode: null,
7957
+ executionResultCode: item.executionResultCode ?? null,
5414
7958
  executedAt: null,
7959
+ executionReportDueAt: handedOff
7960
+ ? item.executionReportDueAt ?? new Date(Date.parse(madeAt) + reportWithinMs).toISOString()
7961
+ : null,
5415
7962
  rationale: item.rationale ?? null,
5416
7963
  placedBy: item.placedBy ?? null,
5417
7964
  parent: item.parent ?? null,
@@ -5421,7 +7968,8 @@ class NhAssistantMockProposals {
5421
7968
  },
5422
7969
  divisionId: item.divisionId ?? null,
5423
7970
  canDecide,
5424
- reasonCode: canDecide ? null : item.reasonCode ?? refusal ?? 'assistant-approval-policy-denied'
7971
+ reasonCode: canDecide ? null : item.reasonCode ?? refusal ?? 'assistant-approval-policy-denied',
7972
+ executedByApplication
5425
7973
  });
5426
7974
  }
5427
7975
  }
@@ -5445,6 +7993,9 @@ class NhAssistantMockProposals {
5445
7993
  if (segments.length === 3 && segments[2] === 'decisions') {
5446
7994
  return method === 'POST' ? this.decideOne(segments[1], body) : { status: 405 };
5447
7995
  }
7996
+ if (segments.length === 3 && segments[2] === 'resolve') {
7997
+ return method === 'POST' ? this.resolve(segments[1], body) : { status: 405 };
7998
+ }
5448
7999
  return null;
5449
8000
  }
5450
8001
  /** The proposals waiting in a thread, newest first; `null` when the scenario lists none there. */
@@ -5509,15 +8060,22 @@ class NhAssistantMockProposals {
5509
8060
  if (divisionId !== null && !guid.test(divisionId)) {
5510
8061
  return { status: 400 };
5511
8062
  }
8063
+ // What waits for a decision by default; what waits for the application's report or for an
8064
+ // approver to resolve an uncertain execution on request, with the same visibility.
8065
+ const status = query.get('status') || 'pending';
8066
+ if (!isInboxStatus(status)) {
8067
+ return mockFailure('assistant-message-invalid');
8068
+ }
5512
8069
  const grouped = query.get('grouped') === 'true';
5513
8070
  const items = this.visible()
5514
- .filter(item => item.proposal.status === 'pending' && item.canDecide)
8071
+ .filter(item => item.proposal.status === status && item.canDecide)
5515
8072
  .filter(item => divisionId === null || sameGuid(item.divisionId, divisionId))
5516
8073
  .map(item => item.proposal)
5517
8074
  .filter(proposal => (subjectType === null || proposal.subjectType === subjectType)
5518
8075
  && (agentId === null || proposal.agentId === agentId)
5519
8076
  && (runId === null || proposal.runId === runId))
5520
- .sort((left, right) => left.expiresAt.localeCompare(right.expiresAt) || left.approvalId.localeCompare(right.approvalId));
8077
+ .sort((left, right) => inboxSortAt(left, status).localeCompare(inboxSortAt(right, status))
8078
+ || left.approvalId.localeCompare(right.approvalId));
5521
8079
  const ordered = grouped ? groupedOrder(items) : items;
5522
8080
  const offset = (page - 1) * itemsPerPage;
5523
8081
  const pageItems = ordered.slice(offset, offset + itemsPerPage);
@@ -5526,6 +8084,7 @@ class NhAssistantMockProposals {
5526
8084
  const body = {
5527
8085
  items: pageItems,
5528
8086
  total: items.length,
8087
+ status,
5529
8088
  scopeKey,
5530
8089
  lastVisitedAt,
5531
8090
  newApprovalIds: pageItems
@@ -5616,16 +8175,59 @@ class NhAssistantMockProposals {
5616
8175
  return { code: stored.reasonCode ?? 'assistant-approval-policy-denied' };
5617
8176
  }
5618
8177
  const now = this.now();
8178
+ // A proposal the application executes is handed to it and waits for its report.
8179
+ const handedOff = approve && stored.executedByApplication;
5619
8180
  stored.proposal = {
5620
8181
  ...stored.proposal,
5621
- status: approve ? 'executed' : 'rejected',
8182
+ status: approve ? (handedOff ? 'awaiting-application' : 'executed') : 'rejected',
5622
8183
  decidedByActorId: this.actorId(),
5623
8184
  decidedAt: now,
5624
- executedAt: approve ? now : null
8185
+ executedAt: approve && !handedOff ? now : null,
8186
+ executionReportDueAt: handedOff ? new Date(Date.parse(now) + reportWithinMs).toISOString() : null
5625
8187
  };
5626
8188
  this.decided(stored.proposal);
5627
8189
  return { code: null };
5628
8190
  }
8191
+ /**
8192
+ * `POST approvals/{id}/resolve` as the server answers it: only an `execution-unknown` proposal, which
8193
+ * a proposal the application executes resolves with `hand-to-application` or `mark-resolved` and
8194
+ * every other one with `re-execute` or `mark-resolved`. Handed to the application again, it waits
8195
+ * for its report once more; run again, it executes at once.
8196
+ */
8197
+ resolve(approvalId, body) {
8198
+ const request = body;
8199
+ const action = request?.action;
8200
+ if (action !== 're-execute' && action !== 'hand-to-application' && action !== 'mark-resolved') {
8201
+ return mockFailure('assistant-approval-decision-invalid');
8202
+ }
8203
+ const stored = this.find(approvalId);
8204
+ if (!stored) {
8205
+ return mockFailure('assistant-approval-not-found');
8206
+ }
8207
+ if (stored.proposal.status !== 'execution-unknown') {
8208
+ return mockFailure('assistant-execution-not-uncertain');
8209
+ }
8210
+ if (!stored.canDecide) {
8211
+ return mockFailure(stored.reasonCode ?? 'assistant-approval-policy-denied');
8212
+ }
8213
+ const handedOff = !!stored.proposal.executionReportDueAt;
8214
+ if ((action === 'hand-to-application' && !handedOff) || (action === 're-execute' && handedOff)) {
8215
+ return mockFailure('assistant-approval-decision-invalid');
8216
+ }
8217
+ const now = this.now();
8218
+ stored.proposal = {
8219
+ ...stored.proposal,
8220
+ status: action === 'mark-resolved' ? 'resolved' : action === 'hand-to-application' ? 'awaiting-application' : 'executed',
8221
+ decidedByActorId: this.actorId(),
8222
+ executedAt: action === 're-execute' ? now : stored.proposal.executedAt,
8223
+ executionResultCode: action === 'mark-resolved' ? 'resolved-by-approver' : null,
8224
+ executionReportDueAt: action === 'hand-to-application'
8225
+ ? new Date(Date.parse(now) + reportWithinMs).toISOString()
8226
+ : stored.proposal.executionReportDueAt
8227
+ };
8228
+ this.decided(stored.proposal);
8229
+ return { status: 200, body: stored.proposal };
8230
+ }
5629
8231
  withRight(item) {
5630
8232
  const pending = item.proposal.status === 'pending';
5631
8233
  return {
@@ -5646,6 +8248,23 @@ function consultationRefusal(route, initiatorActorId, actorId) {
5646
8248
  }
5647
8249
  return route === 'subject-agent-rights' && initiatorActorId === actorId ? fourEyesCode : null;
5648
8250
  }
8251
+ function isInboxStatus(status) {
8252
+ return status === 'pending' || status === 'awaiting-application' || status === 'execution-unknown';
8253
+ }
8254
+ /**
8255
+ * The time the inbox lists a proposal by, as the server: a pending one by its expiry, one handed to the
8256
+ * application by its report deadline, an uncertain execution by its decision.
8257
+ */
8258
+ function inboxSortAt(proposal, status) {
8259
+ switch (status) {
8260
+ case 'awaiting-application':
8261
+ return proposal.executionReportDueAt ?? proposal.expiresAt;
8262
+ case 'execution-unknown':
8263
+ return proposal.decidedAt ?? proposal.createdAt;
8264
+ default:
8265
+ return proposal.expiresAt;
8266
+ }
8267
+ }
5649
8268
  /** The scope of the inbox's visits: `global`, or the scope key of a division. */
5650
8269
  function scopeKeyOf(divisionId) {
5651
8270
  return divisionId === null ? 'global' : `division:${divisionId.replaceAll('-', '').toLowerCase()}`;
@@ -5780,6 +8399,8 @@ const NH_ASSISTANT_MOCK_SCENARIO = new InjectionToken('NH_ASSISTANT_MOCK_SCENARI
5780
8399
  const NH_ASSISTANT_MOCK_HUB_PATH = '/hub/assistant-mock';
5781
8400
  const defaultLimits = { maxMessageChars: 4_000, maxToolCallsPerTurn: 8 };
5782
8401
  const defaultUser = { actorId: 'mock-user', displayName: 'You' };
8402
+ /** The tool of `ask_question`, as the server records its call. */
8403
+ const askQuestionToolId = 'assistant.ask-question';
5783
8404
  /** When the scenario's consultations started, when they name no time. */
5784
8405
  const consultationStart = new Date().toISOString();
5785
8406
  class RunStopped extends Error {
@@ -5795,6 +8416,7 @@ class NhAssistantMockBackend {
5795
8416
  this.config = inject(NH_ASSISTANT_CONFIG);
5796
8417
  this.conversations = new Map();
5797
8418
  this.pendingApprovals = new Map();
8419
+ this.pendingQuestions = new Map();
5798
8420
  this.runs = new Map();
5799
8421
  /** Page context of the latest message per conversation, as the server stores it. */
5800
8422
  this.pageContexts = new Map();
@@ -5822,6 +8444,25 @@ class NhAssistantMockBackend {
5822
8444
  agents: () => this.admin.allAgents(),
5823
8445
  administers: () => this.admin.administers
5824
8446
  }, () => new Date().toISOString());
8447
+ this.agentMetadata = new NhAssistantMockAgentMetadata(this.scenario, {
8448
+ agent: agentId => this.admin.findAgent(agentId),
8449
+ administers: () => this.admin.administers,
8450
+ nextVersion: agentId => this.admin.nextAgentVersion(agentId)
8451
+ }, () => new Date().toISOString());
8452
+ /** Agent packages: their endpoints, and the locks and links of every other administration request. */
8453
+ this.agentPackages = new NhAssistantMockAgentPackages({
8454
+ call: (method, path, body) => this.adminCall(method, path, body),
8455
+ codeDefinition: agentId => this.admin.codeDefinition(agentId),
8456
+ snapshot: () => {
8457
+ const restores = [this.admin.snapshot(), this.agentMetadata.snapshot(), this.boards.snapshot()];
8458
+ return () => {
8459
+ for (const restore of restores) {
8460
+ restore();
8461
+ }
8462
+ };
8463
+ },
8464
+ now: () => new Date().toISOString()
8465
+ });
5825
8466
  this.canAdministerState = signal(this.admin.administers, ...(ngDevMode ? [{ debugName: "canAdministerState" }] : []));
5826
8467
  this.members = new Map();
5827
8468
  this.readPositions = new Map();
@@ -5896,6 +8537,8 @@ class NhAssistantMockBackend {
5896
8537
  this.conversations.set(copy.id, copy);
5897
8538
  this.readPositions.set(copy.id, lastSequence(copy));
5898
8539
  }
8540
+ // The packages the host imported before, once its agents and run configurations are in place.
8541
+ this.agentPackages.importPresets(this.scenario.admin?.agentPackages ?? []);
5899
8542
  }
5900
8543
  /**
5901
8544
  * Plays a scripted turn as another person in the conversation, as if a colleague with an
@@ -5990,6 +8633,17 @@ class NhAssistantMockBackend {
5990
8633
  setMcpConnectionStatus(serverId, status) {
5991
8634
  this.admin.setMcpConnectionStatus(serverId, status);
5992
8635
  }
8636
+ /**
8637
+ * Publishes or changes one version of a text asset of the application. A skill or agent that names
8638
+ * this version with another hash then reports `asset-changed`, as after the server's next load.
8639
+ */
8640
+ setInstructionAsset(asset) {
8641
+ this.admin.instructionAssets.setAsset(asset);
8642
+ }
8643
+ /** Removes one version of a text asset; what names it reports `asset-missing`. */
8644
+ removeInstructionAsset(assetId, version) {
8645
+ this.admin.instructionAssets.removeAsset(assetId, version);
8646
+ }
5993
8647
  /** The files people uploaded, oldest first, as the application's file source stored them. */
5994
8648
  get uploads() {
5995
8649
  return this.files.uploads;
@@ -6032,23 +8686,15 @@ class NhAssistantMockBackend {
6032
8686
  if (segments[0] === 'admin' && segments[1] === 'agents' && segments[3] === 'runs' && segments.length === 4) {
6033
8687
  return this.result(this.agentRuns.handleAgentRuns(method, segments[2], query, language));
6034
8688
  }
6035
- if (segments[0] === 'admin' && segments[1] === 'agents' && segments[3] === 'groups' && segments.length === 4) {
6036
- return this.result(this.boards.handleAdminGroups(method, segments[2], body));
6037
- }
6038
8689
  if (segments[0] === 'admin') {
6039
- const result = this.admin.handleAdmin(method, segments, body, query);
6040
- if (method === 'DELETE' && segments[1] === 'agents' && segments.length === 3 && result.status === 204) {
6041
- this.boards.forget(segments[2]);
6042
- }
6043
- // The run catalog lists the subject types and the groups in the request's language, as the
6044
- // server does; agents carry their groups.
6045
- if (method === 'GET' && segments.length === 2 && segments[1] === 'run-catalog' && result.status === 200) {
6046
- return this.result({
6047
- ...result,
6048
- body: { ...result.body, subjectTypes: this.subjects.catalog(language), agentGroups: this.boards.catalog(language) }
6049
- });
8690
+ // Agent packages have endpoints of their own; every other save runs under their locks, and
8691
+ // every read of the administration carries the links of the parts.
8692
+ const packaged = this.agentPackages.handle(method, segments, body);
8693
+ if (packaged) {
8694
+ return this.result(packaged);
6050
8695
  }
6051
- return this.result(segments[1] === 'agents' && result.status >= 200 && result.status < 300 ? this.withGroups(result) : result);
8696
+ const result = this.agentPackages.guard(method, segments, () => this.adminResult(method, segments, query, body, language));
8697
+ return this.result(this.agentPackages.decorate(method, segments, result));
6052
8698
  }
6053
8699
  if (segments[0] === 'boards') {
6054
8700
  return this.result(this.boards.handle(method, segments, query, language) ?? { status: 404 });
@@ -6090,6 +8736,7 @@ class NhAssistantMockBackend {
6090
8736
  if (method === 'DELETE') {
6091
8737
  this.conversations.delete(conversation.id);
6092
8738
  this.pendingApprovals.delete(conversation.id);
8739
+ this.pendingQuestions.delete(conversation.id);
6093
8740
  this.emitLive({ kind: 'removed', data: { conversationId: conversation.id } });
6094
8741
  return this.empty(204);
6095
8742
  }
@@ -6134,6 +8781,12 @@ class NhAssistantMockBackend {
6134
8781
  if (method === 'POST' && segments.length === 5 && segments[2] === 'approvals' && segments[4] === 'decide') {
6135
8782
  return this.decide(conversation, segments[3], body);
6136
8783
  }
8784
+ if (method === 'POST' && segments.length === 5 && segments[2] === 'approvals' && segments[4] === 'resolve') {
8785
+ return this.resolve(conversation, segments[3], body);
8786
+ }
8787
+ if (method === 'POST' && segments.length === 5 && segments[2] === 'questions' && segments[4] === 'answer') {
8788
+ return this.answer(conversation, segments[3], body);
8789
+ }
6137
8790
  return this.empty(404);
6138
8791
  }
6139
8792
  list(query) {
@@ -6277,12 +8930,92 @@ class NhAssistantMockBackend {
6277
8930
  }
6278
8931
  });
6279
8932
  }
8933
+ /**
8934
+ * `POST conversations/{id}/approvals/{approvalId}/resolve`: the person who approved a proposal in this
8935
+ * chat resolves its uncertain hand-off (`canResolve`), as the server does: `hand-to-application` hands
8936
+ * it to the application again with a new report deadline, `mark-resolved` confirms it. Another action
8937
+ * is refused, as are an approval in another status and one the caller may not resolve. Returns the
8938
+ * conversation.
8939
+ */
8940
+ resolve(conversation, approvalId, request) {
8941
+ const part = conversation.messages
8942
+ .flatMap(message => message.parts)
8943
+ .find((item) => item.type === 'approval' && item.approvalId === approvalId);
8944
+ if (!part) {
8945
+ return this.error('assistant-approval-not-found');
8946
+ }
8947
+ const action = request?.action;
8948
+ if (action !== 'hand-to-application' && action !== 'mark-resolved') {
8949
+ return this.error('assistant-approval-decision-invalid');
8950
+ }
8951
+ if (part.status !== 'execution-unknown') {
8952
+ return this.error('assistant-execution-not-uncertain');
8953
+ }
8954
+ if (part.canResolve !== true) {
8955
+ return this.error('assistant-approval-forbidden');
8956
+ }
8957
+ const reportDueAt = new Date(Date.now() + mockReportWithinMs).toISOString();
8958
+ this.update(conversation.id, current => withResolvedApproval(current, approvalId, action, reportDueAt));
8959
+ return this.json(200, this.view(this.conversations.get(conversation.id) ?? conversation));
8960
+ }
8961
+ /**
8962
+ * `POST conversations/{id}/questions/{questionId}/answer`: the addressee answers the question the
8963
+ * turn waits for with the index of a choice, as on the server. Anyone else is refused, as are a
8964
+ * choice the question does not offer and a question whose wait ended, which releases the
8965
+ * conversation. The answer resumes the turn with the step's answered steps.
8966
+ */
8967
+ answer(conversation, questionId, request) {
8968
+ const pending = this.pendingQuestions.get(conversation.id);
8969
+ const known = conversation.messages.some(message => message.parts.some(part => part.type === 'question' && part.questionId === questionId));
8970
+ if (!known) {
8971
+ return this.error('assistant-question-not-found');
8972
+ }
8973
+ if (pending?.question.questionId === questionId && pending.question.addresseeActorId !== this.currentUser.actorId) {
8974
+ return this.error('assistant-question-forbidden');
8975
+ }
8976
+ if (!pending || pending.question.questionId !== questionId) {
8977
+ return this.error('assistant-question-not-pending');
8978
+ }
8979
+ const choice = request?.choice;
8980
+ if (typeof choice !== 'number' || !Number.isInteger(choice) || choice < 0 || choice >= pending.question.choices.length) {
8981
+ return this.error('assistant-question-answer-invalid');
8982
+ }
8983
+ if (Date.parse(pending.question.expiresAt) <= Date.now()) {
8984
+ this.pendingQuestions.delete(conversation.id);
8985
+ this.update(conversation.id, current => withClosedQuestion(current, questionId, 'expired'));
8986
+ return this.error('assistant-question-expired');
8987
+ }
8988
+ this.pendingQuestions.delete(conversation.id);
8989
+ this.activeActors.set(conversation.id, this.currentUser.actorId);
8990
+ const lastUser = [...conversation.messages].reverse().find(message => message.role === 'user');
8991
+ this.update(conversation.id, current => applyNhAssistantQuestionAnswer(current, questionId, choice));
8992
+ const answer = pending.question.choices[choice];
8993
+ const steps = typeof pending.step.answered === 'function' ? pending.step.answered(answer, choice) : pending.step.answered;
8994
+ const assistantMessageId = this.nextId();
8995
+ return this.stream(conversation.id, async (emit) => {
8996
+ await emit({ type: 'turn.started', data: { turnId: this.nextId(), userMessageId: lastUser?.id ?? questionId, assistantMessageId } });
8997
+ await emit({
8998
+ type: 'tool.completed',
8999
+ data: { invocationId: questionId, status: 'succeeded', resultCode: null, resultPreview: JSON.stringify({ answer }) }
9000
+ });
9001
+ await emit({
9002
+ type: 'question.answered',
9003
+ data: { ...pending.question, status: 'answered', answerIndex: choice, closedAt: new Date().toISOString() }
9004
+ });
9005
+ await this.playSteps(conversation.id, assistantMessageId, steps, emit);
9006
+ });
9007
+ }
6280
9008
  cancel(conversation) {
6281
9009
  const run = this.runs.get(conversation.id);
6282
9010
  if (run) {
6283
9011
  run.cancelled = true;
6284
9012
  return this.empty(202);
6285
9013
  }
9014
+ const question = this.pendingQuestions.get(conversation.id);
9015
+ if (question) {
9016
+ this.pendingQuestions.delete(conversation.id);
9017
+ this.update(conversation.id, current => withClosedQuestion(current, question.question.questionId, 'cancelled'));
9018
+ }
6286
9019
  const pending = this.pendingApprovals.get(conversation.id);
6287
9020
  if (pending) {
6288
9021
  this.pendingApprovals.delete(conversation.id);
@@ -6333,6 +9066,12 @@ class NhAssistantMockBackend {
6333
9066
  await emit({ type: 'turn.completed', data: { turnId: this.nextId(), status: 'waiting-for-approval', usage, errorCode: null } });
6334
9067
  return;
6335
9068
  }
9069
+ else if ('question' in step) {
9070
+ await this.pauseForQuestion(conversationId, step.question, emit);
9071
+ usage.toolCalls++;
9072
+ await emit({ type: 'turn.completed', data: { turnId: this.nextId(), status: 'waiting-for-approval', usage, errorCode: null } });
9073
+ return;
9074
+ }
6336
9075
  else if ('error' in step) {
6337
9076
  await emit({ type: 'error', data: step.error });
6338
9077
  // The mock keeps the conversation usable after a failed turn.
@@ -6400,6 +9139,31 @@ class NhAssistantMockBackend {
6400
9139
  this.pendingApprovals.set(conversationId, { approval, invocationId, assistantMessageId, step });
6401
9140
  await emit({ type: 'approval.required', data: approval });
6402
9141
  }
9142
+ /** Records the `ask_question` call and posts the question; the turn then waits for its answer. */
9143
+ async pauseForQuestion(conversationId, step, emit) {
9144
+ const questionId = this.nextId();
9145
+ await emit({
9146
+ type: 'tool.started',
9147
+ data: {
9148
+ invocationId: questionId,
9149
+ toolId: askQuestionToolId,
9150
+ toolVersion: 1,
9151
+ displayName: 'ask_question',
9152
+ argumentsPreview: JSON.stringify({ question: step.question, choices: step.choices })
9153
+ }
9154
+ });
9155
+ const question = {
9156
+ type: 'question',
9157
+ questionId,
9158
+ question: step.question,
9159
+ choices: [...step.choices],
9160
+ addresseeActorId: step.addresseeActorId ?? this.activeActors.get(conversationId) ?? this.currentUser.actorId,
9161
+ expiresAt: new Date(Date.now() + (step.expiresInSeconds ?? 900) * 1000).toISOString(),
9162
+ status: 'pending'
9163
+ };
9164
+ this.pendingQuestions.set(conversationId, { question, step });
9165
+ await emit({ type: 'question.asked', data: question });
9166
+ }
6403
9167
  stream(conversationId, play) {
6404
9168
  const timing = this.scenario.timing ?? {};
6405
9169
  const firstDelay = timing.firstEventDelayMs ?? 200;
@@ -6690,6 +9454,44 @@ class NhAssistantMockBackend {
6690
9454
  const suffix = this.sequence.toString(16).padStart(12, '0');
6691
9455
  return `00000000-0000-4000-8000-${suffix}`;
6692
9456
  }
9457
+ /**
9458
+ * The answer of an administration endpoint (`admin/...`) as its area gives it, with the groups of
9459
+ * agents and the run catalog in the request's language, without the package locks and links.
9460
+ */
9461
+ adminResult(method, segments, query, body, language) {
9462
+ if (segments[1] === 'agents' && segments[3] === 'groups' && segments.length === 4) {
9463
+ return this.boards.handleAdminGroups(method, segments[2], body);
9464
+ }
9465
+ if (segments[1] === 'agents' && segments[3] === 'metadata' && segments.length <= 5) {
9466
+ return this.agentMetadata.handle(method, segments[2], segments[4], query, body);
9467
+ }
9468
+ const result = this.admin.handleAdmin(method, segments, body, query);
9469
+ if (method === 'DELETE' && segments[1] === 'agents' && segments.length === 3 && result.status === 204) {
9470
+ this.boards.forget(segments[2]);
9471
+ this.agentMetadata.forget(segments[2]);
9472
+ this.agentPackages.forget(segments[2]);
9473
+ }
9474
+ // The run catalog lists the subject types and the groups in the request's language, as the
9475
+ // server does; agents carry their groups.
9476
+ if (method === 'GET' && segments.length === 2 && segments[1] === 'run-catalog' && result.status === 200) {
9477
+ return {
9478
+ ...result,
9479
+ body: {
9480
+ ...result.body,
9481
+ subjectTypes: this.subjects.catalog(language),
9482
+ agentGroups: this.boards.catalog(language),
9483
+ agentMetadata: this.agentMetadata.catalog()
9484
+ }
9485
+ };
9486
+ }
9487
+ return segments[1] === 'agents' && result.status >= 200 && result.status < 300 ? this.withGroups(result) : result;
9488
+ }
9489
+ /** An administration request of the agent packages, as `adminResult` answers it, for a path with its query. */
9490
+ adminCall(method, path, body) {
9491
+ const url = new URL(path, 'http://mock.local/');
9492
+ const segments = url.pathname.slice(1).split('/').map(segment => decodeURIComponent(segment));
9493
+ return this.adminResult(method, segments, url.searchParams, body, null);
9494
+ }
6693
9495
  /** An administration answer about agents with each agent's groups, as the server answers it. */
6694
9496
  withGroups(result) {
6695
9497
  const enrich = (item) => {
@@ -6705,6 +9507,9 @@ class NhAssistantMockBackend {
6705
9507
  return { groups: membership.groups, codeGroups: membership.codeGroups };
6706
9508
  }
6707
9509
  result(result) {
9510
+ if (result.text !== undefined) {
9511
+ return new Response(result.text, { status: result.status, headers: { 'content-type': 'application/json', ...result.headers } });
9512
+ }
6708
9513
  return result.body === undefined ? this.empty(result.status) : this.json(result.status, result.body);
6709
9514
  }
6710
9515
  json(status, value) {
@@ -6739,6 +9544,51 @@ function withMovedApproval(conversation, approvalId, movedToApprovalId) {
6739
9544
  messages: conversation.messages.map(message => ({ ...message, parts: message.parts.map(moved) }))
6740
9545
  };
6741
9546
  }
9547
+ /** How long the mock's application may take to report after a hand-off. */
9548
+ const mockReportWithinMs = 4 * 60 * 60 * 1000;
9549
+ /**
9550
+ * The approval after its uncertain hand-off was resolved: handed to the application again, waiting for
9551
+ * its report until `reportDueAt`, or resolved. Either way nobody resolves it again.
9552
+ */
9553
+ function withResolvedApproval(conversation, approvalId, action, reportDueAt) {
9554
+ const resolved = (part) => {
9555
+ if (part.type !== 'approval' || part.approvalId !== approvalId) {
9556
+ return part;
9557
+ }
9558
+ const { canResolve: _resolved, ...rest } = part;
9559
+ return action === 'hand-to-application'
9560
+ ? { ...rest, status: 'awaiting-application', executionResultCode: 'hand-off-acknowledged', executionReportDueAt: reportDueAt }
9561
+ : { ...rest, status: 'resolved', executionResultCode: 'resolved-by-approver' };
9562
+ };
9563
+ return {
9564
+ ...conversation,
9565
+ messages: conversation.messages.map(message => ({ ...message, parts: message.parts.map(resolved) }))
9566
+ };
9567
+ }
9568
+ /**
9569
+ * A question that closed without an answer, as the server reads it afterwards: expired or withdrawn
9570
+ * with its stopped turn. Its call ends, and the conversation takes the next message.
9571
+ */
9572
+ function withClosedQuestion(conversation, questionId, status) {
9573
+ const closedAt = new Date().toISOString();
9574
+ const closed = (part) => {
9575
+ if (part.type === 'question' && part.questionId === questionId) {
9576
+ return { ...part, status, closedAt };
9577
+ }
9578
+ if (part.type === 'tool-call' && part.invocationId === questionId) {
9579
+ return status === 'expired'
9580
+ ? { ...part, status: 'failed', resultCode: 'assistant-question-expired' }
9581
+ : { ...part, status: 'not-executed', resultCode: 'assistant-question-cancelled' };
9582
+ }
9583
+ return part;
9584
+ };
9585
+ return {
9586
+ ...conversation,
9587
+ status: 'idle',
9588
+ pendingQuestion: null,
9589
+ messages: conversation.messages.map(message => ({ ...message, parts: message.parts.map(closed) }))
9590
+ };
9591
+ }
6742
9592
  /** Like the server: a new message starts in an idle conversation or after a failed turn. */
6743
9593
  function acceptsMessage(status) {
6744
9594
  return status === 'idle' || status === 'failed';