@newheap/platform-ai-chat 0.19.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, 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
  /**
@@ -25,6 +25,8 @@ const statusTable = {
25
25
  'assistant-run-filter-invalid',
26
26
  'assistant-board-scope-invalid',
27
27
  'assistant-feedback-invalid',
28
+ 'assistant-question-invalid',
29
+ 'assistant-question-answer-invalid',
28
30
  'assistant-trigger-filter-not-found',
29
31
  'assistant-replay-not-shadow',
30
32
  'assistant-evaluation-not-variant',
@@ -55,6 +57,7 @@ const statusTable = {
55
57
  'assistant-context-unavailable',
56
58
  'assistant-owner-required',
57
59
  'assistant-approval-forbidden',
60
+ 'assistant-question-forbidden',
58
61
  'assistant-turn-forbidden',
59
62
  'assistant-subject-access-denied',
60
63
  'assistant-approval-policy-denied',
@@ -71,6 +74,7 @@ const statusTable = {
71
74
  'assistant-metadata-entry-not-found',
72
75
  'assistant-instructions-asset-not-found',
73
76
  'assistant-approval-not-found',
77
+ 'assistant-question-not-found',
74
78
  'assistant-share-link-invalid',
75
79
  'assistant-participant-not-found',
76
80
  'assistant-directory-unavailable',
@@ -111,6 +115,7 @@ const statusTable = {
111
115
  'assistant-agent-does-not-work-on',
112
116
  'assistant-moved-to-shadow',
113
117
  'assistant-shadow-variant',
118
+ 'assistant-not-shadow-variant',
114
119
  'assistant-service-account-not-used',
115
120
  'assistant-consult-route-not-allowed',
116
121
  'assistant-consult-proposal-refused',
@@ -133,6 +138,9 @@ const statusTable = {
133
138
  'assistant-memory-conflict',
134
139
  'assistant-replay-active',
135
140
  'assistant-feedback-not-accepted',
141
+ 'assistant-question-not-pending',
142
+ 'assistant-question-expired',
143
+ 'assistant-question-cancelled',
136
144
  'assistant-trigger-rate-limited',
137
145
  'assistant-version-conflict',
138
146
  'assistant-agent-exists',
@@ -391,6 +399,10 @@ function mockWorksOnErrors(worksOn, knownTypes, agentPolicies, divisions) {
391
399
  }
392
400
  /** Whether an agent works on subjects of the type, as the server's rule decides it. */
393
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
+ }
394
406
  if (subjectType === mockSystemSubjectType) {
395
407
  return agent.worksOnApplication === true;
396
408
  }
@@ -410,6 +422,8 @@ const consultationRoutes = [
410
422
  const mockChatProfile = 'chat';
411
423
  const autonomyLevels = ['observe', 'explain', 'propose', 'simulate', 'execute'];
412
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;
413
427
  /**
414
428
  * The agents of the mock administration: agents in code and agents created as data, their state,
415
429
  * shadow modes, technical accounts, feedback, evaluation and replays. The mock runs no agents on
@@ -426,18 +440,24 @@ class NhAssistantMockAdminAgents {
426
440
  const agents = admin.agents
427
441
  ?? scenario.agents.map(agent => ({ ...codeAgentFromSummary(agent), source: 'code' }));
428
442
  // How an agent runs, in code or as data, belongs to its run configuration, not to the agent.
429
- 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) {
430
444
  const variant = typeof shadowOf === 'string';
431
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;
432
448
  const initial = this.toAgent(input, source, 1, false);
433
449
  this.agents.set(input.id, {
434
450
  agent: {
435
451
  ...initial,
436
452
  // Code says what its agents work on; a scenario agent that says nothing works on all objects.
437
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
+ : {}),
438
458
  knownSubjectTypes: knownSubjectTypes ?? [],
439
459
  shadowOf: shadowOf ?? null,
440
- shadow: shadowState(agentShadow, variant || shadow === true, null, variant),
460
+ shadow: shadowState(agentShadow, variant || shadow === true, null, variant || live),
441
461
  serviceAccount: serviceAccount === undefined
442
462
  ? null
443
463
  : { actorId: serviceAccount, configuredActorId: serviceAccount, overridden: false, changedAt: null, changedBy: null, reason: null },
@@ -455,7 +475,7 @@ class NhAssistantMockAdminAgents {
455
475
  dailyTokens: null,
456
476
  cancelledRuns: 0,
457
477
  startedRuns: 0,
458
- 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)
459
479
  }))
460
480
  },
461
481
  codeDefinition: source === 'code' ? structuredClone(input) : null,
@@ -500,6 +520,64 @@ class NhAssistantMockAdminAgents {
500
520
  stored.agent = { ...stored.agent, version: stored.agent.version + 1, updatedAt: this.now() };
501
521
  return stored.agent;
502
522
  }
523
+ /**
524
+ * Copies a data shadow variant's definition onto its live agent as the live agent's next version,
525
+ * as a promotion does on the server before it deletes the variant: the live agent keeps its name,
526
+ * MCP servers, state and shadow mode; an agent in code takes only the fields it can override, an
527
+ * agent created here also the variant's profile, classification, what it works on and its asset.
528
+ */
529
+ promote(variantId, liveId) {
530
+ const variant = this.agents.get(variantId);
531
+ const live = this.agents.get(liveId);
532
+ if (!variant || !live) {
533
+ throw new Error(`The mock has no agent '${variant ? liveId : variantId}'.`);
534
+ }
535
+ const from = variant.agent;
536
+ const runtime = live.agent.source === 'admin';
537
+ const definition = {
538
+ id: live.agent.id,
539
+ displayName: live.agent.displayName,
540
+ description: from.description,
541
+ instructions: runtime && from.instructionsAsset ? '' : from.instructions,
542
+ toolSelectors: [...from.toolSelectors],
543
+ mcpServerIds: [...live.agent.mcpServerIds],
544
+ requiredPolicy: from.requiredPolicy,
545
+ autonomy: from.autonomy,
546
+ ...(runtime
547
+ ? {
548
+ ...pickWorksOn(from),
549
+ profile: from.profile,
550
+ dataClassification: from.dataClassification ?? null,
551
+ instructionsAsset: from.instructionsAsset ?? null
552
+ }
553
+ : {})
554
+ };
555
+ live.agent = this.toAgent(definition, live.agent.source, live.agent.version + 1, live.agent.source === 'code', live.agent);
556
+ return live.agent;
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
+ }
503
581
  /** Removes a deleted MCP server from the agents it was assigned to. */
504
582
  removeServer(serverId) {
505
583
  for (const agent of this.agents.values()) {
@@ -557,7 +635,7 @@ class NhAssistantMockAdminAgents {
557
635
  if (refused) {
558
636
  return refused;
559
637
  }
560
- const created = this.toAgent(input, 'admin', 1, false);
638
+ const created = this.toAgent({ ...input, mcpServerIds: input.mcpServerIds ?? [] }, 'admin', 1, false);
561
639
  // A data shadow variant always works in shadow and is never offered in the chat.
562
640
  const agent = shadowOf === null
563
641
  ? created
@@ -617,7 +695,8 @@ class NhAssistantMockAdminAgents {
617
695
  return mockFailure('assistant-version-conflict');
618
696
  }
619
697
  const { expectedVersion, ...definition } = input;
620
- 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);
621
700
  // A data shadow variant keeps its live agent and stays out of the chat.
622
701
  stored.agent = stored.agent.source === 'admin' && stored.agent.shadowOf ? { ...updated, offeredInChat: false } : updated;
623
702
  return { status: 200, body: stored.agent };
@@ -671,6 +750,10 @@ class NhAssistantMockAdminAgents {
671
750
  if (request === null) {
672
751
  return mockValidationFailure();
673
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
+ }
674
757
  const agentShadow = stored.agent.shadow ?? shadowState(false, false, null, false);
675
758
  if (agentShadow.locked) {
676
759
  return mockFailure('assistant-shadow-variant');
@@ -845,6 +928,10 @@ class NhAssistantMockAdminAgents {
845
928
  if (request === null) {
846
929
  return mockValidationFailure();
847
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
+ }
848
935
  const current = stored.agent.shadow ?? shadowState(false, false, null, false);
849
936
  if (current.locked) {
850
937
  return mockFailure('assistant-shadow-variant');
@@ -1085,7 +1172,9 @@ class NhAssistantMockAdminAgents {
1085
1172
  typeof input.description === 'string' &&
1086
1173
  typeof input.instructions === 'string' && input.instructions.length <= mockMaxInstructions &&
1087
1174
  Array.isArray(input.toolSelectors) && input.toolSelectors.every(selector => /^[a-z0-9*][a-z0-9.*-]*$/.test(selector)) &&
1088
- 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)))) &&
1089
1178
  (input.requiredPolicy === null || (typeof input.requiredPolicy === 'string' && (!policies || policies.includes(input.requiredPolicy)))) &&
1090
1179
  autonomyLevels.includes(input.autonomy) &&
1091
1180
  (input.isEnabled === undefined || typeof input.isEnabled === 'boolean');
@@ -1108,6 +1197,8 @@ class NhAssistantMockAdminAgents {
1108
1197
  const classification = chosenClassification(dataClassification);
1109
1198
  return this.withWarnings({
1110
1199
  ...definition,
1200
+ // The callers name the servers; an omitted list keeps those of the agent, or none.
1201
+ mcpServerIds: definition.mcpServerIds ?? previous?.mcpServerIds ?? [],
1111
1202
  // An agent created here keeps its profile when the input omits it; code decides for the others.
1112
1203
  profile: chosenProfile ?? previous?.profile ?? mockChatProfile,
1113
1204
  // The same for its classification; an agent in code keeps the one of its scenario.
@@ -1274,6 +1365,9 @@ const readerPriority = { min: -1000, max: 1000 };
1274
1365
  const classifications$1 = ['public', 'internal', 'confidential', 'restricted'];
1275
1366
  const mediaTypePattern$1 = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
1276
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;
1277
1371
  /**
1278
1372
  * The MCP servers of the mock administration and the caller's own connections: secrets are
1279
1373
  * write-only, new tools start disabled as mutations, a changed remote schema disables a tool, OAuth
@@ -1701,19 +1795,20 @@ class NhAssistantMockAdminMcp {
1701
1795
  if (length === 5 && action === 'tools' && method === 'PUT' && child !== undefined) {
1702
1796
  const tool = stored.tools.get(child);
1703
1797
  const input = body;
1704
- if (!tool) {
1705
- return mockFailure('assistant-mcp-tool-not-found');
1706
- }
1707
1798
  if (typeof input?.isEnabled !== 'boolean' || (input.effect !== 'read-only' && input.effect !== 'mutation') ||
1708
1799
  (input.descriptionOverride !== null && typeof input.descriptionOverride !== 'string')) {
1709
1800
  return mockValidationFailure();
1710
1801
  }
1711
- if (input.isEnabled && tool.status === 'missing') {
1712
- return mockValidationFailure('isEnabled');
1713
- }
1714
1802
  const override = typeof input.descriptionOverride === 'string' && input.descriptionOverride.trim().length > 0
1715
1803
  ? input.descriptionOverride.slice(0, 1_000)
1716
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
+ }
1717
1812
  const updated = {
1718
1813
  ...tool,
1719
1814
  isEnabled: input.isEnabled,
@@ -1724,14 +1819,77 @@ class NhAssistantMockAdminMcp {
1724
1819
  stored.tools.set(child, updated);
1725
1820
  return { status: 200, body: toTool(updated) };
1726
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
+ }
1727
1836
  return mockFailure('assistant-not-found');
1728
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
+ }
1729
1872
  sync(stored) {
1730
1873
  const seen = new Set();
1731
1874
  for (const remote of stored.remoteTools) {
1732
1875
  seen.add(remote.remoteName);
1733
- const existing = stored.tools.get(remote.remoteName);
1876
+ const existing = stored.tools.get(remote.remoteName) ?? this.reviewedInOtherSpelling(stored, remote.remoteName);
1734
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
+ }
1735
1893
  if (!existing) {
1736
1894
  stored.tools.set(remote.remoteName, {
1737
1895
  remoteName: remote.remoteName,
@@ -1757,11 +1915,19 @@ class NhAssistantMockAdminMcp {
1757
1915
  });
1758
1916
  }
1759
1917
  for (const [name, tool] of stored.tools) {
1760
- 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) {
1761
1920
  stored.tools.set(name, { ...tool, isEnabled: false, status: 'missing' });
1762
1921
  }
1763
1922
  }
1764
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
+ }
1765
1931
  /**
1766
1932
  * The field errors of a server change, as the server reports them in one refusal: the fields
1767
1933
  * themselves, the credential and the OAuth settings. Empty when the change is valid.
@@ -2439,7 +2605,7 @@ function allowedServerUrl(value) {
2439
2605
  }
2440
2606
  }
2441
2607
  function toTool({ inputSchemaHash, ...tool }) {
2442
- return tool;
2608
+ return { ...tool, awaitingSync: tool.awaitingSync === true };
2443
2609
  }
2444
2610
  function toTools(stored) {
2445
2611
  return [...stored.tools.values()].map(toTool);
@@ -2740,10 +2906,16 @@ class NhAssistantMockHandOffs {
2740
2906
  allowsDataVariants: allowed,
2741
2907
  maxDataVariants: allowed ? this.maxVariants() : 0,
2742
2908
  variants: [
2743
- ...code.map(agent => ({ agentId: agent.id, displayName: agent.displayName, source: 'code', hasRunSettings: true, inEffect: true, problem: null })),
2909
+ ...code.map(agent => ({
2910
+ agentId: agent.id, displayName: agent.displayName, source: 'code', hasRunSettings: true, inEffect: true, problem: null, version: agent.version
2911
+ })),
2744
2912
  ...data.map(agent => {
2745
2913
  const hasRunSettings = this.agents.isConfigured(agent);
2746
- return { agentId: agent.id, displayName: agent.displayName, source: 'data', hasRunSettings, inEffect: hasRunSettings, problem: null };
2914
+ return {
2915
+ agentId: agent.id, displayName: agent.displayName, source: 'data', hasRunSettings, inEffect: hasRunSettings, problem: null, version: agent.version,
2916
+ packageId: null,
2917
+ packageVersion: null
2918
+ };
2747
2919
  })
2748
2920
  ]
2749
2921
  };
@@ -4166,6 +4338,54 @@ class NhAssistantMockRunConfiguration {
4166
4338
  existing.reason = typeof request.reason === 'string' && request.reason.trim().length > 0 ? request.reason.trim() : null;
4167
4339
  return { status: 200, body: withoutOwner(existing) };
4168
4340
  }
4341
+ /**
4342
+ * The ids of the variant's data skills that would move to `live` and the failure of one that cannot,
4343
+ * as a promotion with `moveSkills` checks them before anything changes: a skill the live agent has
4344
+ * in code stays (`assistant-skill-in-code`).
4345
+ */
4346
+ skillsToMove(variantId, live) {
4347
+ const skillIds = this.liveSkills(variantId).map(skill => skill.skillId);
4348
+ const inCode = (live.skills ?? []).some(skill => skillIds.includes(skill.skillId) && !this.skills.has(key$1(live.id, skill.skillId)));
4349
+ return { skillIds, failure: inCode ? mockFailure('assistant-skill-in-code') : null };
4350
+ }
4351
+ /**
4352
+ * Moves the variant's data skills to `live` as a promotion does: each becomes the next live version
4353
+ * of the live agent's skill with the same id, or a new skill, which starts in shadow. Answers the ids
4354
+ * that moved, in order, or the failure of the first save.
4355
+ */
4356
+ moveSkills(variantId, live, reason) {
4357
+ const moved = [];
4358
+ for (const skill of this.liveSkills(variantId)) {
4359
+ const current = (this.skills.get(key$1(live.id, skill.skillId)) ?? []).find(version => version.status === 'live');
4360
+ const request = {
4361
+ description: skill.description,
4362
+ instructions: skill.instructionsAsset ? null : skill.instructions,
4363
+ tools: [...skill.tools],
4364
+ profile: skill.profile,
4365
+ ruleHandler: skill.ruleHandler,
4366
+ maxTokensPerRun: skill.maxTokensPerRun,
4367
+ dailyToolCalls: skill.dailyToolCalls,
4368
+ dailyTokens: skill.dailyTokens,
4369
+ approvalPolicy: skill.approvalPolicy,
4370
+ expectedVersion: current?.version ?? null,
4371
+ reason,
4372
+ ...(current ? { apply: 'live' } : {}),
4373
+ dataClassification: skill.dataClassification ?? null,
4374
+ acceptsFiles: skill.acceptsFiles ?? false,
4375
+ maxFileBytes: skill.maxFileBytes ?? null,
4376
+ maxFilePages: skill.maxFilePages ?? null,
4377
+ maxFilesPerTurn: skill.maxFilesPerTurn ?? null,
4378
+ maxFileTokensPerTurn: skill.maxFileTokensPerTurn ?? null,
4379
+ instructionsAsset: skill.instructionsAsset ?? null
4380
+ };
4381
+ const saved = this.skill('PUT', live, skill.skillId, request, new URLSearchParams());
4382
+ if (saved.status !== 200) {
4383
+ return { moved, failure: saved };
4384
+ }
4385
+ moved.push(skill.skillId);
4386
+ }
4387
+ return { moved, failure: null };
4388
+ }
4169
4389
  /** An agent in code has a run configuration; one created in the administration once it has run settings. */
4170
4390
  isConfigured(agent) {
4171
4391
  return agent.source === 'code' || this.settings.has(agent.id);
@@ -4340,6 +4560,7 @@ class NhAssistantMockAdmin {
4340
4560
  agents: () => this.agents.all(),
4341
4561
  chatAgentIds: () => this.agents.chatAgents().map(agent => agent.id),
4342
4562
  removeServer: serverId => this.agents.removeServer(serverId),
4563
+ setAssignment: (serverId, agentId, assign) => this.agents.setMcpAssignment(agentId, serverId, assign),
4343
4564
  canAdminister: () => this.canAdminister
4344
4565
  });
4345
4566
  this.presetRunConfigurations(admin.agents ?? []);
@@ -4468,6 +4689,9 @@ class NhAssistantMockAdmin {
4468
4689
  if (id !== undefined && (action === 'replay' || action === 'replays') && segments.length === 4) {
4469
4690
  return this.agents.agentReplay(method, id, action, body);
4470
4691
  }
4692
+ if (id !== undefined && action === 'promote' && segments.length === 4) {
4693
+ return this.promote(method, id, body);
4694
+ }
4471
4695
  if (id !== undefined && (action === 'hand-offs' || action === 'shadow-variants')) {
4472
4696
  const handed = this.handOffs.handle(method, id, segments.slice(3), body, query);
4473
4697
  if (handed) {
@@ -4495,6 +4719,56 @@ class NhAssistantMockAdmin {
4495
4719
  return mockFailure('assistant-not-found');
4496
4720
  }
4497
4721
  }
4722
+ /**
4723
+ * `POST admin/agents/{id}/promote`: a data shadow variant's definition becomes its live agent's next
4724
+ * version, its skills move along with `moveSkills`, and the variant is deleted, as on the server. A
4725
+ * stale version of either agent answers `409`; a skill the live agent has in code stops the promotion
4726
+ * before anything changes.
4727
+ */
4728
+ promote(method, variantId, body) {
4729
+ if (method !== 'POST') {
4730
+ return { status: 405 };
4731
+ }
4732
+ const variant = this.agents.find(variantId);
4733
+ if (!variant) {
4734
+ return mockFailure('assistant-agent-not-found');
4735
+ }
4736
+ if (variant.source !== 'admin' || !variant.shadowOf) {
4737
+ return mockFailure('assistant-not-shadow-variant');
4738
+ }
4739
+ const request = body;
4740
+ if (typeof request?.expectedVersion !== 'number') {
4741
+ return mockFailure('assistant-validation', { expectedVersion: ['required'] });
4742
+ }
4743
+ if (typeof request.reason === 'string' && request.reason.length > 500) {
4744
+ return mockFailure('assistant-validation', { reason: ['too-long'] });
4745
+ }
4746
+ const live = this.agents.find(variant.shadowOf);
4747
+ if (!live) {
4748
+ return mockFailure('assistant-agent-not-found');
4749
+ }
4750
+ if (request.expectedVersion !== live.version
4751
+ || (typeof request.expectedVariantVersion === 'number' && request.expectedVariantVersion !== variant.version)) {
4752
+ return mockFailure('assistant-version-conflict');
4753
+ }
4754
+ if (request.moveSkills === true) {
4755
+ const refused = this.runConfiguration.skillsToMove(variant.id, live).failure;
4756
+ if (refused) {
4757
+ return refused;
4758
+ }
4759
+ }
4760
+ // In the server's order: the definition, the skills, then the variant leaves.
4761
+ const agent = this.agents.promote(variant.id, live.id);
4762
+ const moved = request.moveSkills === true
4763
+ ? this.runConfiguration.moveSkills(variant.id, agent, typeof request.reason === 'string' ? request.reason.trim() || null : null)
4764
+ : { moved: [], failure: null };
4765
+ if (moved.failure) {
4766
+ return moved.failure;
4767
+ }
4768
+ this.agents.agentsEndpoint('DELETE', variant.id, undefined, undefined, 3);
4769
+ const promotion = { agent: this.agents.find(agent.id), variantId: variant.id, movedSkills: moved.moved };
4770
+ return { status: 200, body: promotion };
4771
+ }
4498
4772
  context(method, sub, body) {
4499
4773
  const current = this.contextVersions[this.contextVersions.length - 1];
4500
4774
  if (sub === 'versions' && method === 'GET') {
@@ -4718,8 +4992,139 @@ class NhAssistantMockAgentPackages {
4718
4992
  if (segments[1] === 'agents' && segments[3] === 'package' && segments[4] === 'unlink' && segments.length === 5) {
4719
4993
  return method === 'POST' ? this.unlink(segments[2], body) : { status: 405 };
4720
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
+ }
4721
5000
  return null;
4722
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
+ }
4723
5128
  /**
4724
5129
  * Runs a save of the administration under the package locks: a save may keep a locked field's value
4725
5130
  * or take the package's, never another; such a change is undone and refused with `{field}: locked`.
@@ -4827,6 +5232,22 @@ class NhAssistantMockAgentPackages {
4827
5232
  return { ...result, body: this.withLink(agentId, `metadata:${id}`, body) };
4828
5233
  }
4829
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
+ }
4830
5251
  default:
4831
5252
  return result;
4832
5253
  }
@@ -7544,7 +7965,8 @@ class NhAssistantMockAgentMetadata {
7544
7965
  name: entry.name,
7545
7966
  editable: entry.editable === true,
7546
7967
  maxBytes: entry.maxBytes ?? defaultMaxBytes,
7547
- indexed: entry.indexBy !== undefined
7968
+ indexed: entry.indexBy !== undefined || entry.indexByEach !== undefined,
7969
+ indexedByEach: entry.indexByEach !== undefined
7548
7970
  }));
7549
7971
  }
7550
7972
  /**
@@ -7660,8 +8082,10 @@ class NhAssistantMockAgentMetadata {
7660
8082
  version: inCode ? stored?.version ?? null : agent.version,
7661
8083
  agentVersion: agent.version,
7662
8084
  codeValue: inCode && code !== undefined ? structuredClone(code) : null,
7663
- indexed: entry.indexBy !== undefined,
8085
+ indexed: entry.indexBy !== undefined || entry.indexByEach !== undefined,
7664
8086
  indexValue: value !== undefined ? indexOf(entry, value) : null,
8087
+ indexedByEach: entry.indexByEach !== undefined,
8088
+ indexValues: value !== undefined ? indexValuesOf(entry, value) : [],
7665
8089
  changedAt: stored?.changedAt ?? null,
7666
8090
  changedBy: stored?.changedBy ?? null
7667
8091
  };
@@ -7703,6 +8127,20 @@ function indexOf(entry, value) {
7703
8127
  const trimmed = typeof text === 'string' ? text.trim() : '';
7704
8128
  return trimmed.length > 0 ? trimmed.toLowerCase() : null;
7705
8129
  }
8130
+ /**
8131
+ * The searchable values of an `IndexByEach` entry's value as the server stores them: trimmed, in
8132
+ * lowercase, without empty values or repeats, in ordinal order.
8133
+ */
8134
+ function indexValuesOf(entry, value) {
8135
+ const texts = entry.indexByEach === undefined ? undefined : value[entry.indexByEach];
8136
+ if (!Array.isArray(texts)) {
8137
+ return [];
8138
+ }
8139
+ const values = texts
8140
+ .map(text => (typeof text === 'string' ? text.trim().toLowerCase() : ''))
8141
+ .filter(text => text.length > 0);
8142
+ return [...new Set(values)].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
8143
+ }
7706
8144
 
7707
8145
  /** How long the application may take to report after a hand-off in the mock. */
7708
8146
  const reportWithinMs = 4 * 60 * 60 * 1000;
@@ -7799,7 +8237,8 @@ class NhAssistantMockProposals {
7799
8237
  parent: item.parent ?? null,
7800
8238
  canGiveFeedback: false,
7801
8239
  consultationId: item.consultationId ?? null,
7802
- consultationRoute: route
8240
+ consultationRoute: route,
8241
+ ...(item.supersededByApprovalId ? { supersededByApprovalId: item.supersededByApprovalId } : {})
7803
8242
  },
7804
8243
  divisionId: item.divisionId ?? null,
7805
8244
  canDecide,
@@ -7895,15 +8334,22 @@ class NhAssistantMockProposals {
7895
8334
  if (divisionId !== null && !guid.test(divisionId)) {
7896
8335
  return { status: 400 };
7897
8336
  }
8337
+ // What waits for a decision by default; what waits for the application's report or for an
8338
+ // approver to resolve an uncertain execution on request, with the same visibility.
8339
+ const status = query.get('status') || 'pending';
8340
+ if (!isInboxStatus(status)) {
8341
+ return mockFailure('assistant-message-invalid');
8342
+ }
7898
8343
  const grouped = query.get('grouped') === 'true';
7899
8344
  const items = this.visible()
7900
- .filter(item => item.proposal.status === 'pending' && item.canDecide)
8345
+ .filter(item => item.proposal.status === status && item.canDecide)
7901
8346
  .filter(item => divisionId === null || sameGuid(item.divisionId, divisionId))
7902
8347
  .map(item => item.proposal)
7903
8348
  .filter(proposal => (subjectType === null || proposal.subjectType === subjectType)
7904
8349
  && (agentId === null || proposal.agentId === agentId)
7905
8350
  && (runId === null || proposal.runId === runId))
7906
- .sort((left, right) => left.expiresAt.localeCompare(right.expiresAt) || left.approvalId.localeCompare(right.approvalId));
8351
+ .sort((left, right) => inboxSortAt(left, status).localeCompare(inboxSortAt(right, status))
8352
+ || left.approvalId.localeCompare(right.approvalId));
7907
8353
  const ordered = grouped ? groupedOrder(items) : items;
7908
8354
  const offset = (page - 1) * itemsPerPage;
7909
8355
  const pageItems = ordered.slice(offset, offset + itemsPerPage);
@@ -7912,6 +8358,7 @@ class NhAssistantMockProposals {
7912
8358
  const body = {
7913
8359
  items: pageItems,
7914
8360
  total: items.length,
8361
+ status,
7915
8362
  scopeKey,
7916
8363
  lastVisitedAt,
7917
8364
  newApprovalIds: pageItems
@@ -8075,6 +8522,23 @@ function consultationRefusal(route, initiatorActorId, actorId) {
8075
8522
  }
8076
8523
  return route === 'subject-agent-rights' && initiatorActorId === actorId ? fourEyesCode : null;
8077
8524
  }
8525
+ function isInboxStatus(status) {
8526
+ return status === 'pending' || status === 'awaiting-application' || status === 'execution-unknown';
8527
+ }
8528
+ /**
8529
+ * The time the inbox lists a proposal by, as the server: a pending one by its expiry, one handed to the
8530
+ * application by its report deadline, an uncertain execution by its decision.
8531
+ */
8532
+ function inboxSortAt(proposal, status) {
8533
+ switch (status) {
8534
+ case 'awaiting-application':
8535
+ return proposal.executionReportDueAt ?? proposal.expiresAt;
8536
+ case 'execution-unknown':
8537
+ return proposal.decidedAt ?? proposal.createdAt;
8538
+ default:
8539
+ return proposal.expiresAt;
8540
+ }
8541
+ }
8078
8542
  /** The scope of the inbox's visits: `global`, or the scope key of a division. */
8079
8543
  function scopeKeyOf(divisionId) {
8080
8544
  return divisionId === null ? 'global' : `division:${divisionId.replaceAll('-', '').toLowerCase()}`;
@@ -8209,6 +8673,8 @@ const NH_ASSISTANT_MOCK_SCENARIO = new InjectionToken('NH_ASSISTANT_MOCK_SCENARI
8209
8673
  const NH_ASSISTANT_MOCK_HUB_PATH = '/hub/assistant-mock';
8210
8674
  const defaultLimits = { maxMessageChars: 4_000, maxToolCallsPerTurn: 8 };
8211
8675
  const defaultUser = { actorId: 'mock-user', displayName: 'You' };
8676
+ /** The tool of `ask_question`, as the server records its call. */
8677
+ const askQuestionToolId = 'assistant.ask-question';
8212
8678
  /** When the scenario's consultations started, when they name no time. */
8213
8679
  const consultationStart = new Date().toISOString();
8214
8680
  class RunStopped extends Error {
@@ -8224,6 +8690,7 @@ class NhAssistantMockBackend {
8224
8690
  this.config = inject(NH_ASSISTANT_CONFIG);
8225
8691
  this.conversations = new Map();
8226
8692
  this.pendingApprovals = new Map();
8693
+ this.pendingQuestions = new Map();
8227
8694
  this.runs = new Map();
8228
8695
  /** Page context of the latest message per conversation, as the server stores it. */
8229
8696
  this.pageContexts = new Map();
@@ -8543,6 +9010,7 @@ class NhAssistantMockBackend {
8543
9010
  if (method === 'DELETE') {
8544
9011
  this.conversations.delete(conversation.id);
8545
9012
  this.pendingApprovals.delete(conversation.id);
9013
+ this.pendingQuestions.delete(conversation.id);
8546
9014
  this.emitLive({ kind: 'removed', data: { conversationId: conversation.id } });
8547
9015
  return this.empty(204);
8548
9016
  }
@@ -8587,6 +9055,12 @@ class NhAssistantMockBackend {
8587
9055
  if (method === 'POST' && segments.length === 5 && segments[2] === 'approvals' && segments[4] === 'decide') {
8588
9056
  return this.decide(conversation, segments[3], body);
8589
9057
  }
9058
+ if (method === 'POST' && segments.length === 5 && segments[2] === 'approvals' && segments[4] === 'resolve') {
9059
+ return this.resolve(conversation, segments[3], body);
9060
+ }
9061
+ if (method === 'POST' && segments.length === 5 && segments[2] === 'questions' && segments[4] === 'answer') {
9062
+ return this.answer(conversation, segments[3], body);
9063
+ }
8590
9064
  return this.empty(404);
8591
9065
  }
8592
9066
  list(query) {
@@ -8630,7 +9104,10 @@ class NhAssistantMockBackend {
8630
9104
  if (filesCode) {
8631
9105
  return this.error(filesCode);
8632
9106
  }
8633
- 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)) {
8634
9111
  return this.empty(409);
8635
9112
  }
8636
9113
  // Like the server: an invalid page context is dropped, never a 400.
@@ -8730,12 +9207,114 @@ class NhAssistantMockBackend {
8730
9207
  }
8731
9208
  });
8732
9209
  }
9210
+ /**
9211
+ * `POST conversations/{id}/approvals/{approvalId}/resolve`: the person who approved a proposal in this
9212
+ * chat resolves its uncertain hand-off (`canResolve`), as the server does: `hand-to-application` hands
9213
+ * it to the application again with a new report deadline, `mark-resolved` confirms it. Another action
9214
+ * is refused, as are an approval in another status and one the caller may not resolve. Returns the
9215
+ * conversation.
9216
+ */
9217
+ resolve(conversation, approvalId, request) {
9218
+ const part = conversation.messages
9219
+ .flatMap(message => message.parts)
9220
+ .find((item) => item.type === 'approval' && item.approvalId === approvalId);
9221
+ if (!part) {
9222
+ return this.error('assistant-approval-not-found');
9223
+ }
9224
+ const action = request?.action;
9225
+ if (action !== 'hand-to-application' && action !== 'mark-resolved') {
9226
+ return this.error('assistant-approval-decision-invalid');
9227
+ }
9228
+ if (part.status !== 'execution-unknown') {
9229
+ return this.error('assistant-execution-not-uncertain');
9230
+ }
9231
+ if (part.canResolve !== true) {
9232
+ return this.error('assistant-approval-forbidden');
9233
+ }
9234
+ const reportDueAt = new Date(Date.now() + mockReportWithinMs).toISOString();
9235
+ this.update(conversation.id, current => withResolvedApproval(current, approvalId, action, reportDueAt));
9236
+ return this.json(200, this.view(this.conversations.get(conversation.id) ?? conversation));
9237
+ }
9238
+ /**
9239
+ * `POST conversations/{id}/questions/{questionId}/answer`: the addressee answers the question the
9240
+ * turn waits for with the index of a choice, as on the server. Anyone else is refused, as are a
9241
+ * choice the question does not offer and a question whose wait ended, which releases the
9242
+ * conversation. The answer resumes the turn with the step's answered steps.
9243
+ */
9244
+ answer(conversation, questionId, request) {
9245
+ const pending = this.pendingQuestions.get(conversation.id);
9246
+ const known = conversation.messages.some(message => message.parts.some(part => part.type === 'question' && part.questionId === questionId));
9247
+ if (!known) {
9248
+ return this.error('assistant-question-not-found');
9249
+ }
9250
+ if (pending?.question.questionId === questionId && pending.question.addresseeActorId !== this.currentUser.actorId) {
9251
+ return this.error('assistant-question-forbidden');
9252
+ }
9253
+ if (!pending || pending.question.questionId !== questionId) {
9254
+ return this.error('assistant-question-not-pending');
9255
+ }
9256
+ const choice = request?.choice;
9257
+ if (typeof choice !== 'number' || !Number.isInteger(choice) || choice < 0 || choice >= pending.question.choices.length) {
9258
+ return this.error('assistant-question-answer-invalid');
9259
+ }
9260
+ if (Date.parse(pending.question.expiresAt) <= Date.now()) {
9261
+ this.pendingQuestions.delete(conversation.id);
9262
+ this.update(conversation.id, current => withClosedQuestion(current, questionId, 'expired'));
9263
+ return this.error('assistant-question-expired');
9264
+ }
9265
+ this.pendingQuestions.delete(conversation.id);
9266
+ this.activeActors.set(conversation.id, this.currentUser.actorId);
9267
+ const lastUser = [...conversation.messages].reverse().find(message => message.role === 'user');
9268
+ this.update(conversation.id, current => applyNhAssistantQuestionAnswer(current, questionId, choice));
9269
+ const answer = pending.question.choices[choice];
9270
+ const steps = typeof pending.step.answered === 'function' ? pending.step.answered(answer, choice) : pending.step.answered;
9271
+ const assistantMessageId = this.nextId();
9272
+ return this.stream(conversation.id, async (emit) => {
9273
+ await emit({ type: 'turn.started', data: { turnId: this.nextId(), userMessageId: lastUser?.id ?? questionId, assistantMessageId } });
9274
+ await emit({
9275
+ type: 'tool.completed',
9276
+ data: { invocationId: questionId, status: 'succeeded', resultCode: null, resultPreview: JSON.stringify({ answer }) }
9277
+ });
9278
+ await emit({
9279
+ type: 'question.answered',
9280
+ data: { ...pending.question, status: 'answered', answerIndex: choice, closedAt: new Date().toISOString() }
9281
+ });
9282
+ await this.playSteps(conversation.id, assistantMessageId, steps, emit);
9283
+ });
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
+ }
8733
9307
  cancel(conversation) {
8734
9308
  const run = this.runs.get(conversation.id);
8735
9309
  if (run) {
8736
9310
  run.cancelled = true;
8737
9311
  return this.empty(202);
8738
9312
  }
9313
+ const question = this.pendingQuestions.get(conversation.id);
9314
+ if (question) {
9315
+ this.pendingQuestions.delete(conversation.id);
9316
+ this.update(conversation.id, current => withClosedQuestion(current, question.question.questionId, 'cancelled'));
9317
+ }
8739
9318
  const pending = this.pendingApprovals.get(conversation.id);
8740
9319
  if (pending) {
8741
9320
  this.pendingApprovals.delete(conversation.id);
@@ -8786,6 +9365,12 @@ class NhAssistantMockBackend {
8786
9365
  await emit({ type: 'turn.completed', data: { turnId: this.nextId(), status: 'waiting-for-approval', usage, errorCode: null } });
8787
9366
  return;
8788
9367
  }
9368
+ else if ('question' in step) {
9369
+ await this.pauseForQuestion(conversationId, step.question, emit);
9370
+ usage.toolCalls++;
9371
+ await emit({ type: 'turn.completed', data: { turnId: this.nextId(), status: 'waiting-for-approval', usage, errorCode: null } });
9372
+ return;
9373
+ }
8789
9374
  else if ('error' in step) {
8790
9375
  await emit({ type: 'error', data: step.error });
8791
9376
  // The mock keeps the conversation usable after a failed turn.
@@ -8853,6 +9438,31 @@ class NhAssistantMockBackend {
8853
9438
  this.pendingApprovals.set(conversationId, { approval, invocationId, assistantMessageId, step });
8854
9439
  await emit({ type: 'approval.required', data: approval });
8855
9440
  }
9441
+ /** Records the `ask_question` call and posts the question; the turn then waits for its answer. */
9442
+ async pauseForQuestion(conversationId, step, emit) {
9443
+ const questionId = this.nextId();
9444
+ await emit({
9445
+ type: 'tool.started',
9446
+ data: {
9447
+ invocationId: questionId,
9448
+ toolId: askQuestionToolId,
9449
+ toolVersion: 1,
9450
+ displayName: 'ask_question',
9451
+ argumentsPreview: JSON.stringify({ question: step.question, choices: step.choices })
9452
+ }
9453
+ });
9454
+ const question = {
9455
+ type: 'question',
9456
+ questionId,
9457
+ question: step.question,
9458
+ choices: [...step.choices],
9459
+ addresseeActorId: step.addresseeActorId ?? this.activeActors.get(conversationId) ?? this.currentUser.actorId,
9460
+ expiresAt: new Date(Date.now() + (step.expiresInSeconds ?? 900) * 1000).toISOString(),
9461
+ status: 'pending'
9462
+ };
9463
+ this.pendingQuestions.set(conversationId, { question, step });
9464
+ await emit({ type: 'question.asked', data: question });
9465
+ }
8856
9466
  stream(conversationId, play) {
8857
9467
  const timing = this.scenario.timing ?? {};
8858
9468
  const firstDelay = timing.firstEventDelayMs ?? 200;
@@ -9233,6 +9843,72 @@ function withMovedApproval(conversation, approvalId, movedToApprovalId) {
9233
9843
  messages: conversation.messages.map(message => ({ ...message, parts: message.parts.map(moved) }))
9234
9844
  };
9235
9845
  }
9846
+ /** How long the mock's application may take to report after a hand-off. */
9847
+ const mockReportWithinMs = 4 * 60 * 60 * 1000;
9848
+ /**
9849
+ * The approval after its uncertain hand-off was resolved: handed to the application again, waiting for
9850
+ * its report until `reportDueAt`, or resolved. Either way nobody resolves it again.
9851
+ */
9852
+ function withResolvedApproval(conversation, approvalId, action, reportDueAt) {
9853
+ const resolved = (part) => {
9854
+ if (part.type !== 'approval' || part.approvalId !== approvalId) {
9855
+ return part;
9856
+ }
9857
+ const { canResolve: _resolved, ...rest } = part;
9858
+ return action === 'hand-to-application'
9859
+ ? { ...rest, status: 'awaiting-application', executionResultCode: 'hand-off-acknowledged', executionReportDueAt: reportDueAt }
9860
+ : { ...rest, status: 'resolved', executionResultCode: 'resolved-by-approver' };
9861
+ };
9862
+ return {
9863
+ ...conversation,
9864
+ messages: conversation.messages.map(message => ({ ...message, parts: message.parts.map(resolved) }))
9865
+ };
9866
+ }
9867
+ /**
9868
+ * A question that closed without an answer, as the server reads it afterwards: expired or withdrawn
9869
+ * with its stopped turn. Its call ends, and the conversation takes the next message.
9870
+ */
9871
+ function withClosedQuestion(conversation, questionId, status) {
9872
+ const closedAt = new Date().toISOString();
9873
+ const closed = (part) => {
9874
+ if (part.type === 'question' && part.questionId === questionId) {
9875
+ return { ...part, status, closedAt };
9876
+ }
9877
+ if (part.type === 'tool-call' && part.invocationId === questionId) {
9878
+ return status === 'expired'
9879
+ ? { ...part, status: 'failed', resultCode: 'assistant-question-expired' }
9880
+ : { ...part, status: 'not-executed', resultCode: 'assistant-question-cancelled' };
9881
+ }
9882
+ return part;
9883
+ };
9884
+ return {
9885
+ ...conversation,
9886
+ status: 'idle',
9887
+ pendingQuestion: null,
9888
+ messages: conversation.messages.map(message => ({ ...message, parts: message.parts.map(closed) }))
9889
+ };
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
+ }
9236
9912
  /** Like the server: a new message starts in an idle conversation or after a failed turn. */
9237
9913
  function acceptsMessage(status) {
9238
9914
  return status === 'idle' || status === 'failed';