@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.
@@ -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