@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.
- package/README.md +19 -2
- package/admin/i18n/en.json +1738 -0
- package/admin/i18n/nl.json +1738 -0
- package/admin/index.d.ts +59 -7
- package/fesm2022/newheap-platform-ai-chat-admin.mjs +3729 -39
- package/fesm2022/newheap-platform-ai-chat-admin.mjs.map +1 -1
- package/fesm2022/newheap-platform-ai-chat-testing.mjs +700 -24
- package/fesm2022/newheap-platform-ai-chat-testing.mjs.map +1 -1
- package/fesm2022/newheap-platform-ai-chat.mjs +1187 -3515
- package/fesm2022/newheap-platform-ai-chat.mjs.map +1 -1
- package/i18n/en.json +49 -1714
- package/i18n/nl.json +49 -1714
- package/index.d.ts +473 -29
- package/package.json +4 -1
- package/testing/index.d.ts +59 -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 => ({
|
|
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 {
|
|
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 ===
|
|
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.
|
|
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
|
-
|
|
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';
|