@newheap/platform-ai-chat 0.16.0 → 0.18.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, 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_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';
4
4
  import { Observable, Subject } from 'rxjs';
5
5
 
6
6
  /**
@@ -25,6 +25,7 @@ const statusTable = {
25
25
  'assistant-run-filter-invalid',
26
26
  'assistant-board-scope-invalid',
27
27
  'assistant-feedback-invalid',
28
+ 'assistant-trigger-filter-not-found',
28
29
  'assistant-replay-not-shadow',
29
30
  'assistant-evaluation-not-variant',
30
31
  'assistant-instructions-too-long',
@@ -82,6 +83,7 @@ const statusTable = {
82
83
  'assistant-board-not-found',
83
84
  'assistant-artifact-not-found',
84
85
  'assistant-trigger-not-found',
86
+ 'assistant-consultation-not-found',
85
87
  'assistant-not-found',
86
88
  'assistant-context-not-found',
87
89
  'assistant-mcp-server-not-found',
@@ -106,6 +108,12 @@ const statusTable = {
106
108
  'assistant-moved-to-shadow',
107
109
  'assistant-shadow-variant',
108
110
  'assistant-service-account-not-used',
111
+ 'assistant-consult-route-not-allowed',
112
+ 'assistant-consult-proposal-refused',
113
+ 'assistant-consult-asker-decides',
114
+ 'assistant-consult-proposal-moved',
115
+ 'assistant-consultation-in-code',
116
+ 'assistant-consultations-not-allowed',
109
117
  'assistant-skill-in-code',
110
118
  'assistant-trigger-in-code',
111
119
  'assistant-skill-in-use',
@@ -282,7 +290,7 @@ class NhAssistantMockAccounts {
282
290
 
283
291
  /** The platform's type of the whole application. */
284
292
  const mockSystemSubjectType = 'nh-system';
285
- const subjectTypePattern = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
293
+ const subjectTypePattern$1 = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
286
294
  const maxSubjectTypes = 20;
287
295
  const worksOnFields = ['worksOnSubjectTypes', 'worksOnAllSubjects', 'worksOnApplication', 'perDivision', 'runPolicy', 'viewPolicy'];
288
296
  /** An agent that does not say what it works on: every object type, not the application, app-wide. */
@@ -335,10 +343,10 @@ function mockWorksOnErrors(worksOn, knownTypes, agentPolicies, divisions) {
335
343
  if (types.length > maxSubjectTypes) {
336
344
  add('worksOnSubjectTypes', 'too-long');
337
345
  }
338
- if (types.some(type => !subjectTypePattern.test(type))) {
346
+ if (types.some(type => !subjectTypePattern$1.test(type))) {
339
347
  add('worksOnSubjectTypes', 'invalid');
340
348
  }
341
- if (types.some(type => subjectTypePattern.test(type) && (type.startsWith('nh-') || !knownTypes.includes(type)))) {
349
+ if (types.some(type => subjectTypePattern$1.test(type) && (type.startsWith('nh-') || !knownTypes.includes(type)))) {
342
350
  add('worksOnSubjectTypes', 'not-found');
343
351
  }
344
352
  if (worksOn.perDivision && !divisions) {
@@ -363,6 +371,12 @@ function mockWorksOnCovers(agent, subjectType) {
363
371
  return agent.worksOnAllSubjects !== false || (agent.worksOnSubjectTypes ?? []).includes(subjectType);
364
372
  }
365
373
 
374
+ /** The routes in the order the server lists them, with the field of the request that switches each. */
375
+ const consultationRoutes = [
376
+ ['asker-decides', 'askerDecides'],
377
+ ['subject-agent-rights', 'subjectAgentRights'],
378
+ ['subject-asker-rights', 'subjectAskerRights']
379
+ ];
366
380
  /** The profile of an agent that did not choose one, as the application's chat profile. */
367
381
  const mockChatProfile = 'chat';
368
382
  const autonomyLevels = ['observe', 'explain', 'propose', 'simulate', 'execute'];
@@ -383,7 +397,7 @@ class NhAssistantMockAdminAgents {
383
397
  const agents = admin.agents
384
398
  ?? scenario.agents.map(agent => ({ ...codeAgentFromSummary(agent), source: 'code' }));
385
399
  // How an agent runs, in code or as data, belongs to its run configuration, not to the agent.
386
- for (const { source, skills, shadowOf, shadow, serviceAccount, worksOnDeclared, knownSubjectTypes, codeRunSettings: _, runConfiguration: __, ...input } of agents) {
400
+ for (const { source, skills, shadowOf, shadow, serviceAccount, worksOnDeclared, knownSubjectTypes, consultations, consultationDescriptions, codeRunSettings: _, runConfiguration: __, consultableAsData: ___, dataConsultations: ____, ...input } of agents) {
387
401
  const agentShadow = shadowOf !== undefined || shadow === true;
388
402
  const initial = this.toAgent(input, source, 1, false);
389
403
  this.agents.set(input.id, {
@@ -414,7 +428,11 @@ class NhAssistantMockAdminAgents {
414
428
  shadow: shadowState(agentShadow || skill.shadow === true, skill.shadow === true, null, shadowOf !== undefined)
415
429
  }))
416
430
  },
417
- codeDefinition: source === 'code' ? structuredClone(input) : null
431
+ codeDefinition: source === 'code' ? structuredClone(input) : null,
432
+ // Consultations in code belong to agents in code; every agent may get consultations as data.
433
+ consultations: noSwitches(source === 'code' && (consultations !== undefined || consultationDescriptions !== undefined)
434
+ ? structuredClone(consultations ?? {})
435
+ : null)
418
436
  });
419
437
  }
420
438
  }
@@ -455,7 +473,10 @@ class NhAssistantMockAdminAgents {
455
473
  }
456
474
  return result;
457
475
  }
458
- /** `admin/agents`, `admin/agents/{id}` and their `reset`, `state`, `shadow`, `service-account` and `kill-switch`. */
476
+ /**
477
+ * `admin/agents`, `admin/agents/{id}` and their `reset`, `state`, `shadow`, `service-account`,
478
+ * `consultation-routes` and `kill-switch`.
479
+ */
459
480
  agentsEndpoint(method, id, action, body, length) {
460
481
  if (id === undefined) {
461
482
  if (method === 'GET') {
@@ -473,7 +494,7 @@ class NhAssistantMockAdminAgents {
473
494
  return mockFailure('assistant-agent-exists');
474
495
  }
475
496
  const agent = this.toAgent(input, 'admin', 1, false);
476
- this.agents.set(agent.id, { agent, codeDefinition: null });
497
+ this.agents.set(agent.id, { agent, codeDefinition: null, consultations: noSwitches(null) });
477
498
  return { status: 201, body: agent };
478
499
  }
479
500
  const stored = this.agents.get(id);
@@ -496,6 +517,9 @@ class NhAssistantMockAdminAgents {
496
517
  if (length === 4 && action === 'service-account' && method === 'POST') {
497
518
  return this.serviceAccountEndpoint(stored, body);
498
519
  }
520
+ if (length === 4 && action === 'consultation-routes' && method === 'POST') {
521
+ return this.consultationRoutesEndpoint(stored, body);
522
+ }
499
523
  if (length === 4 && action === 'kill-switch' && method === 'POST') {
500
524
  const engaged = body?.engaged;
501
525
  if (typeof engaged !== 'boolean') {
@@ -503,7 +527,7 @@ class NhAssistantMockAdminAgents {
503
527
  }
504
528
  // Releasing never switches on an agent that was off, as on the server: one that was off when
505
529
  // it stopped goes back off, and one that is off stays off.
506
- const released = stored.agent.runState === 'off' || stored.offBeforeStop === true ? 'off' : 'active';
530
+ const released = stored.agent.runState === 'off' || stored.agent.offBeforeStop === true ? 'off' : 'active';
507
531
  const changed = this.agentStateEndpoint(stored, 'POST', { state: engaged ? 'stopped' : released });
508
532
  return changed.status === 200
509
533
  ? {
@@ -764,7 +788,7 @@ class NhAssistantMockAdminAgents {
764
788
  skills: (stored.agent.skills ?? []).map(skill => ({ ...skill, shadow: withAgentShadow(skill.shadow, shadow) }))
765
789
  };
766
790
  }
767
- return { status: 200, body: toAgentState(stored.agent) };
791
+ return { status: 200, body: this.toAgentState(stored) };
768
792
  }
769
793
  /**
770
794
  * `POST admin/agents/{id}/service-account`: the technical account of an agent that answers to
@@ -801,7 +825,96 @@ class NhAssistantMockAdminAgents {
801
825
  }
802
826
  };
803
827
  }
804
- return { status: 200, body: toAgentState(stored.agent) };
828
+ return { status: 200, body: this.toAgentState(stored) };
829
+ }
830
+ /**
831
+ * `POST admin/agents/{id}/consultation-routes`: each route on, off or back to what the consultations
832
+ * allow; a route left out keeps its switch. The consultations in code and as data in effect are the
833
+ * ceiling, as on the server: switching on a route that none of them allows, or switching an agent
834
+ * nobody can consult, answers `409 assistant-consult-route-not-allowed`.
835
+ */
836
+ consultationRoutesEndpoint(stored, body) {
837
+ const request = body;
838
+ if (!request || typeof request !== 'object') {
839
+ return mockValidationFailure();
840
+ }
841
+ for (const [, field] of consultationRoutes) {
842
+ const value = request[field];
843
+ if (value !== undefined && value !== null && value !== 'on' && value !== 'off' && value !== 'code') {
844
+ return mockValidationFailure(field);
845
+ }
846
+ }
847
+ const reason = typeof request['reason'] === 'string' && request['reason'].trim().length > 0 ? request['reason'].trim() : null;
848
+ if ((request['reason'] !== undefined && request['reason'] !== null && typeof request['reason'] !== 'string')
849
+ || (reason !== null && reason.length > mockMaxStateReason)) {
850
+ return mockValidationFailure('reason');
851
+ }
852
+ const current = stored.consultations;
853
+ const allowed = this.allowedRoutes(stored);
854
+ if (this.consultationRoutesState(stored) === null
855
+ || !consultationRoutes.some(([route]) => (allowed[route]?.length ?? 0) > 0)
856
+ || consultationRoutes.some(([route, field]) => request[field] === 'on' && (allowed[route]?.length ?? 0) === 0)) {
857
+ return mockFailure('assistant-consult-route-not-allowed');
858
+ }
859
+ const overrides = { ...current.overrides };
860
+ for (const [route, field] of consultationRoutes) {
861
+ const value = request[field];
862
+ if (value === 'on' || value === 'off') {
863
+ overrides[route] = value === 'on';
864
+ }
865
+ else if (value === 'code') {
866
+ delete overrides[route];
867
+ }
868
+ }
869
+ const changed = consultationRoutes.some(([route]) => overrides[route] !== current.overrides[route]);
870
+ if (changed) {
871
+ stored.consultations = { ...current, overrides, changedAt: this.now(), changedBy: 'current-user', reason };
872
+ }
873
+ return { status: 200, body: this.toAgentState(stored) };
874
+ }
875
+ /**
876
+ * The subject types whose consultation allows each route: in code, and as data with the routes in
877
+ * effect, as the server reads both from the agent's options in effect.
878
+ */
879
+ allowedRoutes(stored) {
880
+ const allowed = structuredClone(stored.consultations.allowed ?? {});
881
+ for (const consultation of this.rules.dataConsultations(stored.agent.id)) {
882
+ for (const route of consultation.routes) {
883
+ allowed[route] = [...(allowed[route] ?? []), consultation.subjectType];
884
+ }
885
+ }
886
+ return allowed;
887
+ }
888
+ /**
889
+ * The routes of an agent's consultations as the server reports them; `null` for an agent nobody can
890
+ * consult: it has no consultation in code and none as data in effect.
891
+ */
892
+ consultationRoutesState(stored) {
893
+ const switches = stored.consultations;
894
+ if (switches.allowed === null && this.rules.dataConsultations(stored.agent.id).length === 0) {
895
+ return null;
896
+ }
897
+ const allowed = this.allowedRoutes(stored);
898
+ return {
899
+ routes: consultationRoutes.map(([route]) => {
900
+ const types = allowed[route] ?? [];
901
+ const override = switches.overrides[route] ?? null;
902
+ return {
903
+ route,
904
+ allowed: types.length > 0,
905
+ override,
906
+ on: types.length > 0 && override !== false,
907
+ subjectTypes: [...types]
908
+ };
909
+ }),
910
+ changedAt: switches.changedAt,
911
+ changedBy: switches.changedBy,
912
+ reason: switches.reason
913
+ };
914
+ }
915
+ /** The agent's state with the routes of its consultations. */
916
+ toAgentState(stored) {
917
+ return { ...toAgentState(stored), consultationRoutes: this.consultationRoutesState(stored) };
805
918
  }
806
919
  /**
807
920
  * `GET` and `POST admin/agents/{id}/state`. The mock runs no agents on their own, so a pause
@@ -810,7 +923,7 @@ class NhAssistantMockAdminAgents {
810
923
  */
811
924
  agentStateEndpoint(stored, method, body) {
812
925
  if (method === 'GET') {
813
- return { status: 200, body: toAgentState(stored.agent) };
926
+ return { status: 200, body: this.toAgentState(stored) };
814
927
  }
815
928
  if (method !== 'POST') {
816
929
  return { status: 405 };
@@ -829,21 +942,21 @@ class NhAssistantMockAdminAgents {
829
942
  if (stored.agent.runState === 'configured-off') {
830
943
  return request.state === 'active'
831
944
  ? mockFailure('assistant-agent-configured-off')
832
- : { status: 200, body: toAgentState(stored.agent) };
945
+ : { status: 200, body: this.toAgentState(stored) };
833
946
  }
834
947
  if (stored.agent.runState !== request.state) {
835
- // A stop remembers whether the agent was off, for the kill switch; every other change forgets it.
836
- stored.offBeforeStop = request.state === 'stopped' && stored.agent.runState === 'off';
837
948
  stored.agent = {
838
949
  ...stored.agent,
839
950
  isEnabled: request.state !== 'stopped',
840
951
  runState: request.state,
952
+ // A stop remembers whether the agent was off, for the kill switch; every other change forgets it.
953
+ offBeforeStop: request.state === 'stopped' && stored.agent.runState === 'off',
841
954
  stateChangedAt: this.now(),
842
955
  stateChangedBy: 'current-user',
843
956
  stateReason: reason
844
957
  };
845
958
  }
846
- return { status: 200, body: toAgentState(stored.agent) };
959
+ return { status: 200, body: this.toAgentState(stored) };
847
960
  }
848
961
  /**
849
962
  * The contract failure for an invalid agent, or null when the agent is valid. What an agent created
@@ -924,6 +1037,7 @@ class NhAssistantMockAdminAgents {
924
1037
  shadowOf: previous?.shadowOf ?? null,
925
1038
  shadow: previous?.shadow ?? shadowState(false, false, null, false),
926
1039
  serviceAccount: previous?.serviceAccount ?? null,
1040
+ offBeforeStop: previous?.offBeforeStop ?? false,
927
1041
  // Code decides for its agents; an agent created here keeps its choice when it is omitted.
928
1042
  offeredInChat: source === 'code' && previous
929
1043
  ? previous.offeredInChat ?? true
@@ -959,7 +1073,8 @@ function codeAgentFromSummary(agent) {
959
1073
  autonomy: agent.canMutate ? 'execute' : 'explain'
960
1074
  };
961
1075
  }
962
- function toAgentState(agent) {
1076
+ function toAgentState(stored) {
1077
+ const agent = stored.agent;
963
1078
  return {
964
1079
  agentId: agent.id,
965
1080
  runState: agent.runState,
@@ -973,9 +1088,15 @@ function toAgentState(agent) {
973
1088
  skills: agent.skills ?? [],
974
1089
  shadowOf: agent.shadowOf ?? null,
975
1090
  shadow: agent.shadow ?? shadowState(false, false, null, false),
976
- serviceAccount: agent.serviceAccount ?? null
1091
+ serviceAccount: agent.serviceAccount ?? null,
1092
+ offBeforeStop: agent.runState === 'stopped' && agent.offBeforeStop === true,
1093
+ consultationRoutes: null
977
1094
  };
978
1095
  }
1096
+ /** The route switches of an agent nobody switched yet, with the routes its consultations in code allow. */
1097
+ function noSwitches(allowed) {
1098
+ return { allowed, overrides: {}, changedAt: null, changedBy: null, reason: null };
1099
+ }
979
1100
  function shadowState(shadow, configured, override, locked) {
980
1101
  return { shadow, configured, override, locked, changedAt: null, changedBy: null, reason: null };
981
1102
  }
@@ -1033,7 +1154,7 @@ const maxReaders = 16;
1033
1154
  const maxReaderMediaTypes = 16;
1034
1155
  const maxReaderToolName = 128;
1035
1156
  const readerPriority = { min: -1000, max: 1000 };
1036
- const classifications = ['public', 'internal', 'confidential', 'restricted'];
1157
+ const classifications$1 = ['public', 'internal', 'confidential', 'restricted'];
1037
1158
  const mediaTypePattern$1 = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
1038
1159
  const controlCharacters = /[\u0000-\u001f\u007f]/;
1039
1160
  /**
@@ -2106,7 +2227,7 @@ function checkReaders(input, stored, add) {
2106
2227
  if (classification === null || classification === undefined) {
2107
2228
  add(field('maxClassification'), 'required');
2108
2229
  }
2109
- else if (!classifications.includes(classification)) {
2230
+ else if (!classifications$1.includes(classification)) {
2110
2231
  add(field('maxClassification'), 'invalid');
2111
2232
  }
2112
2233
  const priority = reader?.priority ?? 0;
@@ -2214,6 +2335,457 @@ function kebab(value) {
2214
2335
  .toLowerCase();
2215
2336
  }
2216
2337
 
2338
+ /** The bounds of `AllowConsultation`, within which a ceiling may only lower the maxima. */
2339
+ const bounds = {
2340
+ toolCalls: { min: 0, max: 32, initial: 4 },
2341
+ timeoutSeconds: { min: 1, max: 600, initial: 60 },
2342
+ answerChars: { min: 200, max: 32_000, initial: 4_000 },
2343
+ history: { min: 0, max: 200, initial: 20 }
2344
+ };
2345
+ const maxDescription$2 = 1_000;
2346
+ const maxInstructions$1 = 4_000;
2347
+ const maxConsultations = 20;
2348
+ const roles = ['reader', 'participant', 'approver'];
2349
+ const allRoutes = ['asker-decides', 'subject-agent-rights', 'subject-asker-rights'];
2350
+ const classifications = ['public', 'internal', 'confidential', 'restricted'];
2351
+ const subjectTypePattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
2352
+ /**
2353
+ * Consultations of the mock API's agents: those in code from the scenario, read-only, and those
2354
+ * defined as data, saved and removed as on the server within the ceilings in code
2355
+ * (`consultationsAsData` for the application, `consultableAsData` for an agent in code). A save
2356
+ * checks a consultation as the server does and refuses with the same codes and field errors; every
2357
+ * 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.
2360
+ */
2361
+ class NhAssistantMockConsultations {
2362
+ constructor(application, agents, rules, now) {
2363
+ this.rules = rules;
2364
+ this.now = now;
2365
+ this.stored = new Map();
2366
+ this.codeAllowed = new Map();
2367
+ this.codeDescriptions = new Map();
2368
+ this.agentCeilings = new Map();
2369
+ this.applicationCeiling = application ? ceilingOf(application) : null;
2370
+ for (const agent of agents) {
2371
+ if (agent.source !== 'code') {
2372
+ continue;
2373
+ }
2374
+ if (agent.consultations !== undefined || agent.consultationDescriptions !== undefined) {
2375
+ this.codeAllowed.set(agent.id, agent.consultations ?? {});
2376
+ this.codeDescriptions.set(agent.id, agent.consultationDescriptions ?? {});
2377
+ }
2378
+ if (agent.consultableAsData !== undefined) {
2379
+ this.agentCeilings.set(agent.id, agent.consultableAsData);
2380
+ }
2381
+ }
2382
+ }
2383
+ /**
2384
+ * The ceiling of the agent: the application's for an agent created in the administration, the
2385
+ * application's and its own together for a consultable agent in code, none otherwise.
2386
+ */
2387
+ ceilingOf(agent) {
2388
+ const application = this.applicationCeiling;
2389
+ if (!application) {
2390
+ return null;
2391
+ }
2392
+ if (agent.source === 'admin') {
2393
+ return application;
2394
+ }
2395
+ const own = this.agentCeilings.get(agent.id);
2396
+ if (!own) {
2397
+ return null;
2398
+ }
2399
+ const narrowed = ceilingOf(own);
2400
+ return {
2401
+ ...application,
2402
+ routes: application.routes.filter(route => narrowed.routes.includes(route)),
2403
+ allowRequests: application.allowRequests && narrowed.allowRequests,
2404
+ allowMcp: application.allowMcp && narrowed.allowMcp,
2405
+ maxToolCalls: Math.min(application.maxToolCalls, narrowed.maxToolCalls),
2406
+ maxTimeoutSeconds: Math.min(application.maxTimeoutSeconds, narrowed.maxTimeoutSeconds),
2407
+ maxAnswerChars: Math.min(application.maxAnswerChars, narrowed.maxAnswerChars),
2408
+ maxHistoryMessages: Math.min(application.maxHistoryMessages, narrowed.maxHistoryMessages)
2409
+ };
2410
+ }
2411
+ /** The agent's consultations in code, by subject type. */
2412
+ codeConsultations(agent) {
2413
+ const allowed = this.codeAllowed.get(agent.id);
2414
+ if (!allowed) {
2415
+ return [];
2416
+ }
2417
+ const descriptions = this.codeDescriptions.get(agent.id) ?? {};
2418
+ const types = new Set([...Object.values(allowed).flat(), ...Object.keys(descriptions)]);
2419
+ return [...types].sort().map(subjectType => {
2420
+ const consultToolId = consultToolOf(agent.id, subjectType);
2421
+ return {
2422
+ subjectType,
2423
+ description: descriptions[subjectType] ?? null,
2424
+ allowedRoutes: allRoutes.filter(route => (allowed[route] ?? []).includes(subjectType)),
2425
+ allowRequests: false,
2426
+ exposure: ['local', 'agent'],
2427
+ consultToolId,
2428
+ requestToolId: null,
2429
+ askingAgents: this.askersOf(agent.id, consultToolId)
2430
+ };
2431
+ });
2432
+ }
2433
+ /** The agent's consultations defined as data, by subject type, each with what is in effect of it. */
2434
+ dataConsultations(agent) {
2435
+ return this.storedOf(agent.id).map(stored => this.toConsultation(agent, stored));
2436
+ }
2437
+ /** The problems of kind `consultation` of the agent's data consultations. */
2438
+ problems(agent) {
2439
+ return this.storedOf(agent.id).flatMap(stored => this.check(agent, stored).problems.map(code => ({
2440
+ agentId: agent.id,
2441
+ kind: 'consultation',
2442
+ id: stored.subjectType,
2443
+ code
2444
+ })));
2445
+ }
2446
+ /** The agent's data consultations in effect with their routes in effect, for its route switches. */
2447
+ inEffect(agentId) {
2448
+ const agent = this.rules.find(agentId);
2449
+ if (!agent) {
2450
+ return [];
2451
+ }
2452
+ return this.storedOf(agentId)
2453
+ .map(stored => ({ subjectType: stored.subjectType, check: this.check(agent, stored) }))
2454
+ .filter(item => item.check.inEffect)
2455
+ .map(item => ({ subjectType: item.subjectType, routes: item.check.routes }));
2456
+ }
2457
+ /** Whether data consultations belong to the agent, which keeps its run settings. */
2458
+ has(agentId) {
2459
+ return this.storedOf(agentId).length > 0;
2460
+ }
2461
+ /** The generated tools of the data consultations in effect, as `GET admin/tools` lists them. */
2462
+ tools() {
2463
+ const tools = [];
2464
+ for (const stored of [...this.stored.values()].sort((left, right) => key$2(left.agentId, left.subjectType).localeCompare(key$2(right.agentId, right.subjectType)))) {
2465
+ const agent = this.rules.find(stored.agentId);
2466
+ const check = agent ? this.check(agent, stored) : null;
2467
+ if (!check?.inEffect) {
2468
+ continue;
2469
+ }
2470
+ tools.push({ id: consultToolOf(stored.agentId, stored.subjectType), source: 'local', effect: 'read-only', description: stored.description, subjectType: null });
2471
+ if (check.allowRequests) {
2472
+ tools.push({ id: requestToolOf(stored.agentId, stored.subjectType), source: 'local', effect: 'idempotent-mutation', description: stored.description, subjectType: null });
2473
+ }
2474
+ }
2475
+ return tools;
2476
+ }
2477
+ /** `PUT` and `DELETE admin/agents/{id}/consultations/{subjectType}`. */
2478
+ handle(method, agent, subjectType, body, query) {
2479
+ if (method === 'DELETE') {
2480
+ return this.remove(agent, subjectType, query);
2481
+ }
2482
+ return method === 'PUT' ? this.save(agent, subjectType, body) : { status: 405 };
2483
+ }
2484
+ save(agent, subjectType, body) {
2485
+ const request = (body ?? {});
2486
+ const reason = text$1(request.reason);
2487
+ if (reason !== null && reason.length > mockMaxStateReason) {
2488
+ return mockFailure('assistant-validation', { reason: ['too-long'] });
2489
+ }
2490
+ const ceiling = this.ceilingOf(agent);
2491
+ if (!ceiling) {
2492
+ return mockFailure('assistant-consultations-not-allowed');
2493
+ }
2494
+ if (!this.rules.isConfigured(agent)) {
2495
+ return mockFailure('assistant-agent-not-configured');
2496
+ }
2497
+ if (this.inCode(agent.id, subjectType)) {
2498
+ return mockFailure('assistant-consultation-in-code');
2499
+ }
2500
+ // 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');
2505
+ const routes = (request.routes ?? []).map(route => (typeof route === 'string' ? route.trim() : ''));
2506
+ const classification = text$1(request.dataClassification);
2507
+ const limits = {
2508
+ maxToolCalls: request.maxToolCalls ?? Math.min(bounds.toolCalls.initial, ceiling.maxToolCalls),
2509
+ timeoutSeconds: request.timeoutSeconds ?? Math.min(bounds.timeoutSeconds.initial, ceiling.maxTimeoutSeconds),
2510
+ maxAnswerChars: request.maxAnswerChars ?? Math.min(bounds.answerChars.initial, ceiling.maxAnswerChars),
2511
+ maxHistoryMessages: request.maxHistoryMessages ?? Math.min(bounds.history.initial, ceiling.maxHistoryMessages)
2512
+ };
2513
+ const errors = {};
2514
+ const add = (field, code) => {
2515
+ errors[field] = [...(errors[field] ?? []), code];
2516
+ };
2517
+ if (!subjectTypePattern.test(subjectType)) {
2518
+ add('subjectType', 'invalid');
2519
+ }
2520
+ if (description === null) {
2521
+ add('description', 'required');
2522
+ }
2523
+ else if (description.length > maxDescription$2) {
2524
+ add('description', 'too-long');
2525
+ }
2526
+ if ((instructions ?? '').length > maxInstructions$1) {
2527
+ add('instructions', 'too-long');
2528
+ }
2529
+ if (!roles.includes(minimumRole)) {
2530
+ add('minimumRole', 'invalid');
2531
+ }
2532
+ if (!roles.includes(proposalRole)) {
2533
+ add('proposalRole', 'invalid');
2534
+ }
2535
+ if (!validRoutes(routes)) {
2536
+ add('routes', 'invalid');
2537
+ }
2538
+ if (!within(limits.maxToolCalls, bounds.toolCalls)
2539
+ || !within(limits.timeoutSeconds, bounds.timeoutSeconds)
2540
+ || !within(limits.maxAnswerChars, bounds.answerChars)
2541
+ || !within(limits.maxHistoryMessages, bounds.history)) {
2542
+ add('limits', 'invalid');
2543
+ }
2544
+ if (classification !== null && !classifications.includes(classification)) {
2545
+ add('dataClassification', 'invalid');
2546
+ }
2547
+ if (Object.keys(errors).length > 0) {
2548
+ return mockFailure('assistant-validation', errors);
2549
+ }
2550
+ const existing = this.stored.get(key$2(agent.id, subjectType));
2551
+ const now = this.now();
2552
+ const row = {
2553
+ agentId: agent.id,
2554
+ subjectType,
2555
+ version: (existing?.version ?? 0) + 1,
2556
+ description: description,
2557
+ instructions,
2558
+ minimumRole,
2559
+ askerPolicy: text$1(request.askerPolicy),
2560
+ routes,
2561
+ proposalRole,
2562
+ proposalPolicy: text$1(request.proposalPolicy),
2563
+ ...limits,
2564
+ allowRequests: request.allowRequests === true,
2565
+ exposeToMcp: request.exposeToMcp === true,
2566
+ dataClassification: classification,
2567
+ createdAt: existing?.createdAt ?? now,
2568
+ createdBy: existing?.createdBy ?? 'current-user',
2569
+ changedAt: now,
2570
+ changedBy: 'current-user',
2571
+ reason
2572
+ };
2573
+ // The same check as every read, refused here instead of degraded.
2574
+ const check = this.check(agent, row);
2575
+ if (check.problems.length > 0) {
2576
+ return fieldFailure(check.problems);
2577
+ }
2578
+ if (request.expectedVersion == null && !existing && this.storedOf(agent.id).length >= maxConsultations) {
2579
+ return mockFailure('assistant-configuration-limit');
2580
+ }
2581
+ if ((request.expectedVersion ?? null) !== (existing?.version ?? null)) {
2582
+ return mockFailure('assistant-version-conflict');
2583
+ }
2584
+ this.stored.set(key$2(agent.id, subjectType), row);
2585
+ return { status: 200, body: this.toConsultation(agent, row) };
2586
+ }
2587
+ remove(agent, subjectType, query) {
2588
+ const reason = query.get('reason');
2589
+ if (reason !== null && reason.trim().length > mockMaxStateReason) {
2590
+ return mockFailure('assistant-validation', { reason: ['too-long'] });
2591
+ }
2592
+ if (this.inCode(agent.id, subjectType)) {
2593
+ return mockFailure('assistant-consultation-in-code');
2594
+ }
2595
+ const existing = this.stored.get(key$2(agent.id, subjectType));
2596
+ if (!existing) {
2597
+ return mockFailure('assistant-consultation-not-found');
2598
+ }
2599
+ const expected = query.get('expectedVersion');
2600
+ if (expected !== null && Number(expected) !== existing.version) {
2601
+ return mockFailure('assistant-version-conflict');
2602
+ }
2603
+ this.stored.delete(key$2(agent.id, subjectType));
2604
+ return { status: 204 };
2605
+ }
2606
+ /**
2607
+ * Checks a consultation against the ceilings and the agent as the server does on save and on every
2608
+ * load: out of effect when the agent may get none, the type is unknown or not worked on or the
2609
+ * asker policy is no longer listed; otherwise without the routes, requests and MCP exposure that
2610
+ * no longer fit and with limits capped at the ceiling, each with a problem.
2611
+ */
2612
+ check(agent, row) {
2613
+ const out = (problem) => ({ inEffect: false, problems: [problem], routes: [], allowRequests: false });
2614
+ const ceiling = this.ceilingOf(agent);
2615
+ if (!ceiling) {
2616
+ return out('not-consultable');
2617
+ }
2618
+ if (!this.rules.knownSubjectTypes().includes(row.subjectType)) {
2619
+ return out('unknown-subject-type');
2620
+ }
2621
+ if (!mockWorksOnCovers(agent, row.subjectType)) {
2622
+ return out('not-worked-on');
2623
+ }
2624
+ if (this.inCode(agent.id, row.subjectType)) {
2625
+ return out('conflicts-with-code');
2626
+ }
2627
+ if (row.askerPolicy !== null && !this.rules.agentPolicyNames().includes(row.askerPolicy)) {
2628
+ return out('unknown-agent-policy');
2629
+ }
2630
+ const problems = [];
2631
+ const addOnce = (problem) => {
2632
+ if (!problems.includes(problem)) {
2633
+ problems.push(problem);
2634
+ }
2635
+ };
2636
+ const accountability = this.rules.accountabilityOf(agent);
2637
+ let kept = [];
2638
+ for (const route of row.routes) {
2639
+ const agentRights = route === 'subject-agent-rights';
2640
+ if (!ceiling.routes.includes(route) || (agentRights && row.subjectType === mockSystemSubjectType)) {
2641
+ addOnce('route-not-allowed');
2642
+ continue;
2643
+ }
2644
+ if (agentRights && accountability === 'subject-responsible') {
2645
+ addOnce('agent-answers-to-person');
2646
+ continue;
2647
+ }
2648
+ // An agent created here that answers for itself has no grants; one in code is assumed to have them.
2649
+ if (agentRights && agent.source === 'admin' && accountability === 'agent') {
2650
+ addOnce('agent-has-no-rights');
2651
+ continue;
2652
+ }
2653
+ kept.push(route);
2654
+ }
2655
+ if (row.proposalPolicy !== null && !this.rules.approvalPolicies().includes(row.proposalPolicy)) {
2656
+ // Without its policy nobody knows who decides a proposal: the consultation only answers.
2657
+ addOnce('unknown-policy');
2658
+ kept = [];
2659
+ }
2660
+ const policies = [row.proposalPolicy, this.rules.approvalPolicyOf(agent)].filter((policy) => policy !== null);
2661
+ if (kept.includes('subject-agent-rights') && policies.some(policy => this.namesOnlyTheInitiator(policy))) {
2662
+ addOnce('no-approver');
2663
+ kept = kept.filter(route => route !== 'subject-agent-rights');
2664
+ }
2665
+ let allowRequests = row.allowRequests;
2666
+ if (allowRequests && !ceiling.allowRequests) {
2667
+ problems.push('requests-not-allowed');
2668
+ allowRequests = false;
2669
+ }
2670
+ if (row.exposeToMcp && !ceiling.allowMcp) {
2671
+ problems.push('mcp-not-allowed');
2672
+ }
2673
+ if (row.maxToolCalls > ceiling.maxToolCalls
2674
+ || row.timeoutSeconds > ceiling.maxTimeoutSeconds
2675
+ || row.maxAnswerChars > ceiling.maxAnswerChars
2676
+ || row.maxHistoryMessages > ceiling.maxHistoryMessages) {
2677
+ problems.push('limit-above-ceiling');
2678
+ }
2679
+ return { inEffect: true, problems, routes: kept, allowRequests };
2680
+ }
2681
+ namesOnlyTheInitiator(policy) {
2682
+ const summary = this.rules.approvalPolicySummaries().find(item => item.name === policy);
2683
+ return summary !== undefined && summary.approvers.length > 0 && summary.approvers.every(approver => approver.kind === 'initiator');
2684
+ }
2685
+ toConsultation(agent, row) {
2686
+ const check = this.check(agent, row);
2687
+ const consultToolId = consultToolOf(agent.id, row.subjectType);
2688
+ const { agentId, ...stored } = row;
2689
+ return {
2690
+ ...stored,
2691
+ routes: [...row.routes],
2692
+ instructionsHash: row.instructions === null ? null : mockHashText(row.instructions),
2693
+ inEffect: check.inEffect,
2694
+ problem: check.problems[0] ?? null,
2695
+ problems: check.problems,
2696
+ routesInEffect: check.routes,
2697
+ consultToolId,
2698
+ requestToolId: check.allowRequests ? requestToolOf(agent.id, row.subjectType) : null,
2699
+ askingAgents: this.askersOf(agent.id, consultToolId)
2700
+ };
2701
+ }
2702
+ /** The other agents whose tool selectors select the tool now, by id: the consultation's second key. */
2703
+ askersOf(agentId, toolId) {
2704
+ return this.rules.all()
2705
+ .filter(agent => agent.id !== agentId && agent.toolSelectors.some(selector => covers$1(selector, toolId)))
2706
+ .map(agent => agent.id)
2707
+ .sort();
2708
+ }
2709
+ inCode(agentId, subjectType) {
2710
+ const allowed = this.codeAllowed.get(agentId);
2711
+ return allowed !== undefined
2712
+ && (Object.values(allowed).some(types => types?.includes(subjectType)) || subjectType in (this.codeDescriptions.get(agentId) ?? {}));
2713
+ }
2714
+ storedOf(agentId) {
2715
+ return [...this.stored.values()]
2716
+ .filter(stored => stored.agentId === agentId)
2717
+ .sort((left, right) => left.subjectType.localeCompare(right.subjectType));
2718
+ }
2719
+ }
2720
+ /** A ceiling of the scenario with the defaults of the ceiling builder. */
2721
+ function ceilingOf(ceiling) {
2722
+ return {
2723
+ routes: [...(ceiling.routes ?? [])],
2724
+ allowRequests: ceiling.allowRequests ?? false,
2725
+ allowMcp: ceiling.allowMcp ?? false,
2726
+ maxToolCalls: ceiling.maxToolCalls ?? bounds.toolCalls.max,
2727
+ maxTimeoutSeconds: ceiling.maxTimeoutSeconds ?? bounds.timeoutSeconds.max,
2728
+ maxAnswerChars: ceiling.maxAnswerChars ?? bounds.answerChars.max,
2729
+ maxHistoryMessages: ceiling.maxHistoryMessages ?? bounds.history.max,
2730
+ maxConsultations,
2731
+ maxInstructionsLength: maxInstructions$1
2732
+ };
2733
+ }
2734
+ /** The field error of each problem a consultation would have, as the server maps them on save. */
2735
+ function fieldFailure(problems) {
2736
+ if (problems.includes('not-consultable')) {
2737
+ return mockFailure('assistant-consultations-not-allowed');
2738
+ }
2739
+ if (problems.includes('conflicts-with-code')) {
2740
+ return mockFailure('assistant-consultation-in-code');
2741
+ }
2742
+ const fields = {
2743
+ 'unknown-subject-type': ['subjectType', 'not-found'],
2744
+ 'not-worked-on': ['subjectType', 'invalid'],
2745
+ 'unknown-agent-policy': ['askerPolicy', 'not-found'],
2746
+ 'route-not-allowed': ['routes', 'not-allowed'],
2747
+ 'agent-answers-to-person': ['routes', 'invalid'],
2748
+ 'agent-has-no-rights': ['routes', 'invalid'],
2749
+ 'unknown-policy': ['proposalPolicy', 'not-found'],
2750
+ 'no-approver': ['proposalPolicy', 'invalid'],
2751
+ 'limit-above-ceiling': ['limits', 'not-allowed'],
2752
+ 'requests-not-allowed': ['allowRequests', 'not-allowed'],
2753
+ 'mcp-not-allowed': ['exposeToMcp', 'not-allowed']
2754
+ };
2755
+ const errors = {};
2756
+ for (const problem of problems) {
2757
+ const [field, code] = fields[problem] ?? ['subjectType', 'invalid'];
2758
+ errors[field] = [...(errors[field] ?? []), code];
2759
+ }
2760
+ return mockFailure('assistant-validation', errors);
2761
+ }
2762
+ /** Known routes, each once, and at most one of the two routes at the subject. */
2763
+ function validRoutes(routes) {
2764
+ return routes.every(route => allRoutes.includes(route))
2765
+ && new Set(routes).size === routes.length
2766
+ && !(routes.includes('subject-agent-rights') && routes.includes('subject-asker-rights'));
2767
+ }
2768
+ function within(value, range) {
2769
+ return Number.isInteger(value) && value >= range.min && value <= range.max;
2770
+ }
2771
+ function consultToolOf(agentId, subjectType) {
2772
+ return `assistant.consult.${agentId}.${subjectType}`;
2773
+ }
2774
+ function requestToolOf(agentId, subjectType) {
2775
+ return `assistant.request.${agentId}.${subjectType}`;
2776
+ }
2777
+ /** The agent's selector covers the tool: the same tool, or a pattern with `*` that matches it. */
2778
+ function covers$1(selector, toolId) {
2779
+ const pattern = selector.split('*').map(part => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*');
2780
+ return new RegExp(`^${pattern}$`).test(toolId);
2781
+ }
2782
+ function key$2(agentId, subjectType) {
2783
+ return `${agentId}/${subjectType}`;
2784
+ }
2785
+ function text$1(value) {
2786
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
2787
+ }
2788
+
2217
2789
  const dashCase$4 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
2218
2790
  const dataClassifications = ['public', 'internal', 'confidential', 'restricted'];
2219
2791
  const accountabilities = ['agent', 'subject-responsible', 'service-account'];
@@ -2222,6 +2794,9 @@ const maxDescription$1 = 300;
2222
2794
  const maxInstructions = 20_000;
2223
2795
  const maxMessage$1 = 2_000;
2224
2796
  const maxReason$1 = 500;
2797
+ /** A data trigger applies at most this many filters of its event, each named in lowercase dash-case. */
2798
+ const maxTriggerFilters = 10;
2799
+ const filterName = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2225
2800
  /** The platform's default file limits (`NhAssistantLimits`). */
2226
2801
  const defaultFileLimits = {
2227
2802
  maxFileBytes: 20 * 1024 * 1024,
@@ -2248,8 +2823,20 @@ class NhAssistantMockRunConfiguration {
2248
2823
  this.skills = new Map();
2249
2824
  this.triggers = new Map();
2250
2825
  const approvalPolicies = scenario.runCatalog?.approvalPolicies ?? scenario.policies ?? [];
2826
+ this.consultations = new NhAssistantMockConsultations(scenario.consultationsAsData, scenario.agents ?? [], {
2827
+ find: agentId => this.agents.find(agentId),
2828
+ all: () => this.agents.all(),
2829
+ knownSubjectTypes: () => this.agents.knownSubjectTypes(),
2830
+ isConfigured: agent => this.isConfigured(agent),
2831
+ accountabilityOf: agent => agent.source === 'admin' ? this.settings.get(agent.id)?.accountability ?? null : this.codeSettingsOf(agent).accountability,
2832
+ approvalPolicyOf: agent => agent.source === 'admin' ? this.settings.get(agent.id)?.approvalPolicy ?? null : this.codeSettingsOf(agent).approvalPolicy,
2833
+ approvalPolicies: () => this.catalog.approvalPolicies,
2834
+ approvalPolicySummaries: () => this.catalog.approvalPolicySummaries ?? [],
2835
+ agentPolicyNames: () => this.agentPolicyNames
2836
+ }, now);
2251
2837
  this.catalog = {
2252
- triggerEvents: scenario.runCatalog?.triggerEvents ?? [],
2838
+ // A trigger event lists its named filters, none unless the scenario's application registers some.
2839
+ triggerEvents: (scenario.runCatalog?.triggerEvents ?? []).map(event => ({ ...event, filters: event.filters ?? [] })),
2253
2840
  subjectSets: scenario.runCatalog?.subjectSets ?? [],
2254
2841
  ruleHandlers: scenario.runCatalog?.ruleHandlers ?? [],
2255
2842
  approvalPolicies,
@@ -2266,7 +2853,8 @@ class NhAssistantMockRunConfiguration {
2266
2853
  agentPolicies: scenario.runCatalog?.agentPolicies ?? [],
2267
2854
  divisions: scenario.runCatalog?.divisions ?? false,
2268
2855
  // The platform's default file limits, unless the scenario's application chose its own.
2269
- fileLimits: scenario.runCatalog?.fileLimits ?? defaultFileLimits
2856
+ fileLimits: scenario.runCatalog?.fileLimits ?? defaultFileLimits,
2857
+ consultationCeiling: this.consultations.applicationCeiling
2270
2858
  };
2271
2859
  this.codeTriggers = (scenario.codeTriggers ?? []).map(trigger => ({
2272
2860
  ...trigger,
@@ -2290,13 +2878,25 @@ class NhAssistantMockRunConfiguration {
2290
2878
  get profileNames() {
2291
2879
  return [...this.catalog.profiles];
2292
2880
  }
2881
+ /**
2882
+ * The agent's consultations defined as data in effect with their routes in effect, which count for
2883
+ * its route switches.
2884
+ */
2885
+ consultationsInEffect(agentId) {
2886
+ return this.consultations.inEffect(agentId);
2887
+ }
2888
+ /** The generated consult and request tools of the data consultations in effect. */
2889
+ consultationTools() {
2890
+ return this.consultations.tools();
2891
+ }
2293
2892
  /** `GET admin/run-catalog`. */
2294
2893
  runCatalog(method) {
2295
2894
  return method === 'GET' ? { status: 200, body: this.catalog } : { status: 405 };
2296
2895
  }
2297
2896
  /**
2298
- * `admin/agents/{id}/configuration`, `run-settings`, `skills/{skillId}[/...]` and
2299
- * `triggers/{triggerId}[/shadow]`; `segments` start after the agent id.
2897
+ * `admin/agents/{id}/configuration`, `run-settings`, `skills/{skillId}[/...]`,
2898
+ * `triggers/{triggerId}[/shadow]` and `consultations/{subjectType}`; `segments` start after the
2899
+ * agent id.
2300
2900
  */
2301
2901
  handle(method, agentId, segments, body, query) {
2302
2902
  const agent = this.agents.find(agentId);
@@ -2325,6 +2925,9 @@ class NhAssistantMockRunConfiguration {
2325
2925
  }
2326
2926
  return action === 'shadow' ? this.triggerShadow(method, agent, id, body) : this.trigger(method, agent, id, body, query);
2327
2927
  }
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');
2930
+ }
2328
2931
  return null;
2329
2932
  }
2330
2933
  configuration(agent) {
@@ -2345,12 +2948,18 @@ class NhAssistantMockRunConfiguration {
2345
2948
  dataSkills,
2346
2949
  codeTriggers: this.codeTriggers.filter(trigger => trigger.agentId === agent.id).map(({ agentId, ...trigger }) => trigger),
2347
2950
  dataTriggers,
2348
- problems: dataTriggers
2349
- .filter(trigger => trigger.problem !== null)
2350
- .map(trigger => ({ agentId: agent.id, kind: 'trigger', id: trigger.triggerId, code: trigger.problem })),
2951
+ problems: [
2952
+ ...dataTriggers
2953
+ .filter(trigger => trigger.problem !== null)
2954
+ .map(trigger => ({ agentId: agent.id, kind: 'trigger', id: trigger.triggerId, code: trigger.problem })),
2955
+ ...this.consultations.problems(agent)
2956
+ ],
2351
2957
  source: agent.source,
2352
2958
  runSettings: agent.source === 'admin' ? this.settings.get(agent.id) ?? null : null,
2353
- codeRunSettings: agent.source === 'code' ? this.codeSettingsOf(agent) : null
2959
+ codeRunSettings: agent.source === 'code' ? this.codeSettingsOf(agent) : null,
2960
+ codeConsultations: this.consultations.codeConsultations(agent),
2961
+ dataConsultations: this.consultations.dataConsultations(agent),
2962
+ consultationCeiling: this.consultations.ceilingOf(agent)
2354
2963
  };
2355
2964
  }
2356
2965
  /**
@@ -2375,8 +2984,13 @@ class NhAssistantMockRunConfiguration {
2375
2984
  */
2376
2985
  triggerProblem(agent, trigger) {
2377
2986
  if (trigger.source === 'event') {
2378
- const subjectType = this.catalog.triggerEvents.find(item => item.name === trigger.event)?.subjectType;
2379
- return subjectType && !mockWorksOnCovers(agent, subjectType) ? 'not-worked-on' : null;
2987
+ const event = this.catalog.triggerEvents.find(item => item.name === trigger.event);
2988
+ if (event?.subjectType && !mockWorksOnCovers(agent, event.subjectType)) {
2989
+ return 'not-worked-on';
2990
+ }
2991
+ // A filter the event no longer offers keeps the trigger from firing.
2992
+ const offered = (event?.filters ?? []).map(filter => filter.name);
2993
+ return (trigger.filters ?? []).some(name => !offered.includes(name)) ? 'unknown-filter' : null;
2380
2994
  }
2381
2995
  return trigger.subjectSet === null && !mockWorksOnCovers(agent, mockSystemSubjectType) ? 'not-worked-on' : null;
2382
2996
  }
@@ -2392,7 +3006,9 @@ class NhAssistantMockRunConfiguration {
2392
3006
  if (!versionMatches(query, current.version)) {
2393
3007
  return mockFailure('assistant-version-conflict');
2394
3008
  }
2395
- if (this.liveSkills(agent.id).length > 0 || [...this.triggers.values()].some(trigger => this.ownerOf(trigger) === agent.id)) {
3009
+ if (this.liveSkills(agent.id).length > 0
3010
+ || [...this.triggers.values()].some(trigger => this.ownerOf(trigger) === agent.id)
3011
+ || this.consultations.has(agent.id)) {
2396
3012
  return mockFailure('assistant-run-settings-in-use');
2397
3013
  }
2398
3014
  this.settings.delete(agent.id);
@@ -2706,9 +3322,20 @@ class NhAssistantMockRunConfiguration {
2706
3322
  if (skill !== null && !this.skillIds(agent).includes(skill)) {
2707
3323
  errors['skill'] = ['not-found'];
2708
3324
  }
3325
+ // The filters in the order chosen, each once; only an event trigger takes them.
3326
+ const filters = [...new Set((request.filters ?? []).map(name => (typeof name === 'string' ? name.trim() : '')))];
3327
+ if (filters.length > maxTriggerFilters || filters.some(name => name.length > 64 || !filterName.test(name)) || (schedule && filters.length > 0)) {
3328
+ errors['filters'] = ['invalid'];
3329
+ }
2709
3330
  if (Object.keys(errors).length > 0) {
2710
3331
  return mockFailure('assistant-validation', errors);
2711
3332
  }
3333
+ if (request.source === 'event') {
3334
+ const offered = (this.catalog.triggerEvents.find(item => item.name === text(request.event))?.filters ?? []).map(filter => filter.name);
3335
+ if (filters.some(name => !offered.includes(name))) {
3336
+ return mockFailure('assistant-trigger-filter-not-found', { filters: ['not-found'] });
3337
+ }
3338
+ }
2712
3339
  if ((request.expectedVersion ?? null) !== (existing?.version ?? null)) {
2713
3340
  return mockFailure('assistant-version-conflict');
2714
3341
  }
@@ -2729,7 +3356,8 @@ class NhAssistantMockRunConfiguration {
2729
3356
  changedBy: 'current-user',
2730
3357
  reason: text(request.reason),
2731
3358
  inEffect: true,
2732
- problem: null
3359
+ problem: null,
3360
+ filters: schedule ? [] : filters
2733
3361
  };
2734
3362
  this.triggers.set(triggerId, Object.assign(saved, { [ownerKey]: agent.id }));
2735
3363
  return { status: 200, body: withoutOwner(saved) };
@@ -2903,11 +3531,14 @@ class NhAssistantMockAdmin {
2903
3531
  hasServer: serverId => this.mcp.hasServer(serverId),
2904
3532
  agentPolicyNames: () => this.runConfiguration.agentPolicyNames,
2905
3533
  divisionsListed: () => this.runConfiguration.divisionsListed,
2906
- profileNames: () => this.runConfiguration.profileNames
3534
+ profileNames: () => this.runConfiguration.profileNames,
3535
+ dataConsultations: agentId => this.runConfiguration.consultationsInEffect(agentId)
2907
3536
  });
2908
3537
  this.accounts = new NhAssistantMockAccounts(admin);
2909
3538
  this.runConfiguration = new NhAssistantMockRunConfiguration(admin, {
2910
3539
  find: agentId => this.agents.find(agentId),
3540
+ all: () => this.agents.all(),
3541
+ knownSubjectTypes: () => [...(scenario.subjectTypes ?? []).map(type => type.id), mockSystemSubjectType],
2911
3542
  setDataSkill: (agentId, skillId, description, startsOff) => this.agents.setDataSkill(agentId, skillId, description, startsOff)
2912
3543
  }, now, this.accounts);
2913
3544
  this.mcp = new NhAssistantMockAdminMcp(scenario, now, {
@@ -3068,7 +3699,8 @@ class NhAssistantMockAdmin {
3068
3699
  /**
3069
3700
  * Saves the run configuration the scenario gives agents created in the administration through the
3070
3701
  * mock's own endpoints, as an administrator would: the run settings, then each skill and trigger,
3071
- * put live when the scenario says so. A part the mock refuses fails the scenario at once.
3702
+ * put live when the scenario says so, and then the consultations defined as data of every agent. A
3703
+ * part the mock refuses fails the scenario at once.
3072
3704
  */
3073
3705
  presetRunConfigurations(agents) {
3074
3706
  const query = new URLSearchParams();
@@ -3094,11 +3726,21 @@ class NhAssistantMockAdmin {
3094
3726
  }
3095
3727
  }
3096
3728
  }
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
+ }
3733
+ }
3097
3734
  }
3098
- /** The scenario's tools, with the object type they name as the server reports it, and the enabled MCP tools. */
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
+ */
3099
3740
  toolCatalog() {
3100
3741
  return [
3101
3742
  ...(this.scenario.admin?.tools ?? []).map(tool => ({ ...tool, subjectType: tool.subjectType ?? null })),
3743
+ ...this.runConfiguration.consultationTools(),
3102
3744
  ...this.mcp.catalogEntries()
3103
3745
  ];
3104
3746
  }
@@ -3116,14 +3758,50 @@ function toContext(version) {
3116
3758
  /** The longest file name a reference takes, as on the server. */
3117
3759
  const maxNameLength = 256;
3118
3760
  const mediaTypePattern = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
3761
+ const zipSignature = [0x50, 0x4b, 0x03, 0x04];
3762
+ const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
3763
+ const jpegSignature = [0xff, 0xd8, 0xff];
3764
+ /**
3765
+ * The binary formats a text upload may be in disguise, as on the server: an OLE2 compound file (Word,
3766
+ * Excel and PowerPoint 97 to 2003), a ZIP package (also an empty one), a PDF, PNG, JPEG or GIF.
3767
+ */
3768
+ const binarySignatures = [
3769
+ [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1],
3770
+ zipSignature,
3771
+ [0x50, 0x4b, 0x05, 0x06],
3772
+ [0x25, 0x50, 0x44, 0x46, 0x2d],
3773
+ pngSignature,
3774
+ jpegSignature,
3775
+ [0x47, 0x49, 0x46, 0x38, 0x37, 0x61],
3776
+ [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]
3777
+ ];
3119
3778
  /** How files of the platform's types start; other accepted types are the application's to check. */
3120
3779
  const signatures = {
3121
3780
  'application/pdf': head => indexOf(head, [0x25, 0x50, 0x44, 0x46, 0x2d]) >= 0,
3122
- 'image/png': head => startsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
3123
- 'image/jpeg': head => startsWith(head, [0xff, 0xd8, 0xff]),
3124
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': head => startsWith(head, [0x50, 0x4b, 0x03, 0x04]),
3125
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': head => startsWith(head, [0x50, 0x4b, 0x03, 0x04])
3781
+ 'image/png': head => startsWith(head, pngSignature),
3782
+ 'image/jpeg': head => startsWith(head, jpegSignature),
3783
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': head => startsWith(head, zipSignature),
3784
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': head => startsWith(head, zipSignature),
3785
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': head => startsWith(head, zipSignature)
3126
3786
  };
3787
+ /**
3788
+ * Whether the content starts as files of the media type do: the signature of a platform type, and for
3789
+ * text (`text/*`, JSON and XML, also with a `+json` or `+xml` suffix) no binary signature. UTF-16 text
3790
+ * passes: there is no NUL-byte check.
3791
+ */
3792
+ function matchesSignature(head, mediaType) {
3793
+ if (isTextMediaType(mediaType)) {
3794
+ return !binarySignatures.some(signature => startsWith(head, signature));
3795
+ }
3796
+ return signatures[mediaType]?.(head) ?? true;
3797
+ }
3798
+ function isTextMediaType(mediaType) {
3799
+ return mediaType.startsWith('text/')
3800
+ || mediaType === 'application/json'
3801
+ || mediaType === 'application/xml'
3802
+ || mediaType.endsWith('+json')
3803
+ || mediaType.endsWith('+xml');
3804
+ }
3127
3805
  /**
3128
3806
  * The files of the mock API: `POST conversations/{id}/files` with the server's checks, and the
3129
3807
  * attachments of a message, which a turn opens again as the server's file source would.
@@ -3163,7 +3841,8 @@ class NhAssistantMockFiles {
3163
3841
  if (name === null || file.size === 0) {
3164
3842
  return mockFailure('assistant-file-upload-invalid');
3165
3843
  }
3166
- const mediaType = normalizeMediaType(file.type);
3844
+ // A missing or generic type takes the accepted type of the file name's extension, as on the server.
3845
+ const mediaType = normalizeMediaType(_nhAssistantMediaTypeOf({ name, type: file.type }, uploads.acceptedMediaTypes, uploads.mappedExtensions));
3167
3846
  if (mediaType === null || !uploads.acceptedMediaTypes.includes(mediaType)) {
3168
3847
  return mockFailure('assistant-file-type-not-accepted');
3169
3848
  }
@@ -3171,7 +3850,7 @@ class NhAssistantMockFiles {
3171
3850
  return mockFailure('assistant-file-too-large');
3172
3851
  }
3173
3852
  const head = new Uint8Array(await file.slice(0, 1024).arrayBuffer());
3174
- if (!(signatures[mediaType]?.(head) ?? true)) {
3853
+ if (!matchesSignature(head, mediaType)) {
3175
3854
  return mockFailure('assistant-file-type-not-accepted');
3176
3855
  }
3177
3856
  const upload = { conversationId, name, mediaType, size: file.size };
@@ -3653,7 +4332,7 @@ class NhAssistantMockAgentRuns {
3653
4332
  result: this.answerOf(found.run.id, language),
3654
4333
  resultTruncated: this.truncated.has(found.run.id),
3655
4334
  summary: found.run.summary,
3656
- proposals: [],
4335
+ proposals: this.context.runProposals?.(found.run.id) ?? [],
3657
4336
  hiddenProposals: 0,
3658
4337
  artifacts: [],
3659
4338
  canOpenConversation: this.mayView(found.thread, language)
@@ -3692,7 +4371,9 @@ class NhAssistantMockAgentRuns {
3692
4371
  return method === 'GET' ? this.lookup(query, language) : { status: 405 };
3693
4372
  }
3694
4373
  if (segments.length === 3 && method === 'GET') {
3695
- const listed = segments[2] === 'proposals' ? this.context.threadProposals?.(segments[1]) ?? null : null;
4374
+ const listed = segments[2] === 'proposals'
4375
+ ? this.context.threadProposals?.(segments[1]) ?? null
4376
+ : segments[2] === 'consultations' ? this.context.threadConsultations?.(segments[1]) ?? null : null;
3696
4377
  if (listed) {
3697
4378
  return { status: 200, body: listed };
3698
4379
  }
@@ -4388,7 +5069,7 @@ const maxGroupId = 64;
4388
5069
  const maxGroups = 100;
4389
5070
  const maxName = 100;
4390
5071
  const maxDescription = 300;
4391
- const guid = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i;
5072
+ const guid$1 = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i;
4392
5073
  /**
4393
5074
  * Groups and boards of the mock API (K15, K16), as the server serves them: the scenario's
4394
5075
  * `agentGroups` are the groups in code with their names per language, checked as the server checks
@@ -4515,7 +5196,7 @@ class NhAssistantMockBoards {
4515
5196
  }
4516
5197
  const groupId = segments[1];
4517
5198
  const divisionId = query.get('divisionId');
4518
- if (divisionId !== null && divisionId !== '' && !guid.test(divisionId)) {
5199
+ if (divisionId !== null && divisionId !== '' && !guid$1.test(divisionId)) {
4519
5200
  return mockFailure('assistant-board-scope-invalid');
4520
5201
  }
4521
5202
  const division = divisionId ? this.division(divisionId, requested) : null;
@@ -4656,9 +5337,14 @@ const dashCase = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
4656
5337
  const maxReason = 500;
4657
5338
  const maxItemsPerPage = 100;
4658
5339
  const maxBulkDecisions = 50;
5340
+ const guid = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i;
4659
5341
  const lifetimeMs = 3 * 24 * 60 * 60 * 1000;
4660
5342
  /** The platform's subject type of the whole application. */
4661
5343
  const systemType = 'nh-system';
5344
+ /** The person who asked decides a consultation's proposal in their own chat; nobody decides it in the thread. */
5345
+ const askerDecidesCode = 'assistant-consult-asker-decides';
5346
+ /** Someone other than the person who started the work decides it. */
5347
+ const fourEyesCode = 'assistant-four-eyes-required';
4662
5348
  /**
4663
5349
  * The proposals the scenario lists, which the mock's agents left, for example one a run for the whole
4664
5350
  * application placed at a project (`placedBy`). The mock runs no agents: it serves them as the server
@@ -4668,15 +5354,21 @@ const systemType = 'nh-system';
4668
5354
  * with the server's validation and statuses. An approved proposal executes at once. A proposal of the
4669
5355
  * whole application (`nh-system`) exists only for the approvers of the whole application who pass the
4670
5356
  * agent's view policy (`mayDecideApplication`): for anybody else the inbox leaves it out, reading it
4671
- * answers `404` and deciding it `404 assistant-approval-not-found`.
5357
+ * answers `404` and deciding it `404 assistant-approval-not-found`. A consultation's proposal that the
5358
+ * person who asked decides in their own chat (`asker-decides`) waits inline and is refused here with
5359
+ * `assistant-consult-asker-decides`; one that runs with the agent's own rights refuses its asker with
5360
+ * `assistant-four-eyes-required`.
4672
5361
  */
4673
5362
  class NhAssistantMockProposals {
4674
- constructor(scenario = [], actorId, now, mayDecideApplication = () => true, decided = () => undefined) {
5363
+ constructor(scenario = [], actorId, now, mayDecideApplication = () => true, decided = () => undefined, mayWorkIn = () => true) {
4675
5364
  this.actorId = actorId;
4676
5365
  this.now = now;
4677
5366
  this.mayDecideApplication = mayDecideApplication;
4678
5367
  this.decided = decided;
5368
+ this.mayWorkIn = mayWorkIn;
4679
5369
  this.proposals = new Map();
5370
+ /** When the caller last looked at the inbox, by scope key. */
5371
+ this.visits = new Map();
4680
5372
  const created = new Date(now()).getTime();
4681
5373
  for (const item of scenario) {
4682
5374
  if (this.proposals.has(item.approvalId)) {
@@ -4685,6 +5377,12 @@ class NhAssistantMockProposals {
4685
5377
  if (!dashCase.test(item.subjectType)) {
4686
5378
  throw new Error(`Mock proposal '${item.approvalId}' names subject type '${item.subjectType}', which is not dash-case.`);
4687
5379
  }
5380
+ if (item.consultationRoute && !item.consultationId) {
5381
+ throw new Error(`Mock proposal '${item.approvalId}' names a consultation route without its consultation.`);
5382
+ }
5383
+ const route = item.consultationRoute ?? null;
5384
+ const refusal = consultationRefusal(route, item.initiatorActorId ?? null, actorId());
5385
+ const canDecide = item.canDecide ?? refusal === null;
4688
5386
  this.proposals.set(item.approvalId, {
4689
5387
  proposal: {
4690
5388
  approvalId: item.approvalId,
@@ -4704,22 +5402,26 @@ class NhAssistantMockProposals {
4704
5402
  argumentsPreview: '{}',
4705
5403
  targets: [],
4706
5404
  status: item.status ?? 'pending',
4707
- mode: 'deferred',
5405
+ mode: route === 'asker-decides' ? 'inline' : 'deferred',
4708
5406
  policyId: null,
4709
- grantSource: 'agent',
4710
- createdAt: new Date(created).toISOString(),
4711
- expiresAt: new Date(created + lifetimeMs).toISOString(),
4712
- initiatorActorId: null,
5407
+ grantSource: item.grantSource ?? (route === 'subject-asker-rights' ? 'delegated' : 'agent'),
5408
+ createdAt: item.createdAt ?? new Date(created).toISOString(),
5409
+ expiresAt: item.expiresAt ?? new Date(Date.parse(item.createdAt ?? new Date(created).toISOString()) + lifetimeMs).toISOString(),
5410
+ initiatorActorId: item.initiatorActorId ?? null,
4713
5411
  decidedByActorId: null,
4714
5412
  decidedAt: null,
4715
5413
  executionResultCode: null,
4716
5414
  executedAt: null,
4717
5415
  rationale: item.rationale ?? null,
4718
5416
  placedBy: item.placedBy ?? null,
4719
- canGiveFeedback: false
5417
+ parent: item.parent ?? null,
5418
+ canGiveFeedback: false,
5419
+ consultationId: item.consultationId ?? null,
5420
+ consultationRoute: route
4720
5421
  },
4721
- canDecide: item.canDecide ?? true,
4722
- reasonCode: item.canDecide === false ? item.reasonCode ?? 'assistant-approval-policy-denied' : null
5422
+ divisionId: item.divisionId ?? null,
5423
+ canDecide,
5424
+ reasonCode: canDecide ? null : item.reasonCode ?? refusal ?? 'assistant-approval-policy-denied'
4723
5425
  });
4724
5426
  }
4725
5427
  }
@@ -4728,6 +5430,9 @@ class NhAssistantMockProposals {
4728
5430
  if (segments.length === 2 && segments[1] === 'inbox') {
4729
5431
  return method === 'GET' ? this.inbox(query) : { status: 405 };
4730
5432
  }
5433
+ if (segments.length === 3 && segments[1] === 'inbox' && segments[2] === 'visit') {
5434
+ return method === 'POST' ? this.visit(query) : { status: 405 };
5435
+ }
4731
5436
  if (segments.length === 2 && segments[1] === 'lookup') {
4732
5437
  return method === 'POST' ? this.lookup(body) : { status: 405 };
4733
5438
  }
@@ -4750,6 +5455,27 @@ class NhAssistantMockProposals {
4750
5455
  .reverse();
4751
5456
  return items.length === 0 ? null : items;
4752
5457
  }
5458
+ /** The proposals a consultation left per route; routes without proposals are left out. */
5459
+ consultationCounts(consultationId) {
5460
+ const byRoute = {};
5461
+ let total = 0;
5462
+ for (const { proposal } of this.proposals.values()) {
5463
+ if (proposal.consultationId !== consultationId) {
5464
+ continue;
5465
+ }
5466
+ total++;
5467
+ if (proposal.consultationRoute) {
5468
+ byRoute[proposal.consultationRoute] = (byRoute[proposal.consultationRoute] ?? 0) + 1;
5469
+ }
5470
+ }
5471
+ return { total, byRoute };
5472
+ }
5473
+ /** The proposals of a run the caller may see, in the order the scenario lists them. */
5474
+ ofRun(runId) {
5475
+ return this.visible()
5476
+ .filter(item => item.proposal.runId === runId)
5477
+ .map(item => item.proposal);
5478
+ }
4753
5479
  /** The pending proposals on a subject with the caller's right to decide each, earliest expiry first. */
4754
5480
  openOfSubject(subjectType, subjectId) {
4755
5481
  return this.visible()
@@ -4779,19 +5505,49 @@ class NhAssistantMockProposals {
4779
5505
  }
4780
5506
  const agentId = query.get('agentId');
4781
5507
  const runId = query.get('runId');
5508
+ const divisionId = query.get('divisionId');
5509
+ if (divisionId !== null && !guid.test(divisionId)) {
5510
+ return { status: 400 };
5511
+ }
5512
+ const grouped = query.get('grouped') === 'true';
4782
5513
  const items = this.visible()
4783
5514
  .filter(item => item.proposal.status === 'pending' && item.canDecide)
5515
+ .filter(item => divisionId === null || sameGuid(item.divisionId, divisionId))
4784
5516
  .map(item => item.proposal)
4785
5517
  .filter(proposal => (subjectType === null || proposal.subjectType === subjectType)
4786
5518
  && (agentId === null || proposal.agentId === agentId)
4787
5519
  && (runId === null || proposal.runId === runId))
4788
5520
  .sort((left, right) => left.expiresAt.localeCompare(right.expiresAt) || left.approvalId.localeCompare(right.approvalId));
5521
+ const ordered = grouped ? groupedOrder(items) : items;
5522
+ const offset = (page - 1) * itemsPerPage;
5523
+ const pageItems = ordered.slice(offset, offset + itemsPerPage);
5524
+ const scopeKey = scopeKeyOf(divisionId);
5525
+ const lastVisitedAt = this.visits.get(scopeKey) ?? null;
4789
5526
  const body = {
4790
- items: items.slice((page - 1) * itemsPerPage, page * itemsPerPage),
4791
- total: items.length
5527
+ items: pageItems,
5528
+ total: items.length,
5529
+ scopeKey,
5530
+ lastVisitedAt,
5531
+ newApprovalIds: pageItems
5532
+ .filter(proposal => lastVisitedAt === null || Date.parse(proposal.createdAt) > Date.parse(lastVisitedAt))
5533
+ .map(proposal => proposal.approvalId),
5534
+ groups: grouped ? groupsOf(ordered, offset, pageItems.length) : null
4792
5535
  };
4793
5536
  return { status: 200, body };
4794
5537
  }
5538
+ /** `POST approvals/inbox/visit`: the whole inbox, or that of a division the caller works in. */
5539
+ visit(query) {
5540
+ const divisionId = query.get('divisionId');
5541
+ if (divisionId !== null && !guid.test(divisionId)) {
5542
+ return { status: 400 };
5543
+ }
5544
+ if (divisionId !== null && !this.mayWorkIn(divisionId)) {
5545
+ return mockFailure('assistant-subject-not-found');
5546
+ }
5547
+ const body = { scopeKey: scopeKeyOf(divisionId), visitedAt: this.now() };
5548
+ this.visits.set(body.scopeKey, body.visitedAt);
5549
+ return { status: 200, body };
5550
+ }
4795
5551
  read(approvalId) {
4796
5552
  const stored = this.find(approvalId);
4797
5553
  return stored ? { status: 200, body: this.withRight(stored) } : mockFailure('assistant-approval-not-found');
@@ -4879,6 +5635,110 @@ class NhAssistantMockProposals {
4879
5635
  };
4880
5636
  }
4881
5637
  }
5638
+ /**
5639
+ * Why nobody, or not the caller, decides a consultation's proposal here: the person who asked decides
5640
+ * an `asker-decides` proposal in their own chat, and four-eyes keeps the asker out of a proposal that
5641
+ * runs with the agent's own rights; `null` when the route does not keep the caller out.
5642
+ */
5643
+ function consultationRefusal(route, initiatorActorId, actorId) {
5644
+ if (route === 'asker-decides') {
5645
+ return askerDecidesCode;
5646
+ }
5647
+ return route === 'subject-agent-rights' && initiatorActorId === actorId ? fourEyesCode : null;
5648
+ }
5649
+ /** The scope of the inbox's visits: `global`, or the scope key of a division. */
5650
+ function scopeKeyOf(divisionId) {
5651
+ return divisionId === null ? 'global' : `division:${divisionId.replaceAll('-', '').toLowerCase()}`;
5652
+ }
5653
+ function sameGuid(left, right) {
5654
+ return left !== null && left.replaceAll('-', '').toLowerCase() === right.replaceAll('-', '').toLowerCase();
5655
+ }
5656
+ /** The group a proposal belongs to: its parent, or its own subject without one. */
5657
+ function groupKeyOf(proposal) {
5658
+ return proposal.parent
5659
+ ? `${proposal.parent.subjectType}|${proposal.parent.subjectId}`
5660
+ : `${proposal.subjectType}|${proposal.subjectId}`;
5661
+ }
5662
+ function subjectKeyOf(proposal) {
5663
+ return `${groupKeyOf(proposal)}|${proposal.subjectType}|${proposal.subjectId}`;
5664
+ }
5665
+ function firstExpiries(items, key) {
5666
+ const first = new Map();
5667
+ for (const proposal of items) {
5668
+ const current = first.get(key(proposal));
5669
+ if (current === undefined || proposal.expiresAt < current) {
5670
+ first.set(key(proposal), proposal.expiresAt);
5671
+ }
5672
+ }
5673
+ return first;
5674
+ }
5675
+ /**
5676
+ * The order of the grouped inbox, as the server sorts: groups by their first expiry, a group's own
5677
+ * subject first, then its other subjects by their first expiry, one run's proposals together, then by
5678
+ * expiry.
5679
+ */
5680
+ function groupedOrder(items) {
5681
+ const groups = firstExpiries(items, groupKeyOf);
5682
+ const subjects = firstExpiries(items, subjectKeyOf);
5683
+ const compare = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
5684
+ return [...items].sort((left, right) => compare(groups.get(groupKeyOf(left)), groups.get(groupKeyOf(right)))
5685
+ || compare(groupKeyOf(left), groupKeyOf(right))
5686
+ || (left.parent ? 1 : 0) - (right.parent ? 1 : 0)
5687
+ || compare(subjects.get(subjectKeyOf(left)), subjects.get(subjectKeyOf(right)))
5688
+ || compare(subjectKeyOf(left), subjectKeyOf(right))
5689
+ || (left.runId === null ? 1 : 0) - (right.runId === null ? 1 : 0)
5690
+ || compare(left.runId ?? '', right.runId ?? '')
5691
+ || compare(left.expiresAt, right.expiresAt)
5692
+ || compare(left.approvalId, right.approvalId));
5693
+ }
5694
+ /** The groups of the page `ordered[offset, offset + count)`, with their totals and continuation. */
5695
+ function groupsOf(ordered, offset, count) {
5696
+ const page = ordered.slice(offset, offset + count);
5697
+ const previous = offset > 0 ? ordered[offset - 1] : undefined;
5698
+ const next = ordered[offset + count];
5699
+ const groups = [];
5700
+ for (let index = 0; index < page.length;) {
5701
+ const key = groupKeyOf(page[index]);
5702
+ let end = index;
5703
+ while (end < page.length && groupKeyOf(page[end]) === key) {
5704
+ end++;
5705
+ }
5706
+ const members = page.slice(index, end);
5707
+ const own = members.find(proposal => !proposal.parent);
5708
+ const parent = members.find(proposal => proposal.parent)?.parent ?? null;
5709
+ const subjects = [];
5710
+ for (let at = 0; at < members.length;) {
5711
+ const subjectKey = subjectKeyOf(members[at]);
5712
+ let stop = at;
5713
+ while (stop < members.length && subjectKeyOf(members[stop]) === subjectKey) {
5714
+ stop++;
5715
+ }
5716
+ subjects.push({
5717
+ subjectType: members[at].subjectType,
5718
+ subjectId: members[at].subjectId,
5719
+ count: stop - at,
5720
+ total: ordered.filter(proposal => subjectKeyOf(proposal) === subjectKey).length,
5721
+ continuesFromPreviousPage: index === 0 && at === 0 && previous !== undefined && subjectKeyOf(previous) === subjectKey,
5722
+ continuesOnNextPage: end === page.length && stop === members.length && next !== undefined && subjectKeyOf(next) === subjectKey
5723
+ });
5724
+ at = stop;
5725
+ }
5726
+ groups.push({
5727
+ subjectType: own?.subjectType ?? parent.subjectType,
5728
+ subjectId: own?.subjectId ?? parent.subjectId,
5729
+ displayName: own ? own.subjectDisplayName : parent.displayName,
5730
+ subjectTypeName: own ? own.subjectTypeName ?? null : parent.subjectTypeName ?? null,
5731
+ subjectLink: own ? own.subjectLink ?? null : parent.subjectLink ?? null,
5732
+ count: members.length,
5733
+ total: ordered.filter(proposal => groupKeyOf(proposal) === key).length,
5734
+ continuesFromPreviousPage: index === 0 && previous !== undefined && groupKeyOf(previous) === key,
5735
+ continuesOnNextPage: end === page.length && next !== undefined && groupKeyOf(next) === key,
5736
+ subjects
5737
+ });
5738
+ index = end;
5739
+ }
5740
+ return groups;
5741
+ }
4882
5742
  function validDecision(request) {
4883
5743
  return !!request
4884
5744
  && (request.decision === 'approve' || request.decision === 'reject')
@@ -4920,6 +5780,8 @@ const NH_ASSISTANT_MOCK_SCENARIO = new InjectionToken('NH_ASSISTANT_MOCK_SCENARI
4920
5780
  const NH_ASSISTANT_MOCK_HUB_PATH = '/hub/assistant-mock';
4921
5781
  const defaultLimits = { maxMessageChars: 4_000, maxToolCallsPerTurn: 8 };
4922
5782
  const defaultUser = { actorId: 'mock-user', displayName: 'You' };
5783
+ /** When the scenario's consultations started, when they name no time. */
5784
+ const consultationStart = new Date().toISOString();
4923
5785
  class RunStopped extends Error {
4924
5786
  }
4925
5787
  /**
@@ -4946,12 +5808,14 @@ class NhAssistantMockBackend {
4946
5808
  runId: proposal.runId,
4947
5809
  subjectType: proposal.subjectType,
4948
5810
  subjectId: proposal.subjectId
4949
- }, proposal.agentId));
5811
+ }, proposal.agentId), (divisionId) => this.subjects.describe(mockSystemSubjectType, divisionSubjectId({ id: divisionId, name: '' }), null, this.admin.administers) != null);
4950
5812
  this.subjects = new NhAssistantMockSubjects(this.scenario.subjectTypes, this.scenario.divisions, (type, id) => this.proposals.openOfSubject(type, id));
4951
5813
  this.agentRuns = new NhAssistantMockAgentRuns(this.scenario, this.subjects, {
4952
5814
  agents: () => this.admin.allAgents(),
4953
5815
  administers: () => this.admin.administers,
4954
5816
  threadProposals: conversationId => this.proposals.ofThread(conversationId),
5817
+ threadConsultations: conversationId => this.consultationsOf(conversationId),
5818
+ runProposals: runId => this.proposals.ofRun(runId),
4955
5819
  workChanged: (change, agentId) => this.publishAgentWork(change, agentId)
4956
5820
  }, () => new Date().toISOString());
4957
5821
  this.boards = new NhAssistantMockBoards(this.scenario, this.subjects, this.agentRuns, {
@@ -5071,6 +5935,37 @@ class NhAssistantMockBackend {
5071
5935
  const turn = this.findTurn(text, conversation.agentId, null);
5072
5936
  await this.playSteps(conversationId, assistantMessageId, turn?.steps ?? [], emit);
5073
5937
  }
5938
+ /**
5939
+ * Posts a message of the application into a conversation, as `INhAssistantConversationMessages`
5940
+ * does on the server: it gets the next position in the conversation and reaches the people of the
5941
+ * conversation as a live update. `trusted` marks an instruction for the assistant, as `Trusted`
5942
+ * does on the server. Returns the stored message, or `null` for an unknown or archived conversation.
5943
+ */
5944
+ postApplicationMessage(conversationId, text, details = {}) {
5945
+ const conversation = this.conversations.get(conversationId);
5946
+ if (!conversation || conversation.status === 'archived') {
5947
+ return null;
5948
+ }
5949
+ const message = {
5950
+ id: this.nextId(),
5951
+ role: 'application',
5952
+ createdAt: new Date().toISOString(),
5953
+ parts: [{ type: 'text', text }],
5954
+ authorActorId: null,
5955
+ authorKind: 'application',
5956
+ application: {
5957
+ title: details.title ?? null,
5958
+ senderName: details.senderName ?? null,
5959
+ link: details.link ?? null,
5960
+ trusted: details.trusted ?? false
5961
+ }
5962
+ };
5963
+ this.update(conversationId, current => ({ ...current, messages: [...current.messages, message] }));
5964
+ const stored = this.conversations.get(conversationId).messages.find(item => item.id === message.id);
5965
+ this.emitLive({ kind: 'event', data: { conversationId, actorId: 'application', type: 'message.created', data: stored } });
5966
+ this.emitChanged(conversationId);
5967
+ return stored;
5968
+ }
5074
5969
  /** Switches the simulated `NewHeap:AI:Assistant:Enabled` flag. */
5075
5970
  setEnabled(enabled) {
5076
5971
  this.enabledState.set(enabled);
@@ -5319,6 +6214,12 @@ class NhAssistantMockBackend {
5319
6214
  await this.playSteps(conversation.id, assistantMessageId, steps, emit);
5320
6215
  });
5321
6216
  }
6217
+ /**
6218
+ * `POST conversations/{id}/approvals/{approvalId}/decide`: approves or rejects the approval the
6219
+ * turn waits for, or leaves a consulted agent's proposal with a `leaveRoute` for the subject's
6220
+ * approvers (`leave`). Leaving closes it here, names the proposal that now waits at the subject
6221
+ * (`consultation.movedToApprovalId`) and resumes the turn with the step's `left` steps.
6222
+ */
5322
6223
  decide(conversation, approvalId, request) {
5323
6224
  const pending = this.pendingApprovals.get(conversation.id);
5324
6225
  if (!pending || pending.approval.approvalId !== approvalId) {
@@ -5328,16 +6229,29 @@ class NhAssistantMockBackend {
5328
6229
  if (decider && decider !== this.currentUser.actorId) {
5329
6230
  return this.error('assistant-approval-forbidden');
5330
6231
  }
5331
- if (request?.expectedProposalHash !== pending.approval.proposalHash) {
6232
+ const decision = request?.decision;
6233
+ if (decision !== 'approve' && decision !== 'reject' && decision !== 'leave') {
6234
+ return this.error('assistant-approval-decision-invalid');
6235
+ }
6236
+ if (decision === 'leave' && !pending.approval.consultation) {
6237
+ return this.error('assistant-approval-decision-invalid');
6238
+ }
6239
+ if (decision === 'leave' && !pending.approval.consultation?.leaveRoute) {
6240
+ return this.error('assistant-consult-proposal-refused');
6241
+ }
6242
+ if (request.expectedProposalHash !== pending.approval.proposalHash) {
5332
6243
  return this.empty(409);
5333
6244
  }
5334
6245
  if (Date.parse(pending.approval.expiresAt) <= Date.now()) {
5335
6246
  return this.empty(409);
5336
6247
  }
5337
6248
  this.pendingApprovals.delete(conversation.id);
5338
- const decision = request.decision;
5339
6249
  const lastUser = [...conversation.messages].reverse().find(message => message.role === 'user');
5340
6250
  this.update(conversation.id, current => applyNhAssistantApprovalDecision(current, approvalId, decision));
6251
+ if (decision === 'leave') {
6252
+ const movedToApprovalId = this.nextId();
6253
+ this.update(conversation.id, current => withMovedApproval(current, approvalId, movedToApprovalId));
6254
+ }
5341
6255
  return this.stream(conversation.id, async (emit) => {
5342
6256
  await emit({
5343
6257
  type: 'turn.started',
@@ -5350,6 +6264,14 @@ class NhAssistantMockBackend {
5350
6264
  });
5351
6265
  await this.playSteps(conversation.id, pending.assistantMessageId, pending.step.approved, emit);
5352
6266
  }
6267
+ else if (decision === 'leave') {
6268
+ // The consult call ends: its proposal now waits for the subject's approvers.
6269
+ await emit({
6270
+ type: 'tool.completed',
6271
+ data: { invocationId: pending.invocationId, status: 'succeeded', resultCode: 'assistant-consult-proposal-moved', resultPreview: null }
6272
+ });
6273
+ await this.playSteps(conversation.id, pending.assistantMessageId, pending.step.left ?? [], emit);
6274
+ }
5353
6275
  else {
5354
6276
  await this.playSteps(conversation.id, pending.assistantMessageId, pending.step.rejected, emit);
5355
6277
  }
@@ -5459,7 +6381,21 @@ class NhAssistantMockBackend {
5459
6381
  argumentsPreview: step.argumentsPreview,
5460
6382
  targets: step.targets,
5461
6383
  expiresAt: new Date(Date.now() + (step.expiresInSeconds ?? 300) * 1000).toISOString(),
5462
- status: 'pending'
6384
+ status: 'pending',
6385
+ ...(step.consultation
6386
+ ? {
6387
+ consultation: {
6388
+ consultationId: step.consultation.consultationId ?? this.nextId(),
6389
+ agentId: step.consultation.agentId,
6390
+ subjectType: step.consultation.subjectType,
6391
+ subjectId: step.consultation.subjectId,
6392
+ subjectDisplayName: step.consultation.subjectDisplayName ?? null,
6393
+ leaveRoute: step.consultation.leaveRoute ?? null,
6394
+ answer: step.consultation.answer ?? null,
6395
+ movedToApprovalId: null
6396
+ }
6397
+ }
6398
+ : {})
5463
6399
  };
5464
6400
  this.pendingApprovals.set(conversationId, { approval, invocationId, assistantMessageId, step });
5465
6401
  await emit({ type: 'approval.required', data: approval });
@@ -5715,6 +6651,40 @@ class NhAssistantMockBackend {
5715
6651
  const policy = agent?.viewPolicy ?? agent?.requiredPolicy ?? null;
5716
6652
  return policy === null || (this.scenario.callerPolicies?.includes(policy) ?? true);
5717
6653
  }
6654
+ /**
6655
+ * The scenario's consultations of a thread's agent, newest first, each with the proposals of the
6656
+ * scenario that name it, counted per route; `null` when the scenario lists none there.
6657
+ */
6658
+ consultationsOf(conversationId) {
6659
+ const listed = (this.scenario.consultations ?? []).filter(item => item.conversationId === conversationId);
6660
+ if (listed.length === 0) {
6661
+ return null;
6662
+ }
6663
+ const items = listed
6664
+ .map(item => {
6665
+ const proposals = this.proposals.consultationCounts(item.id);
6666
+ const createdAt = item.createdAt ?? consultationStart;
6667
+ const status = item.status ?? 'completed';
6668
+ return {
6669
+ id: item.id,
6670
+ askerActorId: item.askerActorId,
6671
+ askerDisplayName: item.askerDisplayName ?? null,
6672
+ askingAgentId: item.askingAgentId ?? null,
6673
+ depth: 1,
6674
+ status,
6675
+ failureCode: status === 'failed' ? 'assistant-consult-failed' : null,
6676
+ toolCalls: 0,
6677
+ inputTokens: 0,
6678
+ outputTokens: 0,
6679
+ proposalCount: proposals.total,
6680
+ createdAt,
6681
+ endedAt: status === 'running' ? null : createdAt,
6682
+ proposalsByRoute: proposals.byRoute
6683
+ };
6684
+ })
6685
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt));
6686
+ return { items, total: items.length };
6687
+ }
5718
6688
  nextId() {
5719
6689
  this.sequence++;
5720
6690
  const suffix = this.sequence.toString(16).padStart(12, '0');
@@ -5756,6 +6726,19 @@ function languageOf(headers) {
5756
6726
  function delay(milliseconds) {
5757
6727
  return new Promise(resolve => setTimeout(resolve, milliseconds));
5758
6728
  }
6729
+ /**
6730
+ * A consulted agent's proposal the person left for the subject's approvers, as the server reads it
6731
+ * afterwards: closed here and naming the proposal that waits at the subject.
6732
+ */
6733
+ function withMovedApproval(conversation, approvalId, movedToApprovalId) {
6734
+ const moved = (part) => part.type === 'approval' && part.approvalId === approvalId && part.consultation
6735
+ ? { ...part, status: 'rejected', consultation: { ...part.consultation, leaveRoute: null, movedToApprovalId } }
6736
+ : part;
6737
+ return {
6738
+ ...conversation,
6739
+ messages: conversation.messages.map(message => ({ ...message, parts: message.parts.map(moved) }))
6740
+ };
6741
+ }
5759
6742
  /** Like the server: a new message starts in an idle conversation or after a failed turn. */
5760
6743
  function acceptsMessage(status) {
5761
6744
  return status === 'idle' || status === 'failed';