@newheap/platform-ai-chat 0.20.0 → 0.21.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
  /**
@@ -399,6 +399,10 @@ function mockWorksOnErrors(worksOn, knownTypes, agentPolicies, divisions) {
399
399
  }
400
400
  /** Whether an agent works on subjects of the type, as the server's rule decides it. */
401
401
  function mockWorksOnCovers(agent, subjectType) {
402
+ // A chat-only agent (`ChatOnly()` in code) works on no subject at all.
403
+ if (agent.chatOnly === true) {
404
+ return false;
405
+ }
402
406
  if (subjectType === mockSystemSubjectType) {
403
407
  return agent.worksOnApplication === true;
404
408
  }
@@ -418,6 +422,8 @@ const consultationRoutes = [
418
422
  const mockChatProfile = 'chat';
419
423
  const autonomyLevels = ['observe', 'explain', 'propose', 'simulate', 'execute'];
420
424
  const dataClassifications$1 = ['public', 'internal', 'confidential', 'restricted'];
425
+ /** The most MCP servers one agent is assigned to, as the server allows. */
426
+ const maxServersPerAgent = 32;
421
427
  /**
422
428
  * The agents of the mock administration: agents in code and agents created as data, their state,
423
429
  * shadow modes, technical accounts, feedback, evaluation and replays. The mock runs no agents on
@@ -434,18 +440,24 @@ class NhAssistantMockAdminAgents {
434
440
  const agents = admin.agents
435
441
  ?? scenario.agents.map(agent => ({ ...codeAgentFromSummary(agent), source: 'code' }));
436
442
  // 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) {
443
+ for (const { source, skills, shadowOf, shadow, serviceAccount, worksOnDeclared, knownSubjectTypes, consultations, consultationDescriptions, chatOnly, codeRunSettings: _, runConfiguration: __, consultableAsData: ___, dataConsultations: ____, consultationTools: _____, codeHandOffs: ______, ...input } of agents) {
438
444
  const variant = typeof shadowOf === 'string';
439
445
  const agentShadow = variant || shadow === true;
446
+ // A chat-only agent has no shadow threads: it and its skills always work live.
447
+ const live = source === 'code' && chatOnly === true;
440
448
  const initial = this.toAgent(input, source, 1, false);
441
449
  this.agents.set(input.id, {
442
450
  agent: {
443
451
  ...initial,
444
452
  // Code says what its agents work on; a scenario agent that says nothing works on all objects.
445
453
  ...(source === 'code' ? { ...(mockWorksOnFromInput(input) ?? mockUndeclaredWorksOn), worksOnDeclared: worksOnDeclared ?? mockWorksOnFromInput(input) !== null } : {}),
454
+ // A chat-only agent in code (`ChatOnly()`) works on nothing, said on purpose.
455
+ ...(source === 'code' && chatOnly === true
456
+ ? { chatOnly: true, worksOnSubjectTypes: [], worksOnAllSubjects: false, worksOnApplication: false, perDivision: false, worksOnDeclared: true }
457
+ : {}),
446
458
  knownSubjectTypes: knownSubjectTypes ?? [],
447
459
  shadowOf: shadowOf ?? null,
448
- shadow: shadowState(agentShadow, variant || shadow === true, null, variant),
460
+ shadow: shadowState(agentShadow, variant || shadow === true, null, variant || live),
449
461
  serviceAccount: serviceAccount === undefined
450
462
  ? null
451
463
  : { actorId: serviceAccount, configuredActorId: serviceAccount, overridden: false, changedAt: null, changedBy: null, reason: null },
@@ -463,7 +475,7 @@ class NhAssistantMockAdminAgents {
463
475
  dailyTokens: null,
464
476
  cancelledRuns: 0,
465
477
  startedRuns: 0,
466
- shadow: shadowState(agentShadow || skill.shadow === true, skill.shadow === true, null, variant)
478
+ shadow: shadowState(!live && (agentShadow || skill.shadow === true), skill.shadow === true, null, variant || live)
467
479
  }))
468
480
  },
469
481
  codeDefinition: source === 'code' ? structuredClone(input) : null,
@@ -543,6 +555,29 @@ class NhAssistantMockAdminAgents {
543
555
  live.agent = this.toAgent(definition, live.agent.source, live.agent.version + 1, live.agent.source === 'code', live.agent);
544
556
  return live.agent;
545
557
  }
558
+ /**
559
+ * Assigns an MCP server to an agent or removes the assignment, as `PUT` and
560
+ * `DELETE admin/mcp-servers/{id}/agents/{agentId}` do: only the assignment changes, never the agent's
561
+ * version; an agent has at most 32 servers.
562
+ */
563
+ setMcpAssignment(agentId, serverId, assign) {
564
+ const stored = this.agents.get(agentId);
565
+ if (!stored) {
566
+ return 'agent-not-found';
567
+ }
568
+ const assigned = stored.agent.mcpServerIds.includes(serverId);
569
+ if (assigned === assign) {
570
+ return 'unchanged';
571
+ }
572
+ if (assign && stored.agent.mcpServerIds.length >= maxServersPerAgent) {
573
+ return 'too-long';
574
+ }
575
+ stored.agent = {
576
+ ...stored.agent,
577
+ mcpServerIds: assign ? [...stored.agent.mcpServerIds, serverId] : stored.agent.mcpServerIds.filter(id => id !== serverId)
578
+ };
579
+ return 'changed';
580
+ }
546
581
  /** Removes a deleted MCP server from the agents it was assigned to. */
547
582
  removeServer(serverId) {
548
583
  for (const agent of this.agents.values()) {
@@ -600,7 +635,7 @@ class NhAssistantMockAdminAgents {
600
635
  if (refused) {
601
636
  return refused;
602
637
  }
603
- const created = this.toAgent(input, 'admin', 1, false);
638
+ const created = this.toAgent({ ...input, mcpServerIds: input.mcpServerIds ?? [] }, 'admin', 1, false);
604
639
  // A data shadow variant always works in shadow and is never offered in the chat.
605
640
  const agent = shadowOf === null
606
641
  ? created
@@ -660,7 +695,8 @@ class NhAssistantMockAdminAgents {
660
695
  return mockFailure('assistant-version-conflict');
661
696
  }
662
697
  const { expectedVersion, ...definition } = input;
663
- const updated = this.toAgent(definition, stored.agent.source, stored.agent.version + 1, stored.agent.source === 'code', stored.agent);
698
+ // An omitted list keeps the agent's assignments; an empty list removes them.
699
+ 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
700
  // A data shadow variant keeps its live agent and stays out of the chat.
665
701
  stored.agent = stored.agent.source === 'admin' && stored.agent.shadowOf ? { ...updated, offeredInChat: false } : updated;
666
702
  return { status: 200, body: stored.agent };
@@ -714,6 +750,10 @@ class NhAssistantMockAdminAgents {
714
750
  if (request === null) {
715
751
  return mockValidationFailure();
716
752
  }
753
+ // The skills of a chat-only agent always work live.
754
+ if (stored.agent.chatOnly === true) {
755
+ return mockFailure('assistant-agent-does-not-work-on');
756
+ }
717
757
  const agentShadow = stored.agent.shadow ?? shadowState(false, false, null, false);
718
758
  if (agentShadow.locked) {
719
759
  return mockFailure('assistant-shadow-variant');
@@ -888,6 +928,10 @@ class NhAssistantMockAdminAgents {
888
928
  if (request === null) {
889
929
  return mockValidationFailure();
890
930
  }
931
+ // A chat-only agent has no runs that could work in shadow threads.
932
+ if (stored.agent.chatOnly === true) {
933
+ return mockFailure('assistant-agent-does-not-work-on');
934
+ }
891
935
  const current = stored.agent.shadow ?? shadowState(false, false, null, false);
892
936
  if (current.locked) {
893
937
  return mockFailure('assistant-shadow-variant');
@@ -1128,7 +1172,9 @@ class NhAssistantMockAdminAgents {
1128
1172
  typeof input.description === 'string' &&
1129
1173
  typeof input.instructions === 'string' && input.instructions.length <= mockMaxInstructions &&
1130
1174
  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)) &&
1175
+ // Omitted, a new agent has no servers and an update keeps them, as on the server.
1176
+ (input.mcpServerIds === undefined || input.mcpServerIds === null ||
1177
+ (Array.isArray(input.mcpServerIds) && input.mcpServerIds.every(serverId => this.rules.hasServer(serverId)))) &&
1132
1178
  (input.requiredPolicy === null || (typeof input.requiredPolicy === 'string' && (!policies || policies.includes(input.requiredPolicy)))) &&
1133
1179
  autonomyLevels.includes(input.autonomy) &&
1134
1180
  (input.isEnabled === undefined || typeof input.isEnabled === 'boolean');
@@ -1151,6 +1197,8 @@ class NhAssistantMockAdminAgents {
1151
1197
  const classification = chosenClassification(dataClassification);
1152
1198
  return this.withWarnings({
1153
1199
  ...definition,
1200
+ // The callers name the servers; an omitted list keeps those of the agent, or none.
1201
+ mcpServerIds: definition.mcpServerIds ?? previous?.mcpServerIds ?? [],
1154
1202
  // An agent created here keeps its profile when the input omits it; code decides for the others.
1155
1203
  profile: chosenProfile ?? previous?.profile ?? mockChatProfile,
1156
1204
  // The same for its classification; an agent in code keeps the one of its scenario.
@@ -1317,6 +1365,9 @@ const readerPriority = { min: -1000, max: 1000 };
1317
1365
  const classifications$1 = ['public', 'internal', 'confidential', 'restricted'];
1318
1366
  const mediaTypePattern$1 = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
1319
1367
  const controlCharacters = /[\u0000-\u001f\u007f]/;
1368
+ /** The longest remote tool name a sync stores, and the most tools of a server that wait for a sync. */
1369
+ const maxRemoteName = 128;
1370
+ const maxToolsAwaitingSync = 100;
1320
1371
  /**
1321
1372
  * The MCP servers of the mock administration and the caller's own connections: secrets are
1322
1373
  * write-only, new tools start disabled as mutations, a changed remote schema disables a tool, OAuth
@@ -1744,19 +1795,20 @@ class NhAssistantMockAdminMcp {
1744
1795
  if (length === 5 && action === 'tools' && method === 'PUT' && child !== undefined) {
1745
1796
  const tool = stored.tools.get(child);
1746
1797
  const input = body;
1747
- if (!tool) {
1748
- return mockFailure('assistant-mcp-tool-not-found');
1749
- }
1750
1798
  if (typeof input?.isEnabled !== 'boolean' || (input.effect !== 'read-only' && input.effect !== 'mutation') ||
1751
1799
  (input.descriptionOverride !== null && typeof input.descriptionOverride !== 'string')) {
1752
1800
  return mockValidationFailure();
1753
1801
  }
1754
- if (input.isEnabled && tool.status === 'missing') {
1755
- return mockValidationFailure('isEnabled');
1756
- }
1757
1802
  const override = typeof input.descriptionOverride === 'string' && input.descriptionOverride.trim().length > 0
1758
1803
  ? input.descriptionOverride.slice(0, 1_000)
1759
1804
  : null;
1805
+ if (!tool) {
1806
+ return this.reviewBeforeSync(stored, child, input.isEnabled, input.effect, override);
1807
+ }
1808
+ // A tool the server no longer lists cannot be enabled; one that no sync has seen yet can.
1809
+ if (input.isEnabled && tool.status === 'missing' && !tool.awaitingSync) {
1810
+ return mockFailure('assistant-mcp-tool-disabled');
1811
+ }
1760
1812
  const updated = {
1761
1813
  ...tool,
1762
1814
  isEnabled: input.isEnabled,
@@ -1767,14 +1819,77 @@ class NhAssistantMockAdminMcp {
1767
1819
  stored.tools.set(child, updated);
1768
1820
  return { status: 200, body: toTool(updated) };
1769
1821
  }
1822
+ if (length === 5 && action === 'agents' && child !== undefined) {
1823
+ if (method !== 'PUT' && method !== 'DELETE') {
1824
+ return { status: 405 };
1825
+ }
1826
+ // Only the assignment changes, never the agent's version, as on the server.
1827
+ const changed = this.context.setAssignment(stored.server.id, child, method === 'PUT');
1828
+ if (changed === 'agent-not-found') {
1829
+ return mockFailure('assistant-agent-not-found');
1830
+ }
1831
+ if (changed === 'too-long') {
1832
+ return mockFailure('assistant-validation', { mcpServerIds: ['too-long'] });
1833
+ }
1834
+ return { status: 200, body: this.toServer(stored) };
1835
+ }
1770
1836
  return mockFailure('assistant-not-found');
1771
1837
  }
1838
+ /**
1839
+ * The reviewed settings of a tool that no sync has seen yet, as the server stores them: a missing tool
1840
+ * without a schema that waits for its sync, with a name a sync could store and no other spelling of
1841
+ * a stored tool's name; at most 100 per server.
1842
+ */
1843
+ reviewBeforeSync(stored, remoteName, isEnabled, effect, descriptionOverride) {
1844
+ if (remoteName.trim().length === 0) {
1845
+ return mockFailure('assistant-validation', { remoteName: ['required'] });
1846
+ }
1847
+ if (remoteName.length > maxRemoteName) {
1848
+ return mockFailure('assistant-validation', { remoteName: ['too-long'] });
1849
+ }
1850
+ const lower = remoteName.toLowerCase();
1851
+ if (controlCharacters.test(remoteName) || [...stored.tools.keys()].some(name => name.toLowerCase() === lower)) {
1852
+ return mockFailure('assistant-validation', { remoteName: ['invalid'] });
1853
+ }
1854
+ if ([...stored.tools.values()].filter(tool => tool.awaitingSync).length >= maxToolsAwaitingSync) {
1855
+ return mockFailure('assistant-configuration-limit');
1856
+ }
1857
+ const tool = {
1858
+ remoteName,
1859
+ localId: `mcp.${stored.server.id}.${kebab(remoteName)}`,
1860
+ description: '',
1861
+ descriptionOverride,
1862
+ isEnabled,
1863
+ effect,
1864
+ status: 'missing',
1865
+ readOnlyHint: null,
1866
+ awaitingSync: true,
1867
+ inputSchemaHash: ''
1868
+ };
1869
+ stored.tools.set(remoteName, tool);
1870
+ return { status: 200, body: toTool(tool) };
1871
+ }
1772
1872
  sync(stored) {
1773
1873
  const seen = new Set();
1774
1874
  for (const remote of stored.remoteTools) {
1775
1875
  seen.add(remote.remoteName);
1776
- const existing = stored.tools.get(remote.remoteName);
1876
+ const existing = stored.tools.get(remote.remoteName) ?? this.reviewedInOtherSpelling(stored, remote.remoteName);
1777
1877
  const description = remote.description.slice(0, 1_000);
1878
+ if (existing?.awaitingSync) {
1879
+ // The first sync that sees a reviewed tool keeps its settings and takes the server's spelling.
1880
+ stored.tools.delete(existing.remoteName);
1881
+ stored.tools.set(remote.remoteName, {
1882
+ ...existing,
1883
+ remoteName: remote.remoteName,
1884
+ localId: `mcp.${stored.server.id}.${kebab(remote.remoteName)}`,
1885
+ description,
1886
+ status: 'available',
1887
+ readOnlyHint: remote.readOnlyHint ?? null,
1888
+ awaitingSync: false,
1889
+ inputSchemaHash: remote.inputSchemaHash
1890
+ });
1891
+ continue;
1892
+ }
1778
1893
  if (!existing) {
1779
1894
  stored.tools.set(remote.remoteName, {
1780
1895
  remoteName: remote.remoteName,
@@ -1800,11 +1915,19 @@ class NhAssistantMockAdminMcp {
1800
1915
  });
1801
1916
  }
1802
1917
  for (const [name, tool] of stored.tools) {
1803
- if (!seen.has(name)) {
1918
+ // A reviewed tool that this sync does not see keeps waiting with its settings.
1919
+ if (!seen.has(name) && !tool.awaitingSync) {
1804
1920
  stored.tools.set(name, { ...tool, isEnabled: false, status: 'missing' });
1805
1921
  }
1806
1922
  }
1807
1923
  }
1924
+ /** A reviewed tool that waits for its sync under another spelling of a name no remote tool has. */
1925
+ reviewedInOtherSpelling(stored, remoteName) {
1926
+ const lower = remoteName.toLowerCase();
1927
+ const names = new Set(stored.remoteTools.map(remote => remote.remoteName));
1928
+ return [...stored.tools.values()]
1929
+ .find(tool => tool.awaitingSync && !names.has(tool.remoteName) && tool.remoteName.toLowerCase() === lower);
1930
+ }
1808
1931
  /**
1809
1932
  * The field errors of a server change, as the server reports them in one refusal: the fields
1810
1933
  * themselves, the credential and the OAuth settings. Empty when the change is valid.
@@ -2482,7 +2605,7 @@ function allowedServerUrl(value) {
2482
2605
  }
2483
2606
  }
2484
2607
  function toTool({ inputSchemaHash, ...tool }) {
2485
- return tool;
2608
+ return { ...tool, awaitingSync: tool.awaitingSync === true };
2486
2609
  }
2487
2610
  function toTools(stored) {
2488
2611
  return [...stored.tools.values()].map(toTool);
@@ -2789,7 +2912,9 @@ class NhAssistantMockHandOffs {
2789
2912
  ...data.map(agent => {
2790
2913
  const hasRunSettings = this.agents.isConfigured(agent);
2791
2914
  return {
2792
- agentId: agent.id, displayName: agent.displayName, source: 'data', hasRunSettings, inEffect: hasRunSettings, problem: null, version: agent.version
2915
+ agentId: agent.id, displayName: agent.displayName, source: 'data', hasRunSettings, inEffect: hasRunSettings, problem: null, version: agent.version,
2916
+ packageId: null,
2917
+ packageVersion: null
2793
2918
  };
2794
2919
  })
2795
2920
  ]
@@ -4435,6 +4560,7 @@ class NhAssistantMockAdmin {
4435
4560
  agents: () => this.agents.all(),
4436
4561
  chatAgentIds: () => this.agents.chatAgents().map(agent => agent.id),
4437
4562
  removeServer: serverId => this.agents.removeServer(serverId),
4563
+ setAssignment: (serverId, agentId, assign) => this.agents.setMcpAssignment(agentId, serverId, assign),
4438
4564
  canAdminister: () => this.canAdminister
4439
4565
  });
4440
4566
  this.presetRunConfigurations(admin.agents ?? []);
@@ -4866,8 +4992,139 @@ class NhAssistantMockAgentPackages {
4866
4992
  if (segments[1] === 'agents' && segments[3] === 'package' && segments[4] === 'unlink' && segments.length === 5) {
4867
4993
  return method === 'POST' ? this.unlink(segments[2], body) : { status: 405 };
4868
4994
  }
4995
+ // A promotion as the package's version is an import of the variant's parts onto its live agent.
4996
+ if (segments[1] === 'agents' && segments[3] === 'promote' && segments.length === 4
4997
+ && body?.asPackageVersion === true) {
4998
+ return method === 'POST' ? this.promoteAsPackageVersion(segments[2], body) : { status: 405 };
4999
+ }
4869
5000
  return null;
4870
5001
  }
5002
+ /**
5003
+ * `POST admin/agents/{id}/promote` with `asPackageVersion` (FB11): a data shadow variant whose every
5004
+ * part is linked to one package becomes that package's next version of its live agent. Its parts,
5005
+ * as an export reads them, are imported onto the live agent with the import's rules and relinked;
5006
+ * the live agent keeps its name and chat offer, an agent in code its run settings in code. The
5007
+ * variant leaves first, with its links, so its trigger ids are free, and the live agent leaves the
5008
+ * package it belonged to; a refused part puts everything back and answers the import's
5009
+ * `409 assistant-package-refused`. A part without a link answers `404 assistant-package-not-linked`.
5010
+ */
5011
+ promoteAsPackageVersion(variantId, body) {
5012
+ const listed = this.context.call('GET', 'admin/agents');
5013
+ if (listed.status !== 200) {
5014
+ return listed;
5015
+ }
5016
+ const request = (body ?? {});
5017
+ if (typeof request.expectedVersion !== 'number') {
5018
+ return mockFailure('assistant-validation', { expectedVersion: ['required'] });
5019
+ }
5020
+ if (request.moveSkills === true) {
5021
+ return mockFailure('assistant-validation', { moveSkills: ['not-allowed'] });
5022
+ }
5023
+ const variantRead = this.read(variantId);
5024
+ if (!variantRead) {
5025
+ return mockFailure('assistant-agent-not-found');
5026
+ }
5027
+ const variant = variantRead.agent;
5028
+ if (variant.source !== 'admin' || !variant.shadowOf) {
5029
+ return mockFailure('assistant-not-shadow-variant');
5030
+ }
5031
+ const liveRead = this.read(variant.shadowOf);
5032
+ if (!liveRead) {
5033
+ return mockFailure('assistant-agent-not-found');
5034
+ }
5035
+ const live = liveRead.agent;
5036
+ if (request.expectedVersion !== live.version
5037
+ || (typeof request.expectedVariantVersion === 'number' && request.expectedVariantVersion !== variant.version)) {
5038
+ return mockFailure('assistant-version-conflict');
5039
+ }
5040
+ // Every part of the variant is linked to its package.
5041
+ const link = this.links.get(variantId);
5042
+ const parts = this.packageAgentOf(variantRead);
5043
+ if (!link || [...partsOf(parts).keys()].some(part => !link.parts.has(part))) {
5044
+ return mockFailure('assistant-package-not-linked');
5045
+ }
5046
+ const inCode = live.source === 'code';
5047
+ const carried = parts.definition;
5048
+ const definition = !carried
5049
+ ? null
5050
+ : inCode
5051
+ ? {
5052
+ description: carried.description,
5053
+ instructions: carried.instructions,
5054
+ toolSelectors: carried.toolSelectors,
5055
+ requiredPolicy: carried.requiredPolicy,
5056
+ autonomy: carried.autonomy
5057
+ }
5058
+ : { ...carried, displayName: live.displayName, offeredInChat: live.offeredInChat !== false, shadowOf: null, startsOff: null, startsInShadow: null };
5059
+ const locks = Object.fromEntries([...link.parts]
5060
+ .filter(([part, partLink]) => partLink.lockedFields.length > 0 && (part !== 'runSettings' || !inCode))
5061
+ .map(([part, partLink]) => [part, [...partLink.lockedFields]]));
5062
+ const agent = withoutNulls({
5063
+ ...parts,
5064
+ id: live.id,
5065
+ source: live.source,
5066
+ definition,
5067
+ runSettings: inCode ? null : parts.runSettings,
5068
+ groups: null,
5069
+ hashes: null,
5070
+ locks: Object.keys(locks).length > 0 ? locks : null
5071
+ });
5072
+ const agentPackage = {
5073
+ format: NH_ASSISTANT_AGENT_PACKAGE_FORMAT,
5074
+ formatVersion: NH_ASSISTANT_AGENT_PACKAGE_FORMAT_VERSION,
5075
+ package: { id: link.packageId, version: link.packageVersion },
5076
+ agents: [agent]
5077
+ };
5078
+ // The live agent's technical account stays; without one it takes the variant's.
5079
+ const account = liveRead.configuration?.runSettings?.serviceAccountActorId
5080
+ ?? variantRead.configuration?.runSettings?.serviceAccountActorId
5081
+ ?? null;
5082
+ // All of it or nothing: the variant leaves first, and the package's other agents stay linked.
5083
+ const restore = [this.context.snapshot(), this.snapshot()];
5084
+ const others = [...this.links].filter(([agentId, item]) => item.packageId === link.packageId && agentId !== variantId);
5085
+ this.links.delete(variantId);
5086
+ const deleted = this.context.call('DELETE', `admin/agents/${encodeURIComponent(variantId)}`);
5087
+ if (deleted.status >= 300) {
5088
+ restore.forEach(put => put());
5089
+ return deleted;
5090
+ }
5091
+ for (const [agentId] of others) {
5092
+ this.links.delete(agentId);
5093
+ }
5094
+ this.links.delete(live.id);
5095
+ for (const installed of this.installed.values()) {
5096
+ installed.agents = installed.agents.filter(agentId => agentId !== live.id && agentId !== variantId);
5097
+ }
5098
+ const imported = this.importPackage({
5099
+ package: agentPackage,
5100
+ options: { relink: [{ agentId: live.id }], serviceAccounts: account ? { [live.id]: account } : null }
5101
+ }, true);
5102
+ if (imported.status !== 200) {
5103
+ restore.forEach(put => put());
5104
+ return imported;
5105
+ }
5106
+ for (const [agentId, item] of others) {
5107
+ this.links.set(agentId, item);
5108
+ }
5109
+ this.installed.set(link.packageId, {
5110
+ version: link.packageVersion,
5111
+ agents: [...others.map(([agentId]) => agentId), live.id].sort()
5112
+ });
5113
+ const result = imported.body;
5114
+ const promoted = this.read(live.id).agent;
5115
+ return {
5116
+ status: 200,
5117
+ body: {
5118
+ agent: this.withAgentPackage(promoted),
5119
+ variantId,
5120
+ movedSkills: result.parts
5121
+ .filter(part => part.part.startsWith('skill:') && part.outcome !== 'refused' && part.outcome !== 'left-behind' && part.outcome !== 'kept-local')
5122
+ .map(part => part.part.slice('skill:'.length))
5123
+ .sort(),
5124
+ package: result
5125
+ }
5126
+ };
5127
+ }
4871
5128
  /**
4872
5129
  * Runs a save of the administration under the package locks: a save may keep a locked field's value
4873
5130
  * or take the package's, never another; such a change is undone and refused with `{field}: locked`.
@@ -4975,6 +5232,22 @@ class NhAssistantMockAgentPackages {
4975
5232
  return { ...result, body: this.withLink(agentId, `metadata:${id}`, body) };
4976
5233
  }
4977
5234
  return { ...result, body: body.map(entry => this.withLink(agentId, `metadata:${entry.name}`, entry)) };
5235
+ case 'shadow-variants': {
5236
+ // A data variant names the package its parts are linked to.
5237
+ const listing = body;
5238
+ return {
5239
+ ...result,
5240
+ body: {
5241
+ ...listing,
5242
+ variants: listing.variants.map(variant => {
5243
+ const link = this.links.get(variant.agentId);
5244
+ return variant.source === 'data' && link && link.parts.size > 0
5245
+ ? { ...variant, packageId: link.packageId, packageVersion: link.packageVersion }
5246
+ : variant;
5247
+ })
5248
+ }
5249
+ };
5250
+ }
4978
5251
  default:
4979
5252
  return result;
4980
5253
  }
@@ -7964,7 +8237,8 @@ class NhAssistantMockProposals {
7964
8237
  parent: item.parent ?? null,
7965
8238
  canGiveFeedback: false,
7966
8239
  consultationId: item.consultationId ?? null,
7967
- consultationRoute: route
8240
+ consultationRoute: route,
8241
+ ...(item.supersededByApprovalId ? { supersededByApprovalId: item.supersededByApprovalId } : {})
7968
8242
  },
7969
8243
  divisionId: item.divisionId ?? null,
7970
8244
  canDecide,
@@ -8830,7 +9104,10 @@ class NhAssistantMockBackend {
8830
9104
  if (filesCode) {
8831
9105
  return this.error(filesCode);
8832
9106
  }
8833
- if (!acceptsMessage(conversation.status)) {
9107
+ // Like the server: a question or an approval that passed its lifetime closes as expired, and the
9108
+ // message starts its turn (CS9).
9109
+ this.closeExpiredWait(conversation);
9110
+ if (!acceptsMessage(this.conversations.get(conversation.id)?.status ?? conversation.status)) {
8834
9111
  return this.empty(409);
8835
9112
  }
8836
9113
  // Like the server: an invalid page context is dropped, never a 400.
@@ -9005,6 +9282,28 @@ class NhAssistantMockBackend {
9005
9282
  await this.playSteps(conversation.id, assistantMessageId, steps, emit);
9006
9283
  });
9007
9284
  }
9285
+ /**
9286
+ * Closes the question or the approval a chat waits for once its lifetime passed, as a late answer or
9287
+ * decision closes it on the server; one that is still valid keeps the chat busy. Agent threads keep
9288
+ * their own rules.
9289
+ */
9290
+ closeExpiredWait(conversation) {
9291
+ if (conversation.status !== 'waiting-for-approval' || conversation.kind === 'thread') {
9292
+ return;
9293
+ }
9294
+ const now = Date.now();
9295
+ const question = this.pendingQuestions.get(conversation.id);
9296
+ if (question && Date.parse(question.question.expiresAt) <= now) {
9297
+ this.pendingQuestions.delete(conversation.id);
9298
+ this.update(conversation.id, current => withClosedQuestion(current, question.question.questionId, 'expired'));
9299
+ return;
9300
+ }
9301
+ const pending = this.pendingApprovals.get(conversation.id);
9302
+ if (pending && Date.parse(pending.approval.expiresAt) <= now) {
9303
+ this.pendingApprovals.delete(conversation.id);
9304
+ this.update(conversation.id, current => withExpiredApproval(current, pending.approval.approvalId, pending.invocationId));
9305
+ }
9306
+ }
9008
9307
  cancel(conversation) {
9009
9308
  const run = this.runs.get(conversation.id);
9010
9309
  if (run) {
@@ -9589,6 +9888,27 @@ function withClosedQuestion(conversation, questionId, status) {
9589
9888
  messages: conversation.messages.map(message => ({ ...message, parts: message.parts.map(closed) }))
9590
9889
  };
9591
9890
  }
9891
+ /**
9892
+ * An approval that passed its lifetime, as the server closes it when the next message arrives: it
9893
+ * expired, its call failed with `assistant-approval-expired`, and the conversation takes the message.
9894
+ */
9895
+ function withExpiredApproval(conversation, approvalId, invocationId) {
9896
+ const expired = (part) => {
9897
+ if (part.type === 'approval' && part.approvalId === approvalId) {
9898
+ return { ...part, status: 'expired' };
9899
+ }
9900
+ if (part.type === 'tool-call' && part.invocationId === invocationId) {
9901
+ return { ...part, status: 'failed', resultCode: 'assistant-approval-expired' };
9902
+ }
9903
+ return part;
9904
+ };
9905
+ return {
9906
+ ...conversation,
9907
+ status: 'idle',
9908
+ pendingApproval: null,
9909
+ messages: conversation.messages.map(message => ({ ...message, parts: message.parts.map(expired) }))
9910
+ };
9911
+ }
9592
9912
  /** Like the server: a new message starts in an idle conversation or after a failed turn. */
9593
9913
  function acceptsMessage(status) {
9594
9914
  return status === 'idle' || status === 'failed';