@newheap/platform-ai-chat 0.17.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
  /**
@@ -83,6 +83,7 @@ const statusTable = {
83
83
  'assistant-board-not-found',
84
84
  'assistant-artifact-not-found',
85
85
  'assistant-trigger-not-found',
86
+ 'assistant-consultation-not-found',
86
87
  'assistant-not-found',
87
88
  'assistant-context-not-found',
88
89
  'assistant-mcp-server-not-found',
@@ -107,6 +108,12 @@ const statusTable = {
107
108
  'assistant-moved-to-shadow',
108
109
  'assistant-shadow-variant',
109
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',
110
117
  'assistant-skill-in-code',
111
118
  'assistant-trigger-in-code',
112
119
  'assistant-skill-in-use',
@@ -283,7 +290,7 @@ class NhAssistantMockAccounts {
283
290
 
284
291
  /** The platform's type of the whole application. */
285
292
  const mockSystemSubjectType = 'nh-system';
286
- const subjectTypePattern = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
293
+ const subjectTypePattern$1 = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
287
294
  const maxSubjectTypes = 20;
288
295
  const worksOnFields = ['worksOnSubjectTypes', 'worksOnAllSubjects', 'worksOnApplication', 'perDivision', 'runPolicy', 'viewPolicy'];
289
296
  /** An agent that does not say what it works on: every object type, not the application, app-wide. */
@@ -336,10 +343,10 @@ function mockWorksOnErrors(worksOn, knownTypes, agentPolicies, divisions) {
336
343
  if (types.length > maxSubjectTypes) {
337
344
  add('worksOnSubjectTypes', 'too-long');
338
345
  }
339
- if (types.some(type => !subjectTypePattern.test(type))) {
346
+ if (types.some(type => !subjectTypePattern$1.test(type))) {
340
347
  add('worksOnSubjectTypes', 'invalid');
341
348
  }
342
- 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)))) {
343
350
  add('worksOnSubjectTypes', 'not-found');
344
351
  }
345
352
  if (worksOn.perDivision && !divisions) {
@@ -364,6 +371,12 @@ function mockWorksOnCovers(agent, subjectType) {
364
371
  return agent.worksOnAllSubjects !== false || (agent.worksOnSubjectTypes ?? []).includes(subjectType);
365
372
  }
366
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
+ ];
367
380
  /** The profile of an agent that did not choose one, as the application's chat profile. */
368
381
  const mockChatProfile = 'chat';
369
382
  const autonomyLevels = ['observe', 'explain', 'propose', 'simulate', 'execute'];
@@ -384,7 +397,7 @@ class NhAssistantMockAdminAgents {
384
397
  const agents = admin.agents
385
398
  ?? scenario.agents.map(agent => ({ ...codeAgentFromSummary(agent), source: 'code' }));
386
399
  // How an agent runs, in code or as data, belongs to its run configuration, not to the agent.
387
- 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) {
388
401
  const agentShadow = shadowOf !== undefined || shadow === true;
389
402
  const initial = this.toAgent(input, source, 1, false);
390
403
  this.agents.set(input.id, {
@@ -415,7 +428,11 @@ class NhAssistantMockAdminAgents {
415
428
  shadow: shadowState(agentShadow || skill.shadow === true, skill.shadow === true, null, shadowOf !== undefined)
416
429
  }))
417
430
  },
418
- 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)
419
436
  });
420
437
  }
421
438
  }
@@ -456,7 +473,10 @@ class NhAssistantMockAdminAgents {
456
473
  }
457
474
  return result;
458
475
  }
459
- /** `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
+ */
460
480
  agentsEndpoint(method, id, action, body, length) {
461
481
  if (id === undefined) {
462
482
  if (method === 'GET') {
@@ -474,7 +494,7 @@ class NhAssistantMockAdminAgents {
474
494
  return mockFailure('assistant-agent-exists');
475
495
  }
476
496
  const agent = this.toAgent(input, 'admin', 1, false);
477
- this.agents.set(agent.id, { agent, codeDefinition: null });
497
+ this.agents.set(agent.id, { agent, codeDefinition: null, consultations: noSwitches(null) });
478
498
  return { status: 201, body: agent };
479
499
  }
480
500
  const stored = this.agents.get(id);
@@ -497,6 +517,9 @@ class NhAssistantMockAdminAgents {
497
517
  if (length === 4 && action === 'service-account' && method === 'POST') {
498
518
  return this.serviceAccountEndpoint(stored, body);
499
519
  }
520
+ if (length === 4 && action === 'consultation-routes' && method === 'POST') {
521
+ return this.consultationRoutesEndpoint(stored, body);
522
+ }
500
523
  if (length === 4 && action === 'kill-switch' && method === 'POST') {
501
524
  const engaged = body?.engaged;
502
525
  if (typeof engaged !== 'boolean') {
@@ -504,7 +527,7 @@ class NhAssistantMockAdminAgents {
504
527
  }
505
528
  // Releasing never switches on an agent that was off, as on the server: one that was off when
506
529
  // it stopped goes back off, and one that is off stays off.
507
- const released = stored.agent.runState === 'off' || stored.offBeforeStop === true ? 'off' : 'active';
530
+ const released = stored.agent.runState === 'off' || stored.agent.offBeforeStop === true ? 'off' : 'active';
508
531
  const changed = this.agentStateEndpoint(stored, 'POST', { state: engaged ? 'stopped' : released });
509
532
  return changed.status === 200
510
533
  ? {
@@ -765,7 +788,7 @@ class NhAssistantMockAdminAgents {
765
788
  skills: (stored.agent.skills ?? []).map(skill => ({ ...skill, shadow: withAgentShadow(skill.shadow, shadow) }))
766
789
  };
767
790
  }
768
- return { status: 200, body: toAgentState(stored.agent) };
791
+ return { status: 200, body: this.toAgentState(stored) };
769
792
  }
770
793
  /**
771
794
  * `POST admin/agents/{id}/service-account`: the technical account of an agent that answers to
@@ -802,7 +825,96 @@ class NhAssistantMockAdminAgents {
802
825
  }
803
826
  };
804
827
  }
805
- 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) };
806
918
  }
807
919
  /**
808
920
  * `GET` and `POST admin/agents/{id}/state`. The mock runs no agents on their own, so a pause
@@ -811,7 +923,7 @@ class NhAssistantMockAdminAgents {
811
923
  */
812
924
  agentStateEndpoint(stored, method, body) {
813
925
  if (method === 'GET') {
814
- return { status: 200, body: toAgentState(stored.agent) };
926
+ return { status: 200, body: this.toAgentState(stored) };
815
927
  }
816
928
  if (method !== 'POST') {
817
929
  return { status: 405 };
@@ -830,21 +942,21 @@ class NhAssistantMockAdminAgents {
830
942
  if (stored.agent.runState === 'configured-off') {
831
943
  return request.state === 'active'
832
944
  ? mockFailure('assistant-agent-configured-off')
833
- : { status: 200, body: toAgentState(stored.agent) };
945
+ : { status: 200, body: this.toAgentState(stored) };
834
946
  }
835
947
  if (stored.agent.runState !== request.state) {
836
- // A stop remembers whether the agent was off, for the kill switch; every other change forgets it.
837
- stored.offBeforeStop = request.state === 'stopped' && stored.agent.runState === 'off';
838
948
  stored.agent = {
839
949
  ...stored.agent,
840
950
  isEnabled: request.state !== 'stopped',
841
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',
842
954
  stateChangedAt: this.now(),
843
955
  stateChangedBy: 'current-user',
844
956
  stateReason: reason
845
957
  };
846
958
  }
847
- return { status: 200, body: toAgentState(stored.agent) };
959
+ return { status: 200, body: this.toAgentState(stored) };
848
960
  }
849
961
  /**
850
962
  * The contract failure for an invalid agent, or null when the agent is valid. What an agent created
@@ -925,6 +1037,7 @@ class NhAssistantMockAdminAgents {
925
1037
  shadowOf: previous?.shadowOf ?? null,
926
1038
  shadow: previous?.shadow ?? shadowState(false, false, null, false),
927
1039
  serviceAccount: previous?.serviceAccount ?? null,
1040
+ offBeforeStop: previous?.offBeforeStop ?? false,
928
1041
  // Code decides for its agents; an agent created here keeps its choice when it is omitted.
929
1042
  offeredInChat: source === 'code' && previous
930
1043
  ? previous.offeredInChat ?? true
@@ -960,7 +1073,8 @@ function codeAgentFromSummary(agent) {
960
1073
  autonomy: agent.canMutate ? 'execute' : 'explain'
961
1074
  };
962
1075
  }
963
- function toAgentState(agent) {
1076
+ function toAgentState(stored) {
1077
+ const agent = stored.agent;
964
1078
  return {
965
1079
  agentId: agent.id,
966
1080
  runState: agent.runState,
@@ -974,9 +1088,15 @@ function toAgentState(agent) {
974
1088
  skills: agent.skills ?? [],
975
1089
  shadowOf: agent.shadowOf ?? null,
976
1090
  shadow: agent.shadow ?? shadowState(false, false, null, false),
977
- serviceAccount: agent.serviceAccount ?? null
1091
+ serviceAccount: agent.serviceAccount ?? null,
1092
+ offBeforeStop: agent.runState === 'stopped' && agent.offBeforeStop === true,
1093
+ consultationRoutes: null
978
1094
  };
979
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
+ }
980
1100
  function shadowState(shadow, configured, override, locked) {
981
1101
  return { shadow, configured, override, locked, changedAt: null, changedBy: null, reason: null };
982
1102
  }
@@ -1034,7 +1154,7 @@ const maxReaders = 16;
1034
1154
  const maxReaderMediaTypes = 16;
1035
1155
  const maxReaderToolName = 128;
1036
1156
  const readerPriority = { min: -1000, max: 1000 };
1037
- const classifications = ['public', 'internal', 'confidential', 'restricted'];
1157
+ const classifications$1 = ['public', 'internal', 'confidential', 'restricted'];
1038
1158
  const mediaTypePattern$1 = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
1039
1159
  const controlCharacters = /[\u0000-\u001f\u007f]/;
1040
1160
  /**
@@ -2107,7 +2227,7 @@ function checkReaders(input, stored, add) {
2107
2227
  if (classification === null || classification === undefined) {
2108
2228
  add(field('maxClassification'), 'required');
2109
2229
  }
2110
- else if (!classifications.includes(classification)) {
2230
+ else if (!classifications$1.includes(classification)) {
2111
2231
  add(field('maxClassification'), 'invalid');
2112
2232
  }
2113
2233
  const priority = reader?.priority ?? 0;
@@ -2215,6 +2335,457 @@ function kebab(value) {
2215
2335
  .toLowerCase();
2216
2336
  }
2217
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
+
2218
2789
  const dashCase$4 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
2219
2790
  const dataClassifications = ['public', 'internal', 'confidential', 'restricted'];
2220
2791
  const accountabilities = ['agent', 'subject-responsible', 'service-account'];
@@ -2252,6 +2823,17 @@ class NhAssistantMockRunConfiguration {
2252
2823
  this.skills = new Map();
2253
2824
  this.triggers = new Map();
2254
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);
2255
2837
  this.catalog = {
2256
2838
  // A trigger event lists its named filters, none unless the scenario's application registers some.
2257
2839
  triggerEvents: (scenario.runCatalog?.triggerEvents ?? []).map(event => ({ ...event, filters: event.filters ?? [] })),
@@ -2271,7 +2853,8 @@ class NhAssistantMockRunConfiguration {
2271
2853
  agentPolicies: scenario.runCatalog?.agentPolicies ?? [],
2272
2854
  divisions: scenario.runCatalog?.divisions ?? false,
2273
2855
  // The platform's default file limits, unless the scenario's application chose its own.
2274
- fileLimits: scenario.runCatalog?.fileLimits ?? defaultFileLimits
2856
+ fileLimits: scenario.runCatalog?.fileLimits ?? defaultFileLimits,
2857
+ consultationCeiling: this.consultations.applicationCeiling
2275
2858
  };
2276
2859
  this.codeTriggers = (scenario.codeTriggers ?? []).map(trigger => ({
2277
2860
  ...trigger,
@@ -2295,13 +2878,25 @@ class NhAssistantMockRunConfiguration {
2295
2878
  get profileNames() {
2296
2879
  return [...this.catalog.profiles];
2297
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
+ }
2298
2892
  /** `GET admin/run-catalog`. */
2299
2893
  runCatalog(method) {
2300
2894
  return method === 'GET' ? { status: 200, body: this.catalog } : { status: 405 };
2301
2895
  }
2302
2896
  /**
2303
- * `admin/agents/{id}/configuration`, `run-settings`, `skills/{skillId}[/...]` and
2304
- * `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.
2305
2900
  */
2306
2901
  handle(method, agentId, segments, body, query) {
2307
2902
  const agent = this.agents.find(agentId);
@@ -2330,6 +2925,9 @@ class NhAssistantMockRunConfiguration {
2330
2925
  }
2331
2926
  return action === 'shadow' ? this.triggerShadow(method, agent, id, body) : this.trigger(method, agent, id, body, query);
2332
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
+ }
2333
2931
  return null;
2334
2932
  }
2335
2933
  configuration(agent) {
@@ -2350,12 +2948,18 @@ class NhAssistantMockRunConfiguration {
2350
2948
  dataSkills,
2351
2949
  codeTriggers: this.codeTriggers.filter(trigger => trigger.agentId === agent.id).map(({ agentId, ...trigger }) => trigger),
2352
2950
  dataTriggers,
2353
- problems: dataTriggers
2354
- .filter(trigger => trigger.problem !== null)
2355
- .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
+ ],
2356
2957
  source: agent.source,
2357
2958
  runSettings: agent.source === 'admin' ? this.settings.get(agent.id) ?? null : null,
2358
- 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)
2359
2963
  };
2360
2964
  }
2361
2965
  /**
@@ -2402,7 +3006,9 @@ class NhAssistantMockRunConfiguration {
2402
3006
  if (!versionMatches(query, current.version)) {
2403
3007
  return mockFailure('assistant-version-conflict');
2404
3008
  }
2405
- 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)) {
2406
3012
  return mockFailure('assistant-run-settings-in-use');
2407
3013
  }
2408
3014
  this.settings.delete(agent.id);
@@ -2925,11 +3531,14 @@ class NhAssistantMockAdmin {
2925
3531
  hasServer: serverId => this.mcp.hasServer(serverId),
2926
3532
  agentPolicyNames: () => this.runConfiguration.agentPolicyNames,
2927
3533
  divisionsListed: () => this.runConfiguration.divisionsListed,
2928
- profileNames: () => this.runConfiguration.profileNames
3534
+ profileNames: () => this.runConfiguration.profileNames,
3535
+ dataConsultations: agentId => this.runConfiguration.consultationsInEffect(agentId)
2929
3536
  });
2930
3537
  this.accounts = new NhAssistantMockAccounts(admin);
2931
3538
  this.runConfiguration = new NhAssistantMockRunConfiguration(admin, {
2932
3539
  find: agentId => this.agents.find(agentId),
3540
+ all: () => this.agents.all(),
3541
+ knownSubjectTypes: () => [...(scenario.subjectTypes ?? []).map(type => type.id), mockSystemSubjectType],
2933
3542
  setDataSkill: (agentId, skillId, description, startsOff) => this.agents.setDataSkill(agentId, skillId, description, startsOff)
2934
3543
  }, now, this.accounts);
2935
3544
  this.mcp = new NhAssistantMockAdminMcp(scenario, now, {
@@ -3090,7 +3699,8 @@ class NhAssistantMockAdmin {
3090
3699
  /**
3091
3700
  * Saves the run configuration the scenario gives agents created in the administration through the
3092
3701
  * mock's own endpoints, as an administrator would: the run settings, then each skill and trigger,
3093
- * 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.
3094
3704
  */
3095
3705
  presetRunConfigurations(agents) {
3096
3706
  const query = new URLSearchParams();
@@ -3116,11 +3726,21 @@ class NhAssistantMockAdmin {
3116
3726
  }
3117
3727
  }
3118
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
+ }
3119
3734
  }
3120
- /** 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
+ */
3121
3740
  toolCatalog() {
3122
3741
  return [
3123
3742
  ...(this.scenario.admin?.tools ?? []).map(tool => ({ ...tool, subjectType: tool.subjectType ?? null })),
3743
+ ...this.runConfiguration.consultationTools(),
3124
3744
  ...this.mcp.catalogEntries()
3125
3745
  ];
3126
3746
  }
@@ -3138,14 +3758,50 @@ function toContext(version) {
3138
3758
  /** The longest file name a reference takes, as on the server. */
3139
3759
  const maxNameLength = 256;
3140
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
+ ];
3141
3778
  /** How files of the platform's types start; other accepted types are the application's to check. */
3142
3779
  const signatures = {
3143
3780
  'application/pdf': head => indexOf(head, [0x25, 0x50, 0x44, 0x46, 0x2d]) >= 0,
3144
- 'image/png': head => startsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
3145
- 'image/jpeg': head => startsWith(head, [0xff, 0xd8, 0xff]),
3146
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': head => startsWith(head, [0x50, 0x4b, 0x03, 0x04]),
3147
- '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)
3148
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
+ }
3149
3805
  /**
3150
3806
  * The files of the mock API: `POST conversations/{id}/files` with the server's checks, and the
3151
3807
  * attachments of a message, which a turn opens again as the server's file source would.
@@ -3185,7 +3841,8 @@ class NhAssistantMockFiles {
3185
3841
  if (name === null || file.size === 0) {
3186
3842
  return mockFailure('assistant-file-upload-invalid');
3187
3843
  }
3188
- 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));
3189
3846
  if (mediaType === null || !uploads.acceptedMediaTypes.includes(mediaType)) {
3190
3847
  return mockFailure('assistant-file-type-not-accepted');
3191
3848
  }
@@ -3193,7 +3850,7 @@ class NhAssistantMockFiles {
3193
3850
  return mockFailure('assistant-file-too-large');
3194
3851
  }
3195
3852
  const head = new Uint8Array(await file.slice(0, 1024).arrayBuffer());
3196
- if (!(signatures[mediaType]?.(head) ?? true)) {
3853
+ if (!matchesSignature(head, mediaType)) {
3197
3854
  return mockFailure('assistant-file-type-not-accepted');
3198
3855
  }
3199
3856
  const upload = { conversationId, name, mediaType, size: file.size };
@@ -3714,7 +4371,9 @@ class NhAssistantMockAgentRuns {
3714
4371
  return method === 'GET' ? this.lookup(query, language) : { status: 405 };
3715
4372
  }
3716
4373
  if (segments.length === 3 && method === 'GET') {
3717
- 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;
3718
4377
  if (listed) {
3719
4378
  return { status: 200, body: listed };
3720
4379
  }
@@ -4682,6 +5341,10 @@ const guid = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$
4682
5341
  const lifetimeMs = 3 * 24 * 60 * 60 * 1000;
4683
5342
  /** The platform's subject type of the whole application. */
4684
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';
4685
5348
  /**
4686
5349
  * The proposals the scenario lists, which the mock's agents left, for example one a run for the whole
4687
5350
  * application placed at a project (`placedBy`). The mock runs no agents: it serves them as the server
@@ -4691,7 +5354,10 @@ const systemType = 'nh-system';
4691
5354
  * with the server's validation and statuses. An approved proposal executes at once. A proposal of the
4692
5355
  * whole application (`nh-system`) exists only for the approvers of the whole application who pass the
4693
5356
  * agent's view policy (`mayDecideApplication`): for anybody else the inbox leaves it out, reading it
4694
- * 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`.
4695
5361
  */
4696
5362
  class NhAssistantMockProposals {
4697
5363
  constructor(scenario = [], actorId, now, mayDecideApplication = () => true, decided = () => undefined, mayWorkIn = () => true) {
@@ -4711,6 +5377,12 @@ class NhAssistantMockProposals {
4711
5377
  if (!dashCase.test(item.subjectType)) {
4712
5378
  throw new Error(`Mock proposal '${item.approvalId}' names subject type '${item.subjectType}', which is not dash-case.`);
4713
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;
4714
5386
  this.proposals.set(item.approvalId, {
4715
5387
  proposal: {
4716
5388
  approvalId: item.approvalId,
@@ -4730,12 +5402,12 @@ class NhAssistantMockProposals {
4730
5402
  argumentsPreview: '{}',
4731
5403
  targets: [],
4732
5404
  status: item.status ?? 'pending',
4733
- mode: 'deferred',
5405
+ mode: route === 'asker-decides' ? 'inline' : 'deferred',
4734
5406
  policyId: null,
4735
- grantSource: 'agent',
5407
+ grantSource: item.grantSource ?? (route === 'subject-asker-rights' ? 'delegated' : 'agent'),
4736
5408
  createdAt: item.createdAt ?? new Date(created).toISOString(),
4737
5409
  expiresAt: item.expiresAt ?? new Date(Date.parse(item.createdAt ?? new Date(created).toISOString()) + lifetimeMs).toISOString(),
4738
- initiatorActorId: null,
5410
+ initiatorActorId: item.initiatorActorId ?? null,
4739
5411
  decidedByActorId: null,
4740
5412
  decidedAt: null,
4741
5413
  executionResultCode: null,
@@ -4743,11 +5415,13 @@ class NhAssistantMockProposals {
4743
5415
  rationale: item.rationale ?? null,
4744
5416
  placedBy: item.placedBy ?? null,
4745
5417
  parent: item.parent ?? null,
4746
- canGiveFeedback: false
5418
+ canGiveFeedback: false,
5419
+ consultationId: item.consultationId ?? null,
5420
+ consultationRoute: route
4747
5421
  },
4748
5422
  divisionId: item.divisionId ?? null,
4749
- canDecide: item.canDecide ?? true,
4750
- reasonCode: item.canDecide === false ? item.reasonCode ?? 'assistant-approval-policy-denied' : null
5423
+ canDecide,
5424
+ reasonCode: canDecide ? null : item.reasonCode ?? refusal ?? 'assistant-approval-policy-denied'
4751
5425
  });
4752
5426
  }
4753
5427
  }
@@ -4781,6 +5455,21 @@ class NhAssistantMockProposals {
4781
5455
  .reverse();
4782
5456
  return items.length === 0 ? null : items;
4783
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
+ }
4784
5473
  /** The proposals of a run the caller may see, in the order the scenario lists them. */
4785
5474
  ofRun(runId) {
4786
5475
  return this.visible()
@@ -4946,6 +5635,17 @@ class NhAssistantMockProposals {
4946
5635
  };
4947
5636
  }
4948
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
+ }
4949
5649
  /** The scope of the inbox's visits: `global`, or the scope key of a division. */
4950
5650
  function scopeKeyOf(divisionId) {
4951
5651
  return divisionId === null ? 'global' : `division:${divisionId.replaceAll('-', '').toLowerCase()}`;
@@ -5080,6 +5780,8 @@ const NH_ASSISTANT_MOCK_SCENARIO = new InjectionToken('NH_ASSISTANT_MOCK_SCENARI
5080
5780
  const NH_ASSISTANT_MOCK_HUB_PATH = '/hub/assistant-mock';
5081
5781
  const defaultLimits = { maxMessageChars: 4_000, maxToolCallsPerTurn: 8 };
5082
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();
5083
5785
  class RunStopped extends Error {
5084
5786
  }
5085
5787
  /**
@@ -5112,6 +5814,7 @@ class NhAssistantMockBackend {
5112
5814
  agents: () => this.admin.allAgents(),
5113
5815
  administers: () => this.admin.administers,
5114
5816
  threadProposals: conversationId => this.proposals.ofThread(conversationId),
5817
+ threadConsultations: conversationId => this.consultationsOf(conversationId),
5115
5818
  runProposals: runId => this.proposals.ofRun(runId),
5116
5819
  workChanged: (change, agentId) => this.publishAgentWork(change, agentId)
5117
5820
  }, () => new Date().toISOString());
@@ -5232,6 +5935,37 @@ class NhAssistantMockBackend {
5232
5935
  const turn = this.findTurn(text, conversation.agentId, null);
5233
5936
  await this.playSteps(conversationId, assistantMessageId, turn?.steps ?? [], emit);
5234
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
+ }
5235
5969
  /** Switches the simulated `NewHeap:AI:Assistant:Enabled` flag. */
5236
5970
  setEnabled(enabled) {
5237
5971
  this.enabledState.set(enabled);
@@ -5480,6 +6214,12 @@ class NhAssistantMockBackend {
5480
6214
  await this.playSteps(conversation.id, assistantMessageId, steps, emit);
5481
6215
  });
5482
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
+ */
5483
6223
  decide(conversation, approvalId, request) {
5484
6224
  const pending = this.pendingApprovals.get(conversation.id);
5485
6225
  if (!pending || pending.approval.approvalId !== approvalId) {
@@ -5489,16 +6229,29 @@ class NhAssistantMockBackend {
5489
6229
  if (decider && decider !== this.currentUser.actorId) {
5490
6230
  return this.error('assistant-approval-forbidden');
5491
6231
  }
5492
- 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) {
5493
6243
  return this.empty(409);
5494
6244
  }
5495
6245
  if (Date.parse(pending.approval.expiresAt) <= Date.now()) {
5496
6246
  return this.empty(409);
5497
6247
  }
5498
6248
  this.pendingApprovals.delete(conversation.id);
5499
- const decision = request.decision;
5500
6249
  const lastUser = [...conversation.messages].reverse().find(message => message.role === 'user');
5501
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
+ }
5502
6255
  return this.stream(conversation.id, async (emit) => {
5503
6256
  await emit({
5504
6257
  type: 'turn.started',
@@ -5511,6 +6264,14 @@ class NhAssistantMockBackend {
5511
6264
  });
5512
6265
  await this.playSteps(conversation.id, pending.assistantMessageId, pending.step.approved, emit);
5513
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
+ }
5514
6275
  else {
5515
6276
  await this.playSteps(conversation.id, pending.assistantMessageId, pending.step.rejected, emit);
5516
6277
  }
@@ -5620,7 +6381,21 @@ class NhAssistantMockBackend {
5620
6381
  argumentsPreview: step.argumentsPreview,
5621
6382
  targets: step.targets,
5622
6383
  expiresAt: new Date(Date.now() + (step.expiresInSeconds ?? 300) * 1000).toISOString(),
5623
- 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
+ : {})
5624
6399
  };
5625
6400
  this.pendingApprovals.set(conversationId, { approval, invocationId, assistantMessageId, step });
5626
6401
  await emit({ type: 'approval.required', data: approval });
@@ -5876,6 +6651,40 @@ class NhAssistantMockBackend {
5876
6651
  const policy = agent?.viewPolicy ?? agent?.requiredPolicy ?? null;
5877
6652
  return policy === null || (this.scenario.callerPolicies?.includes(policy) ?? true);
5878
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
+ }
5879
6688
  nextId() {
5880
6689
  this.sequence++;
5881
6690
  const suffix = this.sequence.toString(16).padStart(12, '0');
@@ -5917,6 +6726,19 @@ function languageOf(headers) {
5917
6726
  function delay(milliseconds) {
5918
6727
  return new Promise(resolve => setTimeout(resolve, milliseconds));
5919
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
+ }
5920
6742
  /** Like the server: a new message starts in an idle conversation or after a failed turn. */
5921
6743
  function acceptsMessage(status) {
5922
6744
  return status === 'idle' || status === 'failed';