@newheap/platform-ai-chat 0.1.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/LICENSE +201 -0
- package/NOTICE +4 -0
- package/README.md +294 -0
- package/admin/index.d.ts +294 -0
- package/fesm2022/newheap-platform-ai-chat-admin.mjs +785 -0
- package/fesm2022/newheap-platform-ai-chat-admin.mjs.map +1 -0
- package/fesm2022/newheap-platform-ai-chat-testing.mjs +911 -0
- package/fesm2022/newheap-platform-ai-chat-testing.mjs.map +1 -0
- package/fesm2022/newheap-platform-ai-chat.mjs +3103 -0
- package/fesm2022/newheap-platform-ai-chat.mjs.map +1 -0
- package/i18n/en.json +376 -0
- package/i18n/nl.json +376 -0
- package/index.d.ts +937 -0
- package/package.json +54 -0
- package/testing/index.d.ts +189 -0
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, inject, signal, Injectable, makeEnvironmentProviders } from '@angular/core';
|
|
3
|
+
import { NH_ASSISTANT_CONFIG, normalizeNhAssistantClientContext, applyNhAssistantApprovalDecision, applyNhAssistantEvent, NH_ASSISTANT_FETCH } from '@newheap/platform-ai-chat';
|
|
4
|
+
|
|
5
|
+
const dashCase = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
6
|
+
const styles = ['default', 'direct', 'personal', 'detailed'];
|
|
7
|
+
const addressForms = ['informal', 'formal'];
|
|
8
|
+
const responseLengths = ['short', 'normal', 'long'];
|
|
9
|
+
const autonomyLevels = ['observe', 'explain', 'propose', 'simulate', 'execute'];
|
|
10
|
+
const authModes = ['none', 'bearer', 'api-key', 'forward-user-token'];
|
|
11
|
+
const maxInstructions = 20_000;
|
|
12
|
+
const maxCustomInstructions = 1_000;
|
|
13
|
+
const defaultPreferences = {
|
|
14
|
+
style: 'default',
|
|
15
|
+
addressForm: 'informal',
|
|
16
|
+
responseLength: 'normal',
|
|
17
|
+
customInstructions: null
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* In-memory administration and preference endpoints of the mock API. Follows the
|
|
21
|
+
* contract rules: secrets are write-only, new MCP tools start disabled as mutations, a
|
|
22
|
+
* changed remote schema disables a tool, and stale versions answer `409`.
|
|
23
|
+
*/
|
|
24
|
+
class NhAssistantMockAdmin {
|
|
25
|
+
constructor(scenario, now) {
|
|
26
|
+
this.scenario = scenario;
|
|
27
|
+
this.now = now;
|
|
28
|
+
this.agents = new Map();
|
|
29
|
+
this.servers = new Map();
|
|
30
|
+
this.contextVersions = [];
|
|
31
|
+
const admin = scenario.admin ?? {};
|
|
32
|
+
this.canAdminister = admin.canAdminister ?? true;
|
|
33
|
+
this.preferences = { ...defaultPreferences, ...scenario.preferences };
|
|
34
|
+
this.contextVersions.push({ version: 1, hash: hashText(admin.context ?? ''), updatedAt: now(), updatedBy: null, text: admin.context ?? '' });
|
|
35
|
+
const agents = admin.agents ?? scenario.agents.map(agent => ({ ...codeAgentFromSummary(agent), source: 'code' }));
|
|
36
|
+
for (const { source, ...input } of agents) {
|
|
37
|
+
this.agents.set(input.id, {
|
|
38
|
+
agent: this.toAgent(input, source, 1, false),
|
|
39
|
+
codeDefinition: source === 'code' ? structuredClone(input) : null
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
for (const { remoteTools, secret, ...input } of admin.mcpServers ?? []) {
|
|
43
|
+
this.servers.set(input.id, {
|
|
44
|
+
server: { ...toServerFields(input), lastSyncAt: null, lastSyncStatus: null },
|
|
45
|
+
secret: secret ?? null,
|
|
46
|
+
remoteTools: remoteTools ?? [],
|
|
47
|
+
tools: new Map()
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
get administers() {
|
|
52
|
+
return this.canAdminister;
|
|
53
|
+
}
|
|
54
|
+
setCanAdminister(value) {
|
|
55
|
+
this.canAdminister = value;
|
|
56
|
+
}
|
|
57
|
+
/** Replaces the tool list of a simulated remote server; the next sync picks it up. */
|
|
58
|
+
setRemoteTools(serverId, tools) {
|
|
59
|
+
const stored = this.servers.get(serverId);
|
|
60
|
+
if (stored) {
|
|
61
|
+
stored.remoteTools = structuredClone(tools);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Chat agents: enabled code agents keep their keys, admin agents carry literal text. */
|
|
65
|
+
chatAgents() {
|
|
66
|
+
const result = [];
|
|
67
|
+
for (const { agent } of this.agents.values()) {
|
|
68
|
+
if (!agent.isEnabled) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const summary = this.scenario.agents.find(item => item.id === agent.id);
|
|
72
|
+
result.push(summary && agent.source === 'code'
|
|
73
|
+
? { ...summary, version: agent.version }
|
|
74
|
+
: {
|
|
75
|
+
id: agent.id,
|
|
76
|
+
version: agent.version,
|
|
77
|
+
displayNameKey: agent.displayName,
|
|
78
|
+
descriptionKey: agent.description,
|
|
79
|
+
canMutate: agent.autonomy === 'execute'
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
handlePreferences(method, body) {
|
|
85
|
+
if (method === 'GET') {
|
|
86
|
+
return { status: 200, body: this.preferences };
|
|
87
|
+
}
|
|
88
|
+
if (method !== 'PUT') {
|
|
89
|
+
return { status: 405 };
|
|
90
|
+
}
|
|
91
|
+
const input = body;
|
|
92
|
+
const instructions = input?.customInstructions ?? null;
|
|
93
|
+
if (!input || !styles.includes(input.style ?? '') || !addressForms.includes(input.addressForm ?? '') ||
|
|
94
|
+
!responseLengths.includes(input.responseLength ?? '') ||
|
|
95
|
+
(instructions !== null && (typeof instructions !== 'string' || instructions.length > maxCustomInstructions))) {
|
|
96
|
+
return validationFailure();
|
|
97
|
+
}
|
|
98
|
+
this.preferences = {
|
|
99
|
+
style: input.style,
|
|
100
|
+
addressForm: input.addressForm,
|
|
101
|
+
responseLength: input.responseLength,
|
|
102
|
+
customInstructions: instructions && instructions.trim().length > 0 ? instructions : null
|
|
103
|
+
};
|
|
104
|
+
return { status: 200, body: this.preferences };
|
|
105
|
+
}
|
|
106
|
+
handleAdmin(method, segments, body) {
|
|
107
|
+
if (!this.canAdminister) {
|
|
108
|
+
return failure(403, 'assistant-forbidden');
|
|
109
|
+
}
|
|
110
|
+
const [, area, id, action, child] = segments;
|
|
111
|
+
switch (area) {
|
|
112
|
+
case 'context':
|
|
113
|
+
return this.context(method, id, body);
|
|
114
|
+
case 'tools':
|
|
115
|
+
return method === 'GET' && segments.length === 2 ? { status: 200, body: this.toolCatalog() } : failure(404, 'assistant-not-found');
|
|
116
|
+
case 'agents':
|
|
117
|
+
return this.agentsEndpoint(method, id, action, body, segments.length);
|
|
118
|
+
case 'mcp-servers':
|
|
119
|
+
return this.serversEndpoint(method, id, action, child, body, segments.length);
|
|
120
|
+
default:
|
|
121
|
+
return failure(404, 'assistant-not-found');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
context(method, sub, body) {
|
|
125
|
+
const current = this.contextVersions[this.contextVersions.length - 1];
|
|
126
|
+
if (sub === 'versions' && method === 'GET') {
|
|
127
|
+
return { status: 200, body: [...this.contextVersions].reverse().map(({ text, ...version }) => version) };
|
|
128
|
+
}
|
|
129
|
+
if (sub !== undefined) {
|
|
130
|
+
return failure(404, 'assistant-not-found');
|
|
131
|
+
}
|
|
132
|
+
if (method === 'GET') {
|
|
133
|
+
return { status: 200, body: toContext(current) };
|
|
134
|
+
}
|
|
135
|
+
if (method !== 'PUT') {
|
|
136
|
+
return { status: 405 };
|
|
137
|
+
}
|
|
138
|
+
const input = body;
|
|
139
|
+
if (typeof input?.text !== 'string' || typeof input.expectedVersion !== 'number') {
|
|
140
|
+
return validationFailure();
|
|
141
|
+
}
|
|
142
|
+
if (input.text.length > maxInstructions) {
|
|
143
|
+
return failure(400, 'assistant-instructions-too-long');
|
|
144
|
+
}
|
|
145
|
+
if (input.expectedVersion !== current.version) {
|
|
146
|
+
return failure(409, 'assistant-version-conflict');
|
|
147
|
+
}
|
|
148
|
+
const next = { version: current.version + 1, hash: hashText(input.text), updatedAt: this.now(), updatedBy: 'current-user', text: input.text };
|
|
149
|
+
this.contextVersions.push(next);
|
|
150
|
+
return { status: 200, body: toContext(next) };
|
|
151
|
+
}
|
|
152
|
+
toolCatalog() {
|
|
153
|
+
const catalog = [...(this.scenario.admin?.tools ?? [])];
|
|
154
|
+
for (const stored of this.servers.values()) {
|
|
155
|
+
for (const tool of stored.tools.values()) {
|
|
156
|
+
if (tool.isEnabled) {
|
|
157
|
+
catalog.push({ id: tool.localId, source: 'mcp', effect: tool.effect, description: tool.descriptionOverride ?? tool.description });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return catalog;
|
|
162
|
+
}
|
|
163
|
+
agentsEndpoint(method, id, action, body, length) {
|
|
164
|
+
if (id === undefined) {
|
|
165
|
+
if (method === 'GET') {
|
|
166
|
+
return { status: 200, body: [...this.agents.values()].map(stored => stored.agent) };
|
|
167
|
+
}
|
|
168
|
+
if (method !== 'POST') {
|
|
169
|
+
return { status: 405 };
|
|
170
|
+
}
|
|
171
|
+
const input = body;
|
|
172
|
+
const invalid = this.agentFailure(input);
|
|
173
|
+
if (invalid) {
|
|
174
|
+
return invalid;
|
|
175
|
+
}
|
|
176
|
+
if (this.agents.has(input.id)) {
|
|
177
|
+
return failure(409, 'assistant-agent-exists');
|
|
178
|
+
}
|
|
179
|
+
const agent = this.toAgent(input, 'admin', 1, false);
|
|
180
|
+
this.agents.set(agent.id, { agent, codeDefinition: null });
|
|
181
|
+
return { status: 201, body: agent };
|
|
182
|
+
}
|
|
183
|
+
const stored = this.agents.get(id);
|
|
184
|
+
if (!stored) {
|
|
185
|
+
return failure(404, 'assistant-not-found');
|
|
186
|
+
}
|
|
187
|
+
if (length === 4 && action === 'reset' && method === 'POST') {
|
|
188
|
+
if (!stored.codeDefinition) {
|
|
189
|
+
return failure(409, 'assistant-agent-not-code');
|
|
190
|
+
}
|
|
191
|
+
stored.agent = this.toAgent(stored.codeDefinition, 'code', stored.agent.version + 1, false);
|
|
192
|
+
return { status: 200, body: stored.agent };
|
|
193
|
+
}
|
|
194
|
+
if (length !== 3) {
|
|
195
|
+
return failure(404, 'assistant-not-found');
|
|
196
|
+
}
|
|
197
|
+
if (method === 'PUT') {
|
|
198
|
+
const input = body;
|
|
199
|
+
const invalid = input?.id !== id ? validationFailure() : this.agentFailure(input);
|
|
200
|
+
if (invalid) {
|
|
201
|
+
return invalid;
|
|
202
|
+
}
|
|
203
|
+
if (input.expectedVersion !== stored.agent.version) {
|
|
204
|
+
return failure(409, 'assistant-version-conflict');
|
|
205
|
+
}
|
|
206
|
+
const { expectedVersion, ...definition } = input;
|
|
207
|
+
stored.agent = this.toAgent(definition, stored.agent.source, stored.agent.version + 1, stored.agent.source === 'code');
|
|
208
|
+
return { status: 200, body: stored.agent };
|
|
209
|
+
}
|
|
210
|
+
if (method === 'DELETE') {
|
|
211
|
+
if (stored.agent.source === 'code') {
|
|
212
|
+
return failure(409, 'assistant-code-agent-not-deletable');
|
|
213
|
+
}
|
|
214
|
+
this.agents.delete(id);
|
|
215
|
+
return { status: 204 };
|
|
216
|
+
}
|
|
217
|
+
return { status: 405 };
|
|
218
|
+
}
|
|
219
|
+
serversEndpoint(method, id, action, child, body, length) {
|
|
220
|
+
if (id === undefined) {
|
|
221
|
+
if (method === 'GET') {
|
|
222
|
+
return { status: 200, body: [...this.servers.values()].map(stored => this.toServer(stored)) };
|
|
223
|
+
}
|
|
224
|
+
if (method !== 'POST') {
|
|
225
|
+
return { status: 405 };
|
|
226
|
+
}
|
|
227
|
+
const input = body;
|
|
228
|
+
if (!validServer(input)) {
|
|
229
|
+
return validationFailure();
|
|
230
|
+
}
|
|
231
|
+
if (this.servers.has(input.id)) {
|
|
232
|
+
return failure(409, 'assistant-mcp-server-exists');
|
|
233
|
+
}
|
|
234
|
+
const stored = {
|
|
235
|
+
server: { ...toServerFields(input), lastSyncAt: null, lastSyncStatus: null },
|
|
236
|
+
secret: input.secret ? input.secret : null,
|
|
237
|
+
remoteTools: [],
|
|
238
|
+
tools: new Map()
|
|
239
|
+
};
|
|
240
|
+
this.servers.set(input.id, stored);
|
|
241
|
+
return { status: 201, body: this.toServer(stored) };
|
|
242
|
+
}
|
|
243
|
+
const stored = this.servers.get(id);
|
|
244
|
+
if (!stored) {
|
|
245
|
+
return failure(404, 'assistant-mcp-server-not-found');
|
|
246
|
+
}
|
|
247
|
+
if (length === 3) {
|
|
248
|
+
if (method === 'PUT') {
|
|
249
|
+
const input = body;
|
|
250
|
+
if (!validServer(input) || input.id !== id) {
|
|
251
|
+
return validationFailure();
|
|
252
|
+
}
|
|
253
|
+
stored.server = { ...stored.server, ...toServerFields(input) };
|
|
254
|
+
if (input.secret !== undefined) {
|
|
255
|
+
stored.secret = input.secret ? input.secret : null;
|
|
256
|
+
}
|
|
257
|
+
return { status: 200, body: this.toServer(stored) };
|
|
258
|
+
}
|
|
259
|
+
if (method === 'DELETE') {
|
|
260
|
+
this.servers.delete(id);
|
|
261
|
+
for (const agent of this.agents.values()) {
|
|
262
|
+
agent.agent = { ...agent.agent, mcpServerIds: agent.agent.mcpServerIds.filter(serverId => serverId !== id) };
|
|
263
|
+
}
|
|
264
|
+
return { status: 204 };
|
|
265
|
+
}
|
|
266
|
+
return { status: 405 };
|
|
267
|
+
}
|
|
268
|
+
if (length === 4 && action === 'test' && method === 'POST') {
|
|
269
|
+
const code = this.connectionFailure(stored);
|
|
270
|
+
const result = code
|
|
271
|
+
? { ok: false, code, toolCount: null }
|
|
272
|
+
: { ok: true, code: null, toolCount: stored.remoteTools.length };
|
|
273
|
+
return { status: 200, body: result };
|
|
274
|
+
}
|
|
275
|
+
if (length === 4 && action === 'sync' && method === 'POST') {
|
|
276
|
+
const code = this.connectionFailure(stored);
|
|
277
|
+
stored.server = { ...stored.server, lastSyncAt: this.now(), lastSyncStatus: code ? 'failed' : 'ok' };
|
|
278
|
+
if (code) {
|
|
279
|
+
// A blocked host is a configuration error; connection failures are upstream errors.
|
|
280
|
+
return failure(code === 'assistant-mcp-host-blocked' ? 400 : 502, code);
|
|
281
|
+
}
|
|
282
|
+
this.sync(stored);
|
|
283
|
+
return { status: 200, body: toTools(stored) };
|
|
284
|
+
}
|
|
285
|
+
if (length === 4 && action === 'tools' && method === 'GET') {
|
|
286
|
+
return { status: 200, body: toTools(stored) };
|
|
287
|
+
}
|
|
288
|
+
if (length === 5 && action === 'tools' && method === 'PUT' && child !== undefined) {
|
|
289
|
+
const tool = stored.tools.get(child);
|
|
290
|
+
const input = body;
|
|
291
|
+
if (!tool) {
|
|
292
|
+
return failure(404, 'assistant-mcp-tool-not-found');
|
|
293
|
+
}
|
|
294
|
+
if (typeof input?.isEnabled !== 'boolean' || (input.effect !== 'read-only' && input.effect !== 'mutation') ||
|
|
295
|
+
(input.descriptionOverride !== null && typeof input.descriptionOverride !== 'string')) {
|
|
296
|
+
return validationFailure();
|
|
297
|
+
}
|
|
298
|
+
if (input.isEnabled && tool.status === 'missing') {
|
|
299
|
+
return validationFailure('isEnabled');
|
|
300
|
+
}
|
|
301
|
+
const override = typeof input.descriptionOverride === 'string' && input.descriptionOverride.trim().length > 0
|
|
302
|
+
? input.descriptionOverride.slice(0, 1_000)
|
|
303
|
+
: null;
|
|
304
|
+
const updated = {
|
|
305
|
+
...tool,
|
|
306
|
+
isEnabled: input.isEnabled,
|
|
307
|
+
effect: input.effect,
|
|
308
|
+
descriptionOverride: override,
|
|
309
|
+
status: input.isEnabled && tool.status === 'schema-changed' ? 'available' : tool.status
|
|
310
|
+
};
|
|
311
|
+
stored.tools.set(child, updated);
|
|
312
|
+
return { status: 200, body: toTool(updated) };
|
|
313
|
+
}
|
|
314
|
+
return failure(404, 'assistant-not-found');
|
|
315
|
+
}
|
|
316
|
+
sync(stored) {
|
|
317
|
+
const seen = new Set();
|
|
318
|
+
for (const remote of stored.remoteTools) {
|
|
319
|
+
seen.add(remote.remoteName);
|
|
320
|
+
const existing = stored.tools.get(remote.remoteName);
|
|
321
|
+
const description = remote.description.slice(0, 1_000);
|
|
322
|
+
if (!existing) {
|
|
323
|
+
stored.tools.set(remote.remoteName, {
|
|
324
|
+
remoteName: remote.remoteName,
|
|
325
|
+
localId: `mcp.${stored.server.id}.${kebab(remote.remoteName)}`,
|
|
326
|
+
description,
|
|
327
|
+
descriptionOverride: null,
|
|
328
|
+
isEnabled: false,
|
|
329
|
+
effect: 'mutation',
|
|
330
|
+
status: 'available',
|
|
331
|
+
readOnlyHint: remote.readOnlyHint ?? null,
|
|
332
|
+
inputSchemaHash: remote.inputSchemaHash
|
|
333
|
+
});
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const changed = existing.inputSchemaHash !== remote.inputSchemaHash;
|
|
337
|
+
stored.tools.set(remote.remoteName, {
|
|
338
|
+
...existing,
|
|
339
|
+
description,
|
|
340
|
+
readOnlyHint: remote.readOnlyHint ?? null,
|
|
341
|
+
inputSchemaHash: remote.inputSchemaHash,
|
|
342
|
+
isEnabled: changed ? false : existing.isEnabled,
|
|
343
|
+
status: changed ? 'schema-changed' : existing.status === 'missing' ? 'available' : existing.status
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
for (const [name, tool] of stored.tools) {
|
|
347
|
+
if (!seen.has(name)) {
|
|
348
|
+
stored.tools.set(name, { ...tool, isEnabled: false, status: 'missing' });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
connectionFailure(stored) {
|
|
353
|
+
let url;
|
|
354
|
+
try {
|
|
355
|
+
url = new URL(stored.server.url);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return 'assistant-mcp-unreachable';
|
|
359
|
+
}
|
|
360
|
+
const host = url.hostname.toLowerCase();
|
|
361
|
+
if (host.startsWith('169.254.') || host.startsWith('[fe80') || host.includes('metadata')) {
|
|
362
|
+
return 'assistant-mcp-host-blocked';
|
|
363
|
+
}
|
|
364
|
+
if (stored.server.authMode === 'forward-user-token' && !(this.scenario.admin?.forwardUserTokenHosts ?? []).includes(host)) {
|
|
365
|
+
return 'assistant-mcp-host-blocked';
|
|
366
|
+
}
|
|
367
|
+
if (host.includes('unreachable')) {
|
|
368
|
+
return 'assistant-mcp-unreachable';
|
|
369
|
+
}
|
|
370
|
+
if ((stored.server.authMode === 'bearer' || stored.server.authMode === 'api-key') && !stored.secret) {
|
|
371
|
+
return 'assistant-mcp-unauthorized';
|
|
372
|
+
}
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
/** The contract failure for an invalid agent, or null when the agent is valid. */
|
|
376
|
+
agentFailure(input) {
|
|
377
|
+
if (typeof input?.instructions === 'string' && input.instructions.length > maxInstructions) {
|
|
378
|
+
return failure(400, 'assistant-instructions-too-long');
|
|
379
|
+
}
|
|
380
|
+
return this.validAgent(input) ? null : validationFailure();
|
|
381
|
+
}
|
|
382
|
+
validAgent(input) {
|
|
383
|
+
const policies = this.scenario.admin?.policies;
|
|
384
|
+
return !!input &&
|
|
385
|
+
typeof input.id === 'string' && input.id.length <= 60 && dashCase.test(input.id) &&
|
|
386
|
+
typeof input.displayName === 'string' && input.displayName.trim().length > 0 &&
|
|
387
|
+
typeof input.description === 'string' &&
|
|
388
|
+
typeof input.instructions === 'string' && input.instructions.length <= maxInstructions &&
|
|
389
|
+
Array.isArray(input.toolSelectors) && input.toolSelectors.every(selector => /^[a-z0-9*][a-z0-9.*-]*$/.test(selector)) &&
|
|
390
|
+
Array.isArray(input.mcpServerIds) && input.mcpServerIds.every(serverId => this.servers.has(serverId)) &&
|
|
391
|
+
(input.requiredPolicy === null || (typeof input.requiredPolicy === 'string' && (!policies || policies.includes(input.requiredPolicy)))) &&
|
|
392
|
+
autonomyLevels.includes(input.autonomy) &&
|
|
393
|
+
typeof input.isEnabled === 'boolean';
|
|
394
|
+
}
|
|
395
|
+
toAgent(input, source, version, isOverridden) {
|
|
396
|
+
return {
|
|
397
|
+
...structuredClone(input),
|
|
398
|
+
version,
|
|
399
|
+
source,
|
|
400
|
+
isOverridden,
|
|
401
|
+
instructionsHash: hashText(input.instructions),
|
|
402
|
+
updatedAt: this.now()
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
toServer(stored) {
|
|
406
|
+
return {
|
|
407
|
+
...stored.server,
|
|
408
|
+
hasSecret: stored.secret !== null,
|
|
409
|
+
assignedAgentIds: [...this.agents.values()]
|
|
410
|
+
.filter(agent => agent.agent.mcpServerIds.includes(stored.server.id))
|
|
411
|
+
.map(agent => agent.agent.id)
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
function codeAgentFromSummary(agent) {
|
|
416
|
+
return {
|
|
417
|
+
id: agent.id,
|
|
418
|
+
displayName: agent.id,
|
|
419
|
+
description: '',
|
|
420
|
+
instructions: '',
|
|
421
|
+
toolSelectors: [],
|
|
422
|
+
mcpServerIds: [],
|
|
423
|
+
requiredPolicy: null,
|
|
424
|
+
autonomy: agent.canMutate ? 'execute' : 'explain',
|
|
425
|
+
isEnabled: true
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
function toServerFields(input) {
|
|
429
|
+
return {
|
|
430
|
+
id: input.id,
|
|
431
|
+
displayName: input.displayName,
|
|
432
|
+
url: input.url,
|
|
433
|
+
authMode: input.authMode,
|
|
434
|
+
headerName: input.authMode === 'api-key' ? (input.headerName || 'X-Api-Key') : null,
|
|
435
|
+
requiredPolicy: input.requiredPolicy,
|
|
436
|
+
isEnabled: input.isEnabled
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
function validServer(input) {
|
|
440
|
+
if (!input || typeof input.id !== 'string' || input.id.length > 40 || !dashCase.test(input.id) ||
|
|
441
|
+
typeof input.displayName !== 'string' || input.displayName.trim().length === 0 ||
|
|
442
|
+
!authModes.includes(input.authMode) || typeof input.isEnabled !== 'boolean') {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
const url = new URL(input.url);
|
|
447
|
+
return url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost');
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
function toTool({ inputSchemaHash, ...tool }) {
|
|
454
|
+
return tool;
|
|
455
|
+
}
|
|
456
|
+
function toTools(stored) {
|
|
457
|
+
return [...stored.tools.values()].map(toTool);
|
|
458
|
+
}
|
|
459
|
+
function toContext(version) {
|
|
460
|
+
return { text: version.text, version: version.version, hash: version.hash, updatedAt: version.updatedAt, updatedBy: version.updatedBy };
|
|
461
|
+
}
|
|
462
|
+
/** A contract error response: `{ code, messageKey, errors? }`, never free text. */
|
|
463
|
+
function failure(status, code, errors) {
|
|
464
|
+
return { status, body: { code, messageKey: `nh-assistant.errors.${code}`, ...(errors ? { errors } : {}) } };
|
|
465
|
+
}
|
|
466
|
+
function validationFailure(field) {
|
|
467
|
+
return failure(400, 'assistant-validation', field ? { [field]: ['invalid'] } : undefined);
|
|
468
|
+
}
|
|
469
|
+
function kebab(value) {
|
|
470
|
+
return value
|
|
471
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
472
|
+
.replace(/[^A-Za-z0-9]+/g, '-')
|
|
473
|
+
.replace(/^-+|-+$/g, '')
|
|
474
|
+
.toLowerCase();
|
|
475
|
+
}
|
|
476
|
+
/** A stable, non-cryptographic fingerprint; the mock only needs change detection. */
|
|
477
|
+
function hashText(text) {
|
|
478
|
+
let hash = 0x811c9dc5;
|
|
479
|
+
for (let index = 0; index < text.length; index++) {
|
|
480
|
+
hash ^= text.charCodeAt(index);
|
|
481
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
482
|
+
}
|
|
483
|
+
return `fnv1a:${hash.toString(16).padStart(8, '0')}`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const NH_ASSISTANT_MOCK_SCENARIO = new InjectionToken('NH_ASSISTANT_MOCK_SCENARIO');
|
|
487
|
+
const defaultLimits = { maxMessageChars: 4_000, maxToolCallsPerTurn: 8 };
|
|
488
|
+
class RunStopped extends Error {
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* In-memory implementation of the assistant HTTP API and its event streams. It keeps
|
|
492
|
+
* conversations, plays scripted turns as contract events through a real
|
|
493
|
+
* `text/event-stream` response and handles approvals, cancellation and the feature flag.
|
|
494
|
+
*/
|
|
495
|
+
class NhAssistantMockBackend {
|
|
496
|
+
constructor() {
|
|
497
|
+
this.scenario = inject(NH_ASSISTANT_MOCK_SCENARIO);
|
|
498
|
+
this.config = inject(NH_ASSISTANT_CONFIG);
|
|
499
|
+
this.conversations = new Map();
|
|
500
|
+
this.pendingApprovals = new Map();
|
|
501
|
+
this.runs = new Map();
|
|
502
|
+
/** Page context of the latest message per conversation, as the server stores it. */
|
|
503
|
+
this.pageContexts = new Map();
|
|
504
|
+
this.enabledState = signal(this.scenario.enabled ?? true, ...(ngDevMode ? [{ debugName: "enabledState" }] : []));
|
|
505
|
+
this.admin = new NhAssistantMockAdmin(this.scenario, () => new Date().toISOString());
|
|
506
|
+
this.canAdministerState = signal(this.admin.administers, ...(ngDevMode ? [{ debugName: "canAdministerState" }] : []));
|
|
507
|
+
this.sequence = 0;
|
|
508
|
+
/** Requests received so far, oldest first. */
|
|
509
|
+
this.requests = [];
|
|
510
|
+
this.enabled = this.enabledState.asReadonly();
|
|
511
|
+
this.canAdminister = this.canAdministerState.asReadonly();
|
|
512
|
+
/** A `fetch` implementation that answers the assistant endpoints below `apiBaseUrl`. */
|
|
513
|
+
this.fetch = async (input, init) => {
|
|
514
|
+
const method = (init.method ?? 'GET').toUpperCase();
|
|
515
|
+
const url = new URL(input, 'http://mock.local');
|
|
516
|
+
const base = new URL(this.config.apiBaseUrl.replace(/\/+$/, ''), 'http://mock.local').pathname;
|
|
517
|
+
const path = url.pathname.startsWith(`${base}/`) ? url.pathname.slice(base.length + 1) : null;
|
|
518
|
+
const body = typeof init.body === 'string' && init.body.length > 0 ? JSON.parse(init.body) : undefined;
|
|
519
|
+
this.requests.push({ method, path: path ?? url.pathname, headers: { ...init.headers }, body });
|
|
520
|
+
if (init.signal?.aborted) {
|
|
521
|
+
throw new DOMException('The operation was aborted.', 'AbortError');
|
|
522
|
+
}
|
|
523
|
+
if (path === null) {
|
|
524
|
+
return this.empty(404);
|
|
525
|
+
}
|
|
526
|
+
return this.route(method, path, url.searchParams, body);
|
|
527
|
+
};
|
|
528
|
+
for (const conversation of this.scenario.conversations ?? []) {
|
|
529
|
+
this.conversations.set(conversation.id, structuredClone(conversation));
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
/** Switches the simulated `NewHeap:AI:Assistant:Enabled` flag. */
|
|
533
|
+
setEnabled(enabled) {
|
|
534
|
+
this.enabledState.set(enabled);
|
|
535
|
+
}
|
|
536
|
+
/** Switches whether the caller passes the admin policy (`canAdminister`, `admin/*`). */
|
|
537
|
+
setCanAdminister(canAdminister) {
|
|
538
|
+
this.admin.setCanAdminister(canAdminister);
|
|
539
|
+
this.canAdministerState.set(canAdminister);
|
|
540
|
+
}
|
|
541
|
+
/** Replaces the tools a simulated MCP server lists; the next sync picks them up. */
|
|
542
|
+
setRemoteTools(serverId, tools) {
|
|
543
|
+
this.admin.setRemoteTools(serverId, tools);
|
|
544
|
+
}
|
|
545
|
+
route(method, path, query, body) {
|
|
546
|
+
const segments = path.split('/').map(segment => decodeURIComponent(segment));
|
|
547
|
+
if (method === 'GET' && path === 'status') {
|
|
548
|
+
const limits = { ...defaultLimits, ...this.scenario.limits };
|
|
549
|
+
return this.json(200, this.enabledState()
|
|
550
|
+
? { enabled: true, agents: this.admin.chatAgents(), limits, canAdminister: this.admin.administers }
|
|
551
|
+
: { enabled: false, agents: [], limits, canAdminister: false });
|
|
552
|
+
}
|
|
553
|
+
if (!this.enabledState()) {
|
|
554
|
+
return this.empty(404);
|
|
555
|
+
}
|
|
556
|
+
if (method === 'GET' && path === 'agents') {
|
|
557
|
+
return this.json(200, this.admin.chatAgents());
|
|
558
|
+
}
|
|
559
|
+
if (path === 'preferences') {
|
|
560
|
+
return this.result(this.admin.handlePreferences(method, body));
|
|
561
|
+
}
|
|
562
|
+
if (segments[0] === 'admin') {
|
|
563
|
+
return this.result(this.admin.handleAdmin(method, segments, body));
|
|
564
|
+
}
|
|
565
|
+
if (segments[0] !== 'conversations') {
|
|
566
|
+
return this.empty(404);
|
|
567
|
+
}
|
|
568
|
+
if (segments.length === 1) {
|
|
569
|
+
if (method === 'GET') {
|
|
570
|
+
return this.json(200, this.list(query));
|
|
571
|
+
}
|
|
572
|
+
if (method === 'POST') {
|
|
573
|
+
return this.create(body);
|
|
574
|
+
}
|
|
575
|
+
return this.empty(405);
|
|
576
|
+
}
|
|
577
|
+
const conversation = this.conversations.get(segments[1]);
|
|
578
|
+
if (!conversation) {
|
|
579
|
+
return this.empty(404);
|
|
580
|
+
}
|
|
581
|
+
if (segments.length === 2) {
|
|
582
|
+
if (method === 'GET') {
|
|
583
|
+
return this.json(200, conversation);
|
|
584
|
+
}
|
|
585
|
+
if (method === 'DELETE') {
|
|
586
|
+
this.conversations.delete(conversation.id);
|
|
587
|
+
this.pendingApprovals.delete(conversation.id);
|
|
588
|
+
return this.empty(204);
|
|
589
|
+
}
|
|
590
|
+
return this.empty(405);
|
|
591
|
+
}
|
|
592
|
+
if (method === 'POST' && segments.length === 3 && segments[2] === 'messages') {
|
|
593
|
+
return this.sendMessage(conversation, body);
|
|
594
|
+
}
|
|
595
|
+
if (method === 'POST' && segments.length === 3 && segments[2] === 'cancel') {
|
|
596
|
+
return this.cancel(conversation);
|
|
597
|
+
}
|
|
598
|
+
if (method === 'POST' && segments.length === 5 && segments[2] === 'approvals' && segments[4] === 'decide') {
|
|
599
|
+
return this.decide(conversation, segments[3], body);
|
|
600
|
+
}
|
|
601
|
+
return this.empty(404);
|
|
602
|
+
}
|
|
603
|
+
list(query) {
|
|
604
|
+
const page = Math.max(1, Number(query.get('page') ?? 1));
|
|
605
|
+
const itemsPerPage = Math.max(1, Number(query.get('itemsPerPage') ?? 20));
|
|
606
|
+
const all = [...this.conversations.values()]
|
|
607
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
|
|
608
|
+
.map(({ id, agentId, title, status, createdAt, updatedAt }) => ({ id, agentId, title, status, createdAt, updatedAt }));
|
|
609
|
+
return { items: all.slice((page - 1) * itemsPerPage, page * itemsPerPage), total: all.length };
|
|
610
|
+
}
|
|
611
|
+
create(request) {
|
|
612
|
+
const agent = this.admin.chatAgents().find(item => item.id === request?.agentId);
|
|
613
|
+
if (!agent) {
|
|
614
|
+
return this.empty(400);
|
|
615
|
+
}
|
|
616
|
+
const now = new Date().toISOString();
|
|
617
|
+
const conversation = {
|
|
618
|
+
id: this.nextId(),
|
|
619
|
+
agentId: agent.id,
|
|
620
|
+
agentVersion: agent.version,
|
|
621
|
+
title: request.title ?? null,
|
|
622
|
+
status: 'idle',
|
|
623
|
+
createdAt: now,
|
|
624
|
+
updatedAt: now,
|
|
625
|
+
messages: [],
|
|
626
|
+
pendingApproval: null
|
|
627
|
+
};
|
|
628
|
+
this.conversations.set(conversation.id, conversation);
|
|
629
|
+
return this.json(201, conversation);
|
|
630
|
+
}
|
|
631
|
+
sendMessage(conversation, request) {
|
|
632
|
+
const text = request?.text?.trim() ?? '';
|
|
633
|
+
const limit = this.scenario.limits?.maxMessageChars ?? defaultLimits.maxMessageChars;
|
|
634
|
+
if (text.length === 0 || text.length > limit || !request.clientMessageId) {
|
|
635
|
+
return this.empty(400);
|
|
636
|
+
}
|
|
637
|
+
if (conversation.status !== 'idle') {
|
|
638
|
+
return this.empty(409);
|
|
639
|
+
}
|
|
640
|
+
// Like the server: an invalid page context is dropped, never a 400.
|
|
641
|
+
const pageContext = normalizeNhAssistantClientContext(request.clientContext);
|
|
642
|
+
this.pageContexts.set(conversation.id, pageContext);
|
|
643
|
+
const turn = this.findTurn(text, conversation.agentId, pageContext);
|
|
644
|
+
const userMessageId = this.nextId();
|
|
645
|
+
const assistantMessageId = this.nextId();
|
|
646
|
+
this.update(conversation.id, current => ({
|
|
647
|
+
...current,
|
|
648
|
+
title: current.title ?? text.slice(0, 60),
|
|
649
|
+
status: 'running',
|
|
650
|
+
messages: [...current.messages, { id: userMessageId, role: 'user', createdAt: new Date().toISOString(), parts: [{ type: 'text', text }] }]
|
|
651
|
+
}));
|
|
652
|
+
return this.stream(conversation.id, async (emit) => {
|
|
653
|
+
await emit({ type: 'turn.started', data: { turnId: this.nextId(), userMessageId, assistantMessageId } });
|
|
654
|
+
await this.playSteps(conversation.id, assistantMessageId, turn?.steps ?? [], emit);
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
decide(conversation, approvalId, request) {
|
|
658
|
+
const pending = this.pendingApprovals.get(conversation.id);
|
|
659
|
+
if (!pending || pending.approval.approvalId !== approvalId) {
|
|
660
|
+
return this.empty(404);
|
|
661
|
+
}
|
|
662
|
+
if (request?.expectedProposalHash !== pending.approval.proposalHash) {
|
|
663
|
+
return this.empty(409);
|
|
664
|
+
}
|
|
665
|
+
if (Date.parse(pending.approval.expiresAt) <= Date.now()) {
|
|
666
|
+
return this.empty(409);
|
|
667
|
+
}
|
|
668
|
+
this.pendingApprovals.delete(conversation.id);
|
|
669
|
+
const decision = request.decision;
|
|
670
|
+
const lastUser = [...conversation.messages].reverse().find(message => message.role === 'user');
|
|
671
|
+
this.update(conversation.id, current => applyNhAssistantApprovalDecision(current, approvalId, decision));
|
|
672
|
+
return this.stream(conversation.id, async (emit) => {
|
|
673
|
+
await emit({
|
|
674
|
+
type: 'turn.started',
|
|
675
|
+
data: { turnId: this.nextId(), userMessageId: lastUser?.id ?? pending.assistantMessageId, assistantMessageId: pending.assistantMessageId }
|
|
676
|
+
});
|
|
677
|
+
if (decision === 'approve') {
|
|
678
|
+
await emit({
|
|
679
|
+
type: 'tool.completed',
|
|
680
|
+
data: { invocationId: pending.invocationId, status: 'succeeded', resultCode: null, resultPreview: pending.step.resultPreview ?? null }
|
|
681
|
+
});
|
|
682
|
+
await this.playSteps(conversation.id, pending.assistantMessageId, pending.step.approved, emit);
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
await this.playSteps(conversation.id, pending.assistantMessageId, pending.step.rejected, emit);
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
cancel(conversation) {
|
|
690
|
+
const run = this.runs.get(conversation.id);
|
|
691
|
+
if (run) {
|
|
692
|
+
run.cancelled = true;
|
|
693
|
+
return this.empty(202);
|
|
694
|
+
}
|
|
695
|
+
const pending = this.pendingApprovals.get(conversation.id);
|
|
696
|
+
if (pending) {
|
|
697
|
+
this.pendingApprovals.delete(conversation.id);
|
|
698
|
+
this.update(conversation.id, current => ({
|
|
699
|
+
...applyNhAssistantApprovalDecision(current, pending.approval.approvalId, 'reject'),
|
|
700
|
+
status: 'idle'
|
|
701
|
+
}));
|
|
702
|
+
}
|
|
703
|
+
return this.empty(202);
|
|
704
|
+
}
|
|
705
|
+
async playSteps(conversationId, assistantMessageId, steps, emit) {
|
|
706
|
+
const usage = { inputTokens: 180, outputTokens: 0, toolCalls: 0 };
|
|
707
|
+
const complete = (status, errorCode = null) => emit({ type: 'turn.completed', data: { turnId: this.nextId(), status, usage, errorCode } });
|
|
708
|
+
for (const step of steps) {
|
|
709
|
+
if ('text' in step) {
|
|
710
|
+
const text = typeof step.text === 'function' ? step.text(this.pageContexts.get(conversationId) ?? null) : step.text;
|
|
711
|
+
for (const piece of text.match(/\S+\s*|\s+/g) ?? []) {
|
|
712
|
+
usage.outputTokens++;
|
|
713
|
+
await emit({ type: 'message.delta', data: { messageId: assistantMessageId, text: piece } });
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
else if ('tool' in step) {
|
|
717
|
+
const invocationId = this.nextId();
|
|
718
|
+
usage.toolCalls++;
|
|
719
|
+
await emit({
|
|
720
|
+
type: 'tool.started',
|
|
721
|
+
data: {
|
|
722
|
+
invocationId,
|
|
723
|
+
toolId: step.tool.toolId,
|
|
724
|
+
toolVersion: step.tool.toolVersion ?? 1,
|
|
725
|
+
displayName: step.tool.displayName,
|
|
726
|
+
argumentsPreview: step.tool.argumentsPreview ?? null
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
await emit({
|
|
730
|
+
type: 'tool.completed',
|
|
731
|
+
data: {
|
|
732
|
+
invocationId,
|
|
733
|
+
status: step.tool.status ?? 'succeeded',
|
|
734
|
+
resultCode: step.tool.resultCode ?? null,
|
|
735
|
+
resultPreview: step.tool.resultPreview ?? null
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
else if ('approval' in step) {
|
|
740
|
+
await this.pauseForApproval(conversationId, assistantMessageId, step.approval, emit);
|
|
741
|
+
usage.toolCalls++;
|
|
742
|
+
await emit({ type: 'turn.completed', data: { turnId: this.nextId(), status: 'waiting-for-approval', usage, errorCode: null } });
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
else if ('error' in step) {
|
|
746
|
+
await emit({ type: 'error', data: step.error });
|
|
747
|
+
// The mock keeps the conversation usable after a failed turn.
|
|
748
|
+
this.update(conversationId, current => ({ ...current, status: 'idle' }));
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
else if ('fail' in step) {
|
|
752
|
+
await complete('failed', step.fail.errorCode);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
await complete('completed');
|
|
757
|
+
}
|
|
758
|
+
async pauseForApproval(conversationId, assistantMessageId, step, emit) {
|
|
759
|
+
const invocationId = this.nextId();
|
|
760
|
+
await emit({
|
|
761
|
+
type: 'tool.started',
|
|
762
|
+
data: {
|
|
763
|
+
invocationId,
|
|
764
|
+
toolId: step.toolId,
|
|
765
|
+
toolVersion: step.toolVersion ?? 1,
|
|
766
|
+
displayName: step.displayName,
|
|
767
|
+
argumentsPreview: step.argumentsPreview
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
const approval = {
|
|
771
|
+
type: 'approval',
|
|
772
|
+
approvalId: this.nextId(),
|
|
773
|
+
proposalId: this.nextId(),
|
|
774
|
+
proposalHash: `sha256:${this.nextId().replace(/-/g, '')}`,
|
|
775
|
+
toolId: step.toolId,
|
|
776
|
+
summary: step.summary,
|
|
777
|
+
argumentsPreview: step.argumentsPreview,
|
|
778
|
+
targets: step.targets,
|
|
779
|
+
expiresAt: new Date(Date.now() + (step.expiresInSeconds ?? 300) * 1000).toISOString(),
|
|
780
|
+
status: 'pending'
|
|
781
|
+
};
|
|
782
|
+
this.pendingApprovals.set(conversationId, { approval, invocationId, assistantMessageId, step });
|
|
783
|
+
await emit({ type: 'approval.required', data: approval });
|
|
784
|
+
}
|
|
785
|
+
stream(conversationId, play) {
|
|
786
|
+
const timing = this.scenario.timing ?? {};
|
|
787
|
+
const firstDelay = timing.firstEventDelayMs ?? 200;
|
|
788
|
+
const eventDelay = timing.eventDelayMs ?? 40;
|
|
789
|
+
const chunkSize = Math.max(1, timing.chunkSize ?? 23);
|
|
790
|
+
const encoder = new TextEncoder();
|
|
791
|
+
const run = { cancelled: false, aborted: false };
|
|
792
|
+
this.runs.set(conversationId, run);
|
|
793
|
+
let first = true;
|
|
794
|
+
let eventCount = 0;
|
|
795
|
+
return new Response(new ReadableStream({
|
|
796
|
+
start: controller => {
|
|
797
|
+
const write = (text) => {
|
|
798
|
+
const bytes = encoder.encode(text);
|
|
799
|
+
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
|
800
|
+
controller.enqueue(bytes.slice(offset, offset + chunkSize));
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
const emit = async (event) => {
|
|
804
|
+
await delay(first ? firstDelay : eventDelay);
|
|
805
|
+
first = false;
|
|
806
|
+
if (run.aborted) {
|
|
807
|
+
throw new RunStopped();
|
|
808
|
+
}
|
|
809
|
+
if (run.cancelled && event.type !== 'turn.started') {
|
|
810
|
+
throw new RunStopped();
|
|
811
|
+
}
|
|
812
|
+
this.update(conversationId, current => applyNhAssistantEvent(current, event));
|
|
813
|
+
if (eventCount++ % 5 === 2) {
|
|
814
|
+
write(': keep-alive\n\n');
|
|
815
|
+
}
|
|
816
|
+
write(`event: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`);
|
|
817
|
+
};
|
|
818
|
+
void play(emit)
|
|
819
|
+
.catch(async (error) => {
|
|
820
|
+
if (!(error instanceof RunStopped) || run.aborted) {
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const usage = { inputTokens: 180, outputTokens: 0, toolCalls: 0 };
|
|
824
|
+
const cancelled = { type: 'turn.completed', data: { turnId: this.nextId(), status: 'cancelled', usage, errorCode: null } };
|
|
825
|
+
this.update(conversationId, current => applyNhAssistantEvent(current, cancelled));
|
|
826
|
+
write(`event: ${cancelled.type}\ndata: ${JSON.stringify(cancelled.data)}\n\n`);
|
|
827
|
+
})
|
|
828
|
+
.finally(() => {
|
|
829
|
+
if (this.runs.get(conversationId) === run) {
|
|
830
|
+
this.runs.delete(conversationId);
|
|
831
|
+
}
|
|
832
|
+
if (!run.aborted) {
|
|
833
|
+
controller.close();
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
},
|
|
837
|
+
cancel: () => {
|
|
838
|
+
run.aborted = true;
|
|
839
|
+
}
|
|
840
|
+
}), { status: 200, headers: { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache' } });
|
|
841
|
+
}
|
|
842
|
+
findTurn(text, agentId, pageContext) {
|
|
843
|
+
return this.scenario.turns.find(turn => {
|
|
844
|
+
const match = turn.match;
|
|
845
|
+
if (match === undefined) {
|
|
846
|
+
return true;
|
|
847
|
+
}
|
|
848
|
+
if (typeof match === 'string') {
|
|
849
|
+
return text.toLowerCase().includes(match.toLowerCase());
|
|
850
|
+
}
|
|
851
|
+
if (match instanceof RegExp) {
|
|
852
|
+
return match.test(text);
|
|
853
|
+
}
|
|
854
|
+
return match(text, agentId, pageContext);
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
update(conversationId, change) {
|
|
858
|
+
const conversation = this.conversations.get(conversationId);
|
|
859
|
+
if (conversation) {
|
|
860
|
+
this.conversations.set(conversationId, { ...change(conversation), updatedAt: new Date().toISOString() });
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
nextId() {
|
|
864
|
+
this.sequence++;
|
|
865
|
+
const suffix = this.sequence.toString(16).padStart(12, '0');
|
|
866
|
+
return `00000000-0000-4000-8000-${suffix}`;
|
|
867
|
+
}
|
|
868
|
+
result(result) {
|
|
869
|
+
return result.body === undefined ? this.empty(result.status) : this.json(result.status, result.body);
|
|
870
|
+
}
|
|
871
|
+
json(status, value) {
|
|
872
|
+
return new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json' } });
|
|
873
|
+
}
|
|
874
|
+
empty(status) {
|
|
875
|
+
return new Response(null, { status });
|
|
876
|
+
}
|
|
877
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.28", ngImport: i0, type: NhAssistantMockBackend, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
878
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.28", ngImport: i0, type: NhAssistantMockBackend }); }
|
|
879
|
+
}
|
|
880
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.28", ngImport: i0, type: NhAssistantMockBackend, decorators: [{
|
|
881
|
+
type: Injectable
|
|
882
|
+
}], ctorParameters: () => [] });
|
|
883
|
+
function delay(milliseconds) {
|
|
884
|
+
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Replaces the assistant transport with an in-memory back-end that plays `script`.
|
|
889
|
+
* Register it after `provideNhAssistant(...)` in the same injector. Every client feature
|
|
890
|
+
* (status, agents, conversations, streamed turns, approvals, cancellation) then works
|
|
891
|
+
* without a server. Inject `NhAssistantMockBackend` to switch the feature flag or to
|
|
892
|
+
* inspect the received requests.
|
|
893
|
+
*/
|
|
894
|
+
function provideNhAssistantMockApi(script) {
|
|
895
|
+
return makeEnvironmentProviders([
|
|
896
|
+
{ provide: NH_ASSISTANT_MOCK_SCENARIO, useValue: script },
|
|
897
|
+
NhAssistantMockBackend,
|
|
898
|
+
{ provide: NH_ASSISTANT_FETCH, useFactory: () => inject(NhAssistantMockBackend).fetch }
|
|
899
|
+
]);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/*
|
|
903
|
+
* Public API surface of @newheap/platform-ai-chat/testing.
|
|
904
|
+
*/
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Generated bundle index. Do not edit.
|
|
908
|
+
*/
|
|
909
|
+
|
|
910
|
+
export { NH_ASSISTANT_MOCK_SCENARIO, NhAssistantMockBackend, provideNhAssistantMockApi };
|
|
911
|
+
//# sourceMappingURL=newheap-platform-ai-chat-testing.mjs.map
|