@newheap/platform-ai-chat 0.15.0 → 0.16.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
  /**
@@ -39,7 +39,12 @@ const statusTable = {
39
39
  'assistant-mcp-authorization-invalid',
40
40
  'assistant-mcp-pkce-unsupported',
41
41
  'assistant-mcp-client-registration-required',
42
- 'assistant-mcp-oauth-not-configured'
42
+ 'assistant-mcp-oauth-not-configured',
43
+ 'assistant-file-reference-invalid',
44
+ 'assistant-file-upload-invalid',
45
+ 'assistant-file-too-many-pages',
46
+ 'assistant-file-count-limit',
47
+ 'assistant-file-token-limit'
43
48
  ],
44
49
  401: [
45
50
  'assistant-webhook-signature-invalid'
@@ -55,7 +60,8 @@ const statusTable = {
55
60
  'assistant-four-eyes-required',
56
61
  'assistant-forbidden',
57
62
  'assistant-mcp-credential-mismatch',
58
- 'assistant-mcp-initiator-only'
63
+ 'assistant-mcp-initiator-only',
64
+ 'assistant-file-forbidden'
59
65
  ],
60
66
  404: [
61
67
  'assistant-disabled',
@@ -79,7 +85,9 @@ const statusTable = {
79
85
  'assistant-not-found',
80
86
  'assistant-context-not-found',
81
87
  'assistant-mcp-server-not-found',
82
- 'assistant-mcp-tool-not-found'
88
+ 'assistant-mcp-tool-not-found',
89
+ 'assistant-files-unavailable',
90
+ 'assistant-file-not-found'
83
91
  ],
84
92
  409: [
85
93
  'assistant-conversation-busy',
@@ -123,10 +131,20 @@ const statusTable = {
123
131
  'assistant-mcp-issuer-changed',
124
132
  'assistant-mcp-authorization-not-confirmed',
125
133
  'assistant-mcp-connection-not-supported',
126
- 'assistant-mcp-secret-unreadable'
134
+ 'assistant-mcp-secret-unreadable',
135
+ 'assistant-files-not-accepted'
127
136
  ],
128
137
  413: [
129
- 'assistant-webhook-payload-too-large'
138
+ 'assistant-webhook-payload-too-large',
139
+ 'assistant-file-too-large'
140
+ ],
141
+ 415: [
142
+ 'assistant-file-type-not-supported',
143
+ 'assistant-file-type-not-accepted'
144
+ ],
145
+ 422: [
146
+ 'assistant-file-upload-refused',
147
+ 'assistant-file-scan-refused'
130
148
  ],
131
149
  429: [
132
150
  'assistant-mcp-too-many-authorizations'
@@ -142,7 +160,9 @@ const statusTable = {
142
160
  503: [
143
161
  'assistant-proposal-check-failed',
144
162
  'assistant-trigger-rolled-back',
145
- 'assistant-mcp-secret-unavailable'
163
+ 'assistant-mcp-secret-unavailable',
164
+ 'assistant-file-unavailable',
165
+ 'assistant-file-upload-failed'
146
166
  ]
147
167
  };
148
168
  const statuses = buildStatuses();
@@ -188,6 +208,16 @@ function mockValidationFailure(field) {
188
208
  return mockFailure('assistant-validation', field ? { [field]: ['invalid'] } : undefined);
189
209
  }
190
210
  /** A stable, non-cryptographic fingerprint; the mock only needs change detection. */
211
+ /**
212
+ * The warnings of an agent or skill that classifies its model calls with `classification` on
213
+ * `profile`: `classification-not-permitted` when the scenario lists what the profile and its
214
+ * fallbacks permit and that does not include it, as the server reports it; none without a
215
+ * classification of its own.
216
+ */
217
+ function mockClassificationWarnings(permitted, profile, classification) {
218
+ const allowed = profile ? permitted?.[profile] : undefined;
219
+ return classification && allowed && !allowed.includes(classification) ? ['classification-not-permitted'] : [];
220
+ }
191
221
  function mockHashText(text) {
192
222
  let hash = 0x811c9dc5;
193
223
  for (let index = 0; index < text.length; index++) {
@@ -333,7 +363,10 @@ function mockWorksOnCovers(agent, subjectType) {
333
363
  return agent.worksOnAllSubjects !== false || (agent.worksOnSubjectTypes ?? []).includes(subjectType);
334
364
  }
335
365
 
366
+ /** The profile of an agent that did not choose one, as the application's chat profile. */
367
+ const mockChatProfile = 'chat';
336
368
  const autonomyLevels = ['observe', 'explain', 'propose', 'simulate', 'execute'];
369
+ const dataClassifications$1 = ['public', 'internal', 'confidential', 'restricted'];
337
370
  /**
338
371
  * The agents of the mock administration: agents in code and agents created as data, their state,
339
372
  * shadow modes, technical accounts, feedback, evaluation and replays. The mock runs no agents on
@@ -367,7 +400,7 @@ class NhAssistantMockAdminAgents {
367
400
  skills: (skills ?? []).map((skill) => ({
368
401
  skillId: skill.skillId,
369
402
  description: skill.description,
370
- runState: 'active',
403
+ runState: skill.startsOff === true ? 'off' : 'active',
371
404
  changedAt: null,
372
405
  changedBy: null,
373
406
  reason: null,
@@ -468,9 +501,15 @@ class NhAssistantMockAdminAgents {
468
501
  if (typeof engaged !== 'boolean') {
469
502
  return mockValidationFailure();
470
503
  }
471
- const changed = this.agentStateEndpoint(stored, 'POST', { state: engaged ? 'stopped' : 'active' });
504
+ // Releasing never switches on an agent that was off, as on the server: one that was off when
505
+ // it stopped goes back off, and one that is off stays off.
506
+ const released = stored.agent.runState === 'off' || stored.offBeforeStop === true ? 'off' : 'active';
507
+ const changed = this.agentStateEndpoint(stored, 'POST', { state: engaged ? 'stopped' : released });
472
508
  return changed.status === 200
473
- ? { status: 200, body: { agentId: id, enabled: stored.agent.runState !== 'stopped', cancelledRuns: 0 } }
509
+ ? {
510
+ status: 200,
511
+ body: { agentId: id, enabled: stored.agent.runState !== 'stopped', cancelledRuns: 0, runState: stored.agent.runState }
512
+ }
474
513
  : changed;
475
514
  }
476
515
  if (length !== 3) {
@@ -511,7 +550,7 @@ class NhAssistantMockAdminAgents {
511
550
  return { status: 405 };
512
551
  }
513
552
  const request = body;
514
- if (request?.state !== 'active' && request?.state !== 'paused' && request?.state !== 'stopped') {
553
+ if (request?.state !== 'active' && request?.state !== 'paused' && request?.state !== 'stopped' && request?.state !== 'off') {
515
554
  return mockValidationFailure();
516
555
  }
517
556
  const reason = typeof request.reason === 'string' && request.reason.trim().length > 0 ? request.reason.trim() : null;
@@ -672,9 +711,9 @@ class NhAssistantMockAdminAgents {
672
711
  }
673
712
  /**
674
713
  * 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.
714
+ * server shows it, and off when it was added with `startsOff`; removing it takes its state along.
676
715
  */
677
- setDataSkill(agentId, skillId, description) {
716
+ setDataSkill(agentId, skillId, description, startsOff = false) {
678
717
  const stored = this.agents.get(agentId);
679
718
  if (!stored) {
680
719
  return;
@@ -688,9 +727,9 @@ class NhAssistantMockAdminAgents {
688
727
  : [...others, existing ? { ...existing, description } : {
689
728
  skillId,
690
729
  description,
691
- runState: 'active',
692
- changedAt: null,
693
- changedBy: null,
730
+ runState: startsOff ? 'off' : 'active',
731
+ changedAt: startsOff ? this.now() : null,
732
+ changedBy: startsOff ? 'current-user' : null,
694
733
  reason: null,
695
734
  waitingInputs: 0,
696
735
  toolCallsToday: 0,
@@ -766,7 +805,8 @@ class NhAssistantMockAdminAgents {
766
805
  }
767
806
  /**
768
807
  * `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.
808
+ * collects no waiting events, a stop cancels no runs and switching an agent off or on starts
809
+ * nothing; the version never changes.
770
810
  */
771
811
  agentStateEndpoint(stored, method, body) {
772
812
  if (method === 'GET') {
@@ -779,7 +819,7 @@ class NhAssistantMockAdminAgents {
779
819
  if (request?.state === undefined || request.state === null) {
780
820
  return mockValidationFailure();
781
821
  }
782
- if (request.state !== 'active' && request.state !== 'paused' && request.state !== 'stopped') {
822
+ if (request.state !== 'active' && request.state !== 'paused' && request.state !== 'stopped' && request.state !== 'off') {
783
823
  return mockValidationFailure();
784
824
  }
785
825
  const reason = typeof request.reason === 'string' && request.reason.trim().length > 0 ? request.reason.trim() : null;
@@ -792,6 +832,8 @@ class NhAssistantMockAdminAgents {
792
832
  : { status: 200, body: toAgentState(stored.agent) };
793
833
  }
794
834
  if (stored.agent.runState !== request.state) {
835
+ // A stop remembers whether the agent was off, for the kill switch; every other change forgets it.
836
+ stored.offBeforeStop = request.state === 'stopped' && stored.agent.runState === 'off';
795
837
  stored.agent = {
796
838
  ...stored.agent,
797
839
  isEnabled: request.state !== 'stopped',
@@ -821,6 +863,19 @@ class NhAssistantMockAdminAgents {
821
863
  return mockFailure('assistant-validation', errors);
822
864
  }
823
865
  }
866
+ // An agent created here uses a profile the run catalog offers, or keeps the one it has or the
867
+ // chat profile it gets by default; an agent in code takes its own.
868
+ const profile = typeof input?.profile === 'string' ? input.profile.trim() : '';
869
+ if (source === 'admin' && profile.length > 0 && profile !== mockChatProfile
870
+ && profile !== this.agents.get(input.id)?.agent.profile
871
+ && !this.rules.profileNames().includes(profile)) {
872
+ return mockFailure('assistant-validation', { profile: ['not-found'] });
873
+ }
874
+ // An agent created here names the classification of its model calls, as a data skill does.
875
+ const classification = chosenClassification(input.dataClassification);
876
+ if (source === 'admin' && classification !== null && !dataClassifications$1.includes(classification)) {
877
+ return mockFailure('assistant-validation', { dataClassification: ['invalid'] });
878
+ }
824
879
  return null;
825
880
  }
826
881
  validAgent(input) {
@@ -841,18 +896,28 @@ class NhAssistantMockAdminAgents {
841
896
  * `previous`, and a new agent starts active.
842
897
  */
843
898
  toAgent(input, source, version, isOverridden, previous) {
844
- const { isEnabled, ...definition } = structuredClone(input);
845
- return {
899
+ // Only a new agent starts off, created that way here or with StartsOff() in code.
900
+ const { isEnabled, startsOff, profile, dataClassification, ...definition } = structuredClone(input);
901
+ const createdOff = previous === undefined && startsOff === true;
902
+ const chosenProfile = source === 'admin' && typeof profile === 'string' && profile.trim().length > 0 ? profile.trim() : null;
903
+ const classification = chosenClassification(dataClassification);
904
+ return this.withWarnings({
846
905
  ...definition,
906
+ // An agent created here keeps its profile when the input omits it; code decides for the others.
907
+ profile: chosenProfile ?? previous?.profile ?? mockChatProfile,
908
+ // The same for its classification; an agent in code keeps the one of its scenario.
909
+ dataClassification: source === 'admin' || previous === undefined
910
+ ? classification ?? previous?.dataClassification ?? null
911
+ : previous.dataClassification ?? null,
847
912
  isEnabled: previous?.isEnabled ?? true,
848
913
  version,
849
914
  source,
850
915
  isOverridden,
851
916
  instructionsHash: mockHashText(input.instructions),
852
917
  updatedAt: this.now(),
853
- runState: previous?.runState ?? 'active',
854
- stateChangedAt: previous?.stateChangedAt ?? null,
855
- stateChangedBy: previous?.stateChangedBy ?? null,
918
+ runState: previous?.runState ?? (createdOff ? 'off' : 'active'),
919
+ stateChangedAt: previous?.stateChangedAt ?? (createdOff && source === 'admin' ? this.now() : null),
920
+ stateChangedBy: previous?.stateChangedBy ?? (createdOff && source === 'admin' ? 'current-user' : null),
856
921
  stateReason: previous?.stateReason ?? null,
857
922
  waitingInputs: previous?.waitingInputs ?? 0,
858
923
  skills: previous?.skills ?? [],
@@ -868,9 +933,20 @@ class NhAssistantMockAdminAgents {
868
933
  ? pickWorksOn(previous)
869
934
  : mockWorksOnFromInput(input) ?? (previous ? pickWorksOn(previous) : { ...mockUndeclaredWorksOn, worksOnDeclared: true })),
870
935
  knownSubjectTypes: previous?.knownSubjectTypes ?? []
936
+ });
937
+ }
938
+ /** The agent with the warnings the server reports for it, from its profile and classification. */
939
+ withWarnings(agent) {
940
+ return {
941
+ ...agent,
942
+ warnings: mockClassificationWarnings(this.scenario.admin?.profileClassifications, agent.profile, agent.dataClassification)
871
943
  };
872
944
  }
873
945
  }
946
+ /** A classification name the input chose, or `null` when it chose none. */
947
+ function chosenClassification(value) {
948
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
949
+ }
874
950
  function codeAgentFromSummary(agent) {
875
951
  return {
876
952
  id: agent.id,
@@ -949,6 +1025,17 @@ const maxUserName = 256;
949
1025
  const maxPolicy = 256;
950
1026
  const maxClientId = 512;
951
1027
  const maxAccountLabel = 200;
1028
+ /**
1029
+ * The readers of a server: at most 16, each with at most 16 media types, a tool name of at most 128
1030
+ * characters and a priority from -1000 to 1000.
1031
+ */
1032
+ const maxReaders = 16;
1033
+ const maxReaderMediaTypes = 16;
1034
+ const maxReaderToolName = 128;
1035
+ const readerPriority = { min: -1000, max: 1000 };
1036
+ const classifications = ['public', 'internal', 'confidential', 'restricted'];
1037
+ const mediaTypePattern$1 = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
1038
+ const controlCharacters = /[\u0000-\u001f\u007f]/;
952
1039
  /**
953
1040
  * The MCP servers of the mock administration and the caller's own connections: secrets are
954
1041
  * write-only, new tools start disabled as mutations, a changed remote schema disables a tool, OAuth
@@ -990,6 +1077,12 @@ class NhAssistantMockAdminMcp {
990
1077
  remoteTools: remoteTools ?? [],
991
1078
  tools: new Map()
992
1079
  };
1080
+ const readers = readersAfter(input, []);
1081
+ if (readers.length > 0) {
1082
+ // Preconfigured readers were set up after a sync of the server's tools.
1083
+ this.sync(stored);
1084
+ stored.server.readers = readers;
1085
+ }
993
1086
  if (oauthModes.includes(input.authMode)) {
994
1087
  // A preconfigured OAuth server was confirmed by an administrator before.
995
1088
  const found = this.discovery(input.url);
@@ -1240,7 +1333,8 @@ class NhAssistantMockAdminMcp {
1240
1333
  ...toServerFields(input, stored.server),
1241
1334
  ...oauthFields(input, stored.server, oauth.authorization),
1242
1335
  headlessUse,
1243
- transport: input.transport ?? stored.server.transport
1336
+ transport: input.transport ?? stored.server.transport,
1337
+ readers: readersAfter(input, stored.server.readers)
1244
1338
  };
1245
1339
  if (!oauthModes.includes(input.authMode)) {
1246
1340
  stored.oauthSecret = null;
@@ -1444,6 +1538,7 @@ class NhAssistantMockAdminMcp {
1444
1538
  checkServerFields(input, stored, this.scenario.admin?.policies, add, this.scenario.admin?.mcpAuthModes ?? []);
1445
1539
  this.checkCredential(input, stored, add);
1446
1540
  this.checkOAuth(input, stored, add);
1541
+ checkReaders(input, stored, add);
1447
1542
  return errors;
1448
1543
  }
1449
1544
  /**
@@ -1933,9 +2028,97 @@ function newServerState(input) {
1933
2028
  headlessUse: input.headlessUse ?? 'never',
1934
2029
  lastConnectAt: null,
1935
2030
  lastConnectCode: null,
1936
- transport: input.transport ?? 'streamable-http'
2031
+ transport: input.transport ?? 'streamable-http',
2032
+ // A new server has no synced tools, so it gets its readers after a sync.
2033
+ readers: []
1937
2034
  };
1938
2035
  }
2036
+ /** A media type in lowercase without parameters, or null for a value that is not one. */
2037
+ function normalizeMediaType$1(value) {
2038
+ if (typeof value !== 'string') {
2039
+ return null;
2040
+ }
2041
+ const essence = value.split(';')[0].trim().toLowerCase();
2042
+ return essence.length <= 128 && mediaTypePattern$1.test(essence) ? essence : null;
2043
+ }
2044
+ /**
2045
+ * The readers after a valid input, as on the server: omitted or null readers keep the stored ones,
2046
+ * an empty list removes them all, and the media types are stored normalized and once each. The
2047
+ * server returns them by priority, highest first, then by tool name.
2048
+ */
2049
+ function readersAfter(input, stored) {
2050
+ const readers = input.readers;
2051
+ if (readers === undefined || readers === null) {
2052
+ return stored;
2053
+ }
2054
+ return readers
2055
+ .map(reader => ({
2056
+ mediaTypes: [...new Set(reader.mediaTypes.map(normalizeMediaType$1).filter((mediaType) => mediaType !== null))],
2057
+ toolName: reader.toolName ?? '',
2058
+ maxClassification: reader.maxClassification ?? 'restricted',
2059
+ priority: reader.priority ?? 0
2060
+ }))
2061
+ .sort((left, right) => right.priority - left.priority || compareOrdinal(left.toolName, right.toolName));
2062
+ }
2063
+ /**
2064
+ * The field errors of the readers, as the server reports them: `readers` (too-long for more than
2065
+ * 16) and per reader, with its index: `readers[0].mediaTypes` (required, invalid, too-long for more
2066
+ * than 16), `readers[0].toolName` (required, invalid also for a second reader of the same tool,
2067
+ * not-found when the server has not synced that tool), `readers[0].maxClassification` (required,
2068
+ * invalid) and `readers[0].priority` (invalid outside -1000 to 1000).
2069
+ */
2070
+ function checkReaders(input, stored, add) {
2071
+ const readers = input.readers;
2072
+ if (!Array.isArray(readers) || readers.length === 0) {
2073
+ return;
2074
+ }
2075
+ if (readers.length > maxReaders) {
2076
+ add('readers', 'too-long');
2077
+ return;
2078
+ }
2079
+ const readTools = new Set();
2080
+ readers.forEach((reader, index) => {
2081
+ const field = (name) => `readers[${index}].${name}`;
2082
+ const mediaTypes = Array.isArray(reader?.mediaTypes) ? reader.mediaTypes.map(normalizeMediaType$1) : [];
2083
+ if (mediaTypes.length === 0) {
2084
+ add(field('mediaTypes'), 'required');
2085
+ }
2086
+ else if (mediaTypes.some(mediaType => mediaType === null)) {
2087
+ add(field('mediaTypes'), 'invalid');
2088
+ }
2089
+ else if (new Set(mediaTypes).size > maxReaderMediaTypes) {
2090
+ add(field('mediaTypes'), 'too-long');
2091
+ }
2092
+ const toolName = reader?.toolName;
2093
+ if (typeof toolName !== 'string' || toolName.trim().length === 0) {
2094
+ add(field('toolName'), 'required');
2095
+ }
2096
+ else if (toolName.length > maxReaderToolName || controlCharacters.test(toolName) || readTools.has(toolName)) {
2097
+ add(field('toolName'), 'invalid');
2098
+ }
2099
+ else if (!stored?.tools.has(toolName)) {
2100
+ add(field('toolName'), 'not-found');
2101
+ }
2102
+ if (typeof toolName === 'string') {
2103
+ readTools.add(toolName);
2104
+ }
2105
+ const classification = reader?.maxClassification;
2106
+ if (classification === null || classification === undefined) {
2107
+ add(field('maxClassification'), 'required');
2108
+ }
2109
+ else if (!classifications.includes(classification)) {
2110
+ add(field('maxClassification'), 'invalid');
2111
+ }
2112
+ const priority = reader?.priority ?? 0;
2113
+ if (!Number.isInteger(priority) || priority < readerPriority.min || priority > readerPriority.max) {
2114
+ add(field('priority'), 'invalid');
2115
+ }
2116
+ });
2117
+ }
2118
+ /** Compares two names by their characters, as the server orders them. */
2119
+ function compareOrdinal(left, right) {
2120
+ return left < right ? -1 : left > right ? 1 : 0;
2121
+ }
1939
2122
  /**
1940
2123
  * `organization` needs a credential of the organization; `personal` is the forwarded token of the
1941
2124
  * person a run acts for.
@@ -2032,12 +2215,21 @@ function kebab(value) {
2032
2215
  }
2033
2216
 
2034
2217
  const dashCase$4 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
2218
+ const dataClassifications = ['public', 'internal', 'confidential', 'restricted'];
2035
2219
  const accountabilities = ['agent', 'subject-responsible', 'service-account'];
2036
2220
  const conflicts = ['coalesce', 'queue', 'supersede', 'ignore'];
2037
2221
  const maxDescription$1 = 300;
2038
2222
  const maxInstructions = 20_000;
2039
2223
  const maxMessage$1 = 2_000;
2040
2224
  const maxReason$1 = 500;
2225
+ /** The platform's default file limits (`NhAssistantLimits`). */
2226
+ const defaultFileLimits = {
2227
+ maxFileBytes: 20 * 1024 * 1024,
2228
+ maxFilePages: 100,
2229
+ maxFilesPerTurn: 5,
2230
+ maxFileTokensPerTurn: 100_000
2231
+ };
2232
+ const fileLimitFields = ['maxFileBytes', 'maxFilePages', 'maxFilesPerTurn', 'maxFileTokensPerTurn'];
2041
2233
  /**
2042
2234
  * In-memory run configuration as data of the mock API: run settings of agents created in the
2043
2235
  * administration, skills defined as data with versions and candidates, and data triggers. Saving
@@ -2072,13 +2264,16 @@ class NhAssistantMockRunConfiguration {
2072
2264
  })),
2073
2265
  accountSearch: accounts.searchable,
2074
2266
  agentPolicies: scenario.runCatalog?.agentPolicies ?? [],
2075
- divisions: scenario.runCatalog?.divisions ?? false
2267
+ divisions: scenario.runCatalog?.divisions ?? false,
2268
+ // The platform's default file limits, unless the scenario's application chose its own.
2269
+ fileLimits: scenario.runCatalog?.fileLimits ?? defaultFileLimits
2076
2270
  };
2077
2271
  this.codeTriggers = (scenario.codeTriggers ?? []).map(trigger => ({
2078
2272
  ...trigger,
2079
2273
  skillId: trigger.skillId ?? null,
2080
2274
  subjectType: trigger.subjectType ?? null
2081
2275
  }));
2276
+ this.profileClassifications = scenario.profileClassifications;
2082
2277
  this.codeRunSettings = new Map((scenario.agents ?? [])
2083
2278
  .filter(agent => agent.source === 'code')
2084
2279
  .map(agent => [agent.id, agent.codeRunSettings ?? {}]));
@@ -2091,6 +2286,10 @@ class NhAssistantMockRunConfiguration {
2091
2286
  get divisionsListed() {
2092
2287
  return this.catalog.divisions === true;
2093
2288
  }
2289
+ /** The profiles the run catalog offers to data skills and to agents created in the administration. */
2290
+ get profileNames() {
2291
+ return [...this.catalog.profiles];
2292
+ }
2094
2293
  /** `GET admin/run-catalog`. */
2095
2294
  runCatalog(method) {
2096
2295
  return method === 'GET' ? { status: 200, body: this.catalog } : { status: 405 };
@@ -2164,7 +2363,9 @@ class NhAssistantMockRunConfiguration {
2164
2363
  accountability: given.accountability ?? (agent.serviceAccount ? 'service-account' : 'agent'),
2165
2364
  delegateFromInitiator: given.delegateFromInitiator ?? false,
2166
2365
  approvalPolicy: given.approvalPolicy ?? null,
2167
- usesRules: given.usesRules ?? false
2366
+ usesRules: given.usesRules ?? false,
2367
+ startsOff: given.startsOff ?? false,
2368
+ acceptsFiles: given.acceptsFiles ?? false
2168
2369
  };
2169
2370
  }
2170
2371
  /**
@@ -2227,6 +2428,7 @@ class NhAssistantMockRunConfiguration {
2227
2428
  this.requireKnown(errors, 'approvalPolicy', request.approvalPolicy, this.catalog.approvalPolicies);
2228
2429
  this.requireKnown(errors, 'triageProfile', request.triageProfile, this.catalog.profiles);
2229
2430
  this.requireKnown(errors, 'ruleHandler', request.ruleHandler, this.catalog.ruleHandlers.map(handler => handler.name));
2431
+ this.requireFileLimits(errors, request.acceptsFiles ?? false, request);
2230
2432
  if (Object.keys(errors).length > 0) {
2231
2433
  return mockFailure('assistant-validation', errors);
2232
2434
  }
@@ -2256,7 +2458,9 @@ class NhAssistantMockRunConfiguration {
2256
2458
  changedBy: 'current-user',
2257
2459
  reason: text(request.reason),
2258
2460
  inEffect: true,
2259
- problem: null
2461
+ problem: null,
2462
+ acceptsFiles: request.acceptsFiles ?? false,
2463
+ ...storedFileLimits(request)
2260
2464
  };
2261
2465
  this.settings.set(agent.id, saved);
2262
2466
  return { status: 200, body: saved };
@@ -2316,9 +2520,18 @@ class NhAssistantMockRunConfiguration {
2316
2520
  if (request.apply === 'shadow' && (request.expectedVersion ?? null) === null) {
2317
2521
  errors['apply'] = ['invalid'];
2318
2522
  }
2523
+ // Only a new skill starts off; a skill that exists keeps its state.
2524
+ if (request.startsOff === true && (request.expectedVersion ?? null) !== null) {
2525
+ errors['startsOff'] = ['invalid'];
2526
+ }
2527
+ const classification = text(request.dataClassification);
2528
+ if (classification !== null && !dataClassifications.includes(classification)) {
2529
+ errors['dataClassification'] = ['invalid'];
2530
+ }
2319
2531
  this.requireKnown(errors, 'profile', request.profile, this.catalog.profiles);
2320
2532
  this.requireKnown(errors, 'ruleHandler', request.ruleHandler, this.catalog.ruleHandlers.map(handler => handler.name));
2321
2533
  this.requireKnown(errors, 'approvalPolicy', request.approvalPolicy, this.catalog.approvalPolicies);
2534
+ this.requireFileLimits(errors, request.acceptsFiles ?? false, request);
2322
2535
  if (Object.keys(errors).length > 0) {
2323
2536
  return mockFailure('assistant-validation', errors);
2324
2537
  }
@@ -2344,6 +2557,9 @@ class NhAssistantMockRunConfiguration {
2344
2557
  instructionsHash: instructions === null ? null : hashText(instructions),
2345
2558
  tools: [...new Set(request.tools ?? [])],
2346
2559
  profile: text(request.profile),
2560
+ dataClassification: classification,
2561
+ acceptsFiles: request.acceptsFiles ?? false,
2562
+ ...storedFileLimits(request),
2347
2563
  ruleHandler: text(request.ruleHandler),
2348
2564
  maxTokensPerRun: request.maxTokensPerRun ?? null,
2349
2565
  dailyToolCalls: request.dailyToolCalls ?? null,
@@ -2357,9 +2573,10 @@ class NhAssistantMockRunConfiguration {
2357
2573
  };
2358
2574
  this.skills.set(key$1(agent.id, skillId), [...versions, saved]);
2359
2575
  if (!candidate) {
2360
- this.agents.setDataSkill(agent.id, skillId, saved.description);
2576
+ this.agents.setDataSkill(agent.id, skillId, saved.description, live === undefined && request.startsOff === true);
2361
2577
  }
2362
- return { status: 200, body: toSkill(saved) };
2578
+ // A classification that no profile permits never refuses the save; the skill reports it.
2579
+ return { status: 200, body: this.withWarnings(agent, toSkill(saved)) };
2363
2580
  }
2364
2581
  candidate(method, agent, skillId, action, body) {
2365
2582
  const versions = this.skills.get(key$1(agent.id, skillId)) ?? [];
@@ -2403,7 +2620,7 @@ class NhAssistantMockRunConfiguration {
2403
2620
  }
2404
2621
  candidate.status = 'live';
2405
2622
  this.agents.setDataSkill(agent.id, skillId, candidate.description);
2406
- return { status: 200, body: toSkill(candidate) };
2623
+ return { status: 200, body: this.withWarnings(agent, toSkill(candidate)) };
2407
2624
  }
2408
2625
  versions(agentId, skillId) {
2409
2626
  return [...(this.skills.get(key$1(agentId, skillId)) ?? [])]
@@ -2547,6 +2764,7 @@ class NhAssistantMockRunConfiguration {
2547
2764
  }
2548
2765
  liveSkills(agentId) {
2549
2766
  const result = [];
2767
+ const agent = this.agents.find(agentId);
2550
2768
  for (const [stored, versions] of this.skills) {
2551
2769
  if (!stored.startsWith(`${agentId}/`)) {
2552
2770
  continue;
@@ -2554,11 +2772,24 @@ class NhAssistantMockRunConfiguration {
2554
2772
  const live = versions.find(version => version.status === 'live');
2555
2773
  if (live) {
2556
2774
  const candidate = versions.find(version => version.status === 'candidate');
2557
- result.push({ ...toSkill(live), candidate: candidate ? toSkill(candidate) : null });
2775
+ result.push({
2776
+ ...this.withWarnings(agent, toSkill(live)),
2777
+ candidate: candidate ? this.withWarnings(agent, toSkill(candidate)) : null
2778
+ });
2558
2779
  }
2559
2780
  }
2560
2781
  return result.sort((left, right) => left.skillId.localeCompare(right.skillId));
2561
2782
  }
2783
+ /**
2784
+ * The skill with the warnings the server reports for it: its classification, or else its agent's,
2785
+ * that its profile, or else its agent's, does not permit.
2786
+ */
2787
+ withWarnings(agent, skill) {
2788
+ return {
2789
+ ...skill,
2790
+ warnings: mockClassificationWarnings(this.profileClassifications, skill.profile ?? agent?.profile, skill.dataClassification ?? agent?.dataClassification)
2791
+ };
2792
+ }
2562
2793
  ownerOf(trigger) {
2563
2794
  return trigger[ownerKey];
2564
2795
  }
@@ -2574,6 +2805,28 @@ class NhAssistantMockRunConfiguration {
2574
2805
  errors[field] = ['not-found'];
2575
2806
  }
2576
2807
  }
2808
+ /**
2809
+ * Lower file limits as the server checks them: each a whole number from 1 to the application's
2810
+ * limit, and only next to `acceptsFiles`.
2811
+ */
2812
+ requireFileLimits(errors, acceptsFiles, request) {
2813
+ const application = this.catalog.fileLimits ?? defaultFileLimits;
2814
+ for (const field of fileLimitFields) {
2815
+ const value = request[field];
2816
+ if (value !== null && value !== undefined && (!acceptsFiles || !inRange(value, 1, application[field]))) {
2817
+ errors[field] = ['invalid'];
2818
+ }
2819
+ }
2820
+ }
2821
+ }
2822
+ /** The lower file limits a save stores, `null` where the request keeps the application's. */
2823
+ function storedFileLimits(request) {
2824
+ return {
2825
+ maxFileBytes: request.maxFileBytes ?? null,
2826
+ maxFilePages: request.maxFilePages ?? null,
2827
+ maxFilesPerTurn: request.maxFilesPerTurn ?? null,
2828
+ maxFileTokensPerTurn: request.maxFileTokensPerTurn ?? null
2829
+ };
2577
2830
  }
2578
2831
  const ownerKey = '__agentId';
2579
2832
  function key$1(agentId, skillId) {
@@ -2649,12 +2902,13 @@ class NhAssistantMockAdmin {
2649
2902
  this.agents = new NhAssistantMockAdminAgents(scenario, now, {
2650
2903
  hasServer: serverId => this.mcp.hasServer(serverId),
2651
2904
  agentPolicyNames: () => this.runConfiguration.agentPolicyNames,
2652
- divisionsListed: () => this.runConfiguration.divisionsListed
2905
+ divisionsListed: () => this.runConfiguration.divisionsListed,
2906
+ profileNames: () => this.runConfiguration.profileNames
2653
2907
  });
2654
2908
  this.accounts = new NhAssistantMockAccounts(admin);
2655
2909
  this.runConfiguration = new NhAssistantMockRunConfiguration(admin, {
2656
2910
  find: agentId => this.agents.find(agentId),
2657
- setDataSkill: (agentId, skillId, description) => this.agents.setDataSkill(agentId, skillId, description)
2911
+ setDataSkill: (agentId, skillId, description, startsOff) => this.agents.setDataSkill(agentId, skillId, description, startsOff)
2658
2912
  }, now, this.accounts);
2659
2913
  this.mcp = new NhAssistantMockAdminMcp(scenario, now, {
2660
2914
  agents: () => this.agents.all(),
@@ -2859,6 +3113,159 @@ function toContext(version) {
2859
3113
  return { text: version.text, version: version.version, hash: version.hash, updatedAt: version.updatedAt, updatedBy: version.updatedBy };
2860
3114
  }
2861
3115
 
3116
+ /** The longest file name a reference takes, as on the server. */
3117
+ const maxNameLength = 256;
3118
+ const mediaTypePattern = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
3119
+ /** How files of the platform's types start; other accepted types are the application's to check. */
3120
+ const signatures = {
3121
+ 'application/pdf': head => indexOf(head, [0x25, 0x50, 0x44, 0x46, 0x2d]) >= 0,
3122
+ 'image/png': head => startsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
3123
+ 'image/jpeg': head => startsWith(head, [0xff, 0xd8, 0xff]),
3124
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': head => startsWith(head, [0x50, 0x4b, 0x03, 0x04]),
3125
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': head => startsWith(head, [0x50, 0x4b, 0x03, 0x04])
3126
+ };
3127
+ /**
3128
+ * The files of the mock API: `POST conversations/{id}/files` with the server's checks, and the
3129
+ * attachments of a message, which a turn opens again as the server's file source would.
3130
+ */
3131
+ class NhAssistantMockFiles {
3132
+ constructor(refuse, scan) {
3133
+ this.refuse = refuse;
3134
+ this.scan = scan;
3135
+ this.stored = new Map();
3136
+ this.next = 0;
3137
+ }
3138
+ /** The uploads the mock stored, oldest first. */
3139
+ get uploads() {
3140
+ return [...this.stored.values()];
3141
+ }
3142
+ /**
3143
+ * Checks an upload as the server does, in its order: a file source, an agent that accepts files,
3144
+ * the upload header, one file part named `file` with a name and content, an accepted media type
3145
+ * whose content matches, the agent's largest size, the application's scanner, and finally the
3146
+ * application's own refusal.
3147
+ */
3148
+ async upload(conversationId, offered, uploads, headers, form) {
3149
+ if (!offered) {
3150
+ return mockFailure('assistant-files-unavailable');
3151
+ }
3152
+ if (!uploads) {
3153
+ return mockFailure('assistant-files-not-accepted');
3154
+ }
3155
+ const header = Object.entries(headers).find(([name]) => name.toLowerCase() === NH_ASSISTANT_UPLOAD_HEADER.toLowerCase());
3156
+ let parts = 0;
3157
+ form.forEach(() => parts++);
3158
+ const file = form.get('file');
3159
+ if (!header?.[1] || parts !== 1 || !(file instanceof Blob)) {
3160
+ return mockFailure('assistant-file-upload-invalid');
3161
+ }
3162
+ const name = nameOf(file instanceof File ? file.name : '');
3163
+ if (name === null || file.size === 0) {
3164
+ return mockFailure('assistant-file-upload-invalid');
3165
+ }
3166
+ const mediaType = normalizeMediaType(file.type);
3167
+ if (mediaType === null || !uploads.acceptedMediaTypes.includes(mediaType)) {
3168
+ return mockFailure('assistant-file-type-not-accepted');
3169
+ }
3170
+ if (file.size > uploads.maxFileBytes) {
3171
+ return mockFailure('assistant-file-too-large');
3172
+ }
3173
+ const head = new Uint8Array(await file.slice(0, 1024).arrayBuffer());
3174
+ if (!(signatures[mediaType]?.(head) ?? true)) {
3175
+ return mockFailure('assistant-file-type-not-accepted');
3176
+ }
3177
+ const upload = { conversationId, name, mediaType, size: file.size };
3178
+ // The application's scanner sees the file before its file source stores anything.
3179
+ const scanned = this.scan?.(upload) ?? null;
3180
+ if (scanned) {
3181
+ return mockFailure(scanned === 'refused' ? 'assistant-file-scan-refused' : 'assistant-file-upload-failed');
3182
+ }
3183
+ const refusal = this.refuse?.(upload) ?? null;
3184
+ if (refusal) {
3185
+ return mockFailure(refusalCode(refusal));
3186
+ }
3187
+ this.next++;
3188
+ const reference = { id: `mock-file-${this.next}`, name, mediaType, size: file.size, hash: null };
3189
+ this.stored.set(reference.id, { ...upload, reference });
3190
+ return { status: 200, body: reference };
3191
+ }
3192
+ /**
3193
+ * The code the server refuses a message's `files` with before the turn starts, or `null`: without
3194
+ * a file source, more files than one message carries, or a reference that is not one.
3195
+ */
3196
+ checkMessage(files, offered, maxFiles) {
3197
+ if (files === undefined || files === null || (Array.isArray(files) && files.length === 0)) {
3198
+ return null;
3199
+ }
3200
+ if (!Array.isArray(files)) {
3201
+ return 'assistant-file-reference-invalid';
3202
+ }
3203
+ if (!offered) {
3204
+ return 'assistant-files-unavailable';
3205
+ }
3206
+ if (files.length > maxFiles) {
3207
+ return 'assistant-file-count-limit';
3208
+ }
3209
+ return files.every(isReference) ? null : 'assistant-file-reference-invalid';
3210
+ }
3211
+ /**
3212
+ * The code of the first attachment the turn cannot open, as the server's file source refuses it,
3213
+ * or `null` when it opens them all. A reference the mock never stored, or one of another
3214
+ * conversation, is not found.
3215
+ */
3216
+ openCode(conversationId, files) {
3217
+ const missing = files.find(file => this.stored.get(file.id)?.conversationId !== conversationId);
3218
+ return missing ? 'assistant-file-not-found' : null;
3219
+ }
3220
+ }
3221
+ /** The file name without a path; `null` when nothing usable is left, as on the server. */
3222
+ function nameOf(fileName) {
3223
+ const name = fileName.slice(Math.max(fileName.lastIndexOf('/'), fileName.lastIndexOf('\\')) + 1).trim();
3224
+ return name.length === 0 || name.length > maxNameLength || /[\u0000-\u001f\u007f]/.test(name) ? null : name;
3225
+ }
3226
+ function normalizeMediaType(value) {
3227
+ const essence = value.split(';')[0].trim().toLowerCase();
3228
+ return essence.length <= 128 && mediaTypePattern.test(essence) ? essence : null;
3229
+ }
3230
+ function isReference(value) {
3231
+ const file = value;
3232
+ return !!file
3233
+ && isText(file.id)
3234
+ && isText(file.name)
3235
+ && typeof file.mediaType === 'string' && normalizeMediaType(file.mediaType) !== null
3236
+ && typeof file.size === 'number' && file.size >= 0;
3237
+ }
3238
+ function isText(value) {
3239
+ return typeof value === 'string' && value.trim().length > 0 && value.length <= maxNameLength && !/[\u0000-\u001f\u007f]/.test(value);
3240
+ }
3241
+ /** The application's refusal as the server passes it on. */
3242
+ function refusalCode(code) {
3243
+ switch (code) {
3244
+ case 'assistant-files-unavailable':
3245
+ case 'assistant-file-too-large':
3246
+ return code;
3247
+ case 'assistant-file-type-not-accepted':
3248
+ case 'assistant-file-type-not-supported':
3249
+ return 'assistant-file-type-not-accepted';
3250
+ case 'assistant-file-upload-failed':
3251
+ case 'assistant-file-unavailable':
3252
+ return 'assistant-file-upload-failed';
3253
+ default:
3254
+ return 'assistant-file-upload-refused';
3255
+ }
3256
+ }
3257
+ function startsWith(head, signature) {
3258
+ return head.length >= signature.length && signature.every((byte, index) => head[index] === byte);
3259
+ }
3260
+ function indexOf(head, signature) {
3261
+ for (let start = 0; start + signature.length <= head.length; start++) {
3262
+ if (signature.every((byte, index) => head[start + index] === byte)) {
3263
+ return start;
3264
+ }
3265
+ }
3266
+ return -1;
3267
+ }
3268
+
2862
3269
  const dashCase$3 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
2863
3270
  const minQueryLength = 2;
2864
3271
  const maxQueryLength = 100;
@@ -3829,9 +4236,9 @@ class NhAssistantMockAgentRuns {
3829
4236
  const picked = localized ? mockPickText({ ...localized }, '', language) : null;
3830
4237
  return picked ?? this.results.get(runId) ?? null;
3831
4238
  }
3832
- /** Neither stopped nor disabled in code; a paused agent still works for people. */
4239
+ /** Neither stopped nor disabled in code; a paused agent, and one that is off, still works for people. */
3833
4240
  isWorking(agent) {
3834
- return agent.runState === 'active' || agent.runState === 'paused';
4241
+ return agent.runState === 'active' || agent.runState === 'paused' || agent.runState === 'off';
3835
4242
  }
3836
4243
  mayUse(agent) {
3837
4244
  return agent.requiredPolicy === null || agent.requiredPolicy === undefined || this.passes(agent.requiredPolicy);
@@ -4557,6 +4964,7 @@ class NhAssistantMockBackend {
4557
4964
  this.shareTokens = new Map();
4558
4965
  this.activeActors = new Map();
4559
4966
  this.liveSubject = new Subject();
4967
+ this.files = new NhAssistantMockFiles(this.scenario.refuseUpload, this.scenario.scanUpload);
4560
4968
  this.pushEnabled = true;
4561
4969
  this.sequence = 0;
4562
4970
  /** Requests received so far, oldest first. */
@@ -4570,12 +4978,45 @@ class NhAssistantMockBackend {
4570
4978
  * provides it as `NH_ASSISTANT_MCP_AUTHORIZATION_WINDOW`.
4571
4979
  */
4572
4980
  this.authorizationWindow = new NhAssistantMockAuthorizationWindow(flowId => this.admin.authorizeFlow(flowId));
4981
+ /**
4982
+ * An upload implementation (`NH_ASSISTANT_UPLOAD`) that answers `POST conversations/{id}/files` with
4983
+ * the server's checks, after reporting the progress in four steps.
4984
+ */
4985
+ this.upload = async (request) => {
4986
+ const path = this.pathOf(request.url);
4987
+ const file = request.body.get('file');
4988
+ this.requests.push({
4989
+ method: 'POST',
4990
+ path: path ?? request.url,
4991
+ headers: { ...request.headers },
4992
+ body: file instanceof Blob ? { name: file instanceof File ? file.name : null, mediaType: file.type, size: file.size } : undefined
4993
+ });
4994
+ const total = file instanceof Blob ? file.size : 0;
4995
+ for (const share of [0.25, 0.5, 0.75, 1]) {
4996
+ if (request.signal.aborted) {
4997
+ throw new DOMException('The operation was aborted.', 'AbortError');
4998
+ }
4999
+ await delay(this.scenario.timing?.eventDelayMs ?? 40);
5000
+ request.onProgress(Math.round(total * share), total);
5001
+ }
5002
+ if (request.signal.aborted) {
5003
+ throw new DOMException('The operation was aborted.', 'AbortError');
5004
+ }
5005
+ const segments = path?.split('/').map(segment => decodeURIComponent(segment)) ?? [];
5006
+ if (!this.enabledState() || segments.length !== 3 || segments[0] !== 'conversations' || segments[2] !== 'files') {
5007
+ return this.empty(404);
5008
+ }
5009
+ const conversation = this.conversations.get(segments[1]);
5010
+ if (!conversation) {
5011
+ return this.error('assistant-conversation-not-found');
5012
+ }
5013
+ return this.result(await this.files.upload(conversation.id, this.offersFiles(), this.uploadsOf(conversation.agentId), request.headers, request.body));
5014
+ };
4573
5015
  /** A `fetch` implementation that answers the assistant endpoints below `apiBaseUrl`. */
4574
5016
  this.fetch = async (input, init) => {
4575
5017
  const method = (init.method ?? 'GET').toUpperCase();
4576
5018
  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;
5019
+ const path = this.pathOf(input);
4579
5020
  const body = typeof init.body === 'string' && init.body.length > 0 ? JSON.parse(init.body) : undefined;
4580
5021
  this.requests.push({ method, path: path ?? url.pathname, headers: { ...init.headers }, body });
4581
5022
  if (init.signal?.aborted) {
@@ -4654,6 +5095,10 @@ class NhAssistantMockBackend {
4654
5095
  setMcpConnectionStatus(serverId, status) {
4655
5096
  this.admin.setMcpConnectionStatus(serverId, status);
4656
5097
  }
5098
+ /** The files people uploaded, oldest first, as the application's file source stored them. */
5099
+ get uploads() {
5100
+ return this.files.uploads;
5101
+ }
4657
5102
  route(method, path, query, body, language = null) {
4658
5103
  const segments = path.split('/').map(segment => decodeURIComponent(segment));
4659
5104
  if (method === 'GET' && path === 'status') {
@@ -4831,6 +5276,12 @@ class NhAssistantMockBackend {
4831
5276
  if (text.length === 0 || text.length > limit || !request.clientMessageId) {
4832
5277
  return this.empty(400);
4833
5278
  }
5279
+ // Like the server: the attachments are checked before the turn claims the conversation.
5280
+ const uploads = this.uploadsOf(conversation.agentId);
5281
+ const filesCode = this.files.checkMessage(request.files, this.offersFiles(), uploads?.maxFiles ?? 5);
5282
+ if (filesCode) {
5283
+ return this.error(filesCode);
5284
+ }
4834
5285
  if (!acceptsMessage(conversation.status)) {
4835
5286
  return this.empty(409);
4836
5287
  }
@@ -4840,6 +5291,14 @@ class NhAssistantMockBackend {
4840
5291
  const turn = this.findTurn(text, conversation.agentId, pageContext);
4841
5292
  const userMessageId = this.nextId();
4842
5293
  const assistantMessageId = this.nextId();
5294
+ const files = request.files ?? [];
5295
+ // The turn opens the attachments with the sender's rights; it ends with the first refusal and the
5296
+ // message keeps only what the model read.
5297
+ const refusal = files.length === 0 ? null : uploads ? this.files.openCode(conversation.id, files) : 'assistant-files-not-accepted';
5298
+ const parts = [
5299
+ { type: 'text', text },
5300
+ ...(refusal ? [] : files.map(file => ({ type: 'file', name: file.name, mediaType: file.mediaType, size: file.size })))
5301
+ ];
4843
5302
  this.activeActors.set(conversation.id, this.currentUser.actorId);
4844
5303
  this.update(conversation.id, current => ({
4845
5304
  ...current,
@@ -4847,14 +5306,17 @@ class NhAssistantMockBackend {
4847
5306
  status: 'running',
4848
5307
  messages: [
4849
5308
  ...current.messages,
4850
- { id: userMessageId, role: 'user', createdAt: new Date().toISOString(), parts: [{ type: 'text', text }], authorActorId: this.currentUser.actorId }
5309
+ { id: userMessageId, role: 'user', createdAt: new Date().toISOString(), parts, authorActorId: this.currentUser.actorId }
4851
5310
  ]
4852
5311
  }));
4853
5312
  // The sender has read their own message.
4854
5313
  this.readPositions.set(conversation.id, lastSequence(this.conversations.get(conversation.id)));
4855
5314
  return this.stream(conversation.id, async (emit) => {
4856
5315
  await emit({ type: 'turn.started', data: { turnId: this.nextId(), userMessageId, assistantMessageId } });
4857
- await this.playSteps(conversation.id, assistantMessageId, turn?.steps ?? [], emit);
5316
+ const steps = refusal
5317
+ ? [{ error: { code: refusal, messageKey: `nh-assistant.errors.${refusal}` } }]
5318
+ : turn?.steps ?? [];
5319
+ await this.playSteps(conversation.id, assistantMessageId, steps, emit);
4858
5320
  });
4859
5321
  }
4860
5322
  decide(conversation, approvalId, request) {
@@ -5218,6 +5680,22 @@ class NhAssistantMockBackend {
5218
5680
  error(code) {
5219
5681
  return this.result(mockFailure(code));
5220
5682
  }
5683
+ /** The path below `apiBaseUrl`, or `null` for another URL. */
5684
+ pathOf(input) {
5685
+ const url = new URL(input, 'http://mock.local');
5686
+ const base = new URL(this.config.apiBaseUrl.replace(/\/+$/, ''), 'http://mock.local').pathname;
5687
+ return url.pathname.startsWith(`${base}/`) ? url.pathname.slice(base.length + 1) : null;
5688
+ }
5689
+ /** Whether the application keeps files for the assistant: an agent of the scenario takes uploads. */
5690
+ offersFiles() {
5691
+ return this.scenario.agents.some(agent => !!agent.uploads);
5692
+ }
5693
+ /** What people may attach to a message to the agent, as its summary says. */
5694
+ uploadsOf(agentId) {
5695
+ return this.admin.chatAgents().find(agent => agent.id === agentId)?.uploads
5696
+ ?? this.scenario.agents.find(agent => agent.id === agentId)?.uploads
5697
+ ?? null;
5698
+ }
5221
5699
  /**
5222
5700
  * Whether the caller decides the agent's proposals of the whole application: an approver of the whole
5223
5701
  * application (an administrator in the mock) who passes the agent's view policy, by default its
@@ -5381,15 +5859,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.34", ngImpo
5381
5859
  * Replaces the assistant transport with an in-memory back-end that plays `script`.
5382
5860
  * Register it after `provideNhAssistant(...)` in the same injector. Every client feature
5383
5861
  * (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.
5862
+ * state, live updates, uploads with their progress and MCP authorizations) then works without a
5863
+ * server. Inject `NhAssistantMockBackend` to switch the feature flag, simulate a colleague or
5864
+ * inspect the received requests and uploads.
5387
5865
  */
5388
5866
  function provideNhAssistantMockApi(script) {
5389
5867
  return makeEnvironmentProviders([
5390
5868
  { provide: NH_ASSISTANT_MOCK_SCENARIO, useValue: script },
5391
5869
  NhAssistantMockBackend,
5392
5870
  { provide: NH_ASSISTANT_FETCH, useFactory: () => inject(NhAssistantMockBackend).fetch },
5871
+ { provide: NH_ASSISTANT_UPLOAD, useFactory: () => inject(NhAssistantMockBackend).upload },
5393
5872
  { provide: NhAssistantLiveService, useClass: NhAssistantMockLiveService },
5394
5873
  { provide: NH_ASSISTANT_MCP_AUTHORIZATION_WINDOW, useFactory: () => inject(NhAssistantMockBackend).authorizationWindow }
5395
5874
  ]);