@newheap/platform-ai-chat 0.20.0 → 0.22.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_AGENT_PACKAGE_MAX_AGENTS, NH_ASSISTANT_AGENT_PACKAGE_FORMAT_VERSION, NH_ASSISTANT_AGENT_PACKAGE_FORMAT, NH_ASSISTANT_PACKAGE_LOCK_ALL, NH_ASSISTANT_PACKAGE_LOCK_FIELDS, NH_ASSISTANT_AGENT_PACKAGE_MAX_BYTES, NH_ASSISTANT_UPLOAD_HEADER, ɵnhAssistantMediaTypeOf as _nhAssistantMediaTypeOf, NH_ASSISTANT_MAX_PROPOSAL_LOOKUP, NH_ASSISTANT_CONFIG, applyNhAssistantEvent, normalizeNhAssistantClientContext, applyNhAssistantApprovalDecision, applyNhAssistantQuestionAnswer, NH_ASSISTANT_FETCH, NH_ASSISTANT_UPLOAD, NhAssistantLiveService, NH_ASSISTANT_MCP_AUTHORIZATION_WINDOW } from '@newheap/platform-ai-chat';
3
+ import { NH_ASSISTANT_AGENT_PACKAGE_FORMAT_VERSION, NH_ASSISTANT_AGENT_PACKAGE_FORMAT, NH_ASSISTANT_AGENT_PACKAGE_MAX_AGENTS, NH_ASSISTANT_PACKAGE_LOCK_ALL, NH_ASSISTANT_PACKAGE_LOCK_FIELDS, NH_ASSISTANT_AGENT_PACKAGE_MAX_BYTES, NH_ASSISTANT_UPLOAD_HEADER, ɵnhAssistantMediaTypeOf as _nhAssistantMediaTypeOf, NH_ASSISTANT_MAX_PROPOSAL_LOOKUP, NH_ASSISTANT_CONFIG, applyNhAssistantEvent, normalizeNhAssistantClientContext, applyNhAssistantApprovalDecision, applyNhAssistantQuestionAnswer, NH_ASSISTANT_FETCH, NH_ASSISTANT_UPLOAD, NhAssistantLiveService, NH_ASSISTANT_MCP_AUTHORIZATION_WINDOW } from '@newheap/platform-ai-chat';
4
4
  import { Observable, Subject } from 'rxjs';
5
5
 
6
6
  /**
@@ -65,7 +65,8 @@ const statusTable = {
65
65
  'assistant-forbidden',
66
66
  'assistant-mcp-credential-mismatch',
67
67
  'assistant-mcp-initiator-only',
68
- 'assistant-file-forbidden'
68
+ 'assistant-file-forbidden',
69
+ 'assistant-not-allowed-while-impersonating'
69
70
  ],
70
71
  404: [
71
72
  'assistant-disabled',
@@ -399,6 +400,10 @@ function mockWorksOnErrors(worksOn, knownTypes, agentPolicies, divisions) {
399
400
  }
400
401
  /** Whether an agent works on subjects of the type, as the server's rule decides it. */
401
402
  function mockWorksOnCovers(agent, subjectType) {
403
+ // A chat-only agent (`ChatOnly()` in code) works on no subject at all.
404
+ if (agent.chatOnly === true) {
405
+ return false;
406
+ }
402
407
  if (subjectType === mockSystemSubjectType) {
403
408
  return agent.worksOnApplication === true;
404
409
  }
@@ -418,6 +423,8 @@ const consultationRoutes = [
418
423
  const mockChatProfile = 'chat';
419
424
  const autonomyLevels = ['observe', 'explain', 'propose', 'simulate', 'execute'];
420
425
  const dataClassifications$1 = ['public', 'internal', 'confidential', 'restricted'];
426
+ /** The most MCP servers one agent is assigned to, as the server allows. */
427
+ const maxServersPerAgent = 32;
421
428
  /**
422
429
  * The agents of the mock administration: agents in code and agents created as data, their state,
423
430
  * shadow modes, technical accounts, feedback, evaluation and replays. The mock runs no agents on
@@ -434,18 +441,24 @@ class NhAssistantMockAdminAgents {
434
441
  const agents = admin.agents
435
442
  ?? scenario.agents.map(agent => ({ ...codeAgentFromSummary(agent), source: 'code' }));
436
443
  // How an agent runs, in code or as data, belongs to its run configuration, not to the agent.
437
- for (const { source, skills, shadowOf, shadow, serviceAccount, worksOnDeclared, knownSubjectTypes, consultations, consultationDescriptions, codeRunSettings: _, runConfiguration: __, consultableAsData: ___, dataConsultations: ____, consultationTools: _____, codeHandOffs: ______, ...input } of agents) {
444
+ for (const { source, skills, shadowOf, shadow, serviceAccount, worksOnDeclared, knownSubjectTypes, consultations, consultationDescriptions, chatOnly, codeRunSettings: _, runConfiguration: __, consultableAsData: ___, dataConsultations: ____, consultationTools: _____, codeHandOffs: ______, ...input } of agents) {
438
445
  const variant = typeof shadowOf === 'string';
439
446
  const agentShadow = variant || shadow === true;
447
+ // A chat-only agent has no shadow threads: it and its skills always work live.
448
+ const live = source === 'code' && chatOnly === true;
440
449
  const initial = this.toAgent(input, source, 1, false);
441
450
  this.agents.set(input.id, {
442
451
  agent: {
443
452
  ...initial,
444
453
  // Code says what its agents work on; a scenario agent that says nothing works on all objects.
445
454
  ...(source === 'code' ? { ...(mockWorksOnFromInput(input) ?? mockUndeclaredWorksOn), worksOnDeclared: worksOnDeclared ?? mockWorksOnFromInput(input) !== null } : {}),
455
+ // A chat-only agent in code (`ChatOnly()`) works on nothing, said on purpose.
456
+ ...(source === 'code' && chatOnly === true
457
+ ? { chatOnly: true, worksOnSubjectTypes: [], worksOnAllSubjects: false, worksOnApplication: false, perDivision: false, worksOnDeclared: true }
458
+ : {}),
446
459
  knownSubjectTypes: knownSubjectTypes ?? [],
447
460
  shadowOf: shadowOf ?? null,
448
- shadow: shadowState(agentShadow, variant || shadow === true, null, variant),
461
+ shadow: shadowState(agentShadow, variant || shadow === true, null, variant || live),
449
462
  serviceAccount: serviceAccount === undefined
450
463
  ? null
451
464
  : { actorId: serviceAccount, configuredActorId: serviceAccount, overridden: false, changedAt: null, changedBy: null, reason: null },
@@ -463,7 +476,7 @@ class NhAssistantMockAdminAgents {
463
476
  dailyTokens: null,
464
477
  cancelledRuns: 0,
465
478
  startedRuns: 0,
466
- shadow: shadowState(agentShadow || skill.shadow === true, skill.shadow === true, null, variant)
479
+ shadow: shadowState(!live && (agentShadow || skill.shadow === true), skill.shadow === true, null, variant || live)
467
480
  }))
468
481
  },
469
482
  codeDefinition: source === 'code' ? structuredClone(input) : null,
@@ -543,6 +556,29 @@ class NhAssistantMockAdminAgents {
543
556
  live.agent = this.toAgent(definition, live.agent.source, live.agent.version + 1, live.agent.source === 'code', live.agent);
544
557
  return live.agent;
545
558
  }
559
+ /**
560
+ * Assigns an MCP server to an agent or removes the assignment, as `PUT` and
561
+ * `DELETE admin/mcp-servers/{id}/agents/{agentId}` do: only the assignment changes, never the agent's
562
+ * version; an agent has at most 32 servers.
563
+ */
564
+ setMcpAssignment(agentId, serverId, assign) {
565
+ const stored = this.agents.get(agentId);
566
+ if (!stored) {
567
+ return 'agent-not-found';
568
+ }
569
+ const assigned = stored.agent.mcpServerIds.includes(serverId);
570
+ if (assigned === assign) {
571
+ return 'unchanged';
572
+ }
573
+ if (assign && stored.agent.mcpServerIds.length >= maxServersPerAgent) {
574
+ return 'too-long';
575
+ }
576
+ stored.agent = {
577
+ ...stored.agent,
578
+ mcpServerIds: assign ? [...stored.agent.mcpServerIds, serverId] : stored.agent.mcpServerIds.filter(id => id !== serverId)
579
+ };
580
+ return 'changed';
581
+ }
546
582
  /** Removes a deleted MCP server from the agents it was assigned to. */
547
583
  removeServer(serverId) {
548
584
  for (const agent of this.agents.values()) {
@@ -600,7 +636,7 @@ class NhAssistantMockAdminAgents {
600
636
  if (refused) {
601
637
  return refused;
602
638
  }
603
- const created = this.toAgent(input, 'admin', 1, false);
639
+ const created = this.toAgent({ ...input, mcpServerIds: input.mcpServerIds ?? [] }, 'admin', 1, false);
604
640
  // A data shadow variant always works in shadow and is never offered in the chat.
605
641
  const agent = shadowOf === null
606
642
  ? created
@@ -660,7 +696,8 @@ class NhAssistantMockAdminAgents {
660
696
  return mockFailure('assistant-version-conflict');
661
697
  }
662
698
  const { expectedVersion, ...definition } = input;
663
- const updated = this.toAgent(definition, stored.agent.source, stored.agent.version + 1, stored.agent.source === 'code', stored.agent);
699
+ // An omitted list keeps the agent's assignments; an empty list removes them.
700
+ const updated = this.toAgent({ ...definition, mcpServerIds: definition.mcpServerIds ?? stored.agent.mcpServerIds }, stored.agent.source, stored.agent.version + 1, stored.agent.source === 'code', stored.agent);
664
701
  // A data shadow variant keeps its live agent and stays out of the chat.
665
702
  stored.agent = stored.agent.source === 'admin' && stored.agent.shadowOf ? { ...updated, offeredInChat: false } : updated;
666
703
  return { status: 200, body: stored.agent };
@@ -714,6 +751,10 @@ class NhAssistantMockAdminAgents {
714
751
  if (request === null) {
715
752
  return mockValidationFailure();
716
753
  }
754
+ // The skills of a chat-only agent always work live.
755
+ if (stored.agent.chatOnly === true) {
756
+ return mockFailure('assistant-agent-does-not-work-on');
757
+ }
717
758
  const agentShadow = stored.agent.shadow ?? shadowState(false, false, null, false);
718
759
  if (agentShadow.locked) {
719
760
  return mockFailure('assistant-shadow-variant');
@@ -888,6 +929,10 @@ class NhAssistantMockAdminAgents {
888
929
  if (request === null) {
889
930
  return mockValidationFailure();
890
931
  }
932
+ // A chat-only agent has no runs that could work in shadow threads.
933
+ if (stored.agent.chatOnly === true) {
934
+ return mockFailure('assistant-agent-does-not-work-on');
935
+ }
891
936
  const current = stored.agent.shadow ?? shadowState(false, false, null, false);
892
937
  if (current.locked) {
893
938
  return mockFailure('assistant-shadow-variant');
@@ -1128,7 +1173,9 @@ class NhAssistantMockAdminAgents {
1128
1173
  typeof input.description === 'string' &&
1129
1174
  typeof input.instructions === 'string' && input.instructions.length <= mockMaxInstructions &&
1130
1175
  Array.isArray(input.toolSelectors) && input.toolSelectors.every(selector => /^[a-z0-9*][a-z0-9.*-]*$/.test(selector)) &&
1131
- Array.isArray(input.mcpServerIds) && input.mcpServerIds.every(serverId => this.rules.hasServer(serverId)) &&
1176
+ // Omitted, a new agent has no servers and an update keeps them, as on the server.
1177
+ (input.mcpServerIds === undefined || input.mcpServerIds === null ||
1178
+ (Array.isArray(input.mcpServerIds) && input.mcpServerIds.every(serverId => this.rules.hasServer(serverId)))) &&
1132
1179
  (input.requiredPolicy === null || (typeof input.requiredPolicy === 'string' && (!policies || policies.includes(input.requiredPolicy)))) &&
1133
1180
  autonomyLevels.includes(input.autonomy) &&
1134
1181
  (input.isEnabled === undefined || typeof input.isEnabled === 'boolean');
@@ -1151,6 +1198,8 @@ class NhAssistantMockAdminAgents {
1151
1198
  const classification = chosenClassification(dataClassification);
1152
1199
  return this.withWarnings({
1153
1200
  ...definition,
1201
+ // The callers name the servers; an omitted list keeps those of the agent, or none.
1202
+ mcpServerIds: definition.mcpServerIds ?? previous?.mcpServerIds ?? [],
1154
1203
  // An agent created here keeps its profile when the input omits it; code decides for the others.
1155
1204
  profile: chosenProfile ?? previous?.profile ?? mockChatProfile,
1156
1205
  // The same for its classification; an agent in code keeps the one of its scenario.
@@ -1317,6 +1366,9 @@ const readerPriority = { min: -1000, max: 1000 };
1317
1366
  const classifications$1 = ['public', 'internal', 'confidential', 'restricted'];
1318
1367
  const mediaTypePattern$1 = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
1319
1368
  const controlCharacters = /[\u0000-\u001f\u007f]/;
1369
+ /** The longest remote tool name a sync stores, and the most tools of a server that wait for a sync. */
1370
+ const maxRemoteName = 128;
1371
+ const maxToolsAwaitingSync = 100;
1320
1372
  /**
1321
1373
  * The MCP servers of the mock administration and the caller's own connections: secrets are
1322
1374
  * write-only, new tools start disabled as mutations, a changed remote schema disables a tool, OAuth
@@ -1636,6 +1688,10 @@ class NhAssistantMockAdminMcp {
1636
1688
  stored.organization?.status === 'issuer-changed') {
1637
1689
  stored.organization = { ...stored.organization, status: 'active' };
1638
1690
  }
1691
+ if (!keepsSecret(before, input)) {
1692
+ // As on the server: a stored secret never follows the server to another destination.
1693
+ stored.secret = null;
1694
+ }
1639
1695
  if (!secretModes.includes(input.authMode)) {
1640
1696
  stored.secret = null;
1641
1697
  stored.server.secretReference = null;
@@ -1744,19 +1800,20 @@ class NhAssistantMockAdminMcp {
1744
1800
  if (length === 5 && action === 'tools' && method === 'PUT' && child !== undefined) {
1745
1801
  const tool = stored.tools.get(child);
1746
1802
  const input = body;
1747
- if (!tool) {
1748
- return mockFailure('assistant-mcp-tool-not-found');
1749
- }
1750
1803
  if (typeof input?.isEnabled !== 'boolean' || (input.effect !== 'read-only' && input.effect !== 'mutation') ||
1751
1804
  (input.descriptionOverride !== null && typeof input.descriptionOverride !== 'string')) {
1752
1805
  return mockValidationFailure();
1753
1806
  }
1754
- if (input.isEnabled && tool.status === 'missing') {
1755
- return mockValidationFailure('isEnabled');
1756
- }
1757
1807
  const override = typeof input.descriptionOverride === 'string' && input.descriptionOverride.trim().length > 0
1758
1808
  ? input.descriptionOverride.slice(0, 1_000)
1759
1809
  : null;
1810
+ if (!tool) {
1811
+ return this.reviewBeforeSync(stored, child, input.isEnabled, input.effect, override);
1812
+ }
1813
+ // A tool the server no longer lists cannot be enabled; one that no sync has seen yet can.
1814
+ if (input.isEnabled && tool.status === 'missing' && !tool.awaitingSync) {
1815
+ return mockFailure('assistant-mcp-tool-disabled');
1816
+ }
1760
1817
  const updated = {
1761
1818
  ...tool,
1762
1819
  isEnabled: input.isEnabled,
@@ -1767,14 +1824,77 @@ class NhAssistantMockAdminMcp {
1767
1824
  stored.tools.set(child, updated);
1768
1825
  return { status: 200, body: toTool(updated) };
1769
1826
  }
1827
+ if (length === 5 && action === 'agents' && child !== undefined) {
1828
+ if (method !== 'PUT' && method !== 'DELETE') {
1829
+ return { status: 405 };
1830
+ }
1831
+ // Only the assignment changes, never the agent's version, as on the server.
1832
+ const changed = this.context.setAssignment(stored.server.id, child, method === 'PUT');
1833
+ if (changed === 'agent-not-found') {
1834
+ return mockFailure('assistant-agent-not-found');
1835
+ }
1836
+ if (changed === 'too-long') {
1837
+ return mockFailure('assistant-validation', { mcpServerIds: ['too-long'] });
1838
+ }
1839
+ return { status: 200, body: this.toServer(stored) };
1840
+ }
1770
1841
  return mockFailure('assistant-not-found');
1771
1842
  }
1843
+ /**
1844
+ * The reviewed settings of a tool that no sync has seen yet, as the server stores them: a missing tool
1845
+ * without a schema that waits for its sync, with a name a sync could store and no other spelling of
1846
+ * a stored tool's name; at most 100 per server.
1847
+ */
1848
+ reviewBeforeSync(stored, remoteName, isEnabled, effect, descriptionOverride) {
1849
+ if (remoteName.trim().length === 0) {
1850
+ return mockFailure('assistant-validation', { remoteName: ['required'] });
1851
+ }
1852
+ if (remoteName.length > maxRemoteName) {
1853
+ return mockFailure('assistant-validation', { remoteName: ['too-long'] });
1854
+ }
1855
+ const lower = remoteName.toLowerCase();
1856
+ if (controlCharacters.test(remoteName) || [...stored.tools.keys()].some(name => name.toLowerCase() === lower)) {
1857
+ return mockFailure('assistant-validation', { remoteName: ['invalid'] });
1858
+ }
1859
+ if ([...stored.tools.values()].filter(tool => tool.awaitingSync).length >= maxToolsAwaitingSync) {
1860
+ return mockFailure('assistant-configuration-limit');
1861
+ }
1862
+ const tool = {
1863
+ remoteName,
1864
+ localId: `mcp.${stored.server.id}.${kebab(remoteName)}`,
1865
+ description: '',
1866
+ descriptionOverride,
1867
+ isEnabled,
1868
+ effect,
1869
+ status: 'missing',
1870
+ readOnlyHint: null,
1871
+ awaitingSync: true,
1872
+ inputSchemaHash: ''
1873
+ };
1874
+ stored.tools.set(remoteName, tool);
1875
+ return { status: 200, body: toTool(tool) };
1876
+ }
1772
1877
  sync(stored) {
1773
1878
  const seen = new Set();
1774
1879
  for (const remote of stored.remoteTools) {
1775
1880
  seen.add(remote.remoteName);
1776
- const existing = stored.tools.get(remote.remoteName);
1881
+ const existing = stored.tools.get(remote.remoteName) ?? this.reviewedInOtherSpelling(stored, remote.remoteName);
1777
1882
  const description = remote.description.slice(0, 1_000);
1883
+ if (existing?.awaitingSync) {
1884
+ // The first sync that sees a reviewed tool keeps its settings and takes the server's spelling.
1885
+ stored.tools.delete(existing.remoteName);
1886
+ stored.tools.set(remote.remoteName, {
1887
+ ...existing,
1888
+ remoteName: remote.remoteName,
1889
+ localId: `mcp.${stored.server.id}.${kebab(remote.remoteName)}`,
1890
+ description,
1891
+ status: 'available',
1892
+ readOnlyHint: remote.readOnlyHint ?? null,
1893
+ awaitingSync: false,
1894
+ inputSchemaHash: remote.inputSchemaHash
1895
+ });
1896
+ continue;
1897
+ }
1778
1898
  if (!existing) {
1779
1899
  stored.tools.set(remote.remoteName, {
1780
1900
  remoteName: remote.remoteName,
@@ -1800,11 +1920,19 @@ class NhAssistantMockAdminMcp {
1800
1920
  });
1801
1921
  }
1802
1922
  for (const [name, tool] of stored.tools) {
1803
- if (!seen.has(name)) {
1923
+ // A reviewed tool that this sync does not see keeps waiting with its settings.
1924
+ if (!seen.has(name) && !tool.awaitingSync) {
1804
1925
  stored.tools.set(name, { ...tool, isEnabled: false, status: 'missing' });
1805
1926
  }
1806
1927
  }
1807
1928
  }
1929
+ /** A reviewed tool that waits for its sync under another spelling of a name no remote tool has. */
1930
+ reviewedInOtherSpelling(stored, remoteName) {
1931
+ const lower = remoteName.toLowerCase();
1932
+ const names = new Set(stored.remoteTools.map(remote => remote.remoteName));
1933
+ return [...stored.tools.values()]
1934
+ .find(tool => tool.awaitingSync && !names.has(tool.remoteName) && tool.remoteName.toLowerCase() === lower);
1935
+ }
1808
1936
  /**
1809
1937
  * The field errors of a server change, as the server reports them in one refusal: the fields
1810
1938
  * themselves, the credential and the OAuth settings. Empty when the change is valid.
@@ -2124,17 +2252,20 @@ class NhAssistantMockAdminMcp {
2124
2252
  if (input.secret && input.secretReference) {
2125
2253
  add('secretReference', 'invalid');
2126
2254
  }
2255
+ // As on the server: a stored secret and reference serve only the destination they were given for.
2256
+ const keeps = keepsSecret(stored?.server, input);
2127
2257
  const reference = input.secretReference === undefined || input.secretReference === null
2128
- ? (input.secret ? null : stored?.server.secretReference ?? null)
2258
+ ? (input.secret || !keeps ? null : stored?.server.secretReference ?? null)
2129
2259
  : input.secretReference || null;
2130
2260
  if (reference !== null && (!secretReference.test(reference) || reference.includes('..'))) {
2131
2261
  add('secretReference', 'invalid');
2132
2262
  }
2133
2263
  if (secretModes.includes(input.authMode)) {
2134
2264
  // As on the server: bearer, api-key and basic need a stored secret or a reference. A new stored
2135
- // secret replaces a reference, a new reference a stored secret.
2265
+ // secret replaces a reference, a new reference a stored secret, and another destination needs
2266
+ // either again.
2136
2267
  const storedSecret = input.secret === undefined || input.secret === null
2137
- ? !!stored?.secret && !input.secretReference
2268
+ ? keeps && !!stored?.secret && !input.secretReference
2138
2269
  : input.secret.length > 0;
2139
2270
  if (!storedSecret && (reference === null || !!input.secret)) {
2140
2271
  add('secret', 'required');
@@ -2208,10 +2339,32 @@ class NhAssistantMockAdminMcp {
2208
2339
  };
2209
2340
  }
2210
2341
  }
2342
+ /**
2343
+ * Whether a stored secret and reference stay with the server after the input, as on the server: the
2344
+ * URL keeps its origin (scheme, host and port), and the authentication mode and the `api-key` header,
2345
+ * in any letter case, stay the same.
2346
+ */
2347
+ function keepsSecret(stored, input) {
2348
+ const before = stored ? originOf(stored.url) : null;
2349
+ if (!stored || before === null || stored.authMode !== input.authMode || before !== originOf(input.url)) {
2350
+ return false;
2351
+ }
2352
+ return input.authMode !== 'api-key'
2353
+ || (stored.headerName || 'X-Api-Key').toLowerCase() === (input.headerName || 'X-Api-Key').toLowerCase();
2354
+ }
2355
+ /** The origin of an absolute URL in lowercase, or `null` for an invalid one. */
2356
+ function originOf(url) {
2357
+ try {
2358
+ return new URL(url.trim()).origin.toLowerCase();
2359
+ }
2360
+ catch {
2361
+ return null;
2362
+ }
2363
+ }
2211
2364
  function toServerFields(input, stored) {
2212
2365
  const usesSecret = secretModes.includes(input.authMode);
2213
2366
  const reference = input.secretReference === undefined || input.secretReference === null
2214
- ? (input.secret ? null : stored?.secretReference ?? null)
2367
+ ? (input.secret || !keepsSecret(stored, input) ? null : stored?.secretReference ?? null)
2215
2368
  : input.secretReference || null;
2216
2369
  const certificate = input.clientCertificateReference === undefined || input.clientCertificateReference === null
2217
2370
  ? stored?.clientCertificateReference ?? null
@@ -2482,7 +2635,7 @@ function allowedServerUrl(value) {
2482
2635
  }
2483
2636
  }
2484
2637
  function toTool({ inputSchemaHash, ...tool }) {
2485
- return tool;
2638
+ return { ...tool, awaitingSync: tool.awaitingSync === true };
2486
2639
  }
2487
2640
  function toTools(stored) {
2488
2641
  return [...stored.tools.values()].map(toTool);
@@ -2789,7 +2942,9 @@ class NhAssistantMockHandOffs {
2789
2942
  ...data.map(agent => {
2790
2943
  const hasRunSettings = this.agents.isConfigured(agent);
2791
2944
  return {
2792
- agentId: agent.id, displayName: agent.displayName, source: 'data', hasRunSettings, inEffect: hasRunSettings, problem: null, version: agent.version
2945
+ agentId: agent.id, displayName: agent.displayName, source: 'data', hasRunSettings, inEffect: hasRunSettings, problem: null, version: agent.version,
2946
+ packageId: null,
2947
+ packageVersion: null
2793
2948
  };
2794
2949
  })
2795
2950
  ]
@@ -4435,6 +4590,7 @@ class NhAssistantMockAdmin {
4435
4590
  agents: () => this.agents.all(),
4436
4591
  chatAgentIds: () => this.agents.chatAgents().map(agent => agent.id),
4437
4592
  removeServer: serverId => this.agents.removeServer(serverId),
4593
+ setAssignment: (serverId, agentId, assign) => this.agents.setMcpAssignment(agentId, serverId, assign),
4438
4594
  canAdminister: () => this.canAdminister
4439
4595
  });
4440
4596
  this.presetRunConfigurations(admin.agents ?? []);
@@ -4866,8 +5022,139 @@ class NhAssistantMockAgentPackages {
4866
5022
  if (segments[1] === 'agents' && segments[3] === 'package' && segments[4] === 'unlink' && segments.length === 5) {
4867
5023
  return method === 'POST' ? this.unlink(segments[2], body) : { status: 405 };
4868
5024
  }
5025
+ // A promotion as the package's version is an import of the variant's parts onto its live agent.
5026
+ if (segments[1] === 'agents' && segments[3] === 'promote' && segments.length === 4
5027
+ && body?.asPackageVersion === true) {
5028
+ return method === 'POST' ? this.promoteAsPackageVersion(segments[2], body) : { status: 405 };
5029
+ }
4869
5030
  return null;
4870
5031
  }
5032
+ /**
5033
+ * `POST admin/agents/{id}/promote` with `asPackageVersion` (FB11): a data shadow variant whose every
5034
+ * part is linked to one package becomes that package's next version of its live agent. Its parts,
5035
+ * as an export reads them, are imported onto the live agent with the import's rules and relinked;
5036
+ * the live agent keeps its name and chat offer, an agent in code its run settings in code. The
5037
+ * variant leaves first, with its links, so its trigger ids are free, and the live agent leaves the
5038
+ * package it belonged to; a refused part puts everything back and answers the import's
5039
+ * `409 assistant-package-refused`. A part without a link answers `404 assistant-package-not-linked`.
5040
+ */
5041
+ promoteAsPackageVersion(variantId, body) {
5042
+ const listed = this.context.call('GET', 'admin/agents');
5043
+ if (listed.status !== 200) {
5044
+ return listed;
5045
+ }
5046
+ const request = (body ?? {});
5047
+ if (typeof request.expectedVersion !== 'number') {
5048
+ return mockFailure('assistant-validation', { expectedVersion: ['required'] });
5049
+ }
5050
+ if (request.moveSkills === true) {
5051
+ return mockFailure('assistant-validation', { moveSkills: ['not-allowed'] });
5052
+ }
5053
+ const variantRead = this.read(variantId);
5054
+ if (!variantRead) {
5055
+ return mockFailure('assistant-agent-not-found');
5056
+ }
5057
+ const variant = variantRead.agent;
5058
+ if (variant.source !== 'admin' || !variant.shadowOf) {
5059
+ return mockFailure('assistant-not-shadow-variant');
5060
+ }
5061
+ const liveRead = this.read(variant.shadowOf);
5062
+ if (!liveRead) {
5063
+ return mockFailure('assistant-agent-not-found');
5064
+ }
5065
+ const live = liveRead.agent;
5066
+ if (request.expectedVersion !== live.version
5067
+ || (typeof request.expectedVariantVersion === 'number' && request.expectedVariantVersion !== variant.version)) {
5068
+ return mockFailure('assistant-version-conflict');
5069
+ }
5070
+ // Every part of the variant is linked to its package.
5071
+ const link = this.links.get(variantId);
5072
+ const parts = this.packageAgentOf(variantRead);
5073
+ if (!link || [...partsOf(parts).keys()].some(part => !link.parts.has(part))) {
5074
+ return mockFailure('assistant-package-not-linked');
5075
+ }
5076
+ const inCode = live.source === 'code';
5077
+ const carried = parts.definition;
5078
+ const definition = !carried
5079
+ ? null
5080
+ : inCode
5081
+ ? {
5082
+ description: carried.description,
5083
+ instructions: carried.instructions,
5084
+ toolSelectors: carried.toolSelectors,
5085
+ requiredPolicy: carried.requiredPolicy,
5086
+ autonomy: carried.autonomy
5087
+ }
5088
+ : { ...carried, displayName: live.displayName, offeredInChat: live.offeredInChat !== false, shadowOf: null, startsOff: null, startsInShadow: null };
5089
+ const locks = Object.fromEntries([...link.parts]
5090
+ .filter(([part, partLink]) => partLink.lockedFields.length > 0 && (part !== 'runSettings' || !inCode))
5091
+ .map(([part, partLink]) => [part, [...partLink.lockedFields]]));
5092
+ const agent = withoutNulls({
5093
+ ...parts,
5094
+ id: live.id,
5095
+ source: live.source,
5096
+ definition,
5097
+ runSettings: inCode ? null : parts.runSettings,
5098
+ groups: null,
5099
+ hashes: null,
5100
+ locks: Object.keys(locks).length > 0 ? locks : null
5101
+ });
5102
+ const agentPackage = {
5103
+ format: NH_ASSISTANT_AGENT_PACKAGE_FORMAT,
5104
+ formatVersion: NH_ASSISTANT_AGENT_PACKAGE_FORMAT_VERSION,
5105
+ package: { id: link.packageId, version: link.packageVersion },
5106
+ agents: [agent]
5107
+ };
5108
+ // The live agent's technical account stays; without one it takes the variant's.
5109
+ const account = liveRead.configuration?.runSettings?.serviceAccountActorId
5110
+ ?? variantRead.configuration?.runSettings?.serviceAccountActorId
5111
+ ?? null;
5112
+ // All of it or nothing: the variant leaves first, and the package's other agents stay linked.
5113
+ const restore = [this.context.snapshot(), this.snapshot()];
5114
+ const others = [...this.links].filter(([agentId, item]) => item.packageId === link.packageId && agentId !== variantId);
5115
+ this.links.delete(variantId);
5116
+ const deleted = this.context.call('DELETE', `admin/agents/${encodeURIComponent(variantId)}`);
5117
+ if (deleted.status >= 300) {
5118
+ restore.forEach(put => put());
5119
+ return deleted;
5120
+ }
5121
+ for (const [agentId] of others) {
5122
+ this.links.delete(agentId);
5123
+ }
5124
+ this.links.delete(live.id);
5125
+ for (const installed of this.installed.values()) {
5126
+ installed.agents = installed.agents.filter(agentId => agentId !== live.id && agentId !== variantId);
5127
+ }
5128
+ const imported = this.importPackage({
5129
+ package: agentPackage,
5130
+ options: { relink: [{ agentId: live.id }], serviceAccounts: account ? { [live.id]: account } : null }
5131
+ }, true);
5132
+ if (imported.status !== 200) {
5133
+ restore.forEach(put => put());
5134
+ return imported;
5135
+ }
5136
+ for (const [agentId, item] of others) {
5137
+ this.links.set(agentId, item);
5138
+ }
5139
+ this.installed.set(link.packageId, {
5140
+ version: link.packageVersion,
5141
+ agents: [...others.map(([agentId]) => agentId), live.id].sort()
5142
+ });
5143
+ const result = imported.body;
5144
+ const promoted = this.read(live.id).agent;
5145
+ return {
5146
+ status: 200,
5147
+ body: {
5148
+ agent: this.withAgentPackage(promoted),
5149
+ variantId,
5150
+ movedSkills: result.parts
5151
+ .filter(part => part.part.startsWith('skill:') && part.outcome !== 'refused' && part.outcome !== 'left-behind' && part.outcome !== 'kept-local')
5152
+ .map(part => part.part.slice('skill:'.length))
5153
+ .sort(),
5154
+ package: result
5155
+ }
5156
+ };
5157
+ }
4871
5158
  /**
4872
5159
  * Runs a save of the administration under the package locks: a save may keep a locked field's value
4873
5160
  * or take the package's, never another; such a change is undone and refused with `{field}: locked`.
@@ -4975,6 +5262,22 @@ class NhAssistantMockAgentPackages {
4975
5262
  return { ...result, body: this.withLink(agentId, `metadata:${id}`, body) };
4976
5263
  }
4977
5264
  return { ...result, body: body.map(entry => this.withLink(agentId, `metadata:${entry.name}`, entry)) };
5265
+ case 'shadow-variants': {
5266
+ // A data variant names the package its parts are linked to.
5267
+ const listing = body;
5268
+ return {
5269
+ ...result,
5270
+ body: {
5271
+ ...listing,
5272
+ variants: listing.variants.map(variant => {
5273
+ const link = this.links.get(variant.agentId);
5274
+ return variant.source === 'data' && link && link.parts.size > 0
5275
+ ? { ...variant, packageId: link.packageId, packageVersion: link.packageVersion }
5276
+ : variant;
5277
+ })
5278
+ }
5279
+ };
5280
+ }
4978
5281
  default:
4979
5282
  return result;
4980
5283
  }
@@ -6951,8 +7254,9 @@ class NhAssistantMockAgentRuns {
6951
7254
  }
6952
7255
  /**
6953
7256
  * `POST runs/{id}/retry`: resumes a failed or cancelled run (`queued`), or restarts the agent in the
6954
- * same thread like a start by hand; needs the approver role on the thread. The mock's resumed
6955
- * attempt ends at once as the scenario's `runNow` says.
7257
+ * same thread like a start by hand; needs the approver role on the thread, and a restart also passes
7258
+ * the start checks of `POST threads/start`. The mock's resumed attempt ends at once as the
7259
+ * scenario's `runNow` says.
6956
7260
  */
6957
7261
  retry(runId, body, language) {
6958
7262
  const mode = body?.mode ?? 'resume';
@@ -6978,7 +7282,17 @@ class NhAssistantMockAgentRuns {
6978
7282
  return mockFailure('assistant-agent-not-found');
6979
7283
  }
6980
7284
  if (mode === 'restart') {
6981
- return this.startNow(agent, found.thread.subject);
7285
+ // A restart starts the agent again for the caller: the rule of a start by hand decides, with the
7286
+ // caller's live role.
7287
+ const subject = this.subjects.describe(found.thread.subject.subjectType, found.thread.subject.subjectId, language, this.context.administers());
7288
+ if (!subject) {
7289
+ return mockFailure('assistant-subject-access-denied');
7290
+ }
7291
+ const refusal = this.refusal(agent, subject);
7292
+ if (refusal) {
7293
+ return mockFailure(refusal);
7294
+ }
7295
+ return this.startNow(agent, subject);
6982
7296
  }
6983
7297
  const runNow = this.scenario.runNow?.[agent.id] ?? {};
6984
7298
  const status = runNow.status ?? 'completed';
@@ -7964,7 +8278,8 @@ class NhAssistantMockProposals {
7964
8278
  parent: item.parent ?? null,
7965
8279
  canGiveFeedback: false,
7966
8280
  consultationId: item.consultationId ?? null,
7967
- consultationRoute: route
8281
+ consultationRoute: route,
8282
+ ...(item.supersededByApprovalId ? { supersededByApprovalId: item.supersededByApprovalId } : {})
7968
8283
  },
7969
8284
  divisionId: item.divisionId ?? null,
7970
8285
  canDecide,
@@ -8830,7 +9145,10 @@ class NhAssistantMockBackend {
8830
9145
  if (filesCode) {
8831
9146
  return this.error(filesCode);
8832
9147
  }
8833
- if (!acceptsMessage(conversation.status)) {
9148
+ // Like the server: a question or an approval that passed its lifetime closes as expired, and the
9149
+ // message starts its turn (CS9).
9150
+ this.closeExpiredWait(conversation);
9151
+ if (!acceptsMessage(this.conversations.get(conversation.id)?.status ?? conversation.status)) {
8834
9152
  return this.empty(409);
8835
9153
  }
8836
9154
  // Like the server: an invalid page context is dropped, never a 400.
@@ -9005,6 +9323,28 @@ class NhAssistantMockBackend {
9005
9323
  await this.playSteps(conversation.id, assistantMessageId, steps, emit);
9006
9324
  });
9007
9325
  }
9326
+ /**
9327
+ * Closes the question or the approval a chat waits for once its lifetime passed, as a late answer or
9328
+ * decision closes it on the server; one that is still valid keeps the chat busy. Agent threads keep
9329
+ * their own rules.
9330
+ */
9331
+ closeExpiredWait(conversation) {
9332
+ if (conversation.status !== 'waiting-for-approval' || conversation.kind === 'thread') {
9333
+ return;
9334
+ }
9335
+ const now = Date.now();
9336
+ const question = this.pendingQuestions.get(conversation.id);
9337
+ if (question && Date.parse(question.question.expiresAt) <= now) {
9338
+ this.pendingQuestions.delete(conversation.id);
9339
+ this.update(conversation.id, current => withClosedQuestion(current, question.question.questionId, 'expired'));
9340
+ return;
9341
+ }
9342
+ const pending = this.pendingApprovals.get(conversation.id);
9343
+ if (pending && Date.parse(pending.approval.expiresAt) <= now) {
9344
+ this.pendingApprovals.delete(conversation.id);
9345
+ this.update(conversation.id, current => withExpiredApproval(current, pending.approval.approvalId, pending.invocationId));
9346
+ }
9347
+ }
9008
9348
  cancel(conversation) {
9009
9349
  const run = this.runs.get(conversation.id);
9010
9350
  if (run) {
@@ -9589,6 +9929,27 @@ function withClosedQuestion(conversation, questionId, status) {
9589
9929
  messages: conversation.messages.map(message => ({ ...message, parts: message.parts.map(closed) }))
9590
9930
  };
9591
9931
  }
9932
+ /**
9933
+ * An approval that passed its lifetime, as the server closes it when the next message arrives: it
9934
+ * expired, its call failed with `assistant-approval-expired`, and the conversation takes the message.
9935
+ */
9936
+ function withExpiredApproval(conversation, approvalId, invocationId) {
9937
+ const expired = (part) => {
9938
+ if (part.type === 'approval' && part.approvalId === approvalId) {
9939
+ return { ...part, status: 'expired' };
9940
+ }
9941
+ if (part.type === 'tool-call' && part.invocationId === invocationId) {
9942
+ return { ...part, status: 'failed', resultCode: 'assistant-approval-expired' };
9943
+ }
9944
+ return part;
9945
+ };
9946
+ return {
9947
+ ...conversation,
9948
+ status: 'idle',
9949
+ pendingApproval: null,
9950
+ messages: conversation.messages.map(message => ({ ...message, parts: message.parts.map(expired) }))
9951
+ };
9952
+ }
9592
9953
  /** Like the server: a new message starts in an idle conversation or after a failed turn. */
9593
9954
  function acceptsMessage(status) {
9594
9955
  return status === 'idle' || status === 'failed';