@newheap/platform-ai-chat 0.19.0 → 0.20.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_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';
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',
@@ -500,6 +508,41 @@ class NhAssistantMockAdminAgents {
500
508
  stored.agent = { ...stored.agent, version: stored.agent.version + 1, updatedAt: this.now() };
501
509
  return stored.agent;
502
510
  }
511
+ /**
512
+ * Copies a data shadow variant's definition onto its live agent as the live agent's next version,
513
+ * as a promotion does on the server before it deletes the variant: the live agent keeps its name,
514
+ * MCP servers, state and shadow mode; an agent in code takes only the fields it can override, an
515
+ * agent created here also the variant's profile, classification, what it works on and its asset.
516
+ */
517
+ promote(variantId, liveId) {
518
+ const variant = this.agents.get(variantId);
519
+ const live = this.agents.get(liveId);
520
+ if (!variant || !live) {
521
+ throw new Error(`The mock has no agent '${variant ? liveId : variantId}'.`);
522
+ }
523
+ const from = variant.agent;
524
+ const runtime = live.agent.source === 'admin';
525
+ const definition = {
526
+ id: live.agent.id,
527
+ displayName: live.agent.displayName,
528
+ description: from.description,
529
+ instructions: runtime && from.instructionsAsset ? '' : from.instructions,
530
+ toolSelectors: [...from.toolSelectors],
531
+ mcpServerIds: [...live.agent.mcpServerIds],
532
+ requiredPolicy: from.requiredPolicy,
533
+ autonomy: from.autonomy,
534
+ ...(runtime
535
+ ? {
536
+ ...pickWorksOn(from),
537
+ profile: from.profile,
538
+ dataClassification: from.dataClassification ?? null,
539
+ instructionsAsset: from.instructionsAsset ?? null
540
+ }
541
+ : {})
542
+ };
543
+ live.agent = this.toAgent(definition, live.agent.source, live.agent.version + 1, live.agent.source === 'code', live.agent);
544
+ return live.agent;
545
+ }
503
546
  /** Removes a deleted MCP server from the agents it was assigned to. */
504
547
  removeServer(serverId) {
505
548
  for (const agent of this.agents.values()) {
@@ -2740,10 +2783,14 @@ class NhAssistantMockHandOffs {
2740
2783
  allowsDataVariants: allowed,
2741
2784
  maxDataVariants: allowed ? this.maxVariants() : 0,
2742
2785
  variants: [
2743
- ...code.map(agent => ({ agentId: agent.id, displayName: agent.displayName, source: 'code', hasRunSettings: true, inEffect: true, problem: null })),
2786
+ ...code.map(agent => ({
2787
+ agentId: agent.id, displayName: agent.displayName, source: 'code', hasRunSettings: true, inEffect: true, problem: null, version: agent.version
2788
+ })),
2744
2789
  ...data.map(agent => {
2745
2790
  const hasRunSettings = this.agents.isConfigured(agent);
2746
- return { agentId: agent.id, displayName: agent.displayName, source: 'data', hasRunSettings, inEffect: hasRunSettings, problem: null };
2791
+ return {
2792
+ agentId: agent.id, displayName: agent.displayName, source: 'data', hasRunSettings, inEffect: hasRunSettings, problem: null, version: agent.version
2793
+ };
2747
2794
  })
2748
2795
  ]
2749
2796
  };
@@ -4166,6 +4213,54 @@ class NhAssistantMockRunConfiguration {
4166
4213
  existing.reason = typeof request.reason === 'string' && request.reason.trim().length > 0 ? request.reason.trim() : null;
4167
4214
  return { status: 200, body: withoutOwner(existing) };
4168
4215
  }
4216
+ /**
4217
+ * The ids of the variant's data skills that would move to `live` and the failure of one that cannot,
4218
+ * as a promotion with `moveSkills` checks them before anything changes: a skill the live agent has
4219
+ * in code stays (`assistant-skill-in-code`).
4220
+ */
4221
+ skillsToMove(variantId, live) {
4222
+ const skillIds = this.liveSkills(variantId).map(skill => skill.skillId);
4223
+ const inCode = (live.skills ?? []).some(skill => skillIds.includes(skill.skillId) && !this.skills.has(key$1(live.id, skill.skillId)));
4224
+ return { skillIds, failure: inCode ? mockFailure('assistant-skill-in-code') : null };
4225
+ }
4226
+ /**
4227
+ * Moves the variant's data skills to `live` as a promotion does: each becomes the next live version
4228
+ * of the live agent's skill with the same id, or a new skill, which starts in shadow. Answers the ids
4229
+ * that moved, in order, or the failure of the first save.
4230
+ */
4231
+ moveSkills(variantId, live, reason) {
4232
+ const moved = [];
4233
+ for (const skill of this.liveSkills(variantId)) {
4234
+ const current = (this.skills.get(key$1(live.id, skill.skillId)) ?? []).find(version => version.status === 'live');
4235
+ const request = {
4236
+ description: skill.description,
4237
+ instructions: skill.instructionsAsset ? null : skill.instructions,
4238
+ tools: [...skill.tools],
4239
+ profile: skill.profile,
4240
+ ruleHandler: skill.ruleHandler,
4241
+ maxTokensPerRun: skill.maxTokensPerRun,
4242
+ dailyToolCalls: skill.dailyToolCalls,
4243
+ dailyTokens: skill.dailyTokens,
4244
+ approvalPolicy: skill.approvalPolicy,
4245
+ expectedVersion: current?.version ?? null,
4246
+ reason,
4247
+ ...(current ? { apply: 'live' } : {}),
4248
+ dataClassification: skill.dataClassification ?? null,
4249
+ acceptsFiles: skill.acceptsFiles ?? false,
4250
+ maxFileBytes: skill.maxFileBytes ?? null,
4251
+ maxFilePages: skill.maxFilePages ?? null,
4252
+ maxFilesPerTurn: skill.maxFilesPerTurn ?? null,
4253
+ maxFileTokensPerTurn: skill.maxFileTokensPerTurn ?? null,
4254
+ instructionsAsset: skill.instructionsAsset ?? null
4255
+ };
4256
+ const saved = this.skill('PUT', live, skill.skillId, request, new URLSearchParams());
4257
+ if (saved.status !== 200) {
4258
+ return { moved, failure: saved };
4259
+ }
4260
+ moved.push(skill.skillId);
4261
+ }
4262
+ return { moved, failure: null };
4263
+ }
4169
4264
  /** An agent in code has a run configuration; one created in the administration once it has run settings. */
4170
4265
  isConfigured(agent) {
4171
4266
  return agent.source === 'code' || this.settings.has(agent.id);
@@ -4468,6 +4563,9 @@ class NhAssistantMockAdmin {
4468
4563
  if (id !== undefined && (action === 'replay' || action === 'replays') && segments.length === 4) {
4469
4564
  return this.agents.agentReplay(method, id, action, body);
4470
4565
  }
4566
+ if (id !== undefined && action === 'promote' && segments.length === 4) {
4567
+ return this.promote(method, id, body);
4568
+ }
4471
4569
  if (id !== undefined && (action === 'hand-offs' || action === 'shadow-variants')) {
4472
4570
  const handed = this.handOffs.handle(method, id, segments.slice(3), body, query);
4473
4571
  if (handed) {
@@ -4495,6 +4593,56 @@ class NhAssistantMockAdmin {
4495
4593
  return mockFailure('assistant-not-found');
4496
4594
  }
4497
4595
  }
4596
+ /**
4597
+ * `POST admin/agents/{id}/promote`: a data shadow variant's definition becomes its live agent's next
4598
+ * version, its skills move along with `moveSkills`, and the variant is deleted, as on the server. A
4599
+ * stale version of either agent answers `409`; a skill the live agent has in code stops the promotion
4600
+ * before anything changes.
4601
+ */
4602
+ promote(method, variantId, body) {
4603
+ if (method !== 'POST') {
4604
+ return { status: 405 };
4605
+ }
4606
+ const variant = this.agents.find(variantId);
4607
+ if (!variant) {
4608
+ return mockFailure('assistant-agent-not-found');
4609
+ }
4610
+ if (variant.source !== 'admin' || !variant.shadowOf) {
4611
+ return mockFailure('assistant-not-shadow-variant');
4612
+ }
4613
+ const request = body;
4614
+ if (typeof request?.expectedVersion !== 'number') {
4615
+ return mockFailure('assistant-validation', { expectedVersion: ['required'] });
4616
+ }
4617
+ if (typeof request.reason === 'string' && request.reason.length > 500) {
4618
+ return mockFailure('assistant-validation', { reason: ['too-long'] });
4619
+ }
4620
+ const live = this.agents.find(variant.shadowOf);
4621
+ if (!live) {
4622
+ return mockFailure('assistant-agent-not-found');
4623
+ }
4624
+ if (request.expectedVersion !== live.version
4625
+ || (typeof request.expectedVariantVersion === 'number' && request.expectedVariantVersion !== variant.version)) {
4626
+ return mockFailure('assistant-version-conflict');
4627
+ }
4628
+ if (request.moveSkills === true) {
4629
+ const refused = this.runConfiguration.skillsToMove(variant.id, live).failure;
4630
+ if (refused) {
4631
+ return refused;
4632
+ }
4633
+ }
4634
+ // In the server's order: the definition, the skills, then the variant leaves.
4635
+ const agent = this.agents.promote(variant.id, live.id);
4636
+ const moved = request.moveSkills === true
4637
+ ? this.runConfiguration.moveSkills(variant.id, agent, typeof request.reason === 'string' ? request.reason.trim() || null : null)
4638
+ : { moved: [], failure: null };
4639
+ if (moved.failure) {
4640
+ return moved.failure;
4641
+ }
4642
+ this.agents.agentsEndpoint('DELETE', variant.id, undefined, undefined, 3);
4643
+ const promotion = { agent: this.agents.find(agent.id), variantId: variant.id, movedSkills: moved.moved };
4644
+ return { status: 200, body: promotion };
4645
+ }
4498
4646
  context(method, sub, body) {
4499
4647
  const current = this.contextVersions[this.contextVersions.length - 1];
4500
4648
  if (sub === 'versions' && method === 'GET') {
@@ -7544,7 +7692,8 @@ class NhAssistantMockAgentMetadata {
7544
7692
  name: entry.name,
7545
7693
  editable: entry.editable === true,
7546
7694
  maxBytes: entry.maxBytes ?? defaultMaxBytes,
7547
- indexed: entry.indexBy !== undefined
7695
+ indexed: entry.indexBy !== undefined || entry.indexByEach !== undefined,
7696
+ indexedByEach: entry.indexByEach !== undefined
7548
7697
  }));
7549
7698
  }
7550
7699
  /**
@@ -7660,8 +7809,10 @@ class NhAssistantMockAgentMetadata {
7660
7809
  version: inCode ? stored?.version ?? null : agent.version,
7661
7810
  agentVersion: agent.version,
7662
7811
  codeValue: inCode && code !== undefined ? structuredClone(code) : null,
7663
- indexed: entry.indexBy !== undefined,
7812
+ indexed: entry.indexBy !== undefined || entry.indexByEach !== undefined,
7664
7813
  indexValue: value !== undefined ? indexOf(entry, value) : null,
7814
+ indexedByEach: entry.indexByEach !== undefined,
7815
+ indexValues: value !== undefined ? indexValuesOf(entry, value) : [],
7665
7816
  changedAt: stored?.changedAt ?? null,
7666
7817
  changedBy: stored?.changedBy ?? null
7667
7818
  };
@@ -7703,6 +7854,20 @@ function indexOf(entry, value) {
7703
7854
  const trimmed = typeof text === 'string' ? text.trim() : '';
7704
7855
  return trimmed.length > 0 ? trimmed.toLowerCase() : null;
7705
7856
  }
7857
+ /**
7858
+ * The searchable values of an `IndexByEach` entry's value as the server stores them: trimmed, in
7859
+ * lowercase, without empty values or repeats, in ordinal order.
7860
+ */
7861
+ function indexValuesOf(entry, value) {
7862
+ const texts = entry.indexByEach === undefined ? undefined : value[entry.indexByEach];
7863
+ if (!Array.isArray(texts)) {
7864
+ return [];
7865
+ }
7866
+ const values = texts
7867
+ .map(text => (typeof text === 'string' ? text.trim().toLowerCase() : ''))
7868
+ .filter(text => text.length > 0);
7869
+ return [...new Set(values)].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
7870
+ }
7706
7871
 
7707
7872
  /** How long the application may take to report after a hand-off in the mock. */
7708
7873
  const reportWithinMs = 4 * 60 * 60 * 1000;
@@ -7895,15 +8060,22 @@ class NhAssistantMockProposals {
7895
8060
  if (divisionId !== null && !guid.test(divisionId)) {
7896
8061
  return { status: 400 };
7897
8062
  }
8063
+ // What waits for a decision by default; what waits for the application's report or for an
8064
+ // approver to resolve an uncertain execution on request, with the same visibility.
8065
+ const status = query.get('status') || 'pending';
8066
+ if (!isInboxStatus(status)) {
8067
+ return mockFailure('assistant-message-invalid');
8068
+ }
7898
8069
  const grouped = query.get('grouped') === 'true';
7899
8070
  const items = this.visible()
7900
- .filter(item => item.proposal.status === 'pending' && item.canDecide)
8071
+ .filter(item => item.proposal.status === status && item.canDecide)
7901
8072
  .filter(item => divisionId === null || sameGuid(item.divisionId, divisionId))
7902
8073
  .map(item => item.proposal)
7903
8074
  .filter(proposal => (subjectType === null || proposal.subjectType === subjectType)
7904
8075
  && (agentId === null || proposal.agentId === agentId)
7905
8076
  && (runId === null || proposal.runId === runId))
7906
- .sort((left, right) => left.expiresAt.localeCompare(right.expiresAt) || left.approvalId.localeCompare(right.approvalId));
8077
+ .sort((left, right) => inboxSortAt(left, status).localeCompare(inboxSortAt(right, status))
8078
+ || left.approvalId.localeCompare(right.approvalId));
7907
8079
  const ordered = grouped ? groupedOrder(items) : items;
7908
8080
  const offset = (page - 1) * itemsPerPage;
7909
8081
  const pageItems = ordered.slice(offset, offset + itemsPerPage);
@@ -7912,6 +8084,7 @@ class NhAssistantMockProposals {
7912
8084
  const body = {
7913
8085
  items: pageItems,
7914
8086
  total: items.length,
8087
+ status,
7915
8088
  scopeKey,
7916
8089
  lastVisitedAt,
7917
8090
  newApprovalIds: pageItems
@@ -8075,6 +8248,23 @@ function consultationRefusal(route, initiatorActorId, actorId) {
8075
8248
  }
8076
8249
  return route === 'subject-agent-rights' && initiatorActorId === actorId ? fourEyesCode : null;
8077
8250
  }
8251
+ function isInboxStatus(status) {
8252
+ return status === 'pending' || status === 'awaiting-application' || status === 'execution-unknown';
8253
+ }
8254
+ /**
8255
+ * The time the inbox lists a proposal by, as the server: a pending one by its expiry, one handed to the
8256
+ * application by its report deadline, an uncertain execution by its decision.
8257
+ */
8258
+ function inboxSortAt(proposal, status) {
8259
+ switch (status) {
8260
+ case 'awaiting-application':
8261
+ return proposal.executionReportDueAt ?? proposal.expiresAt;
8262
+ case 'execution-unknown':
8263
+ return proposal.decidedAt ?? proposal.createdAt;
8264
+ default:
8265
+ return proposal.expiresAt;
8266
+ }
8267
+ }
8078
8268
  /** The scope of the inbox's visits: `global`, or the scope key of a division. */
8079
8269
  function scopeKeyOf(divisionId) {
8080
8270
  return divisionId === null ? 'global' : `division:${divisionId.replaceAll('-', '').toLowerCase()}`;
@@ -8209,6 +8399,8 @@ const NH_ASSISTANT_MOCK_SCENARIO = new InjectionToken('NH_ASSISTANT_MOCK_SCENARI
8209
8399
  const NH_ASSISTANT_MOCK_HUB_PATH = '/hub/assistant-mock';
8210
8400
  const defaultLimits = { maxMessageChars: 4_000, maxToolCallsPerTurn: 8 };
8211
8401
  const defaultUser = { actorId: 'mock-user', displayName: 'You' };
8402
+ /** The tool of `ask_question`, as the server records its call. */
8403
+ const askQuestionToolId = 'assistant.ask-question';
8212
8404
  /** When the scenario's consultations started, when they name no time. */
8213
8405
  const consultationStart = new Date().toISOString();
8214
8406
  class RunStopped extends Error {
@@ -8224,6 +8416,7 @@ class NhAssistantMockBackend {
8224
8416
  this.config = inject(NH_ASSISTANT_CONFIG);
8225
8417
  this.conversations = new Map();
8226
8418
  this.pendingApprovals = new Map();
8419
+ this.pendingQuestions = new Map();
8227
8420
  this.runs = new Map();
8228
8421
  /** Page context of the latest message per conversation, as the server stores it. */
8229
8422
  this.pageContexts = new Map();
@@ -8543,6 +8736,7 @@ class NhAssistantMockBackend {
8543
8736
  if (method === 'DELETE') {
8544
8737
  this.conversations.delete(conversation.id);
8545
8738
  this.pendingApprovals.delete(conversation.id);
8739
+ this.pendingQuestions.delete(conversation.id);
8546
8740
  this.emitLive({ kind: 'removed', data: { conversationId: conversation.id } });
8547
8741
  return this.empty(204);
8548
8742
  }
@@ -8587,6 +8781,12 @@ class NhAssistantMockBackend {
8587
8781
  if (method === 'POST' && segments.length === 5 && segments[2] === 'approvals' && segments[4] === 'decide') {
8588
8782
  return this.decide(conversation, segments[3], body);
8589
8783
  }
8784
+ if (method === 'POST' && segments.length === 5 && segments[2] === 'approvals' && segments[4] === 'resolve') {
8785
+ return this.resolve(conversation, segments[3], body);
8786
+ }
8787
+ if (method === 'POST' && segments.length === 5 && segments[2] === 'questions' && segments[4] === 'answer') {
8788
+ return this.answer(conversation, segments[3], body);
8789
+ }
8590
8790
  return this.empty(404);
8591
8791
  }
8592
8792
  list(query) {
@@ -8730,12 +8930,92 @@ class NhAssistantMockBackend {
8730
8930
  }
8731
8931
  });
8732
8932
  }
8933
+ /**
8934
+ * `POST conversations/{id}/approvals/{approvalId}/resolve`: the person who approved a proposal in this
8935
+ * chat resolves its uncertain hand-off (`canResolve`), as the server does: `hand-to-application` hands
8936
+ * it to the application again with a new report deadline, `mark-resolved` confirms it. Another action
8937
+ * is refused, as are an approval in another status and one the caller may not resolve. Returns the
8938
+ * conversation.
8939
+ */
8940
+ resolve(conversation, approvalId, request) {
8941
+ const part = conversation.messages
8942
+ .flatMap(message => message.parts)
8943
+ .find((item) => item.type === 'approval' && item.approvalId === approvalId);
8944
+ if (!part) {
8945
+ return this.error('assistant-approval-not-found');
8946
+ }
8947
+ const action = request?.action;
8948
+ if (action !== 'hand-to-application' && action !== 'mark-resolved') {
8949
+ return this.error('assistant-approval-decision-invalid');
8950
+ }
8951
+ if (part.status !== 'execution-unknown') {
8952
+ return this.error('assistant-execution-not-uncertain');
8953
+ }
8954
+ if (part.canResolve !== true) {
8955
+ return this.error('assistant-approval-forbidden');
8956
+ }
8957
+ const reportDueAt = new Date(Date.now() + mockReportWithinMs).toISOString();
8958
+ this.update(conversation.id, current => withResolvedApproval(current, approvalId, action, reportDueAt));
8959
+ return this.json(200, this.view(this.conversations.get(conversation.id) ?? conversation));
8960
+ }
8961
+ /**
8962
+ * `POST conversations/{id}/questions/{questionId}/answer`: the addressee answers the question the
8963
+ * turn waits for with the index of a choice, as on the server. Anyone else is refused, as are a
8964
+ * choice the question does not offer and a question whose wait ended, which releases the
8965
+ * conversation. The answer resumes the turn with the step's answered steps.
8966
+ */
8967
+ answer(conversation, questionId, request) {
8968
+ const pending = this.pendingQuestions.get(conversation.id);
8969
+ const known = conversation.messages.some(message => message.parts.some(part => part.type === 'question' && part.questionId === questionId));
8970
+ if (!known) {
8971
+ return this.error('assistant-question-not-found');
8972
+ }
8973
+ if (pending?.question.questionId === questionId && pending.question.addresseeActorId !== this.currentUser.actorId) {
8974
+ return this.error('assistant-question-forbidden');
8975
+ }
8976
+ if (!pending || pending.question.questionId !== questionId) {
8977
+ return this.error('assistant-question-not-pending');
8978
+ }
8979
+ const choice = request?.choice;
8980
+ if (typeof choice !== 'number' || !Number.isInteger(choice) || choice < 0 || choice >= pending.question.choices.length) {
8981
+ return this.error('assistant-question-answer-invalid');
8982
+ }
8983
+ if (Date.parse(pending.question.expiresAt) <= Date.now()) {
8984
+ this.pendingQuestions.delete(conversation.id);
8985
+ this.update(conversation.id, current => withClosedQuestion(current, questionId, 'expired'));
8986
+ return this.error('assistant-question-expired');
8987
+ }
8988
+ this.pendingQuestions.delete(conversation.id);
8989
+ this.activeActors.set(conversation.id, this.currentUser.actorId);
8990
+ const lastUser = [...conversation.messages].reverse().find(message => message.role === 'user');
8991
+ this.update(conversation.id, current => applyNhAssistantQuestionAnswer(current, questionId, choice));
8992
+ const answer = pending.question.choices[choice];
8993
+ const steps = typeof pending.step.answered === 'function' ? pending.step.answered(answer, choice) : pending.step.answered;
8994
+ const assistantMessageId = this.nextId();
8995
+ return this.stream(conversation.id, async (emit) => {
8996
+ await emit({ type: 'turn.started', data: { turnId: this.nextId(), userMessageId: lastUser?.id ?? questionId, assistantMessageId } });
8997
+ await emit({
8998
+ type: 'tool.completed',
8999
+ data: { invocationId: questionId, status: 'succeeded', resultCode: null, resultPreview: JSON.stringify({ answer }) }
9000
+ });
9001
+ await emit({
9002
+ type: 'question.answered',
9003
+ data: { ...pending.question, status: 'answered', answerIndex: choice, closedAt: new Date().toISOString() }
9004
+ });
9005
+ await this.playSteps(conversation.id, assistantMessageId, steps, emit);
9006
+ });
9007
+ }
8733
9008
  cancel(conversation) {
8734
9009
  const run = this.runs.get(conversation.id);
8735
9010
  if (run) {
8736
9011
  run.cancelled = true;
8737
9012
  return this.empty(202);
8738
9013
  }
9014
+ const question = this.pendingQuestions.get(conversation.id);
9015
+ if (question) {
9016
+ this.pendingQuestions.delete(conversation.id);
9017
+ this.update(conversation.id, current => withClosedQuestion(current, question.question.questionId, 'cancelled'));
9018
+ }
8739
9019
  const pending = this.pendingApprovals.get(conversation.id);
8740
9020
  if (pending) {
8741
9021
  this.pendingApprovals.delete(conversation.id);
@@ -8786,6 +9066,12 @@ class NhAssistantMockBackend {
8786
9066
  await emit({ type: 'turn.completed', data: { turnId: this.nextId(), status: 'waiting-for-approval', usage, errorCode: null } });
8787
9067
  return;
8788
9068
  }
9069
+ else if ('question' in step) {
9070
+ await this.pauseForQuestion(conversationId, step.question, emit);
9071
+ usage.toolCalls++;
9072
+ await emit({ type: 'turn.completed', data: { turnId: this.nextId(), status: 'waiting-for-approval', usage, errorCode: null } });
9073
+ return;
9074
+ }
8789
9075
  else if ('error' in step) {
8790
9076
  await emit({ type: 'error', data: step.error });
8791
9077
  // The mock keeps the conversation usable after a failed turn.
@@ -8853,6 +9139,31 @@ class NhAssistantMockBackend {
8853
9139
  this.pendingApprovals.set(conversationId, { approval, invocationId, assistantMessageId, step });
8854
9140
  await emit({ type: 'approval.required', data: approval });
8855
9141
  }
9142
+ /** Records the `ask_question` call and posts the question; the turn then waits for its answer. */
9143
+ async pauseForQuestion(conversationId, step, emit) {
9144
+ const questionId = this.nextId();
9145
+ await emit({
9146
+ type: 'tool.started',
9147
+ data: {
9148
+ invocationId: questionId,
9149
+ toolId: askQuestionToolId,
9150
+ toolVersion: 1,
9151
+ displayName: 'ask_question',
9152
+ argumentsPreview: JSON.stringify({ question: step.question, choices: step.choices })
9153
+ }
9154
+ });
9155
+ const question = {
9156
+ type: 'question',
9157
+ questionId,
9158
+ question: step.question,
9159
+ choices: [...step.choices],
9160
+ addresseeActorId: step.addresseeActorId ?? this.activeActors.get(conversationId) ?? this.currentUser.actorId,
9161
+ expiresAt: new Date(Date.now() + (step.expiresInSeconds ?? 900) * 1000).toISOString(),
9162
+ status: 'pending'
9163
+ };
9164
+ this.pendingQuestions.set(conversationId, { question, step });
9165
+ await emit({ type: 'question.asked', data: question });
9166
+ }
8856
9167
  stream(conversationId, play) {
8857
9168
  const timing = this.scenario.timing ?? {};
8858
9169
  const firstDelay = timing.firstEventDelayMs ?? 200;
@@ -9233,6 +9544,51 @@ function withMovedApproval(conversation, approvalId, movedToApprovalId) {
9233
9544
  messages: conversation.messages.map(message => ({ ...message, parts: message.parts.map(moved) }))
9234
9545
  };
9235
9546
  }
9547
+ /** How long the mock's application may take to report after a hand-off. */
9548
+ const mockReportWithinMs = 4 * 60 * 60 * 1000;
9549
+ /**
9550
+ * The approval after its uncertain hand-off was resolved: handed to the application again, waiting for
9551
+ * its report until `reportDueAt`, or resolved. Either way nobody resolves it again.
9552
+ */
9553
+ function withResolvedApproval(conversation, approvalId, action, reportDueAt) {
9554
+ const resolved = (part) => {
9555
+ if (part.type !== 'approval' || part.approvalId !== approvalId) {
9556
+ return part;
9557
+ }
9558
+ const { canResolve: _resolved, ...rest } = part;
9559
+ return action === 'hand-to-application'
9560
+ ? { ...rest, status: 'awaiting-application', executionResultCode: 'hand-off-acknowledged', executionReportDueAt: reportDueAt }
9561
+ : { ...rest, status: 'resolved', executionResultCode: 'resolved-by-approver' };
9562
+ };
9563
+ return {
9564
+ ...conversation,
9565
+ messages: conversation.messages.map(message => ({ ...message, parts: message.parts.map(resolved) }))
9566
+ };
9567
+ }
9568
+ /**
9569
+ * A question that closed without an answer, as the server reads it afterwards: expired or withdrawn
9570
+ * with its stopped turn. Its call ends, and the conversation takes the next message.
9571
+ */
9572
+ function withClosedQuestion(conversation, questionId, status) {
9573
+ const closedAt = new Date().toISOString();
9574
+ const closed = (part) => {
9575
+ if (part.type === 'question' && part.questionId === questionId) {
9576
+ return { ...part, status, closedAt };
9577
+ }
9578
+ if (part.type === 'tool-call' && part.invocationId === questionId) {
9579
+ return status === 'expired'
9580
+ ? { ...part, status: 'failed', resultCode: 'assistant-question-expired' }
9581
+ : { ...part, status: 'not-executed', resultCode: 'assistant-question-cancelled' };
9582
+ }
9583
+ return part;
9584
+ };
9585
+ return {
9586
+ ...conversation,
9587
+ status: 'idle',
9588
+ pendingQuestion: null,
9589
+ messages: conversation.messages.map(message => ({ ...message, parts: message.parts.map(closed) }))
9590
+ };
9591
+ }
9236
9592
  /** Like the server: a new message starts in an idle conversation or after a failed turn. */
9237
9593
  function acceptsMessage(status) {
9238
9594
  return status === 'idle' || status === 'failed';