@newheap/platform-ai-chat 0.15.0 → 0.17.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_MAX_PROPOSAL_LOOKUP, NH_ASSISTANT_CONFIG, applyNhAssistantEvent, normalizeNhAssistantClientContext, applyNhAssistantApprovalDecision, NH_ASSISTANT_FETCH, NhAssistantLiveService, NH_ASSISTANT_MCP_AUTHORIZATION_WINDOW } from '@newheap/platform-ai-chat';
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';
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',
@@ -39,7 +40,12 @@ const statusTable = {
39
40
  'assistant-mcp-authorization-invalid',
40
41
  'assistant-mcp-pkce-unsupported',
41
42
  'assistant-mcp-client-registration-required',
42
- 'assistant-mcp-oauth-not-configured'
43
+ 'assistant-mcp-oauth-not-configured',
44
+ 'assistant-file-reference-invalid',
45
+ 'assistant-file-upload-invalid',
46
+ 'assistant-file-too-many-pages',
47
+ 'assistant-file-count-limit',
48
+ 'assistant-file-token-limit'
43
49
  ],
44
50
  401: [
45
51
  'assistant-webhook-signature-invalid'
@@ -55,7 +61,8 @@ const statusTable = {
55
61
  'assistant-four-eyes-required',
56
62
  'assistant-forbidden',
57
63
  'assistant-mcp-credential-mismatch',
58
- 'assistant-mcp-initiator-only'
64
+ 'assistant-mcp-initiator-only',
65
+ 'assistant-file-forbidden'
59
66
  ],
60
67
  404: [
61
68
  'assistant-disabled',
@@ -79,7 +86,9 @@ const statusTable = {
79
86
  'assistant-not-found',
80
87
  'assistant-context-not-found',
81
88
  'assistant-mcp-server-not-found',
82
- 'assistant-mcp-tool-not-found'
89
+ 'assistant-mcp-tool-not-found',
90
+ 'assistant-files-unavailable',
91
+ 'assistant-file-not-found'
83
92
  ],
84
93
  409: [
85
94
  'assistant-conversation-busy',
@@ -123,10 +132,20 @@ const statusTable = {
123
132
  'assistant-mcp-issuer-changed',
124
133
  'assistant-mcp-authorization-not-confirmed',
125
134
  'assistant-mcp-connection-not-supported',
126
- 'assistant-mcp-secret-unreadable'
135
+ 'assistant-mcp-secret-unreadable',
136
+ 'assistant-files-not-accepted'
127
137
  ],
128
138
  413: [
129
- 'assistant-webhook-payload-too-large'
139
+ 'assistant-webhook-payload-too-large',
140
+ 'assistant-file-too-large'
141
+ ],
142
+ 415: [
143
+ 'assistant-file-type-not-supported',
144
+ 'assistant-file-type-not-accepted'
145
+ ],
146
+ 422: [
147
+ 'assistant-file-upload-refused',
148
+ 'assistant-file-scan-refused'
130
149
  ],
131
150
  429: [
132
151
  'assistant-mcp-too-many-authorizations'
@@ -142,7 +161,9 @@ const statusTable = {
142
161
  503: [
143
162
  'assistant-proposal-check-failed',
144
163
  'assistant-trigger-rolled-back',
145
- 'assistant-mcp-secret-unavailable'
164
+ 'assistant-mcp-secret-unavailable',
165
+ 'assistant-file-unavailable',
166
+ 'assistant-file-upload-failed'
146
167
  ]
147
168
  };
148
169
  const statuses = buildStatuses();
@@ -188,6 +209,16 @@ function mockValidationFailure(field) {
188
209
  return mockFailure('assistant-validation', field ? { [field]: ['invalid'] } : undefined);
189
210
  }
190
211
  /** A stable, non-cryptographic fingerprint; the mock only needs change detection. */
212
+ /**
213
+ * The warnings of an agent or skill that classifies its model calls with `classification` on
214
+ * `profile`: `classification-not-permitted` when the scenario lists what the profile and its
215
+ * fallbacks permit and that does not include it, as the server reports it; none without a
216
+ * classification of its own.
217
+ */
218
+ function mockClassificationWarnings(permitted, profile, classification) {
219
+ const allowed = profile ? permitted?.[profile] : undefined;
220
+ return classification && allowed && !allowed.includes(classification) ? ['classification-not-permitted'] : [];
221
+ }
191
222
  function mockHashText(text) {
192
223
  let hash = 0x811c9dc5;
193
224
  for (let index = 0; index < text.length; index++) {
@@ -333,7 +364,10 @@ function mockWorksOnCovers(agent, subjectType) {
333
364
  return agent.worksOnAllSubjects !== false || (agent.worksOnSubjectTypes ?? []).includes(subjectType);
334
365
  }
335
366
 
367
+ /** The profile of an agent that did not choose one, as the application's chat profile. */
368
+ const mockChatProfile = 'chat';
336
369
  const autonomyLevels = ['observe', 'explain', 'propose', 'simulate', 'execute'];
370
+ const dataClassifications$1 = ['public', 'internal', 'confidential', 'restricted'];
337
371
  /**
338
372
  * The agents of the mock administration: agents in code and agents created as data, their state,
339
373
  * shadow modes, technical accounts, feedback, evaluation and replays. The mock runs no agents on
@@ -367,7 +401,7 @@ class NhAssistantMockAdminAgents {
367
401
  skills: (skills ?? []).map((skill) => ({
368
402
  skillId: skill.skillId,
369
403
  description: skill.description,
370
- runState: 'active',
404
+ runState: skill.startsOff === true ? 'off' : 'active',
371
405
  changedAt: null,
372
406
  changedBy: null,
373
407
  reason: null,
@@ -468,9 +502,15 @@ class NhAssistantMockAdminAgents {
468
502
  if (typeof engaged !== 'boolean') {
469
503
  return mockValidationFailure();
470
504
  }
471
- const changed = this.agentStateEndpoint(stored, 'POST', { state: engaged ? 'stopped' : 'active' });
505
+ // Releasing never switches on an agent that was off, as on the server: one that was off when
506
+ // it stopped goes back off, and one that is off stays off.
507
+ const released = stored.agent.runState === 'off' || stored.offBeforeStop === true ? 'off' : 'active';
508
+ const changed = this.agentStateEndpoint(stored, 'POST', { state: engaged ? 'stopped' : released });
472
509
  return changed.status === 200
473
- ? { status: 200, body: { agentId: id, enabled: stored.agent.runState !== 'stopped', cancelledRuns: 0 } }
510
+ ? {
511
+ status: 200,
512
+ body: { agentId: id, enabled: stored.agent.runState !== 'stopped', cancelledRuns: 0, runState: stored.agent.runState }
513
+ }
474
514
  : changed;
475
515
  }
476
516
  if (length !== 3) {
@@ -511,7 +551,7 @@ class NhAssistantMockAdminAgents {
511
551
  return { status: 405 };
512
552
  }
513
553
  const request = body;
514
- if (request?.state !== 'active' && request?.state !== 'paused' && request?.state !== 'stopped') {
554
+ if (request?.state !== 'active' && request?.state !== 'paused' && request?.state !== 'stopped' && request?.state !== 'off') {
515
555
  return mockValidationFailure();
516
556
  }
517
557
  const reason = typeof request.reason === 'string' && request.reason.trim().length > 0 ? request.reason.trim() : null;
@@ -672,9 +712,9 @@ class NhAssistantMockAdminAgents {
672
712
  }
673
713
  /**
674
714
  * A skill defined as data shows among the agent's skills as a skill in shadow by default, as the
675
- * server shows it; removing it takes its state along.
715
+ * server shows it, and off when it was added with `startsOff`; removing it takes its state along.
676
716
  */
677
- setDataSkill(agentId, skillId, description) {
717
+ setDataSkill(agentId, skillId, description, startsOff = false) {
678
718
  const stored = this.agents.get(agentId);
679
719
  if (!stored) {
680
720
  return;
@@ -688,9 +728,9 @@ class NhAssistantMockAdminAgents {
688
728
  : [...others, existing ? { ...existing, description } : {
689
729
  skillId,
690
730
  description,
691
- runState: 'active',
692
- changedAt: null,
693
- changedBy: null,
731
+ runState: startsOff ? 'off' : 'active',
732
+ changedAt: startsOff ? this.now() : null,
733
+ changedBy: startsOff ? 'current-user' : null,
694
734
  reason: null,
695
735
  waitingInputs: 0,
696
736
  toolCallsToday: 0,
@@ -766,7 +806,8 @@ class NhAssistantMockAdminAgents {
766
806
  }
767
807
  /**
768
808
  * `GET` and `POST admin/agents/{id}/state`. The mock runs no agents on their own, so a pause
769
- * collects no waiting events and a stop cancels no runs; the version never changes.
809
+ * collects no waiting events, a stop cancels no runs and switching an agent off or on starts
810
+ * nothing; the version never changes.
770
811
  */
771
812
  agentStateEndpoint(stored, method, body) {
772
813
  if (method === 'GET') {
@@ -779,7 +820,7 @@ class NhAssistantMockAdminAgents {
779
820
  if (request?.state === undefined || request.state === null) {
780
821
  return mockValidationFailure();
781
822
  }
782
- if (request.state !== 'active' && request.state !== 'paused' && request.state !== 'stopped') {
823
+ if (request.state !== 'active' && request.state !== 'paused' && request.state !== 'stopped' && request.state !== 'off') {
783
824
  return mockValidationFailure();
784
825
  }
785
826
  const reason = typeof request.reason === 'string' && request.reason.trim().length > 0 ? request.reason.trim() : null;
@@ -792,6 +833,8 @@ class NhAssistantMockAdminAgents {
792
833
  : { status: 200, body: toAgentState(stored.agent) };
793
834
  }
794
835
  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';
795
838
  stored.agent = {
796
839
  ...stored.agent,
797
840
  isEnabled: request.state !== 'stopped',
@@ -821,6 +864,19 @@ class NhAssistantMockAdminAgents {
821
864
  return mockFailure('assistant-validation', errors);
822
865
  }
823
866
  }
867
+ // An agent created here uses a profile the run catalog offers, or keeps the one it has or the
868
+ // chat profile it gets by default; an agent in code takes its own.
869
+ const profile = typeof input?.profile === 'string' ? input.profile.trim() : '';
870
+ if (source === 'admin' && profile.length > 0 && profile !== mockChatProfile
871
+ && profile !== this.agents.get(input.id)?.agent.profile
872
+ && !this.rules.profileNames().includes(profile)) {
873
+ return mockFailure('assistant-validation', { profile: ['not-found'] });
874
+ }
875
+ // An agent created here names the classification of its model calls, as a data skill does.
876
+ const classification = chosenClassification(input.dataClassification);
877
+ if (source === 'admin' && classification !== null && !dataClassifications$1.includes(classification)) {
878
+ return mockFailure('assistant-validation', { dataClassification: ['invalid'] });
879
+ }
824
880
  return null;
825
881
  }
826
882
  validAgent(input) {
@@ -841,18 +897,28 @@ class NhAssistantMockAdminAgents {
841
897
  * `previous`, and a new agent starts active.
842
898
  */
843
899
  toAgent(input, source, version, isOverridden, previous) {
844
- const { isEnabled, ...definition } = structuredClone(input);
845
- return {
900
+ // Only a new agent starts off, created that way here or with StartsOff() in code.
901
+ const { isEnabled, startsOff, profile, dataClassification, ...definition } = structuredClone(input);
902
+ const createdOff = previous === undefined && startsOff === true;
903
+ const chosenProfile = source === 'admin' && typeof profile === 'string' && profile.trim().length > 0 ? profile.trim() : null;
904
+ const classification = chosenClassification(dataClassification);
905
+ return this.withWarnings({
846
906
  ...definition,
907
+ // An agent created here keeps its profile when the input omits it; code decides for the others.
908
+ profile: chosenProfile ?? previous?.profile ?? mockChatProfile,
909
+ // The same for its classification; an agent in code keeps the one of its scenario.
910
+ dataClassification: source === 'admin' || previous === undefined
911
+ ? classification ?? previous?.dataClassification ?? null
912
+ : previous.dataClassification ?? null,
847
913
  isEnabled: previous?.isEnabled ?? true,
848
914
  version,
849
915
  source,
850
916
  isOverridden,
851
917
  instructionsHash: mockHashText(input.instructions),
852
918
  updatedAt: this.now(),
853
- runState: previous?.runState ?? 'active',
854
- stateChangedAt: previous?.stateChangedAt ?? null,
855
- stateChangedBy: previous?.stateChangedBy ?? null,
919
+ runState: previous?.runState ?? (createdOff ? 'off' : 'active'),
920
+ stateChangedAt: previous?.stateChangedAt ?? (createdOff && source === 'admin' ? this.now() : null),
921
+ stateChangedBy: previous?.stateChangedBy ?? (createdOff && source === 'admin' ? 'current-user' : null),
856
922
  stateReason: previous?.stateReason ?? null,
857
923
  waitingInputs: previous?.waitingInputs ?? 0,
858
924
  skills: previous?.skills ?? [],
@@ -868,9 +934,20 @@ class NhAssistantMockAdminAgents {
868
934
  ? pickWorksOn(previous)
869
935
  : mockWorksOnFromInput(input) ?? (previous ? pickWorksOn(previous) : { ...mockUndeclaredWorksOn, worksOnDeclared: true })),
870
936
  knownSubjectTypes: previous?.knownSubjectTypes ?? []
937
+ });
938
+ }
939
+ /** The agent with the warnings the server reports for it, from its profile and classification. */
940
+ withWarnings(agent) {
941
+ return {
942
+ ...agent,
943
+ warnings: mockClassificationWarnings(this.scenario.admin?.profileClassifications, agent.profile, agent.dataClassification)
871
944
  };
872
945
  }
873
946
  }
947
+ /** A classification name the input chose, or `null` when it chose none. */
948
+ function chosenClassification(value) {
949
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
950
+ }
874
951
  function codeAgentFromSummary(agent) {
875
952
  return {
876
953
  id: agent.id,
@@ -949,6 +1026,17 @@ const maxUserName = 256;
949
1026
  const maxPolicy = 256;
950
1027
  const maxClientId = 512;
951
1028
  const maxAccountLabel = 200;
1029
+ /**
1030
+ * The readers of a server: at most 16, each with at most 16 media types, a tool name of at most 128
1031
+ * characters and a priority from -1000 to 1000.
1032
+ */
1033
+ const maxReaders = 16;
1034
+ const maxReaderMediaTypes = 16;
1035
+ const maxReaderToolName = 128;
1036
+ const readerPriority = { min: -1000, max: 1000 };
1037
+ const classifications = ['public', 'internal', 'confidential', 'restricted'];
1038
+ const mediaTypePattern$1 = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
1039
+ const controlCharacters = /[\u0000-\u001f\u007f]/;
952
1040
  /**
953
1041
  * The MCP servers of the mock administration and the caller's own connections: secrets are
954
1042
  * write-only, new tools start disabled as mutations, a changed remote schema disables a tool, OAuth
@@ -990,6 +1078,12 @@ class NhAssistantMockAdminMcp {
990
1078
  remoteTools: remoteTools ?? [],
991
1079
  tools: new Map()
992
1080
  };
1081
+ const readers = readersAfter(input, []);
1082
+ if (readers.length > 0) {
1083
+ // Preconfigured readers were set up after a sync of the server's tools.
1084
+ this.sync(stored);
1085
+ stored.server.readers = readers;
1086
+ }
993
1087
  if (oauthModes.includes(input.authMode)) {
994
1088
  // A preconfigured OAuth server was confirmed by an administrator before.
995
1089
  const found = this.discovery(input.url);
@@ -1240,7 +1334,8 @@ class NhAssistantMockAdminMcp {
1240
1334
  ...toServerFields(input, stored.server),
1241
1335
  ...oauthFields(input, stored.server, oauth.authorization),
1242
1336
  headlessUse,
1243
- transport: input.transport ?? stored.server.transport
1337
+ transport: input.transport ?? stored.server.transport,
1338
+ readers: readersAfter(input, stored.server.readers)
1244
1339
  };
1245
1340
  if (!oauthModes.includes(input.authMode)) {
1246
1341
  stored.oauthSecret = null;
@@ -1444,6 +1539,7 @@ class NhAssistantMockAdminMcp {
1444
1539
  checkServerFields(input, stored, this.scenario.admin?.policies, add, this.scenario.admin?.mcpAuthModes ?? []);
1445
1540
  this.checkCredential(input, stored, add);
1446
1541
  this.checkOAuth(input, stored, add);
1542
+ checkReaders(input, stored, add);
1447
1543
  return errors;
1448
1544
  }
1449
1545
  /**
@@ -1933,9 +2029,97 @@ function newServerState(input) {
1933
2029
  headlessUse: input.headlessUse ?? 'never',
1934
2030
  lastConnectAt: null,
1935
2031
  lastConnectCode: null,
1936
- transport: input.transport ?? 'streamable-http'
2032
+ transport: input.transport ?? 'streamable-http',
2033
+ // A new server has no synced tools, so it gets its readers after a sync.
2034
+ readers: []
1937
2035
  };
1938
2036
  }
2037
+ /** A media type in lowercase without parameters, or null for a value that is not one. */
2038
+ function normalizeMediaType$1(value) {
2039
+ if (typeof value !== 'string') {
2040
+ return null;
2041
+ }
2042
+ const essence = value.split(';')[0].trim().toLowerCase();
2043
+ return essence.length <= 128 && mediaTypePattern$1.test(essence) ? essence : null;
2044
+ }
2045
+ /**
2046
+ * The readers after a valid input, as on the server: omitted or null readers keep the stored ones,
2047
+ * an empty list removes them all, and the media types are stored normalized and once each. The
2048
+ * server returns them by priority, highest first, then by tool name.
2049
+ */
2050
+ function readersAfter(input, stored) {
2051
+ const readers = input.readers;
2052
+ if (readers === undefined || readers === null) {
2053
+ return stored;
2054
+ }
2055
+ return readers
2056
+ .map(reader => ({
2057
+ mediaTypes: [...new Set(reader.mediaTypes.map(normalizeMediaType$1).filter((mediaType) => mediaType !== null))],
2058
+ toolName: reader.toolName ?? '',
2059
+ maxClassification: reader.maxClassification ?? 'restricted',
2060
+ priority: reader.priority ?? 0
2061
+ }))
2062
+ .sort((left, right) => right.priority - left.priority || compareOrdinal(left.toolName, right.toolName));
2063
+ }
2064
+ /**
2065
+ * The field errors of the readers, as the server reports them: `readers` (too-long for more than
2066
+ * 16) and per reader, with its index: `readers[0].mediaTypes` (required, invalid, too-long for more
2067
+ * than 16), `readers[0].toolName` (required, invalid also for a second reader of the same tool,
2068
+ * not-found when the server has not synced that tool), `readers[0].maxClassification` (required,
2069
+ * invalid) and `readers[0].priority` (invalid outside -1000 to 1000).
2070
+ */
2071
+ function checkReaders(input, stored, add) {
2072
+ const readers = input.readers;
2073
+ if (!Array.isArray(readers) || readers.length === 0) {
2074
+ return;
2075
+ }
2076
+ if (readers.length > maxReaders) {
2077
+ add('readers', 'too-long');
2078
+ return;
2079
+ }
2080
+ const readTools = new Set();
2081
+ readers.forEach((reader, index) => {
2082
+ const field = (name) => `readers[${index}].${name}`;
2083
+ const mediaTypes = Array.isArray(reader?.mediaTypes) ? reader.mediaTypes.map(normalizeMediaType$1) : [];
2084
+ if (mediaTypes.length === 0) {
2085
+ add(field('mediaTypes'), 'required');
2086
+ }
2087
+ else if (mediaTypes.some(mediaType => mediaType === null)) {
2088
+ add(field('mediaTypes'), 'invalid');
2089
+ }
2090
+ else if (new Set(mediaTypes).size > maxReaderMediaTypes) {
2091
+ add(field('mediaTypes'), 'too-long');
2092
+ }
2093
+ const toolName = reader?.toolName;
2094
+ if (typeof toolName !== 'string' || toolName.trim().length === 0) {
2095
+ add(field('toolName'), 'required');
2096
+ }
2097
+ else if (toolName.length > maxReaderToolName || controlCharacters.test(toolName) || readTools.has(toolName)) {
2098
+ add(field('toolName'), 'invalid');
2099
+ }
2100
+ else if (!stored?.tools.has(toolName)) {
2101
+ add(field('toolName'), 'not-found');
2102
+ }
2103
+ if (typeof toolName === 'string') {
2104
+ readTools.add(toolName);
2105
+ }
2106
+ const classification = reader?.maxClassification;
2107
+ if (classification === null || classification === undefined) {
2108
+ add(field('maxClassification'), 'required');
2109
+ }
2110
+ else if (!classifications.includes(classification)) {
2111
+ add(field('maxClassification'), 'invalid');
2112
+ }
2113
+ const priority = reader?.priority ?? 0;
2114
+ if (!Number.isInteger(priority) || priority < readerPriority.min || priority > readerPriority.max) {
2115
+ add(field('priority'), 'invalid');
2116
+ }
2117
+ });
2118
+ }
2119
+ /** Compares two names by their characters, as the server orders them. */
2120
+ function compareOrdinal(left, right) {
2121
+ return left < right ? -1 : left > right ? 1 : 0;
2122
+ }
1939
2123
  /**
1940
2124
  * `organization` needs a credential of the organization; `personal` is the forwarded token of the
1941
2125
  * person a run acts for.
@@ -2032,12 +2216,24 @@ function kebab(value) {
2032
2216
  }
2033
2217
 
2034
2218
  const dashCase$4 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
2219
+ const dataClassifications = ['public', 'internal', 'confidential', 'restricted'];
2035
2220
  const accountabilities = ['agent', 'subject-responsible', 'service-account'];
2036
2221
  const conflicts = ['coalesce', 'queue', 'supersede', 'ignore'];
2037
2222
  const maxDescription$1 = 300;
2038
2223
  const maxInstructions = 20_000;
2039
2224
  const maxMessage$1 = 2_000;
2040
2225
  const maxReason$1 = 500;
2226
+ /** A data trigger applies at most this many filters of its event, each named in lowercase dash-case. */
2227
+ const maxTriggerFilters = 10;
2228
+ const filterName = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2229
+ /** The platform's default file limits (`NhAssistantLimits`). */
2230
+ const defaultFileLimits = {
2231
+ maxFileBytes: 20 * 1024 * 1024,
2232
+ maxFilePages: 100,
2233
+ maxFilesPerTurn: 5,
2234
+ maxFileTokensPerTurn: 100_000
2235
+ };
2236
+ const fileLimitFields = ['maxFileBytes', 'maxFilePages', 'maxFilesPerTurn', 'maxFileTokensPerTurn'];
2041
2237
  /**
2042
2238
  * In-memory run configuration as data of the mock API: run settings of agents created in the
2043
2239
  * administration, skills defined as data with versions and candidates, and data triggers. Saving
@@ -2057,7 +2253,8 @@ class NhAssistantMockRunConfiguration {
2057
2253
  this.triggers = new Map();
2058
2254
  const approvalPolicies = scenario.runCatalog?.approvalPolicies ?? scenario.policies ?? [];
2059
2255
  this.catalog = {
2060
- triggerEvents: scenario.runCatalog?.triggerEvents ?? [],
2256
+ // A trigger event lists its named filters, none unless the scenario's application registers some.
2257
+ triggerEvents: (scenario.runCatalog?.triggerEvents ?? []).map(event => ({ ...event, filters: event.filters ?? [] })),
2061
2258
  subjectSets: scenario.runCatalog?.subjectSets ?? [],
2062
2259
  ruleHandlers: scenario.runCatalog?.ruleHandlers ?? [],
2063
2260
  approvalPolicies,
@@ -2072,13 +2269,16 @@ class NhAssistantMockRunConfiguration {
2072
2269
  })),
2073
2270
  accountSearch: accounts.searchable,
2074
2271
  agentPolicies: scenario.runCatalog?.agentPolicies ?? [],
2075
- divisions: scenario.runCatalog?.divisions ?? false
2272
+ divisions: scenario.runCatalog?.divisions ?? false,
2273
+ // The platform's default file limits, unless the scenario's application chose its own.
2274
+ fileLimits: scenario.runCatalog?.fileLimits ?? defaultFileLimits
2076
2275
  };
2077
2276
  this.codeTriggers = (scenario.codeTriggers ?? []).map(trigger => ({
2078
2277
  ...trigger,
2079
2278
  skillId: trigger.skillId ?? null,
2080
2279
  subjectType: trigger.subjectType ?? null
2081
2280
  }));
2281
+ this.profileClassifications = scenario.profileClassifications;
2082
2282
  this.codeRunSettings = new Map((scenario.agents ?? [])
2083
2283
  .filter(agent => agent.source === 'code')
2084
2284
  .map(agent => [agent.id, agent.codeRunSettings ?? {}]));
@@ -2091,6 +2291,10 @@ class NhAssistantMockRunConfiguration {
2091
2291
  get divisionsListed() {
2092
2292
  return this.catalog.divisions === true;
2093
2293
  }
2294
+ /** The profiles the run catalog offers to data skills and to agents created in the administration. */
2295
+ get profileNames() {
2296
+ return [...this.catalog.profiles];
2297
+ }
2094
2298
  /** `GET admin/run-catalog`. */
2095
2299
  runCatalog(method) {
2096
2300
  return method === 'GET' ? { status: 200, body: this.catalog } : { status: 405 };
@@ -2164,7 +2368,9 @@ class NhAssistantMockRunConfiguration {
2164
2368
  accountability: given.accountability ?? (agent.serviceAccount ? 'service-account' : 'agent'),
2165
2369
  delegateFromInitiator: given.delegateFromInitiator ?? false,
2166
2370
  approvalPolicy: given.approvalPolicy ?? null,
2167
- usesRules: given.usesRules ?? false
2371
+ usesRules: given.usesRules ?? false,
2372
+ startsOff: given.startsOff ?? false,
2373
+ acceptsFiles: given.acceptsFiles ?? false
2168
2374
  };
2169
2375
  }
2170
2376
  /**
@@ -2174,8 +2380,13 @@ class NhAssistantMockRunConfiguration {
2174
2380
  */
2175
2381
  triggerProblem(agent, trigger) {
2176
2382
  if (trigger.source === 'event') {
2177
- const subjectType = this.catalog.triggerEvents.find(item => item.name === trigger.event)?.subjectType;
2178
- return subjectType && !mockWorksOnCovers(agent, subjectType) ? 'not-worked-on' : null;
2383
+ const event = this.catalog.triggerEvents.find(item => item.name === trigger.event);
2384
+ if (event?.subjectType && !mockWorksOnCovers(agent, event.subjectType)) {
2385
+ return 'not-worked-on';
2386
+ }
2387
+ // A filter the event no longer offers keeps the trigger from firing.
2388
+ const offered = (event?.filters ?? []).map(filter => filter.name);
2389
+ return (trigger.filters ?? []).some(name => !offered.includes(name)) ? 'unknown-filter' : null;
2179
2390
  }
2180
2391
  return trigger.subjectSet === null && !mockWorksOnCovers(agent, mockSystemSubjectType) ? 'not-worked-on' : null;
2181
2392
  }
@@ -2227,6 +2438,7 @@ class NhAssistantMockRunConfiguration {
2227
2438
  this.requireKnown(errors, 'approvalPolicy', request.approvalPolicy, this.catalog.approvalPolicies);
2228
2439
  this.requireKnown(errors, 'triageProfile', request.triageProfile, this.catalog.profiles);
2229
2440
  this.requireKnown(errors, 'ruleHandler', request.ruleHandler, this.catalog.ruleHandlers.map(handler => handler.name));
2441
+ this.requireFileLimits(errors, request.acceptsFiles ?? false, request);
2230
2442
  if (Object.keys(errors).length > 0) {
2231
2443
  return mockFailure('assistant-validation', errors);
2232
2444
  }
@@ -2256,7 +2468,9 @@ class NhAssistantMockRunConfiguration {
2256
2468
  changedBy: 'current-user',
2257
2469
  reason: text(request.reason),
2258
2470
  inEffect: true,
2259
- problem: null
2471
+ problem: null,
2472
+ acceptsFiles: request.acceptsFiles ?? false,
2473
+ ...storedFileLimits(request)
2260
2474
  };
2261
2475
  this.settings.set(agent.id, saved);
2262
2476
  return { status: 200, body: saved };
@@ -2316,9 +2530,18 @@ class NhAssistantMockRunConfiguration {
2316
2530
  if (request.apply === 'shadow' && (request.expectedVersion ?? null) === null) {
2317
2531
  errors['apply'] = ['invalid'];
2318
2532
  }
2533
+ // Only a new skill starts off; a skill that exists keeps its state.
2534
+ if (request.startsOff === true && (request.expectedVersion ?? null) !== null) {
2535
+ errors['startsOff'] = ['invalid'];
2536
+ }
2537
+ const classification = text(request.dataClassification);
2538
+ if (classification !== null && !dataClassifications.includes(classification)) {
2539
+ errors['dataClassification'] = ['invalid'];
2540
+ }
2319
2541
  this.requireKnown(errors, 'profile', request.profile, this.catalog.profiles);
2320
2542
  this.requireKnown(errors, 'ruleHandler', request.ruleHandler, this.catalog.ruleHandlers.map(handler => handler.name));
2321
2543
  this.requireKnown(errors, 'approvalPolicy', request.approvalPolicy, this.catalog.approvalPolicies);
2544
+ this.requireFileLimits(errors, request.acceptsFiles ?? false, request);
2322
2545
  if (Object.keys(errors).length > 0) {
2323
2546
  return mockFailure('assistant-validation', errors);
2324
2547
  }
@@ -2344,6 +2567,9 @@ class NhAssistantMockRunConfiguration {
2344
2567
  instructionsHash: instructions === null ? null : hashText(instructions),
2345
2568
  tools: [...new Set(request.tools ?? [])],
2346
2569
  profile: text(request.profile),
2570
+ dataClassification: classification,
2571
+ acceptsFiles: request.acceptsFiles ?? false,
2572
+ ...storedFileLimits(request),
2347
2573
  ruleHandler: text(request.ruleHandler),
2348
2574
  maxTokensPerRun: request.maxTokensPerRun ?? null,
2349
2575
  dailyToolCalls: request.dailyToolCalls ?? null,
@@ -2357,9 +2583,10 @@ class NhAssistantMockRunConfiguration {
2357
2583
  };
2358
2584
  this.skills.set(key$1(agent.id, skillId), [...versions, saved]);
2359
2585
  if (!candidate) {
2360
- this.agents.setDataSkill(agent.id, skillId, saved.description);
2586
+ this.agents.setDataSkill(agent.id, skillId, saved.description, live === undefined && request.startsOff === true);
2361
2587
  }
2362
- return { status: 200, body: toSkill(saved) };
2588
+ // A classification that no profile permits never refuses the save; the skill reports it.
2589
+ return { status: 200, body: this.withWarnings(agent, toSkill(saved)) };
2363
2590
  }
2364
2591
  candidate(method, agent, skillId, action, body) {
2365
2592
  const versions = this.skills.get(key$1(agent.id, skillId)) ?? [];
@@ -2403,7 +2630,7 @@ class NhAssistantMockRunConfiguration {
2403
2630
  }
2404
2631
  candidate.status = 'live';
2405
2632
  this.agents.setDataSkill(agent.id, skillId, candidate.description);
2406
- return { status: 200, body: toSkill(candidate) };
2633
+ return { status: 200, body: this.withWarnings(agent, toSkill(candidate)) };
2407
2634
  }
2408
2635
  versions(agentId, skillId) {
2409
2636
  return [...(this.skills.get(key$1(agentId, skillId)) ?? [])]
@@ -2489,9 +2716,20 @@ class NhAssistantMockRunConfiguration {
2489
2716
  if (skill !== null && !this.skillIds(agent).includes(skill)) {
2490
2717
  errors['skill'] = ['not-found'];
2491
2718
  }
2719
+ // The filters in the order chosen, each once; only an event trigger takes them.
2720
+ const filters = [...new Set((request.filters ?? []).map(name => (typeof name === 'string' ? name.trim() : '')))];
2721
+ if (filters.length > maxTriggerFilters || filters.some(name => name.length > 64 || !filterName.test(name)) || (schedule && filters.length > 0)) {
2722
+ errors['filters'] = ['invalid'];
2723
+ }
2492
2724
  if (Object.keys(errors).length > 0) {
2493
2725
  return mockFailure('assistant-validation', errors);
2494
2726
  }
2727
+ if (request.source === 'event') {
2728
+ const offered = (this.catalog.triggerEvents.find(item => item.name === text(request.event))?.filters ?? []).map(filter => filter.name);
2729
+ if (filters.some(name => !offered.includes(name))) {
2730
+ return mockFailure('assistant-trigger-filter-not-found', { filters: ['not-found'] });
2731
+ }
2732
+ }
2495
2733
  if ((request.expectedVersion ?? null) !== (existing?.version ?? null)) {
2496
2734
  return mockFailure('assistant-version-conflict');
2497
2735
  }
@@ -2512,7 +2750,8 @@ class NhAssistantMockRunConfiguration {
2512
2750
  changedBy: 'current-user',
2513
2751
  reason: text(request.reason),
2514
2752
  inEffect: true,
2515
- problem: null
2753
+ problem: null,
2754
+ filters: schedule ? [] : filters
2516
2755
  };
2517
2756
  this.triggers.set(triggerId, Object.assign(saved, { [ownerKey]: agent.id }));
2518
2757
  return { status: 200, body: withoutOwner(saved) };
@@ -2547,6 +2786,7 @@ class NhAssistantMockRunConfiguration {
2547
2786
  }
2548
2787
  liveSkills(agentId) {
2549
2788
  const result = [];
2789
+ const agent = this.agents.find(agentId);
2550
2790
  for (const [stored, versions] of this.skills) {
2551
2791
  if (!stored.startsWith(`${agentId}/`)) {
2552
2792
  continue;
@@ -2554,11 +2794,24 @@ class NhAssistantMockRunConfiguration {
2554
2794
  const live = versions.find(version => version.status === 'live');
2555
2795
  if (live) {
2556
2796
  const candidate = versions.find(version => version.status === 'candidate');
2557
- result.push({ ...toSkill(live), candidate: candidate ? toSkill(candidate) : null });
2797
+ result.push({
2798
+ ...this.withWarnings(agent, toSkill(live)),
2799
+ candidate: candidate ? this.withWarnings(agent, toSkill(candidate)) : null
2800
+ });
2558
2801
  }
2559
2802
  }
2560
2803
  return result.sort((left, right) => left.skillId.localeCompare(right.skillId));
2561
2804
  }
2805
+ /**
2806
+ * The skill with the warnings the server reports for it: its classification, or else its agent's,
2807
+ * that its profile, or else its agent's, does not permit.
2808
+ */
2809
+ withWarnings(agent, skill) {
2810
+ return {
2811
+ ...skill,
2812
+ warnings: mockClassificationWarnings(this.profileClassifications, skill.profile ?? agent?.profile, skill.dataClassification ?? agent?.dataClassification)
2813
+ };
2814
+ }
2562
2815
  ownerOf(trigger) {
2563
2816
  return trigger[ownerKey];
2564
2817
  }
@@ -2574,6 +2827,28 @@ class NhAssistantMockRunConfiguration {
2574
2827
  errors[field] = ['not-found'];
2575
2828
  }
2576
2829
  }
2830
+ /**
2831
+ * Lower file limits as the server checks them: each a whole number from 1 to the application's
2832
+ * limit, and only next to `acceptsFiles`.
2833
+ */
2834
+ requireFileLimits(errors, acceptsFiles, request) {
2835
+ const application = this.catalog.fileLimits ?? defaultFileLimits;
2836
+ for (const field of fileLimitFields) {
2837
+ const value = request[field];
2838
+ if (value !== null && value !== undefined && (!acceptsFiles || !inRange(value, 1, application[field]))) {
2839
+ errors[field] = ['invalid'];
2840
+ }
2841
+ }
2842
+ }
2843
+ }
2844
+ /** The lower file limits a save stores, `null` where the request keeps the application's. */
2845
+ function storedFileLimits(request) {
2846
+ return {
2847
+ maxFileBytes: request.maxFileBytes ?? null,
2848
+ maxFilePages: request.maxFilePages ?? null,
2849
+ maxFilesPerTurn: request.maxFilesPerTurn ?? null,
2850
+ maxFileTokensPerTurn: request.maxFileTokensPerTurn ?? null
2851
+ };
2577
2852
  }
2578
2853
  const ownerKey = '__agentId';
2579
2854
  function key$1(agentId, skillId) {
@@ -2649,12 +2924,13 @@ class NhAssistantMockAdmin {
2649
2924
  this.agents = new NhAssistantMockAdminAgents(scenario, now, {
2650
2925
  hasServer: serverId => this.mcp.hasServer(serverId),
2651
2926
  agentPolicyNames: () => this.runConfiguration.agentPolicyNames,
2652
- divisionsListed: () => this.runConfiguration.divisionsListed
2927
+ divisionsListed: () => this.runConfiguration.divisionsListed,
2928
+ profileNames: () => this.runConfiguration.profileNames
2653
2929
  });
2654
2930
  this.accounts = new NhAssistantMockAccounts(admin);
2655
2931
  this.runConfiguration = new NhAssistantMockRunConfiguration(admin, {
2656
2932
  find: agentId => this.agents.find(agentId),
2657
- setDataSkill: (agentId, skillId, description) => this.agents.setDataSkill(agentId, skillId, description)
2933
+ setDataSkill: (agentId, skillId, description, startsOff) => this.agents.setDataSkill(agentId, skillId, description, startsOff)
2658
2934
  }, now, this.accounts);
2659
2935
  this.mcp = new NhAssistantMockAdminMcp(scenario, now, {
2660
2936
  agents: () => this.agents.all(),
@@ -2859,6 +3135,159 @@ function toContext(version) {
2859
3135
  return { text: version.text, version: version.version, hash: version.hash, updatedAt: version.updatedAt, updatedBy: version.updatedBy };
2860
3136
  }
2861
3137
 
3138
+ /** The longest file name a reference takes, as on the server. */
3139
+ const maxNameLength = 256;
3140
+ const mediaTypePattern = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
3141
+ /** How files of the platform's types start; other accepted types are the application's to check. */
3142
+ const signatures = {
3143
+ '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])
3148
+ };
3149
+ /**
3150
+ * The files of the mock API: `POST conversations/{id}/files` with the server's checks, and the
3151
+ * attachments of a message, which a turn opens again as the server's file source would.
3152
+ */
3153
+ class NhAssistantMockFiles {
3154
+ constructor(refuse, scan) {
3155
+ this.refuse = refuse;
3156
+ this.scan = scan;
3157
+ this.stored = new Map();
3158
+ this.next = 0;
3159
+ }
3160
+ /** The uploads the mock stored, oldest first. */
3161
+ get uploads() {
3162
+ return [...this.stored.values()];
3163
+ }
3164
+ /**
3165
+ * Checks an upload as the server does, in its order: a file source, an agent that accepts files,
3166
+ * the upload header, one file part named `file` with a name and content, an accepted media type
3167
+ * whose content matches, the agent's largest size, the application's scanner, and finally the
3168
+ * application's own refusal.
3169
+ */
3170
+ async upload(conversationId, offered, uploads, headers, form) {
3171
+ if (!offered) {
3172
+ return mockFailure('assistant-files-unavailable');
3173
+ }
3174
+ if (!uploads) {
3175
+ return mockFailure('assistant-files-not-accepted');
3176
+ }
3177
+ const header = Object.entries(headers).find(([name]) => name.toLowerCase() === NH_ASSISTANT_UPLOAD_HEADER.toLowerCase());
3178
+ let parts = 0;
3179
+ form.forEach(() => parts++);
3180
+ const file = form.get('file');
3181
+ if (!header?.[1] || parts !== 1 || !(file instanceof Blob)) {
3182
+ return mockFailure('assistant-file-upload-invalid');
3183
+ }
3184
+ const name = nameOf(file instanceof File ? file.name : '');
3185
+ if (name === null || file.size === 0) {
3186
+ return mockFailure('assistant-file-upload-invalid');
3187
+ }
3188
+ const mediaType = normalizeMediaType(file.type);
3189
+ if (mediaType === null || !uploads.acceptedMediaTypes.includes(mediaType)) {
3190
+ return mockFailure('assistant-file-type-not-accepted');
3191
+ }
3192
+ if (file.size > uploads.maxFileBytes) {
3193
+ return mockFailure('assistant-file-too-large');
3194
+ }
3195
+ const head = new Uint8Array(await file.slice(0, 1024).arrayBuffer());
3196
+ if (!(signatures[mediaType]?.(head) ?? true)) {
3197
+ return mockFailure('assistant-file-type-not-accepted');
3198
+ }
3199
+ const upload = { conversationId, name, mediaType, size: file.size };
3200
+ // The application's scanner sees the file before its file source stores anything.
3201
+ const scanned = this.scan?.(upload) ?? null;
3202
+ if (scanned) {
3203
+ return mockFailure(scanned === 'refused' ? 'assistant-file-scan-refused' : 'assistant-file-upload-failed');
3204
+ }
3205
+ const refusal = this.refuse?.(upload) ?? null;
3206
+ if (refusal) {
3207
+ return mockFailure(refusalCode(refusal));
3208
+ }
3209
+ this.next++;
3210
+ const reference = { id: `mock-file-${this.next}`, name, mediaType, size: file.size, hash: null };
3211
+ this.stored.set(reference.id, { ...upload, reference });
3212
+ return { status: 200, body: reference };
3213
+ }
3214
+ /**
3215
+ * The code the server refuses a message's `files` with before the turn starts, or `null`: without
3216
+ * a file source, more files than one message carries, or a reference that is not one.
3217
+ */
3218
+ checkMessage(files, offered, maxFiles) {
3219
+ if (files === undefined || files === null || (Array.isArray(files) && files.length === 0)) {
3220
+ return null;
3221
+ }
3222
+ if (!Array.isArray(files)) {
3223
+ return 'assistant-file-reference-invalid';
3224
+ }
3225
+ if (!offered) {
3226
+ return 'assistant-files-unavailable';
3227
+ }
3228
+ if (files.length > maxFiles) {
3229
+ return 'assistant-file-count-limit';
3230
+ }
3231
+ return files.every(isReference) ? null : 'assistant-file-reference-invalid';
3232
+ }
3233
+ /**
3234
+ * The code of the first attachment the turn cannot open, as the server's file source refuses it,
3235
+ * or `null` when it opens them all. A reference the mock never stored, or one of another
3236
+ * conversation, is not found.
3237
+ */
3238
+ openCode(conversationId, files) {
3239
+ const missing = files.find(file => this.stored.get(file.id)?.conversationId !== conversationId);
3240
+ return missing ? 'assistant-file-not-found' : null;
3241
+ }
3242
+ }
3243
+ /** The file name without a path; `null` when nothing usable is left, as on the server. */
3244
+ function nameOf(fileName) {
3245
+ const name = fileName.slice(Math.max(fileName.lastIndexOf('/'), fileName.lastIndexOf('\\')) + 1).trim();
3246
+ return name.length === 0 || name.length > maxNameLength || /[\u0000-\u001f\u007f]/.test(name) ? null : name;
3247
+ }
3248
+ function normalizeMediaType(value) {
3249
+ const essence = value.split(';')[0].trim().toLowerCase();
3250
+ return essence.length <= 128 && mediaTypePattern.test(essence) ? essence : null;
3251
+ }
3252
+ function isReference(value) {
3253
+ const file = value;
3254
+ return !!file
3255
+ && isText(file.id)
3256
+ && isText(file.name)
3257
+ && typeof file.mediaType === 'string' && normalizeMediaType(file.mediaType) !== null
3258
+ && typeof file.size === 'number' && file.size >= 0;
3259
+ }
3260
+ function isText(value) {
3261
+ return typeof value === 'string' && value.trim().length > 0 && value.length <= maxNameLength && !/[\u0000-\u001f\u007f]/.test(value);
3262
+ }
3263
+ /** The application's refusal as the server passes it on. */
3264
+ function refusalCode(code) {
3265
+ switch (code) {
3266
+ case 'assistant-files-unavailable':
3267
+ case 'assistant-file-too-large':
3268
+ return code;
3269
+ case 'assistant-file-type-not-accepted':
3270
+ case 'assistant-file-type-not-supported':
3271
+ return 'assistant-file-type-not-accepted';
3272
+ case 'assistant-file-upload-failed':
3273
+ case 'assistant-file-unavailable':
3274
+ return 'assistant-file-upload-failed';
3275
+ default:
3276
+ return 'assistant-file-upload-refused';
3277
+ }
3278
+ }
3279
+ function startsWith(head, signature) {
3280
+ return head.length >= signature.length && signature.every((byte, index) => head[index] === byte);
3281
+ }
3282
+ function indexOf(head, signature) {
3283
+ for (let start = 0; start + signature.length <= head.length; start++) {
3284
+ if (signature.every((byte, index) => head[start + index] === byte)) {
3285
+ return start;
3286
+ }
3287
+ }
3288
+ return -1;
3289
+ }
3290
+
2862
3291
  const dashCase$3 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
2863
3292
  const minQueryLength = 2;
2864
3293
  const maxQueryLength = 100;
@@ -3246,7 +3675,7 @@ class NhAssistantMockAgentRuns {
3246
3675
  result: this.answerOf(found.run.id, language),
3247
3676
  resultTruncated: this.truncated.has(found.run.id),
3248
3677
  summary: found.run.summary,
3249
- proposals: [],
3678
+ proposals: this.context.runProposals?.(found.run.id) ?? [],
3250
3679
  hiddenProposals: 0,
3251
3680
  artifacts: [],
3252
3681
  canOpenConversation: this.mayView(found.thread, language)
@@ -3829,9 +4258,9 @@ class NhAssistantMockAgentRuns {
3829
4258
  const picked = localized ? mockPickText({ ...localized }, '', language) : null;
3830
4259
  return picked ?? this.results.get(runId) ?? null;
3831
4260
  }
3832
- /** Neither stopped nor disabled in code; a paused agent still works for people. */
4261
+ /** Neither stopped nor disabled in code; a paused agent, and one that is off, still works for people. */
3833
4262
  isWorking(agent) {
3834
- return agent.runState === 'active' || agent.runState === 'paused';
4263
+ return agent.runState === 'active' || agent.runState === 'paused' || agent.runState === 'off';
3835
4264
  }
3836
4265
  mayUse(agent) {
3837
4266
  return agent.requiredPolicy === null || agent.requiredPolicy === undefined || this.passes(agent.requiredPolicy);
@@ -3981,7 +4410,7 @@ const maxGroupId = 64;
3981
4410
  const maxGroups = 100;
3982
4411
  const maxName = 100;
3983
4412
  const maxDescription = 300;
3984
- const guid = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i;
4413
+ const guid$1 = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i;
3985
4414
  /**
3986
4415
  * Groups and boards of the mock API (K15, K16), as the server serves them: the scenario's
3987
4416
  * `agentGroups` are the groups in code with their names per language, checked as the server checks
@@ -4108,7 +4537,7 @@ class NhAssistantMockBoards {
4108
4537
  }
4109
4538
  const groupId = segments[1];
4110
4539
  const divisionId = query.get('divisionId');
4111
- if (divisionId !== null && divisionId !== '' && !guid.test(divisionId)) {
4540
+ if (divisionId !== null && divisionId !== '' && !guid$1.test(divisionId)) {
4112
4541
  return mockFailure('assistant-board-scope-invalid');
4113
4542
  }
4114
4543
  const division = divisionId ? this.division(divisionId, requested) : null;
@@ -4249,6 +4678,7 @@ const dashCase = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
4249
4678
  const maxReason = 500;
4250
4679
  const maxItemsPerPage = 100;
4251
4680
  const maxBulkDecisions = 50;
4681
+ const guid = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i;
4252
4682
  const lifetimeMs = 3 * 24 * 60 * 60 * 1000;
4253
4683
  /** The platform's subject type of the whole application. */
4254
4684
  const systemType = 'nh-system';
@@ -4264,12 +4694,15 @@ const systemType = 'nh-system';
4264
4694
  * answers `404` and deciding it `404 assistant-approval-not-found`.
4265
4695
  */
4266
4696
  class NhAssistantMockProposals {
4267
- constructor(scenario = [], actorId, now, mayDecideApplication = () => true, decided = () => undefined) {
4697
+ constructor(scenario = [], actorId, now, mayDecideApplication = () => true, decided = () => undefined, mayWorkIn = () => true) {
4268
4698
  this.actorId = actorId;
4269
4699
  this.now = now;
4270
4700
  this.mayDecideApplication = mayDecideApplication;
4271
4701
  this.decided = decided;
4702
+ this.mayWorkIn = mayWorkIn;
4272
4703
  this.proposals = new Map();
4704
+ /** When the caller last looked at the inbox, by scope key. */
4705
+ this.visits = new Map();
4273
4706
  const created = new Date(now()).getTime();
4274
4707
  for (const item of scenario) {
4275
4708
  if (this.proposals.has(item.approvalId)) {
@@ -4300,8 +4733,8 @@ class NhAssistantMockProposals {
4300
4733
  mode: 'deferred',
4301
4734
  policyId: null,
4302
4735
  grantSource: 'agent',
4303
- createdAt: new Date(created).toISOString(),
4304
- expiresAt: new Date(created + lifetimeMs).toISOString(),
4736
+ createdAt: item.createdAt ?? new Date(created).toISOString(),
4737
+ expiresAt: item.expiresAt ?? new Date(Date.parse(item.createdAt ?? new Date(created).toISOString()) + lifetimeMs).toISOString(),
4305
4738
  initiatorActorId: null,
4306
4739
  decidedByActorId: null,
4307
4740
  decidedAt: null,
@@ -4309,8 +4742,10 @@ class NhAssistantMockProposals {
4309
4742
  executedAt: null,
4310
4743
  rationale: item.rationale ?? null,
4311
4744
  placedBy: item.placedBy ?? null,
4745
+ parent: item.parent ?? null,
4312
4746
  canGiveFeedback: false
4313
4747
  },
4748
+ divisionId: item.divisionId ?? null,
4314
4749
  canDecide: item.canDecide ?? true,
4315
4750
  reasonCode: item.canDecide === false ? item.reasonCode ?? 'assistant-approval-policy-denied' : null
4316
4751
  });
@@ -4321,6 +4756,9 @@ class NhAssistantMockProposals {
4321
4756
  if (segments.length === 2 && segments[1] === 'inbox') {
4322
4757
  return method === 'GET' ? this.inbox(query) : { status: 405 };
4323
4758
  }
4759
+ if (segments.length === 3 && segments[1] === 'inbox' && segments[2] === 'visit') {
4760
+ return method === 'POST' ? this.visit(query) : { status: 405 };
4761
+ }
4324
4762
  if (segments.length === 2 && segments[1] === 'lookup') {
4325
4763
  return method === 'POST' ? this.lookup(body) : { status: 405 };
4326
4764
  }
@@ -4343,6 +4781,12 @@ class NhAssistantMockProposals {
4343
4781
  .reverse();
4344
4782
  return items.length === 0 ? null : items;
4345
4783
  }
4784
+ /** The proposals of a run the caller may see, in the order the scenario lists them. */
4785
+ ofRun(runId) {
4786
+ return this.visible()
4787
+ .filter(item => item.proposal.runId === runId)
4788
+ .map(item => item.proposal);
4789
+ }
4346
4790
  /** The pending proposals on a subject with the caller's right to decide each, earliest expiry first. */
4347
4791
  openOfSubject(subjectType, subjectId) {
4348
4792
  return this.visible()
@@ -4372,19 +4816,49 @@ class NhAssistantMockProposals {
4372
4816
  }
4373
4817
  const agentId = query.get('agentId');
4374
4818
  const runId = query.get('runId');
4819
+ const divisionId = query.get('divisionId');
4820
+ if (divisionId !== null && !guid.test(divisionId)) {
4821
+ return { status: 400 };
4822
+ }
4823
+ const grouped = query.get('grouped') === 'true';
4375
4824
  const items = this.visible()
4376
4825
  .filter(item => item.proposal.status === 'pending' && item.canDecide)
4826
+ .filter(item => divisionId === null || sameGuid(item.divisionId, divisionId))
4377
4827
  .map(item => item.proposal)
4378
4828
  .filter(proposal => (subjectType === null || proposal.subjectType === subjectType)
4379
4829
  && (agentId === null || proposal.agentId === agentId)
4380
4830
  && (runId === null || proposal.runId === runId))
4381
4831
  .sort((left, right) => left.expiresAt.localeCompare(right.expiresAt) || left.approvalId.localeCompare(right.approvalId));
4832
+ const ordered = grouped ? groupedOrder(items) : items;
4833
+ const offset = (page - 1) * itemsPerPage;
4834
+ const pageItems = ordered.slice(offset, offset + itemsPerPage);
4835
+ const scopeKey = scopeKeyOf(divisionId);
4836
+ const lastVisitedAt = this.visits.get(scopeKey) ?? null;
4382
4837
  const body = {
4383
- items: items.slice((page - 1) * itemsPerPage, page * itemsPerPage),
4384
- total: items.length
4838
+ items: pageItems,
4839
+ total: items.length,
4840
+ scopeKey,
4841
+ lastVisitedAt,
4842
+ newApprovalIds: pageItems
4843
+ .filter(proposal => lastVisitedAt === null || Date.parse(proposal.createdAt) > Date.parse(lastVisitedAt))
4844
+ .map(proposal => proposal.approvalId),
4845
+ groups: grouped ? groupsOf(ordered, offset, pageItems.length) : null
4385
4846
  };
4386
4847
  return { status: 200, body };
4387
4848
  }
4849
+ /** `POST approvals/inbox/visit`: the whole inbox, or that of a division the caller works in. */
4850
+ visit(query) {
4851
+ const divisionId = query.get('divisionId');
4852
+ if (divisionId !== null && !guid.test(divisionId)) {
4853
+ return { status: 400 };
4854
+ }
4855
+ if (divisionId !== null && !this.mayWorkIn(divisionId)) {
4856
+ return mockFailure('assistant-subject-not-found');
4857
+ }
4858
+ const body = { scopeKey: scopeKeyOf(divisionId), visitedAt: this.now() };
4859
+ this.visits.set(body.scopeKey, body.visitedAt);
4860
+ return { status: 200, body };
4861
+ }
4388
4862
  read(approvalId) {
4389
4863
  const stored = this.find(approvalId);
4390
4864
  return stored ? { status: 200, body: this.withRight(stored) } : mockFailure('assistant-approval-not-found');
@@ -4472,6 +4946,99 @@ class NhAssistantMockProposals {
4472
4946
  };
4473
4947
  }
4474
4948
  }
4949
+ /** The scope of the inbox's visits: `global`, or the scope key of a division. */
4950
+ function scopeKeyOf(divisionId) {
4951
+ return divisionId === null ? 'global' : `division:${divisionId.replaceAll('-', '').toLowerCase()}`;
4952
+ }
4953
+ function sameGuid(left, right) {
4954
+ return left !== null && left.replaceAll('-', '').toLowerCase() === right.replaceAll('-', '').toLowerCase();
4955
+ }
4956
+ /** The group a proposal belongs to: its parent, or its own subject without one. */
4957
+ function groupKeyOf(proposal) {
4958
+ return proposal.parent
4959
+ ? `${proposal.parent.subjectType}|${proposal.parent.subjectId}`
4960
+ : `${proposal.subjectType}|${proposal.subjectId}`;
4961
+ }
4962
+ function subjectKeyOf(proposal) {
4963
+ return `${groupKeyOf(proposal)}|${proposal.subjectType}|${proposal.subjectId}`;
4964
+ }
4965
+ function firstExpiries(items, key) {
4966
+ const first = new Map();
4967
+ for (const proposal of items) {
4968
+ const current = first.get(key(proposal));
4969
+ if (current === undefined || proposal.expiresAt < current) {
4970
+ first.set(key(proposal), proposal.expiresAt);
4971
+ }
4972
+ }
4973
+ return first;
4974
+ }
4975
+ /**
4976
+ * The order of the grouped inbox, as the server sorts: groups by their first expiry, a group's own
4977
+ * subject first, then its other subjects by their first expiry, one run's proposals together, then by
4978
+ * expiry.
4979
+ */
4980
+ function groupedOrder(items) {
4981
+ const groups = firstExpiries(items, groupKeyOf);
4982
+ const subjects = firstExpiries(items, subjectKeyOf);
4983
+ const compare = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
4984
+ return [...items].sort((left, right) => compare(groups.get(groupKeyOf(left)), groups.get(groupKeyOf(right)))
4985
+ || compare(groupKeyOf(left), groupKeyOf(right))
4986
+ || (left.parent ? 1 : 0) - (right.parent ? 1 : 0)
4987
+ || compare(subjects.get(subjectKeyOf(left)), subjects.get(subjectKeyOf(right)))
4988
+ || compare(subjectKeyOf(left), subjectKeyOf(right))
4989
+ || (left.runId === null ? 1 : 0) - (right.runId === null ? 1 : 0)
4990
+ || compare(left.runId ?? '', right.runId ?? '')
4991
+ || compare(left.expiresAt, right.expiresAt)
4992
+ || compare(left.approvalId, right.approvalId));
4993
+ }
4994
+ /** The groups of the page `ordered[offset, offset + count)`, with their totals and continuation. */
4995
+ function groupsOf(ordered, offset, count) {
4996
+ const page = ordered.slice(offset, offset + count);
4997
+ const previous = offset > 0 ? ordered[offset - 1] : undefined;
4998
+ const next = ordered[offset + count];
4999
+ const groups = [];
5000
+ for (let index = 0; index < page.length;) {
5001
+ const key = groupKeyOf(page[index]);
5002
+ let end = index;
5003
+ while (end < page.length && groupKeyOf(page[end]) === key) {
5004
+ end++;
5005
+ }
5006
+ const members = page.slice(index, end);
5007
+ const own = members.find(proposal => !proposal.parent);
5008
+ const parent = members.find(proposal => proposal.parent)?.parent ?? null;
5009
+ const subjects = [];
5010
+ for (let at = 0; at < members.length;) {
5011
+ const subjectKey = subjectKeyOf(members[at]);
5012
+ let stop = at;
5013
+ while (stop < members.length && subjectKeyOf(members[stop]) === subjectKey) {
5014
+ stop++;
5015
+ }
5016
+ subjects.push({
5017
+ subjectType: members[at].subjectType,
5018
+ subjectId: members[at].subjectId,
5019
+ count: stop - at,
5020
+ total: ordered.filter(proposal => subjectKeyOf(proposal) === subjectKey).length,
5021
+ continuesFromPreviousPage: index === 0 && at === 0 && previous !== undefined && subjectKeyOf(previous) === subjectKey,
5022
+ continuesOnNextPage: end === page.length && stop === members.length && next !== undefined && subjectKeyOf(next) === subjectKey
5023
+ });
5024
+ at = stop;
5025
+ }
5026
+ groups.push({
5027
+ subjectType: own?.subjectType ?? parent.subjectType,
5028
+ subjectId: own?.subjectId ?? parent.subjectId,
5029
+ displayName: own ? own.subjectDisplayName : parent.displayName,
5030
+ subjectTypeName: own ? own.subjectTypeName ?? null : parent.subjectTypeName ?? null,
5031
+ subjectLink: own ? own.subjectLink ?? null : parent.subjectLink ?? null,
5032
+ count: members.length,
5033
+ total: ordered.filter(proposal => groupKeyOf(proposal) === key).length,
5034
+ continuesFromPreviousPage: index === 0 && previous !== undefined && groupKeyOf(previous) === key,
5035
+ continuesOnNextPage: end === page.length && next !== undefined && groupKeyOf(next) === key,
5036
+ subjects
5037
+ });
5038
+ index = end;
5039
+ }
5040
+ return groups;
5041
+ }
4475
5042
  function validDecision(request) {
4476
5043
  return !!request
4477
5044
  && (request.decision === 'approve' || request.decision === 'reject')
@@ -4539,12 +5106,13 @@ class NhAssistantMockBackend {
4539
5106
  runId: proposal.runId,
4540
5107
  subjectType: proposal.subjectType,
4541
5108
  subjectId: proposal.subjectId
4542
- }, proposal.agentId));
5109
+ }, proposal.agentId), (divisionId) => this.subjects.describe(mockSystemSubjectType, divisionSubjectId({ id: divisionId, name: '' }), null, this.admin.administers) != null);
4543
5110
  this.subjects = new NhAssistantMockSubjects(this.scenario.subjectTypes, this.scenario.divisions, (type, id) => this.proposals.openOfSubject(type, id));
4544
5111
  this.agentRuns = new NhAssistantMockAgentRuns(this.scenario, this.subjects, {
4545
5112
  agents: () => this.admin.allAgents(),
4546
5113
  administers: () => this.admin.administers,
4547
5114
  threadProposals: conversationId => this.proposals.ofThread(conversationId),
5115
+ runProposals: runId => this.proposals.ofRun(runId),
4548
5116
  workChanged: (change, agentId) => this.publishAgentWork(change, agentId)
4549
5117
  }, () => new Date().toISOString());
4550
5118
  this.boards = new NhAssistantMockBoards(this.scenario, this.subjects, this.agentRuns, {
@@ -4557,6 +5125,7 @@ class NhAssistantMockBackend {
4557
5125
  this.shareTokens = new Map();
4558
5126
  this.activeActors = new Map();
4559
5127
  this.liveSubject = new Subject();
5128
+ this.files = new NhAssistantMockFiles(this.scenario.refuseUpload, this.scenario.scanUpload);
4560
5129
  this.pushEnabled = true;
4561
5130
  this.sequence = 0;
4562
5131
  /** Requests received so far, oldest first. */
@@ -4570,12 +5139,45 @@ class NhAssistantMockBackend {
4570
5139
  * provides it as `NH_ASSISTANT_MCP_AUTHORIZATION_WINDOW`.
4571
5140
  */
4572
5141
  this.authorizationWindow = new NhAssistantMockAuthorizationWindow(flowId => this.admin.authorizeFlow(flowId));
5142
+ /**
5143
+ * An upload implementation (`NH_ASSISTANT_UPLOAD`) that answers `POST conversations/{id}/files` with
5144
+ * the server's checks, after reporting the progress in four steps.
5145
+ */
5146
+ this.upload = async (request) => {
5147
+ const path = this.pathOf(request.url);
5148
+ const file = request.body.get('file');
5149
+ this.requests.push({
5150
+ method: 'POST',
5151
+ path: path ?? request.url,
5152
+ headers: { ...request.headers },
5153
+ body: file instanceof Blob ? { name: file instanceof File ? file.name : null, mediaType: file.type, size: file.size } : undefined
5154
+ });
5155
+ const total = file instanceof Blob ? file.size : 0;
5156
+ for (const share of [0.25, 0.5, 0.75, 1]) {
5157
+ if (request.signal.aborted) {
5158
+ throw new DOMException('The operation was aborted.', 'AbortError');
5159
+ }
5160
+ await delay(this.scenario.timing?.eventDelayMs ?? 40);
5161
+ request.onProgress(Math.round(total * share), total);
5162
+ }
5163
+ if (request.signal.aborted) {
5164
+ throw new DOMException('The operation was aborted.', 'AbortError');
5165
+ }
5166
+ const segments = path?.split('/').map(segment => decodeURIComponent(segment)) ?? [];
5167
+ if (!this.enabledState() || segments.length !== 3 || segments[0] !== 'conversations' || segments[2] !== 'files') {
5168
+ return this.empty(404);
5169
+ }
5170
+ const conversation = this.conversations.get(segments[1]);
5171
+ if (!conversation) {
5172
+ return this.error('assistant-conversation-not-found');
5173
+ }
5174
+ return this.result(await this.files.upload(conversation.id, this.offersFiles(), this.uploadsOf(conversation.agentId), request.headers, request.body));
5175
+ };
4573
5176
  /** A `fetch` implementation that answers the assistant endpoints below `apiBaseUrl`. */
4574
5177
  this.fetch = async (input, init) => {
4575
5178
  const method = (init.method ?? 'GET').toUpperCase();
4576
5179
  const url = new URL(input, 'http://mock.local');
4577
- const base = new URL(this.config.apiBaseUrl.replace(/\/+$/, ''), 'http://mock.local').pathname;
4578
- const path = url.pathname.startsWith(`${base}/`) ? url.pathname.slice(base.length + 1) : null;
5180
+ const path = this.pathOf(input);
4579
5181
  const body = typeof init.body === 'string' && init.body.length > 0 ? JSON.parse(init.body) : undefined;
4580
5182
  this.requests.push({ method, path: path ?? url.pathname, headers: { ...init.headers }, body });
4581
5183
  if (init.signal?.aborted) {
@@ -4654,6 +5256,10 @@ class NhAssistantMockBackend {
4654
5256
  setMcpConnectionStatus(serverId, status) {
4655
5257
  this.admin.setMcpConnectionStatus(serverId, status);
4656
5258
  }
5259
+ /** The files people uploaded, oldest first, as the application's file source stored them. */
5260
+ get uploads() {
5261
+ return this.files.uploads;
5262
+ }
4657
5263
  route(method, path, query, body, language = null) {
4658
5264
  const segments = path.split('/').map(segment => decodeURIComponent(segment));
4659
5265
  if (method === 'GET' && path === 'status') {
@@ -4831,6 +5437,12 @@ class NhAssistantMockBackend {
4831
5437
  if (text.length === 0 || text.length > limit || !request.clientMessageId) {
4832
5438
  return this.empty(400);
4833
5439
  }
5440
+ // Like the server: the attachments are checked before the turn claims the conversation.
5441
+ const uploads = this.uploadsOf(conversation.agentId);
5442
+ const filesCode = this.files.checkMessage(request.files, this.offersFiles(), uploads?.maxFiles ?? 5);
5443
+ if (filesCode) {
5444
+ return this.error(filesCode);
5445
+ }
4834
5446
  if (!acceptsMessage(conversation.status)) {
4835
5447
  return this.empty(409);
4836
5448
  }
@@ -4840,6 +5452,14 @@ class NhAssistantMockBackend {
4840
5452
  const turn = this.findTurn(text, conversation.agentId, pageContext);
4841
5453
  const userMessageId = this.nextId();
4842
5454
  const assistantMessageId = this.nextId();
5455
+ const files = request.files ?? [];
5456
+ // The turn opens the attachments with the sender's rights; it ends with the first refusal and the
5457
+ // message keeps only what the model read.
5458
+ const refusal = files.length === 0 ? null : uploads ? this.files.openCode(conversation.id, files) : 'assistant-files-not-accepted';
5459
+ const parts = [
5460
+ { type: 'text', text },
5461
+ ...(refusal ? [] : files.map(file => ({ type: 'file', name: file.name, mediaType: file.mediaType, size: file.size })))
5462
+ ];
4843
5463
  this.activeActors.set(conversation.id, this.currentUser.actorId);
4844
5464
  this.update(conversation.id, current => ({
4845
5465
  ...current,
@@ -4847,14 +5467,17 @@ class NhAssistantMockBackend {
4847
5467
  status: 'running',
4848
5468
  messages: [
4849
5469
  ...current.messages,
4850
- { id: userMessageId, role: 'user', createdAt: new Date().toISOString(), parts: [{ type: 'text', text }], authorActorId: this.currentUser.actorId }
5470
+ { id: userMessageId, role: 'user', createdAt: new Date().toISOString(), parts, authorActorId: this.currentUser.actorId }
4851
5471
  ]
4852
5472
  }));
4853
5473
  // The sender has read their own message.
4854
5474
  this.readPositions.set(conversation.id, lastSequence(this.conversations.get(conversation.id)));
4855
5475
  return this.stream(conversation.id, async (emit) => {
4856
5476
  await emit({ type: 'turn.started', data: { turnId: this.nextId(), userMessageId, assistantMessageId } });
4857
- await this.playSteps(conversation.id, assistantMessageId, turn?.steps ?? [], emit);
5477
+ const steps = refusal
5478
+ ? [{ error: { code: refusal, messageKey: `nh-assistant.errors.${refusal}` } }]
5479
+ : turn?.steps ?? [];
5480
+ await this.playSteps(conversation.id, assistantMessageId, steps, emit);
4858
5481
  });
4859
5482
  }
4860
5483
  decide(conversation, approvalId, request) {
@@ -5218,6 +5841,22 @@ class NhAssistantMockBackend {
5218
5841
  error(code) {
5219
5842
  return this.result(mockFailure(code));
5220
5843
  }
5844
+ /** The path below `apiBaseUrl`, or `null` for another URL. */
5845
+ pathOf(input) {
5846
+ const url = new URL(input, 'http://mock.local');
5847
+ const base = new URL(this.config.apiBaseUrl.replace(/\/+$/, ''), 'http://mock.local').pathname;
5848
+ return url.pathname.startsWith(`${base}/`) ? url.pathname.slice(base.length + 1) : null;
5849
+ }
5850
+ /** Whether the application keeps files for the assistant: an agent of the scenario takes uploads. */
5851
+ offersFiles() {
5852
+ return this.scenario.agents.some(agent => !!agent.uploads);
5853
+ }
5854
+ /** What people may attach to a message to the agent, as its summary says. */
5855
+ uploadsOf(agentId) {
5856
+ return this.admin.chatAgents().find(agent => agent.id === agentId)?.uploads
5857
+ ?? this.scenario.agents.find(agent => agent.id === agentId)?.uploads
5858
+ ?? null;
5859
+ }
5221
5860
  /**
5222
5861
  * Whether the caller decides the agent's proposals of the whole application: an approver of the whole
5223
5862
  * application (an administrator in the mock) who passes the agent's view policy, by default its
@@ -5381,15 +6020,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.34", ngImpo
5381
6020
  * Replaces the assistant transport with an in-memory back-end that plays `script`.
5382
6021
  * Register it after `provideNhAssistant(...)` in the same injector. Every client feature
5383
6022
  * (status, agents, conversations, streamed turns, approvals, cancellation, sharing, read
5384
- * state, live updates and MCP authorizations) then works without a server. Inject
5385
- * `NhAssistantMockBackend` to switch the feature flag, simulate a colleague or inspect the
5386
- * received requests.
6023
+ * state, live updates, uploads with their progress and MCP authorizations) then works without a
6024
+ * server. Inject `NhAssistantMockBackend` to switch the feature flag, simulate a colleague or
6025
+ * inspect the received requests and uploads.
5387
6026
  */
5388
6027
  function provideNhAssistantMockApi(script) {
5389
6028
  return makeEnvironmentProviders([
5390
6029
  { provide: NH_ASSISTANT_MOCK_SCENARIO, useValue: script },
5391
6030
  NhAssistantMockBackend,
5392
6031
  { provide: NH_ASSISTANT_FETCH, useFactory: () => inject(NhAssistantMockBackend).fetch },
6032
+ { provide: NH_ASSISTANT_UPLOAD, useFactory: () => inject(NhAssistantMockBackend).upload },
5393
6033
  { provide: NhAssistantLiveService, useClass: NhAssistantMockLiveService },
5394
6034
  { provide: NH_ASSISTANT_MCP_AUTHORIZATION_WINDOW, useFactory: () => inject(NhAssistantMockBackend).authorizationWindow }
5395
6035
  ]);