@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.
- package/README.md +7 -0
- package/admin/index.d.ts +12 -1
- package/fesm2022/newheap-platform-ai-chat-admin.mjs +119 -26
- package/fesm2022/newheap-platform-ai-chat-admin.mjs.map +1 -1
- package/fesm2022/newheap-platform-ai-chat-testing.mjs +363 -7
- package/fesm2022/newheap-platform-ai-chat-testing.mjs.map +1 -1
- package/fesm2022/newheap-platform-ai-chat.mjs +1074 -59
- package/fesm2022/newheap-platform-ai-chat.mjs.map +1 -1
- package/i18n/en.json +60 -3
- package/i18n/nl.json +60 -3
- package/index.d.ts +377 -16
- package/package.json +1 -1
- package/testing/index.d.ts +43 -2
|
@@ -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 => ({
|
|
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 {
|
|
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 ===
|
|
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.
|
|
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';
|