@threadplane/langgraph 0.0.46

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,2928 @@
1
+ import * as i0 from '@angular/core';
2
+ import { InjectionToken, signal, Injectable, inject, DestroyRef, isSignal, computed, effect } from '@angular/core';
3
+ import { toObservable, toSignal } from '@angular/core/rxjs-interop';
4
+ import { takeUntil, Subject, BehaviorSubject, of, throttleTime, asyncScheduler } from 'rxjs';
5
+ import { takeUntil as takeUntil$1 } from 'rxjs/operators';
6
+ import { Client } from '@langchain/langgraph-sdk';
7
+ import { getToolCallsWithResults } from '@langchain/langgraph-sdk/utils';
8
+
9
+ // SPDX-License-Identifier: MIT
10
+ const AGENT_CONFIG = new InjectionToken('AGENT_CONFIG');
11
+ /**
12
+ * Angular provider factory that registers global defaults for all
13
+ * agent instances in the application.
14
+ */
15
+ function provideAgent(config) {
16
+ return { provide: AGENT_CONFIG, useValue: config };
17
+ }
18
+
19
+ // SPDX-License-Identifier: MIT
20
+ /**
21
+ * Optional registry that collects per-instance agent lifecycles within
22
+ * an Angular injection context. External instrumentation packages
23
+ * (e.g. cockpit-telemetry) provide this token and read from it.
24
+ *
25
+ * `@threadplane/langgraph` does NOT provide this itself — `agent()` writes to
26
+ * the registry only when an external consumer has provided it.
27
+ */
28
+ class AgentLifecycleRegistry {
29
+ _lifecycles = signal([], ...(ngDevMode ? [{ debugName: "_lifecycles" }] : []));
30
+ /** Reactive list of registered lifecycles. */
31
+ lifecycles = this._lifecycles.asReadonly();
32
+ register(lifecycle) {
33
+ this._lifecycles.update((curr) => [...curr, lifecycle]);
34
+ }
35
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: AgentLifecycleRegistry, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
36
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: AgentLifecycleRegistry });
37
+ }
38
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: AgentLifecycleRegistry, decorators: [{
39
+ type: Injectable
40
+ }] });
41
+
42
+ /**
43
+ * Runtime constant mirroring Angular's ResourceStatus string-union type.
44
+ * Angular 21 ships ResourceStatus as a pure string-union type (no runtime value),
45
+ * so we provide a const-object shim for code that needs runtime comparisons.
46
+ */
47
+ const ResourceStatus = {
48
+ Idle: 'idle',
49
+ Loading: 'loading',
50
+ Reloading: 'reloading',
51
+ Resolved: 'resolved',
52
+ Error: 'error',
53
+ Local: 'local',
54
+ };
55
+
56
+ // SPDX-License-Identifier: MIT
57
+ /**
58
+ * Construct a LangGraph SDK Client that accepts both absolute URLs
59
+ * (`http://localhost:2024`) and relative `/api`-style paths that get
60
+ * proxied by middleware in production. The SDK itself rejects
61
+ * relative URLs, so this helper rewrites them against
62
+ * `window.location.origin` when running in the browser.
63
+ *
64
+ * Single source of truth for the absolute-URL rewrite — the streaming
65
+ * transport (`fetch-stream.transport.ts`) and the threads adapter
66
+ * (`LangGraphThreadsAdapter`) both go through here.
67
+ *
68
+ * @example
69
+ * ```ts
70
+ * const client = createLangGraphClient(environment.langGraphApiUrl);
71
+ * const threads = await client.threads.search({ limit: 50 });
72
+ * ```
73
+ */
74
+ function createLangGraphClient(apiUrl) {
75
+ return new Client({ apiUrl: toAbsoluteApiUrl(apiUrl) });
76
+ }
77
+ /** Exported separately so non-Client callers (e.g. raw fetch) can
78
+ * share the same normalization logic. */
79
+ function toAbsoluteApiUrl(apiUrl) {
80
+ if (apiUrl.startsWith('http://') || apiUrl.startsWith('https://'))
81
+ return apiUrl;
82
+ return typeof window !== 'undefined' ? `${window.location.origin}${apiUrl}` : apiUrl;
83
+ }
84
+
85
+ /**
86
+ * Production transport that connects to a LangGraph Platform API via HTTP and SSE.
87
+ *
88
+ * Creates threads automatically if no threadId is provided, and streams events
89
+ * using the LangGraph SDK client.
90
+ *
91
+ * @example
92
+ * ```typescript
93
+ * const transport = new FetchStreamTransport(
94
+ * 'http://localhost:2024',
95
+ * (id) => console.log('New thread:', id),
96
+ * );
97
+ * ```
98
+ */
99
+ class FetchStreamTransport {
100
+ client;
101
+ onThreadId;
102
+ /**
103
+ * @param apiUrl - Base URL of the LangGraph Platform API
104
+ * @param onThreadId - Optional callback invoked when a new thread is created
105
+ */
106
+ constructor(apiUrl, onThreadId) {
107
+ // createLangGraphClient handles the absolute-URL normalization
108
+ // required by the SDK when `apiUrl` is a relative `/api`-style
109
+ // path proxied by middleware in production.
110
+ this.client = createLangGraphClient(apiUrl);
111
+ this.onThreadId = onThreadId;
112
+ }
113
+ /** Open a streaming connection, creating a thread if needed. */
114
+ async *stream(assistantId, threadId, payload, signal, options) {
115
+ let thread = threadId;
116
+ if (!thread) {
117
+ const t = await this.client.threads.create();
118
+ thread = t.thread_id;
119
+ this.onThreadId?.(thread);
120
+ }
121
+ const run = this.client.runs.stream(thread, assistantId, buildRunPayload(payload, signal, options));
122
+ for await (const event of run) {
123
+ yield normalizeSdkEvent(event.event, event.data);
124
+ }
125
+ }
126
+ /** Join an already-started run without creating a new thread. */
127
+ async *joinStream(threadId, runId, lastEventId, signal) {
128
+ // SDK joinStream: joins an already-started run without creating a new one.
129
+ const run = this.client.runs.joinStream(threadId, runId, {
130
+ signal,
131
+ ...(lastEventId !== undefined ? { lastEventId } : {}),
132
+ });
133
+ for await (const event of run) {
134
+ yield normalizeSdkEvent(event.event, event.data);
135
+ }
136
+ }
137
+ /** Create a pending server-side run using LangGraph's enqueue strategy. */
138
+ async createQueuedRun(assistantId, threadId, payload, signal, options) {
139
+ const run = await this.client.runs.create(threadId, assistantId, {
140
+ ...buildRunPayload(payload, signal, options),
141
+ multitaskStrategy: 'enqueue',
142
+ });
143
+ return {
144
+ id: run.run_id,
145
+ threadId: run.thread_id ?? threadId,
146
+ values: payload,
147
+ options: { multitaskStrategy: 'enqueue', signal },
148
+ createdAt: run.created_at ? new Date(run.created_at) : new Date(),
149
+ };
150
+ }
151
+ /** Cancel a server-side run. */
152
+ async cancelRun(threadId, runId, signal) {
153
+ await this.client.runs.cancel(threadId, runId, false, 'interrupt', { signal });
154
+ }
155
+ /** Load persisted checkpoint history for a thread. */
156
+ async getHistory(threadId, signal) {
157
+ return this.client.threads.getHistory(threadId, { signal });
158
+ }
159
+ /** Update server-side thread state, e.g. to remove messages for regenerate rollback. */
160
+ async updateState(threadId, values, _signal, options) {
161
+ const body = { values };
162
+ if (options?.asNode !== undefined) {
163
+ body.asNode = options.asNode;
164
+ }
165
+ await this.client.threads.updateState(threadId, body);
166
+ }
167
+ }
168
+ function buildRunPayload(input, signal, options) {
169
+ const runOptions = { ...(options ?? {}) };
170
+ const hasCheckpoint = Object.prototype.hasOwnProperty.call(runOptions, 'checkpoint');
171
+ const checkpoint = runOptions.checkpoint;
172
+ const streamMode = runOptions.streamMode;
173
+ const streamSubgraphs = runOptions.streamSubgraphs;
174
+ delete runOptions.signal;
175
+ delete runOptions.resume;
176
+ delete runOptions.checkpoint;
177
+ delete runOptions.streamMode;
178
+ delete runOptions.streamSubgraphs;
179
+ return {
180
+ ...runOptions,
181
+ ...(hasCheckpoint ? { checkpoint } : {}),
182
+ input: input,
183
+ streamMode: streamMode ?? defaultStreamMode(),
184
+ streamSubgraphs: streamSubgraphs ?? true,
185
+ signal,
186
+ };
187
+ }
188
+ function defaultStreamMode() {
189
+ // 'tools' is intentionally omitted: not supported by langgraph_api < 0.9.x
190
+ // Servers reject the entire request with HTTP 422 if any stream_mode in
191
+ // the array is unknown to them. Tool-call data is still derivable from
192
+ // the messages stream.
193
+ return ['values', 'messages-tuple', 'updates', 'custom'];
194
+ }
195
+ function normalizeSdkEvent(type, data) {
196
+ const namespace = extractNamespace(type);
197
+ const baseType = getBaseEventType$1(type);
198
+ if (baseType === 'messages' && Array.isArray(data) && data.length === 2 && isRecord$2(data[1])) {
199
+ return { type, ...(namespace ? { namespace } : {}), messages: [data[0]], messageMetadata: data[1], data };
200
+ }
201
+ if (isMessagesEvent$1(type) && Array.isArray(data)) {
202
+ return { type, ...(namespace ? { namespace } : {}), messages: data, data };
203
+ }
204
+ if (isRecord$2(data)) {
205
+ return { type, ...(namespace ? { namespace } : {}), ...data, data };
206
+ }
207
+ return { type, ...(namespace ? { namespace } : {}), data };
208
+ }
209
+ function isMessagesEvent$1(type) {
210
+ const baseType = getBaseEventType$1(type);
211
+ return baseType === 'messages' || baseType.startsWith('messages/');
212
+ }
213
+ function getBaseEventType$1(type) {
214
+ return String(type).split('|')[0];
215
+ }
216
+ function extractNamespace(type) {
217
+ const parts = String(type).split('|');
218
+ return parts.length > 1 ? parts.slice(1) : undefined;
219
+ }
220
+ function isRecord$2(value) {
221
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
222
+ }
223
+
224
+ const DEFAULT_SUBAGENT_TOOL_NAMES = ['task'];
225
+ /**
226
+ * Lightweight Angular adapter for LangGraph subagent stream state.
227
+ *
228
+ * This intentionally mirrors only the SDK behavior this package exposes. Using
229
+ * the SDK UI barrel at runtime pulls StreamManager/client utilities into every
230
+ * Angular bundle, which breaks cockpit production budgets.
231
+ */
232
+ class SubagentTracker {
233
+ subagentToolNames;
234
+ onSubagentChange;
235
+ subagents = new Map();
236
+ namespaceToToolCallId = new Map();
237
+ pendingMatches = new Map();
238
+ constructor(options = {}) {
239
+ this.subagentToolNames = new Set(options.subagentToolNames ?? DEFAULT_SUBAGENT_TOOL_NAMES);
240
+ this.onSubagentChange = options.onSubagentChange;
241
+ }
242
+ clear() {
243
+ this.subagents.clear();
244
+ this.namespaceToToolCallId.clear();
245
+ this.pendingMatches.clear();
246
+ this.onSubagentChange?.();
247
+ }
248
+ getSubagents() {
249
+ const visible = new Map();
250
+ for (const [id, subagent] of this.subagents) {
251
+ if (subagent.status !== 'pending') {
252
+ visible.set(id, subagent);
253
+ }
254
+ }
255
+ return visible;
256
+ }
257
+ registerFromToolCalls(toolCalls, aiMessageId) {
258
+ let changed = false;
259
+ for (const toolCall of toolCalls) {
260
+ if (!this.subagentToolNames.has(toolCall.name))
261
+ continue;
262
+ const id = toolCall.id;
263
+ if (!id)
264
+ continue;
265
+ const args = parseToolCallArgs(toolCall.args);
266
+ if (!isValidSubagentType(args['subagent_type']))
267
+ continue;
268
+ const existing = this.subagents.get(id);
269
+ this.subagents.set(id, {
270
+ id,
271
+ status: existing?.status ?? 'pending',
272
+ toolCall: {
273
+ id,
274
+ name: toolCall.name,
275
+ args: {
276
+ ...args,
277
+ ...(aiMessageId ? { _aiMessageId: aiMessageId } : {}),
278
+ },
279
+ },
280
+ values: existing?.values ?? {},
281
+ messages: existing?.messages ?? [],
282
+ });
283
+ changed = true;
284
+ }
285
+ if (changed) {
286
+ this.retryPendingMatches();
287
+ this.onSubagentChange?.();
288
+ }
289
+ }
290
+ reconstructFromMessages(messages, options = {}) {
291
+ if (options.skipIfPopulated && this.subagents.size > 0)
292
+ return;
293
+ for (const message of messages) {
294
+ const raw = message;
295
+ if (isAiMessageWithToolCalls$1(raw)) {
296
+ this.registerFromToolCalls(raw['tool_calls'], typeof raw['id'] === 'string' ? raw['id'] : null);
297
+ }
298
+ else if (isToolMessage$1(raw)) {
299
+ this.processToolMessage(raw['tool_call_id'], raw['content'], raw['status'] === 'error' ? 'error' : 'success');
300
+ }
301
+ }
302
+ }
303
+ matchSubgraphToSubagent(namespaceId, description) {
304
+ if (this.namespaceToToolCallId.has(namespaceId)) {
305
+ return this.namespaceToToolCallId.get(namespaceId);
306
+ }
307
+ const mapped = new Set(this.namespaceToToolCallId.values());
308
+ const establish = (toolCallId) => {
309
+ this.namespaceToToolCallId.set(namespaceId, toolCallId);
310
+ const subagent = this.subagents.get(toolCallId);
311
+ if (subagent) {
312
+ this.subagents.set(toolCallId, {
313
+ ...subagent,
314
+ status: subagent.status === 'complete' || subagent.status === 'error' ? subagent.status : 'running',
315
+ });
316
+ }
317
+ this.onSubagentChange?.();
318
+ return toolCallId;
319
+ };
320
+ for (const [toolCallId, subagent] of this.subagents) {
321
+ if (mapped.has(toolCallId))
322
+ continue;
323
+ if (subagent.toolCall.args['description'] === description) {
324
+ return establish(toolCallId);
325
+ }
326
+ }
327
+ for (const [toolCallId, subagent] of this.subagents) {
328
+ if (mapped.has(toolCallId))
329
+ continue;
330
+ const subagentDescription = subagent.toolCall.args['description'];
331
+ if (typeof subagentDescription !== 'string' || !subagentDescription)
332
+ continue;
333
+ if (description.includes(subagentDescription) || subagentDescription.includes(description)) {
334
+ return establish(toolCallId);
335
+ }
336
+ }
337
+ for (const [toolCallId, subagent] of this.subagents) {
338
+ if (!mapped.has(toolCallId) && (subagent.status === 'pending' || subagent.status === 'running')) {
339
+ return establish(toolCallId);
340
+ }
341
+ }
342
+ if (description) {
343
+ this.pendingMatches.set(namespaceId, description);
344
+ }
345
+ return undefined;
346
+ }
347
+ markRunningFromNamespace(namespaceId, namespace) {
348
+ const toolCallId = this.resolveToolCallId(namespaceId);
349
+ const subagent = this.subagents.get(toolCallId);
350
+ if (!subagent)
351
+ return;
352
+ if (!this.namespaceToToolCallId.has(namespaceId)) {
353
+ this.namespaceToToolCallId.set(namespaceId, toolCallId);
354
+ }
355
+ this.subagents.set(toolCallId, {
356
+ ...subagent,
357
+ status: subagent.status === 'complete' || subagent.status === 'error' ? subagent.status : 'running',
358
+ values: {
359
+ ...subagent.values,
360
+ ...(namespace ? { namespace } : {}),
361
+ },
362
+ });
363
+ this.onSubagentChange?.();
364
+ }
365
+ updateSubagentValues(namespaceId, values) {
366
+ const toolCallId = this.resolveToolCallId(namespaceId);
367
+ const subagent = this.subagents.get(toolCallId);
368
+ if (!subagent)
369
+ return;
370
+ this.subagents.set(toolCallId, {
371
+ ...subagent,
372
+ status: subagent.status === 'complete' || subagent.status === 'error' ? subagent.status : 'running',
373
+ values,
374
+ });
375
+ this.onSubagentChange?.();
376
+ }
377
+ addMessageToSubagent(namespaceId, message) {
378
+ const toolCallId = this.resolveToolCallId(namespaceId);
379
+ const subagent = this.subagents.get(toolCallId);
380
+ if (!subagent)
381
+ return;
382
+ this.subagents.set(toolCallId, {
383
+ ...subagent,
384
+ status: subagent.status === 'complete' || subagent.status === 'error' ? subagent.status : 'running',
385
+ messages: mergeMessages$1(subagent.messages, [message]),
386
+ });
387
+ this.onSubagentChange?.();
388
+ }
389
+ processToolMessage(toolCallId, content, status) {
390
+ const subagent = this.subagents.get(toolCallId);
391
+ if (!subagent)
392
+ return;
393
+ this.subagents.set(toolCallId, {
394
+ ...subagent,
395
+ status: status === 'error' ? 'error' : 'complete',
396
+ values: {
397
+ ...subagent.values,
398
+ result: content,
399
+ },
400
+ });
401
+ this.onSubagentChange?.();
402
+ }
403
+ retryPendingMatches() {
404
+ for (const [namespaceId, description] of this.pendingMatches) {
405
+ if (this.matchSubgraphToSubagent(namespaceId, description)) {
406
+ this.pendingMatches.delete(namespaceId);
407
+ }
408
+ }
409
+ }
410
+ resolveToolCallId(namespaceId) {
411
+ return this.namespaceToToolCallId.get(namespaceId) ?? namespaceId;
412
+ }
413
+ }
414
+ function isSubagentNamespace(namespace) {
415
+ if (!namespace)
416
+ return false;
417
+ if (typeof namespace === 'string')
418
+ return namespace.includes('tools:');
419
+ return namespace.some(segment => segment.startsWith('tools:'));
420
+ }
421
+ function extractToolCallIdFromNamespace(namespace) {
422
+ if (!namespace)
423
+ return undefined;
424
+ for (const segment of namespace) {
425
+ if (segment.startsWith('tools:'))
426
+ return segment.slice(6);
427
+ }
428
+ return undefined;
429
+ }
430
+ function parseToolCallArgs(args) {
431
+ if (typeof args !== 'string')
432
+ return args;
433
+ try {
434
+ const parsed = JSON.parse(args);
435
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
436
+ ? parsed
437
+ : {};
438
+ }
439
+ catch {
440
+ return {};
441
+ }
442
+ }
443
+ function isValidSubagentType(value) {
444
+ return typeof value === 'string'
445
+ && value.length >= 3
446
+ && value.length <= 50
447
+ && /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(value);
448
+ }
449
+ function isAiMessageWithToolCalls$1(value) {
450
+ return (value['type'] === 'ai' || value['type'] === 'assistant')
451
+ && Array.isArray(value['tool_calls']);
452
+ }
453
+ function isToolMessage$1(value) {
454
+ return value['type'] === 'tool' && typeof value['tool_call_id'] === 'string';
455
+ }
456
+ function mergeMessages$1(existing, incoming) {
457
+ const merged = [...existing];
458
+ for (const msg of incoming) {
459
+ const id = getMessageId(msg);
460
+ const idx = id ? merged.findIndex(m => getMessageId(m) === id) : -1;
461
+ if (idx >= 0) {
462
+ merged[idx] = msg;
463
+ }
464
+ else {
465
+ merged.push(msg);
466
+ }
467
+ }
468
+ return merged;
469
+ }
470
+ function getMessageId(message) {
471
+ return message.id;
472
+ }
473
+
474
+ // SPDX-License-Identifier: MIT
475
+ // Local copy of the trace harness — same gating as @threadplane/chat's trace.ts.
476
+ // Duplicated here to avoid an @threadplane/chat dep on the langgraph internals path.
477
+ function isLgTraceEnabled() {
478
+ if (typeof globalThis === 'undefined')
479
+ return false;
480
+ const win = globalThis.window;
481
+ if (!win)
482
+ return false;
483
+ if (win.__threadplaneChatTrace === true)
484
+ return true;
485
+ try {
486
+ return win.localStorage?.getItem('THREADPLANE_CHAT_STREAM_TRACE') === '1';
487
+ }
488
+ catch {
489
+ return false;
490
+ }
491
+ }
492
+ function lgTrace(...args) {
493
+ if (isLgTraceEnabled()) {
494
+ // eslint-disable-next-line no-console
495
+ console.debug('[ngaf-chat-stream]', ...args);
496
+ }
497
+ }
498
+ function captureAgentRuntimeTelemetry(sink, event, properties) {
499
+ if (!sink)
500
+ return;
501
+ try {
502
+ void Promise.resolve(sink({ event, properties })).catch(() => undefined);
503
+ }
504
+ catch {
505
+ // Keep telemetry side effects isolated from stream control flow.
506
+ }
507
+ }
508
+ function agentRuntimeTelemetryErrorClass(error) {
509
+ if (error instanceof Error)
510
+ return error.name || error.constructor.name || 'Error';
511
+ if (error
512
+ && typeof error === 'object'
513
+ && 'name' in error
514
+ && typeof error.name === 'string'
515
+ && error.name.length > 0) {
516
+ return error.name;
517
+ }
518
+ return 'UnknownError';
519
+ }
520
+ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
521
+ // Intercept onThreadId to update currentThreadId when the transport
522
+ // auto-creates a thread. Without this, each submit() creates a new thread
523
+ // because currentThreadId stays null.
524
+ const userOnThreadId = options.onThreadId;
525
+ const wrappedOnThreadId = (id) => {
526
+ currentThreadId = id;
527
+ userOnThreadId?.(id);
528
+ };
529
+ const transport = options.transport ?? new FetchStreamTransport(options.apiUrl, wrappedOnThreadId);
530
+ let currentThreadId = null;
531
+ let lastPayload = null;
532
+ let lastOptions;
533
+ let abortController = null;
534
+ let historyAbortController = null;
535
+ let hasSeenThreadId = false;
536
+ const toolProgressMap = new Map();
537
+ const queuedRuns = [];
538
+ let drainingQueue = false;
539
+ const subagentManager = new SubagentTracker({
540
+ subagentToolNames: options.subagentToolNames,
541
+ onSubagentChange: publishSubagents,
542
+ });
543
+ const telemetryProperties = { transport: 'langgraph', surface: 'agent' };
544
+ captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:runtime_instance_created', telemetryProperties);
545
+ function captureRuntimeRequestTelemetry(requestType) {
546
+ captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:runtime_request_created', {
547
+ ...telemetryProperties,
548
+ requestType,
549
+ });
550
+ }
551
+ /**
552
+ * Tracks reasoning timing per message id. Keys are message ids; values
553
+ * record when reasoning content first arrived and when response text
554
+ * first appeared (or the canonical message arrived). Cleared on
555
+ * resetThreadState() and on bridge teardown.
556
+ */
557
+ const reasoningTimingMap = new Map();
558
+ function resetThreadState() {
559
+ historyAbortController?.abort();
560
+ subjects.values$.next({});
561
+ subjects.messages$.next([]);
562
+ subjects.history$.next([]);
563
+ subjects.interrupt$.next(undefined);
564
+ subjects.interrupts$.next([]);
565
+ subjects.toolProgress$.next([]);
566
+ subjects.toolCalls$.next([]);
567
+ subjects.messageMetadata$.next(new Map());
568
+ subjects.subagents$.next(new Map());
569
+ void cancelQueueEntries(takeQueuedRuns()).catch(err => subjects.error$.next(err));
570
+ publishQueue();
571
+ subjects.custom$.next([]);
572
+ subjects.isThreadLoading$.next(false);
573
+ toolProgressMap.clear();
574
+ subagentManager.clear();
575
+ reasoningTimingMap.clear();
576
+ }
577
+ function setThreadId(id, resetState) {
578
+ if (resetState) {
579
+ abortController?.abort();
580
+ }
581
+ currentThreadId = id;
582
+ if (resetState) {
583
+ resetThreadState();
584
+ }
585
+ void refreshHistory();
586
+ }
587
+ // Track threadId changes
588
+ threadId$.pipe(takeUntil(destroy$)).subscribe(id => {
589
+ const shouldReset = hasSeenThreadId && currentThreadId !== id;
590
+ hasSeenThreadId = true;
591
+ setThreadId(id, shouldReset);
592
+ });
593
+ destroy$.subscribe(() => {
594
+ abortController?.abort();
595
+ historyAbortController?.abort();
596
+ reasoningTimingMap.clear();
597
+ });
598
+ async function refreshHistory(force = false) {
599
+ const getHistory = transport.getHistory?.bind(transport);
600
+ if (!currentThreadId || !getHistory)
601
+ return;
602
+ historyAbortController?.abort();
603
+ const controller = new AbortController();
604
+ historyAbortController = controller;
605
+ const threadId = currentThreadId;
606
+ subjects.isThreadLoading$.next(true);
607
+ try {
608
+ const history = await getHistory(threadId, controller.signal);
609
+ if (!controller.signal.aborted && currentThreadId === threadId) {
610
+ subjects.history$.next(history);
611
+ // Project the latest checkpoint into messages$ + values$:
612
+ // - On first connect (`force=false`): only when messages$ is
613
+ // empty, so optimistic local state isn't clobbered.
614
+ // - At run completion (`force=true`): always — server state is
615
+ // authoritative for node-level mutations (e.g. RemoveMessage
616
+ // or id-match content replacement performed by post-process
617
+ // nodes), and the streaming SDK doesn't always restream those.
618
+ const latest = history[0];
619
+ const shouldProject = latest?.values
620
+ && (force || subjects.messages$.value.length === 0);
621
+ if (shouldProject) {
622
+ const restoredMessages = latest.values?.messages ?? [];
623
+ const restoredValues = { ...latest.values };
624
+ // Strip the `messages` field from values — messages$ is the
625
+ // canonical surface for them; keeping a duplicate in values$
626
+ // would confuse downstream consumers reading both subjects.
627
+ delete restoredValues.messages;
628
+ subjects.messages$.next(restoredMessages);
629
+ subjects.values$.next(restoredValues);
630
+ // Rebuild derived subjects from the new authoritative messages$.
631
+ // Tool-call results displayed by chat-tool-calls come from
632
+ // toolCalls$, which is built from messages$; without this, the
633
+ // panel keeps showing the streamed pre-mutation content.
634
+ syncToolCallsFromMessages();
635
+ }
636
+ // Hydrate pending interrupts from the latest checkpoint. When a
637
+ // thread is reloaded mid-pause (paused at an interrupt), the
638
+ // streaming events won't replay — interrupts live on the
639
+ // checkpoint's tasks[i].interrupts and must be projected manually
640
+ // so the interrupt panel re-renders on page reload.
641
+ hydrateInterruptsFromHistory(history, subjects);
642
+ }
643
+ }
644
+ catch (err) {
645
+ if (!controller.signal.aborted && err?.name !== 'AbortError') {
646
+ subjects.error$.next(err);
647
+ }
648
+ }
649
+ finally {
650
+ if (historyAbortController === controller) {
651
+ historyAbortController = null;
652
+ subjects.isThreadLoading$.next(false);
653
+ }
654
+ }
655
+ }
656
+ function publishQueue() {
657
+ subjects.queue$.next(createQueueSnapshot());
658
+ }
659
+ function createQueueSnapshot() {
660
+ return {
661
+ entries: [...queuedRuns],
662
+ size: queuedRuns.length,
663
+ cancel: cancelQueuedRun,
664
+ clear: clearQueue,
665
+ };
666
+ }
667
+ async function enqueueRun(payload, opts) {
668
+ if (!currentThreadId) {
669
+ throw new Error('Cannot enqueue a run before a LangGraph thread exists.');
670
+ }
671
+ if (!transport.createQueuedRun) {
672
+ throw new Error('The configured LangGraph transport does not support server-side queueing.');
673
+ }
674
+ captureRuntimeRequestTelemetry('enqueue');
675
+ const controller = new AbortController();
676
+ const entry = await transport.createQueuedRun(options.assistantId, currentThreadId, payload, opts?.signal ?? controller.signal, opts);
677
+ queuedRuns.push({
678
+ ...entry,
679
+ values: payload,
680
+ options: { ...opts, multitaskStrategy: 'enqueue' },
681
+ createdAt: entry.createdAt ?? new Date(),
682
+ });
683
+ publishQueue();
684
+ }
685
+ async function cancelQueuedRun(id) {
686
+ const index = queuedRuns.findIndex(entry => entry.id === id);
687
+ if (index === -1)
688
+ return false;
689
+ const [entry] = queuedRuns.splice(index, 1);
690
+ publishQueue();
691
+ if (!entry || !transport.cancelRun)
692
+ return false;
693
+ await cancelQueueEntries([entry]);
694
+ return true;
695
+ }
696
+ async function clearQueue() {
697
+ const entries = takeQueuedRuns();
698
+ publishQueue();
699
+ await cancelQueueEntries(entries);
700
+ }
701
+ function takeQueuedRuns() {
702
+ return queuedRuns.splice(0, queuedRuns.length);
703
+ }
704
+ async function cancelQueueEntries(entries) {
705
+ const cancelRun = transport.cancelRun?.bind(transport);
706
+ if (!cancelRun)
707
+ return;
708
+ await Promise.all(entries.map(entry => cancelRun(entry.threadId, entry.id, new AbortController().signal)));
709
+ }
710
+ async function drainQueue() {
711
+ if (drainingQueue || queuedRuns.length === 0)
712
+ return;
713
+ drainingQueue = true;
714
+ try {
715
+ while (queuedRuns.length > 0) {
716
+ const entry = queuedRuns.shift();
717
+ publishQueue();
718
+ if (!entry || !transport.joinStream)
719
+ continue;
720
+ await joinQueuedRun(entry);
721
+ }
722
+ }
723
+ finally {
724
+ drainingQueue = false;
725
+ }
726
+ }
727
+ async function joinQueuedRun(entry) {
728
+ abortController = new AbortController();
729
+ const startedAt = Date.now();
730
+ captureRuntimeRequestTelemetry('join_queued');
731
+ captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_started', telemetryProperties);
732
+ subjects.custom$.next([]);
733
+ subjects.toolProgress$.next([]);
734
+ toolProgressMap.clear();
735
+ subjects.status$.next(ResourceStatus.Loading);
736
+ try {
737
+ const iter = transport.joinStream
738
+ ? transport.joinStream(entry.threadId, entry.id, undefined, abortController.signal)
739
+ : [];
740
+ for await (const event of iter) {
741
+ if (abortController.signal.aborted)
742
+ break;
743
+ processEvent(event);
744
+ }
745
+ if (!abortController.signal.aborted) {
746
+ subjects.status$.next(ResourceStatus.Resolved);
747
+ // force=true: rehydrate from server-authoritative state so any
748
+ // post-process node mutations (RemoveMessage, id-match content
749
+ // replacement) reflected on the server are picked up client-side.
750
+ await refreshHistory(true);
751
+ captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_ended', {
752
+ ...telemetryProperties,
753
+ durationMs: Date.now() - startedAt,
754
+ });
755
+ }
756
+ }
757
+ catch (err) {
758
+ subjects.error$.next(err);
759
+ subjects.status$.next(ResourceStatus.Error);
760
+ captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_errored', {
761
+ ...telemetryProperties,
762
+ durationMs: Date.now() - startedAt,
763
+ errorClass: agentRuntimeTelemetryErrorClass(err),
764
+ });
765
+ }
766
+ }
767
+ async function runStream(payload, opts, requestType = 'submit') {
768
+ abortController?.abort();
769
+ abortController = new AbortController();
770
+ const startedAt = Date.now();
771
+ captureRuntimeRequestTelemetry(requestType);
772
+ captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_started', telemetryProperties);
773
+ subjects.status$.next(ResourceStatus.Loading);
774
+ subjects.error$.next(undefined);
775
+ subjects.custom$.next([]);
776
+ subjects.toolProgress$.next([]);
777
+ toolProgressMap.clear();
778
+ lastPayload = payload;
779
+ lastOptions = opts;
780
+ // Optimistically inject human messages so they appear immediately
781
+ // without waiting for the server to echo them back. Assign a stable id
782
+ // when missing — track-by-id in the chat-message-list relies on stable
783
+ // ids across re-emissions, otherwise the optimistic message gets torn
784
+ // down + recreated on every messages$.next() during streaming, which
785
+ // restarts caret/typing animations and causes visible flicker.
786
+ const inputMessages = payload?.['messages'];
787
+ if (Array.isArray(inputMessages) && inputMessages.length > 0) {
788
+ const stamped = inputMessages.map((m) => {
789
+ const raw = m;
790
+ if (typeof raw['id'] === 'string' && raw['id'])
791
+ return m;
792
+ const id = `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
793
+ return { ...m, id };
794
+ });
795
+ const existing = subjects.messages$.value;
796
+ subjects.messages$.next([...existing, ...stamped]);
797
+ }
798
+ try {
799
+ const iter = transport.stream(options.assistantId, currentThreadId, payload, opts?.signal ?? abortController.signal, opts);
800
+ for await (const event of iter) {
801
+ if (abortController.signal.aborted)
802
+ break;
803
+ processEvent(event);
804
+ }
805
+ if (!abortController.signal.aborted) {
806
+ subjects.status$.next(ResourceStatus.Resolved);
807
+ // force=true: see refreshHistory comment — server state is
808
+ // authoritative after run completion.
809
+ await refreshHistory(true);
810
+ await drainQueue();
811
+ captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_ended', {
812
+ ...telemetryProperties,
813
+ durationMs: Date.now() - startedAt,
814
+ });
815
+ }
816
+ }
817
+ catch (err) {
818
+ if (err?.name === 'AbortError') {
819
+ subjects.status$.next(ResourceStatus.Resolved);
820
+ }
821
+ else {
822
+ subjects.error$.next(err);
823
+ subjects.status$.next(ResourceStatus.Error);
824
+ captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_errored', {
825
+ ...telemetryProperties,
826
+ durationMs: Date.now() - startedAt,
827
+ errorClass: agentRuntimeTelemetryErrorClass(err),
828
+ });
829
+ }
830
+ }
831
+ }
832
+ function processEvent(event) {
833
+ const baseType = getBaseEventType(event.type);
834
+ const namespace = getEventNamespace(event);
835
+ if (isMessagesEvent(event.type)) {
836
+ const msgs = normalizeMessages(event);
837
+ if (!msgs)
838
+ return;
839
+ const normalized = options.toMessage
840
+ ? msgs.map(options.toMessage)
841
+ : msgs;
842
+ if (isSubagentNamespace(namespace)) {
843
+ const namespaceId = namespace ? extractToolCallIdFromNamespace(namespace) : undefined;
844
+ if (namespaceId) {
845
+ for (const msg of normalized) {
846
+ subagentManager.addMessageToSubagent(namespaceId, msg);
847
+ }
848
+ publishSubagents();
849
+ }
850
+ if (options.filterSubagentMessages) {
851
+ return;
852
+ }
853
+ }
854
+ // Partial and message-tuple events are incremental. Merge them by id
855
+ // so optimistic human messages and earlier tool messages are preserved.
856
+ if (event.type === 'messages/partial' || event.messageMetadata) {
857
+ subjects.messages$.next(mergeMessages(subjects.messages$.value, normalized, reasoningTimingMap));
858
+ if (isLgTraceEnabled()) {
859
+ const msgs = subjects.messages$.value;
860
+ const last = msgs[msgs.length - 1];
861
+ lgTrace('bridge.messages-tuple', { id: last?.['id'], count: msgs.length });
862
+ }
863
+ }
864
+ else if (normalized.length === 0) {
865
+ // Defensive: skip empty replacements during streaming. An empty
866
+ // batch shouldn't tear down the entire UI (causes message DOM
867
+ // teardown + streaming renderer reset = visible jank).
868
+ }
869
+ else {
870
+ // Preserve existing ids by content so the final-id swap doesn't
871
+ // tear down the chat-message DOM (and its streaming-md renderer).
872
+ subjects.messages$.next(preserveIds(subjects.messages$.value, normalized));
873
+ }
874
+ storeMessageMetadata(normalized, event);
875
+ syncSubagentsFromMessages(normalized);
876
+ syncToolCallsFromMessages();
877
+ return;
878
+ }
879
+ // normalizeSdkEvent spreads event data directly into the event object,
880
+ // so the values/updates payload is at event['data'] (the original data object),
881
+ // NOT at event['values'] or event['updates'].
882
+ switch (baseType) {
883
+ case 'values': {
884
+ const vals = extractEventData(event);
885
+ if (isSubagentNamespace(namespace) && isRecord$1(vals)) {
886
+ updateSubagentValues(namespace, vals);
887
+ break;
888
+ }
889
+ if (vals != null) {
890
+ extractInterrupts(vals, subjects);
891
+ subjects.values$.next(vals);
892
+ // Also sync messages$ from the values state so the full message
893
+ // history (including human messages) is available to consumers.
894
+ const stateMessages = vals['messages'];
895
+ if (Array.isArray(stateMessages) && stateMessages.length > 0) {
896
+ // Defensive: only sync when state carries messages. An empty
897
+ // values payload shouldn't wipe the UI mid-stream.
898
+ const projected = options.toMessage
899
+ ? stateMessages.map(options.toMessage)
900
+ : stateMessages;
901
+ // Drop empty-content AI placeholders before merging. LangGraph
902
+ // emits intermediate `values` events whose `state.messages`
903
+ // includes an unfilled assistant turn at the tail. Keeping it
904
+ // would create a phantom slot that competes with the chunk-
905
+ // streamed AIMessageChunk arriving via messages-tuple — they'd
906
+ // never merge (different ids; non-overlapping content fragments)
907
+ // and the user sees two assistant bubbles.
908
+ const filtered = projected.filter((m, i) => {
909
+ if (i !== projected.length - 1)
910
+ return true;
911
+ const t = normalizeMessageType(typeof m._getType === 'function' ? m._getType() : m['type']);
912
+ if (t !== 'ai')
913
+ return true;
914
+ const text = extractText(m.content);
915
+ return text.length > 0;
916
+ });
917
+ // Preserve existing ids by content match (server echo / final-id swap).
918
+ const remapped = preserveIds(subjects.messages$.value, filtered);
919
+ // ALWAYS merge values-derived messages into existing rather
920
+ // than replacing. LangGraph emits intermediate values events
921
+ // during streaming where state.messages can lag behind what
922
+ // we've already seen via messages-tuple — replacing would
923
+ // drop the partial AI (or even the optimistic human) and
924
+ // tear down their DOM mid-stream. Merge by id keeps both,
925
+ // updates content where ids match, preserves the rest.
926
+ subjects.messages$.next(mergeMessages(subjects.messages$.value, remapped, reasoningTimingMap));
927
+ if (isLgTraceEnabled()) {
928
+ lgTrace('bridge.values-sync', {
929
+ incomingLength: stateMessages.length,
930
+ mergedLength: subjects.messages$.value.length,
931
+ });
932
+ }
933
+ syncSubagentsFromMessages(stateMessages);
934
+ subagentManager.reconstructFromMessages(stateMessages, { skipIfPopulated: true });
935
+ publishSubagents();
936
+ syncToolCallsFromMessages();
937
+ }
938
+ }
939
+ break;
940
+ }
941
+ case 'updates': {
942
+ const upd = extractEventData(event);
943
+ if (isSubagentNamespace(namespace)) {
944
+ markSubagentRunning(namespace);
945
+ break;
946
+ }
947
+ if (upd != null) {
948
+ extractInterrupts(upd, subjects);
949
+ subjects.values$.next({
950
+ ...subjects.values$.value,
951
+ ...upd,
952
+ });
953
+ }
954
+ break;
955
+ }
956
+ case 'error':
957
+ subjects.error$.next(event['error']);
958
+ subjects.status$.next(ResourceStatus.Error);
959
+ break;
960
+ case 'interrupt':
961
+ subjects.interrupt$.next(event['interrupt']);
962
+ break;
963
+ case 'interrupts':
964
+ subjects.interrupts$.next(event['interrupts']);
965
+ break;
966
+ case 'custom': {
967
+ const eventData = event['data'];
968
+ const name = (event['name'] ?? eventData?.['name'] ?? '');
969
+ const data = eventData?.['data'] ?? eventData;
970
+ const current = subjects.custom$.value;
971
+ subjects.custom$.next([...current, { name, data }]);
972
+ break;
973
+ }
974
+ case 'tools':
975
+ updateToolProgress(event);
976
+ break;
977
+ }
978
+ }
979
+ function syncToolCallsFromMessages() {
980
+ const toolCalls = getToolCallsWithResults(subjects.messages$.value);
981
+ subjects.toolCalls$.next(toolCalls);
982
+ }
983
+ function syncSubagentsFromMessages(messages) {
984
+ for (const message of messages) {
985
+ const raw = message;
986
+ if (isAiMessageWithToolCalls(raw)) {
987
+ subagentManager.registerFromToolCalls(raw['tool_calls'], typeof raw['id'] === 'string' ? raw['id'] : null);
988
+ }
989
+ if (isToolMessage(raw)) {
990
+ const content = typeof raw['content'] === 'string'
991
+ ? raw['content']
992
+ : JSON.stringify(raw['content']);
993
+ const status = raw['status'] === 'error' ? 'error' : 'success';
994
+ subagentManager.processToolMessage(raw['tool_call_id'], content, status);
995
+ }
996
+ }
997
+ publishSubagents();
998
+ }
999
+ function updateSubagentValues(namespace, values) {
1000
+ const namespaceId = namespace ? extractToolCallIdFromNamespace(namespace) : undefined;
1001
+ if (!namespaceId)
1002
+ return;
1003
+ const messages = values['messages'];
1004
+ if (Array.isArray(messages) && messages.length > 0) {
1005
+ const first = messages[0];
1006
+ if (isRecord$1(first) && (first['type'] === 'human' || first['type'] === 'user') && typeof first['content'] === 'string') {
1007
+ subagentManager.matchSubgraphToSubagent(namespaceId, first['content']);
1008
+ }
1009
+ }
1010
+ subagentManager.updateSubagentValues(namespaceId, values);
1011
+ publishSubagents();
1012
+ }
1013
+ function markSubagentRunning(namespace) {
1014
+ const namespaceId = namespace ? extractToolCallIdFromNamespace(namespace) : undefined;
1015
+ if (!namespaceId)
1016
+ return;
1017
+ subagentManager.markRunningFromNamespace(namespaceId, namespace);
1018
+ publishSubagents();
1019
+ }
1020
+ function publishSubagents() {
1021
+ subjects.subagents$.next(toSubagentRefs(subagentManager.getSubagents()));
1022
+ }
1023
+ function storeMessageMetadata(messages, event) {
1024
+ if (!event.messageMetadata)
1025
+ return;
1026
+ const next = new Map(subjects.messageMetadata$.value);
1027
+ messages.forEach((message, index) => {
1028
+ const id = message['id'];
1029
+ const messageId = String(id ?? index);
1030
+ next.set(messageId, {
1031
+ messageId,
1032
+ firstSeenState: undefined,
1033
+ branch: undefined,
1034
+ branchOptions: undefined,
1035
+ streamMetadata: event.messageMetadata,
1036
+ });
1037
+ });
1038
+ subjects.messageMetadata$.next(next);
1039
+ }
1040
+ function updateToolProgress(event) {
1041
+ const data = extractEventData(event);
1042
+ if (!isRecord$1(data))
1043
+ return;
1044
+ const toolEvent = data['event'];
1045
+ const name = data['name'];
1046
+ if (typeof toolEvent !== 'string' || typeof name !== 'string')
1047
+ return;
1048
+ const toolCallId = typeof data['toolCallId'] === 'string' ? data['toolCallId'] : undefined;
1049
+ const key = toolCallId ?? name;
1050
+ const existing = toolProgressMap.get(key);
1051
+ switch (toolEvent) {
1052
+ case 'on_tool_start':
1053
+ toolProgressMap.set(key, {
1054
+ toolCallId,
1055
+ name,
1056
+ state: 'starting',
1057
+ input: data['input'],
1058
+ });
1059
+ break;
1060
+ case 'on_tool_event':
1061
+ toolProgressMap.set(key, {
1062
+ toolCallId,
1063
+ name,
1064
+ ...existing,
1065
+ state: 'running',
1066
+ data: data['data'],
1067
+ });
1068
+ break;
1069
+ case 'on_tool_end':
1070
+ toolProgressMap.set(key, {
1071
+ toolCallId,
1072
+ name,
1073
+ ...existing,
1074
+ state: 'completed',
1075
+ result: data['output'],
1076
+ });
1077
+ break;
1078
+ case 'on_tool_error':
1079
+ toolProgressMap.set(key, {
1080
+ toolCallId,
1081
+ name,
1082
+ ...existing,
1083
+ state: 'error',
1084
+ error: data['error'],
1085
+ });
1086
+ break;
1087
+ default:
1088
+ return;
1089
+ }
1090
+ subjects.toolProgress$.next([...toolProgressMap.values()]);
1091
+ }
1092
+ return {
1093
+ submit: async (payload, opts) => {
1094
+ if (opts?.multitaskStrategy === 'enqueue' && subjects.status$.value === ResourceStatus.Loading) {
1095
+ await enqueueRun(payload, opts);
1096
+ return;
1097
+ }
1098
+ await runStream(payload, opts);
1099
+ },
1100
+ stop: async () => {
1101
+ abortController?.abort();
1102
+ await clearQueue();
1103
+ subjects.status$.next(ResourceStatus.Resolved);
1104
+ },
1105
+ switchThread: (id) => {
1106
+ setThreadId(id, true);
1107
+ },
1108
+ joinStream: async (runId, lastEventId) => {
1109
+ if (!currentThreadId)
1110
+ return;
1111
+ abortController?.abort();
1112
+ abortController = new AbortController();
1113
+ const startedAt = Date.now();
1114
+ captureRuntimeRequestTelemetry('join');
1115
+ captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_started', telemetryProperties);
1116
+ subjects.custom$.next([]);
1117
+ subjects.toolProgress$.next([]);
1118
+ toolProgressMap.clear();
1119
+ subjects.status$.next(ResourceStatus.Loading);
1120
+ try {
1121
+ const iter = transport.joinStream
1122
+ ? transport.joinStream(currentThreadId, runId, lastEventId, abortController.signal)
1123
+ : [];
1124
+ for await (const event of iter) {
1125
+ processEvent(event);
1126
+ }
1127
+ subjects.status$.next(ResourceStatus.Resolved);
1128
+ await refreshHistory();
1129
+ captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_ended', {
1130
+ ...telemetryProperties,
1131
+ durationMs: Date.now() - startedAt,
1132
+ });
1133
+ }
1134
+ catch (err) {
1135
+ subjects.error$.next(err);
1136
+ subjects.status$.next(ResourceStatus.Error);
1137
+ captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_errored', {
1138
+ ...telemetryProperties,
1139
+ durationMs: Date.now() - startedAt,
1140
+ errorClass: agentRuntimeTelemetryErrorClass(err),
1141
+ });
1142
+ }
1143
+ },
1144
+ resubmitLast: async () => {
1145
+ if (lastPayload !== null) {
1146
+ await runStream(lastPayload, lastOptions, 'resubmit');
1147
+ }
1148
+ },
1149
+ getReasoningDurationMs: (id) => {
1150
+ const entry = reasoningTimingMap.get(id);
1151
+ if (!entry)
1152
+ return undefined;
1153
+ if (entry.endedAt === undefined)
1154
+ return undefined;
1155
+ return entry.endedAt - entry.startedAt;
1156
+ },
1157
+ updateState: async (values, opts) => {
1158
+ // No-op when there is no thread yet or the transport doesn't support
1159
+ // updateState (e.g. MockAgentTransport in unit tests without a threadId).
1160
+ if (!currentThreadId || !transport.updateState) {
1161
+ return;
1162
+ }
1163
+ await transport.updateState(currentThreadId, values, new AbortController().signal, opts);
1164
+ },
1165
+ get currentThreadId() {
1166
+ return currentThreadId;
1167
+ },
1168
+ };
1169
+ }
1170
+ /**
1171
+ * Extracts the payload data from a normalized SDK event.
1172
+ *
1173
+ * Handles two formats:
1174
+ * 1. SDK events (via normalizeSdkEvent): data at event['data'] (record) + spread into event
1175
+ * 2. Mock/test events: data at event[event.type] (e.g., event['values'], event['updates'])
1176
+ */
1177
+ /**
1178
+ * LangGraph emits interrupts as part of `updates`/`values` events under the
1179
+ * special `__interrupt__` key, not as standalone events. When such a payload
1180
+ * appears, mirror it onto `interrupt$` (latest) and `interrupts$` (full list)
1181
+ * so consumers can react via `agent.interrupt()` / `agent.interrupts()`.
1182
+ */
1183
+ function extractInterrupts(payload, subjects) {
1184
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
1185
+ return;
1186
+ const raw = payload['__interrupt__'];
1187
+ // Cast through unknown — Interrupt$ is parameterized over Bag's InterruptType,
1188
+ // and the SDK delivers raw Interrupt payloads here.
1189
+ if (Array.isArray(raw) && raw.length > 0) {
1190
+ const list = raw;
1191
+ subjects.interrupts$.next(list);
1192
+ subjects.interrupt$.next(list[list.length - 1]);
1193
+ return;
1194
+ }
1195
+ // Payload has no `__interrupt__` key. Clear any stale interrupt so the UI
1196
+ // dismisses the panel after a resume completes (LangGraph does not emit a
1197
+ // separate "cleared" event — the absence of `__interrupt__` in subsequent
1198
+ // values/updates is the signal). No-op if interrupt$ was already empty.
1199
+ if (subjects.interrupt$.value !== undefined) {
1200
+ subjects.interrupt$.next(undefined);
1201
+ subjects.interrupts$.next([]);
1202
+ }
1203
+ }
1204
+ /**
1205
+ * Projects pending interrupts from the latest history checkpoint onto the
1206
+ * interrupt$ / interrupts$ subjects. ThreadState exposes interrupts under
1207
+ * `tasks[i].interrupts` (per the LangGraph SDK schema). When the latest
1208
+ * checkpoint contains any pending interrupts, mirror them so consumers can
1209
+ * react via `agent.interrupt()` on thread reload without needing a fresh
1210
+ * stream event.
1211
+ *
1212
+ * If no interrupts are present, this is a no-op so existing streamed state
1213
+ * (mid-run) isn't clobbered by a stale history refresh.
1214
+ */
1215
+ function hydrateInterruptsFromHistory(history, subjects) {
1216
+ const latest = history[0];
1217
+ if (!latest || !Array.isArray(latest.tasks))
1218
+ return;
1219
+ const collected = [];
1220
+ for (const task of latest.tasks) {
1221
+ if (task && Array.isArray(task.interrupts) && task.interrupts.length > 0) {
1222
+ for (const ix of task.interrupts) {
1223
+ collected.push(ix);
1224
+ }
1225
+ }
1226
+ }
1227
+ if (collected.length > 0) {
1228
+ subjects.interrupts$.next(collected);
1229
+ subjects.interrupt$.next(collected[collected.length - 1]);
1230
+ }
1231
+ }
1232
+ function extractEventData(event) {
1233
+ // Try event['data'] first (SDK format from normalizeSdkEvent)
1234
+ const d = event['data'];
1235
+ if (d != null && typeof d === 'object' && !Array.isArray(d)) {
1236
+ return d;
1237
+ }
1238
+ // Try event[event.type] (mock/test format: { type: 'values', values: {...} })
1239
+ const named = event[event.type];
1240
+ if (named != null && typeof named === 'object' && !Array.isArray(named)) {
1241
+ return named;
1242
+ }
1243
+ // Fallback: reconstruct from remaining keys
1244
+ const rest = Object.fromEntries(Object.entries(event).filter(([key]) => key !== 'type' && key !== 'data'));
1245
+ return Object.keys(rest).length > 0 ? rest : d;
1246
+ }
1247
+ function isMessagesEvent(type) {
1248
+ const baseType = getBaseEventType(type);
1249
+ return baseType === 'messages' || baseType.startsWith('messages/');
1250
+ }
1251
+ function getBaseEventType(type) {
1252
+ return String(type).split('|')[0];
1253
+ }
1254
+ function getEventNamespace(event) {
1255
+ if (Array.isArray(event.namespace))
1256
+ return event.namespace;
1257
+ const parts = String(event.type).split('|');
1258
+ return parts.length > 1 ? parts.slice(1) : undefined;
1259
+ }
1260
+ function normalizeMessages(event) {
1261
+ const directMessages = event['messages'];
1262
+ if (Array.isArray(directMessages)) {
1263
+ // Filter out non-message metadata objects (e.g. { langgraph_node, langgraph_triggers })
1264
+ // that the LangGraph SDK includes alongside real messages in messages/* events.
1265
+ const filtered = directMessages.filter(isMessageLike);
1266
+ return filtered.length > 0 ? filtered : null;
1267
+ }
1268
+ const data = event['data'];
1269
+ if (Array.isArray(data)) {
1270
+ if (data.every(isMessageLike)) {
1271
+ return data;
1272
+ }
1273
+ if (isMessageLike(data[0])) {
1274
+ return [data[0]];
1275
+ }
1276
+ }
1277
+ const indexedValues = Object.keys(event)
1278
+ .filter(key => /^\d+$/.test(key))
1279
+ .sort((left, right) => Number(left) - Number(right))
1280
+ .map(key => event[key]);
1281
+ if (indexedValues.every(isMessageLike)) {
1282
+ return indexedValues;
1283
+ }
1284
+ if (isMessageLike(indexedValues[0])) {
1285
+ return [indexedValues[0]];
1286
+ }
1287
+ return null;
1288
+ }
1289
+ /**
1290
+ * Collapse adjacent AI messages where one's text is a prefix of the other.
1291
+ *
1292
+ * When complex-content streaming is in play, the same conceptual assistant
1293
+ * message can land in two slots: the canonical AI from values-sync (id
1294
+ * `resp_…` or run id) and the chunk-streamed AIMessageChunk from
1295
+ * messages-tuple (id `lc_run--…`). Both slots fill in parallel; once both
1296
+ * carry the full text we collapse them, keeping the older slot's id so
1297
+ * track-by-id stays stable in the chat list.
1298
+ */
1299
+ function collapseAdjacentAi(messages) {
1300
+ if (messages.length < 2)
1301
+ return messages;
1302
+ const out = [];
1303
+ for (const msg of messages) {
1304
+ const last = out[out.length - 1];
1305
+ if (!last) {
1306
+ out.push(msg);
1307
+ continue;
1308
+ }
1309
+ const lastType = normalizeMessageType(typeof last._getType === 'function' ? last._getType() : last['type']);
1310
+ const msgType = normalizeMessageType(typeof msg._getType === 'function' ? msg._getType() : msg['type']);
1311
+ if (lastType === 'ai' && msgType === 'ai') {
1312
+ const lastText = extractText(last.content);
1313
+ const msgText = extractText(msg.content);
1314
+ if (lastText.length === 0
1315
+ || msgText.length === 0
1316
+ || lastText === msgText
1317
+ || lastText.startsWith(msgText)
1318
+ || msgText.startsWith(lastText)) {
1319
+ // Keep the longer content; preserve last (older) id and metadata.
1320
+ const longerText = msgText.length >= lastText.length ? msgText : lastText;
1321
+ out[out.length - 1] = { ...last, content: longerText };
1322
+ continue;
1323
+ }
1324
+ }
1325
+ out.push(msg);
1326
+ }
1327
+ return out;
1328
+ }
1329
+ function mergeMessages(existing, incoming, reasoningTimingMap) {
1330
+ const merged = [...existing];
1331
+ for (const msg of incoming) {
1332
+ const rawIn = msg;
1333
+ const id = rawIn['id'];
1334
+ let idx = id ? merged.findIndex(m => m['id'] === id) : -1;
1335
+ // Fallback: match by (role, content) when ids differ. This is the path
1336
+ // that fires when the server echoes back our optimistic human message
1337
+ // with a server-assigned id, or when partial AI tokens carry a chunk
1338
+ // id but the final canonical message has a run id. Preserving the
1339
+ // existing id here keeps track-by-id stable in the chat list and
1340
+ // prevents DOM teardown + animation restarts mid-stream.
1341
+ if (idx < 0) {
1342
+ idx = findContentMatch(merged, msg);
1343
+ }
1344
+ // When an AIMessageChunk arrives without an id-match or content-prefix
1345
+ // match, treat the trailing AI message as its accumulator. The
1346
+ // OpenAI Responses API emits per-chunk events whose ids identify the
1347
+ // *event*, not the message, so consecutive chunks land here. Without
1348
+ // this we'd append every chunk as a separate bubble.
1349
+ if (idx < 0) {
1350
+ const inType = normalizeMessageType(rawIn['type']);
1351
+ if (inType === 'ai') {
1352
+ for (let i = merged.length - 1; i >= 0; i--) {
1353
+ const t = normalizeMessageType(typeof merged[i]._getType === 'function'
1354
+ ? merged[i]._getType()
1355
+ : merged[i]['type']);
1356
+ if (t === 'ai') {
1357
+ idx = i;
1358
+ break;
1359
+ }
1360
+ if (t === 'human' || t === 'tool' || t === 'system')
1361
+ break;
1362
+ }
1363
+ }
1364
+ }
1365
+ if (idx >= 0) {
1366
+ const existing = merged[idx];
1367
+ const existingId = existing['id'];
1368
+ const incomingRaw = msg;
1369
+ // Keep the *existing* id so downstream track-by-id sees stable identity.
1370
+ // For complex-content streaming (OpenAI gpt-5/o-series, Anthropic) the
1371
+ // SDK emits per-chunk *delta* arrays — not accumulated arrays — so a
1372
+ // straight replacement collapses the rendered bubble to just the
1373
+ // latest token. Accumulate text-bearing content across chunks here
1374
+ // and hand a string to consumers; downstream code already handles
1375
+ // string content uniformly.
1376
+ const accumulatedContent = accumulateContent(existing.content, incomingRaw['content']);
1377
+ // Only accumulate reasoning when the incoming message explicitly carries
1378
+ // a `reasoning` field or complex-content array blocks with
1379
+ // type='reasoning'/'thinking'. Never use a plain string content value
1380
+ // as reasoning source — that would wrongly treat every assistant
1381
+ // message text as reasoning content.
1382
+ const incomingReasoningSource = 'reasoning' in incomingRaw
1383
+ ? incomingRaw['reasoning']
1384
+ : (Array.isArray(incomingRaw['content']) ? incomingRaw['content'] : undefined);
1385
+ const accumulatedReasoning = accumulateReasoning(existing['reasoning'], incomingReasoningSource);
1386
+ const idForTiming = existingId ?? incomingRaw['id'];
1387
+ if (idForTiming && reasoningTimingMap) {
1388
+ const hasReasoning = accumulatedReasoning.length > 0;
1389
+ const hasText = (typeof accumulatedContent === 'string' ? accumulatedContent : '').length > 0;
1390
+ if (hasReasoning) {
1391
+ const entry = reasoningTimingMap.get(idForTiming) ?? { startedAt: Date.now() };
1392
+ if (hasText && entry.endedAt === undefined)
1393
+ entry.endedAt = Date.now();
1394
+ reasoningTimingMap.set(idForTiming, entry);
1395
+ }
1396
+ }
1397
+ const next = { ...msg, content: accumulatedContent };
1398
+ next['reasoning'] = accumulatedReasoning;
1399
+ if (existingId) {
1400
+ next['id'] = existingId;
1401
+ }
1402
+ merged[idx] = next;
1403
+ }
1404
+ else {
1405
+ const incomingRaw = msg;
1406
+ const initialReasoningSource = 'reasoning' in incomingRaw
1407
+ ? incomingRaw['reasoning']
1408
+ : (Array.isArray(incomingRaw['content']) ? incomingRaw['content'] : undefined);
1409
+ const initialReasoning = accumulateReasoning(undefined, initialReasoningSource);
1410
+ if (initialReasoning.length > 0 && reasoningTimingMap) {
1411
+ const msgId = incomingRaw['id'];
1412
+ if (msgId && !reasoningTimingMap.has(msgId)) {
1413
+ reasoningTimingMap.set(msgId, { startedAt: Date.now() });
1414
+ }
1415
+ }
1416
+ const next = { ...msg };
1417
+ next['reasoning'] = initialReasoning;
1418
+ merged.push(next);
1419
+ }
1420
+ }
1421
+ return collapseAdjacentAi(merged);
1422
+ }
1423
+ /**
1424
+ * Merge an incoming chunk's content into prior accumulated content for the
1425
+ * same message id.
1426
+ *
1427
+ * - string + string → concat (delta append)
1428
+ * - array + array → concat extracted text from existing + incoming blocks
1429
+ * - array + string → use the string (server final-id swap)
1430
+ * - empty existing → use incoming as-is
1431
+ *
1432
+ * We deliberately collapse complex content arrays to a string at this layer.
1433
+ * The langgraph-sdk client does not accumulate complex-content arrays the
1434
+ * way it accumulates strings, and per-chunk arrays carry only the latest
1435
+ * delta. Concatenating extracted text gives consumers the same uniform
1436
+ * string they get for non-reasoning models.
1437
+ */
1438
+ /**
1439
+ * Heuristic: does this content look like a "final canonical" array
1440
+ * carrying both reasoning and visible text blocks? OpenAI's Responses
1441
+ * API ships the final assistant message in this shape after the
1442
+ * streaming token chunks complete. Detection is narrow (requires BOTH
1443
+ * a reasoning-shape block AND a text-shape block in the same array)
1444
+ * so it doesn't trip on routine streaming chunks.
1445
+ */
1446
+ function isFinalCanonicalReasoningContent(content) {
1447
+ if (!Array.isArray(content))
1448
+ return false;
1449
+ let hasReasoning = false;
1450
+ let hasText = false;
1451
+ for (const block of content) {
1452
+ if (block == null || typeof block !== 'object')
1453
+ continue;
1454
+ const t = block['type'];
1455
+ if (t === 'reasoning' || t === 'thinking')
1456
+ hasReasoning = true;
1457
+ else if (t === 'text' || t === 'output_text')
1458
+ hasText = true;
1459
+ }
1460
+ return hasReasoning && hasText;
1461
+ }
1462
+ function accumulateContent(existing, incoming) {
1463
+ const existingText = extractText(existing);
1464
+ const incomingText = extractText(incoming);
1465
+ // Always return a string. We never want array content escaping the bridge:
1466
+ // (a) downstream consumers expect string content, and (b) findContentMatch
1467
+ // stringifies arrays, which would prevent the canonical-message id-swap
1468
+ // dedupe from matching the streamed-chunk message after a partial chunk.
1469
+ if (existingText.length === 0)
1470
+ return incomingText;
1471
+ if (incomingText.length === 0)
1472
+ return existingText;
1473
+ // Incoming is a strict-superset of accumulated (final-id swap with full content).
1474
+ if (incomingText.startsWith(existingText))
1475
+ return incomingText;
1476
+ // Existing already a strict-superset — chunk arrived after the canonical
1477
+ // message merged in via values-sync. Keep what we have.
1478
+ if (existingText.startsWith(incomingText))
1479
+ return existingText;
1480
+ // Final-canonical detection: when incoming is the "reasoning + text"
1481
+ // array shape that ships the authoritative final message after a
1482
+ // streaming run, replace the partial streamed accumulator with the
1483
+ // canonical text instead of appending. Without this branch a small
1484
+ // formatting difference between the streamed accumulator and the
1485
+ // canonical text breaks the prefix checks above and visible content
1486
+ // is duplicated (`existingText + incomingText`).
1487
+ if (isFinalCanonicalReasoningContent(incoming))
1488
+ return incomingText;
1489
+ // Otherwise treat incoming as a delta and append.
1490
+ return existingText + incomingText;
1491
+ }
1492
+ function extractText(content) {
1493
+ if (typeof content === 'string')
1494
+ return content;
1495
+ if (!Array.isArray(content))
1496
+ return '';
1497
+ let out = '';
1498
+ for (const block of content) {
1499
+ if (typeof block === 'string') {
1500
+ out += block;
1501
+ continue;
1502
+ }
1503
+ if (block == null || typeof block !== 'object')
1504
+ continue;
1505
+ const rec = block;
1506
+ const t = rec['type'];
1507
+ if (t === 'text' || t === 'output_text' || t === undefined) {
1508
+ const text = rec['text'];
1509
+ if (typeof text === 'string')
1510
+ out += text;
1511
+ }
1512
+ }
1513
+ return out;
1514
+ }
1515
+ function extractReasoning(content) {
1516
+ if (typeof content === 'string')
1517
+ return '';
1518
+ if (!Array.isArray(content))
1519
+ return '';
1520
+ let out = '';
1521
+ for (const block of content) {
1522
+ if (block == null || typeof block !== 'object')
1523
+ continue;
1524
+ const rec = block;
1525
+ const t = rec['type'];
1526
+ if (t === 'reasoning' || t === 'thinking') {
1527
+ // Direct text field — Anthropic-style "thinking" blocks and
1528
+ // some LangChain-shaped reasoning blocks land here.
1529
+ const text = rec['text'];
1530
+ if (typeof text === 'string')
1531
+ out += text;
1532
+ // OpenAI Responses API: when `reasoning.summary='auto'` was
1533
+ // requested, reasoning blocks carry a `summary` array of
1534
+ // `{type: 'summary_text', text: '...'}` items. Concatenate
1535
+ // their texts in order.
1536
+ const summary = rec['summary'];
1537
+ if (Array.isArray(summary)) {
1538
+ for (const item of summary) {
1539
+ if (item == null || typeof item !== 'object')
1540
+ continue;
1541
+ const itemText = item['text'];
1542
+ if (typeof itemText === 'string')
1543
+ out += itemText;
1544
+ }
1545
+ }
1546
+ }
1547
+ }
1548
+ return out;
1549
+ }
1550
+ function accumulateReasoning(existing, incoming) {
1551
+ const existingText = typeof existing === 'string' ? existing : extractReasoning(existing);
1552
+ const incomingText = typeof incoming === 'string' ? incoming : extractReasoning(incoming);
1553
+ if (existingText.length === 0)
1554
+ return incomingText;
1555
+ if (incomingText.length === 0)
1556
+ return existingText;
1557
+ if (incomingText.startsWith(existingText))
1558
+ return incomingText;
1559
+ if (existingText.startsWith(incomingText))
1560
+ return existingText;
1561
+ return existingText + incomingText;
1562
+ }
1563
+ /**
1564
+ * Replace the incoming messages' ids with the existing array's ids whenever
1565
+ * (role, content) matches positionally and the existing id differs. Keeps
1566
+ * track-by-id stable across server echoes and final-id swaps.
1567
+ */
1568
+ function preserveIds(existing, incoming) {
1569
+ if (existing.length === 0)
1570
+ return collapseAdjacentAi(incoming);
1571
+ const usedExisting = new Set();
1572
+ const remapped = incoming.map((msg, i) => {
1573
+ const inRaw = msg;
1574
+ const inId = inRaw['id'];
1575
+ // First try same-position match (the dominant case).
1576
+ let matchIdx = -1;
1577
+ if (i < existing.length && !usedExisting.has(i) && sameRoleAndContent(existing[i], msg)) {
1578
+ matchIdx = i;
1579
+ }
1580
+ else {
1581
+ // Fallback: any unused existing message with matching role+content.
1582
+ matchIdx = existing.findIndex((m, j) => !usedExisting.has(j) && sameRoleAndContent(m, msg));
1583
+ }
1584
+ if (matchIdx < 0)
1585
+ return msg;
1586
+ usedExisting.add(matchIdx);
1587
+ const existingId = existing[matchIdx]['id'];
1588
+ if (!existingId || existingId === inId)
1589
+ return msg;
1590
+ return { ...msg, id: existingId };
1591
+ });
1592
+ return collapseAdjacentAi(remapped);
1593
+ }
1594
+ function sameRoleAndContent(a, b) {
1595
+ const aType = normalizeMessageType(typeof a._getType === 'function' ? a._getType() : a['type']);
1596
+ const bType = normalizeMessageType(typeof b._getType === 'function' ? b._getType() : b['type']);
1597
+ if (aType !== bType)
1598
+ return false;
1599
+ const aContent = typeof a.content === 'string' ? a.content : JSON.stringify(a.content);
1600
+ const bContent = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
1601
+ if (aContent === bContent)
1602
+ return true;
1603
+ // For AI messages we accept prefix relationships (streaming → final).
1604
+ if (aType === 'ai' && typeof aContent === 'string' && typeof bContent === 'string') {
1605
+ return aContent.length > 0 && (bContent.startsWith(aContent) || aContent.startsWith(bContent));
1606
+ }
1607
+ return false;
1608
+ }
1609
+ function findContentMatch(merged, incoming) {
1610
+ const inRaw = incoming;
1611
+ const inType = normalizeMessageType(typeof incoming._getType === 'function' ? incoming._getType() : inRaw['type']);
1612
+ const inContent = typeof incoming.content === 'string' ? incoming.content : JSON.stringify(incoming.content);
1613
+ // Only worth matching for human messages (where the optimistic→echo
1614
+ // mismatch happens) and for AI messages where content is a strict prefix
1615
+ // of the existing (token-streaming + final-id swap pattern).
1616
+ for (let i = merged.length - 1; i >= 0; i--) {
1617
+ const m = merged[i];
1618
+ const mType = normalizeMessageType(typeof merged[i]._getType === 'function'
1619
+ ? merged[i]._getType()
1620
+ : m['type']);
1621
+ if (mType !== inType)
1622
+ continue;
1623
+ const mContent = typeof merged[i].content === 'string'
1624
+ ? merged[i].content
1625
+ : JSON.stringify(merged[i].content);
1626
+ if (inType === 'human' && mContent === inContent)
1627
+ return i;
1628
+ if (inType === 'ai') {
1629
+ // Skip empty placeholders. We don't want a pre-existing empty AI
1630
+ // (created by an early values-sync emission with `state.messages`
1631
+ // including an unfilled assistant turn) to absorb the first chunk
1632
+ // arriving via messages-tuple — that strands subsequent chunks in a
1633
+ // separate slot whose content no longer prefix-matches the canonical.
1634
+ const aSafe = typeof mContent === 'string' ? mContent : '';
1635
+ const bSafe = typeof inContent === 'string' ? inContent : '';
1636
+ if (aSafe.length === 0 || bSafe.length === 0)
1637
+ continue;
1638
+ if (mContent === inContent || aSafe.startsWith(bSafe) || bSafe.startsWith(aSafe))
1639
+ return i;
1640
+ }
1641
+ }
1642
+ return -1;
1643
+ }
1644
+ /**
1645
+ * Normalize message type so AIMessage and AIMessageChunk compare equal.
1646
+ * The LangGraph SDK emits type='AIMessageChunk' on the messages-tuple
1647
+ * streaming path and type='ai' on the values-sync path for the same
1648
+ * canonical assistant message — distinguishing them prevents the
1649
+ * content-prefix dedupe from collapsing the duplicate bubbles.
1650
+ */
1651
+ function normalizeMessageType(t) {
1652
+ if (!t)
1653
+ return t;
1654
+ if (t === 'AIMessageChunk' || t === 'AIMessage' || t === 'assistant')
1655
+ return 'ai';
1656
+ if (t === 'HumanMessage' || t === 'HumanMessageChunk' || t === 'user')
1657
+ return 'human';
1658
+ if (t === 'ToolMessage')
1659
+ return 'tool';
1660
+ if (t === 'SystemMessage')
1661
+ return 'system';
1662
+ return t;
1663
+ }
1664
+ function toSubagentRefs(subagents) {
1665
+ const refs = new Map();
1666
+ subagents.forEach((subagent, key) => {
1667
+ refs.set(key, {
1668
+ toolCallId: subagent.id,
1669
+ name: typeof subagent.toolCall.args['subagent_type'] === 'string'
1670
+ ? subagent.toolCall.args['subagent_type']
1671
+ : undefined,
1672
+ status: signal(subagent.status),
1673
+ values: signal(subagent.values),
1674
+ messages: signal(subagent.messages),
1675
+ });
1676
+ });
1677
+ return refs;
1678
+ }
1679
+ function isAiMessageWithToolCalls(value) {
1680
+ return (value['type'] === 'ai' || value['type'] === 'assistant')
1681
+ && Array.isArray(value['tool_calls']);
1682
+ }
1683
+ function isToolMessage(value) {
1684
+ return value['type'] === 'tool' && typeof value['tool_call_id'] === 'string';
1685
+ }
1686
+ function isMessageLike(value) {
1687
+ return typeof value === 'object'
1688
+ && value !== null
1689
+ && ('content' in value
1690
+ || 'type' in value
1691
+ || 'id' in value);
1692
+ }
1693
+ function isRecord$1(value) {
1694
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
1695
+ }
1696
+ const _internalsForTesting = {
1697
+ extractText,
1698
+ extractReasoning,
1699
+ accumulateContent,
1700
+ accumulateReasoning,
1701
+ collapseAdjacentAi,
1702
+ mergeMessages,
1703
+ preserveIds,
1704
+ normalizeMessageType,
1705
+ isFinalCanonicalReasoningContent,
1706
+ };
1707
+
1708
+ const ROOT_ID = '$';
1709
+ /**
1710
+ * Builds a branch-aware checkpoint tree from LangGraph thread history.
1711
+ *
1712
+ * This mirrors the small SDK UI branching data shape without importing the
1713
+ * SDK UI runtime helper, keeping Angular bundles independent of React UI code.
1714
+ */
1715
+ function buildBranchTree(history = []) {
1716
+ if (history.length <= 1) {
1717
+ return {
1718
+ type: 'sequence',
1719
+ items: history.map(value => ({ type: 'node', value, path: [] })),
1720
+ };
1721
+ }
1722
+ const nodeIds = new Set();
1723
+ const childrenMap = {};
1724
+ for (const state of history) {
1725
+ const parentId = state.parent_checkpoint?.checkpoint_id ?? ROOT_ID;
1726
+ childrenMap[parentId] ??= [];
1727
+ childrenMap[parentId].push(state);
1728
+ const checkpointId = state.checkpoint?.checkpoint_id;
1729
+ if (checkpointId != null) {
1730
+ nodeIds.add(checkpointId);
1731
+ }
1732
+ }
1733
+ const orphanRoot = findLatestOrphanRoot(childrenMap, nodeIds);
1734
+ if (orphanRoot != null) {
1735
+ childrenMap[ROOT_ID] = childrenMap[orphanRoot];
1736
+ }
1737
+ const rootSequence = { type: 'sequence', items: [] };
1738
+ const queue = [
1739
+ { id: ROOT_ID, sequence: rootSequence, path: [] },
1740
+ ];
1741
+ const visited = new Set();
1742
+ while (queue.length > 0) {
1743
+ const task = queue.shift();
1744
+ if (!task || visited.has(task.id))
1745
+ continue;
1746
+ visited.add(task.id);
1747
+ const children = childrenMap[task.id];
1748
+ if (!children?.length)
1749
+ continue;
1750
+ let fork;
1751
+ if (children.length > 1) {
1752
+ fork = { type: 'fork', items: [] };
1753
+ task.sequence.items.push(fork);
1754
+ }
1755
+ for (const value of children) {
1756
+ const id = value.checkpoint?.checkpoint_id;
1757
+ if (id == null)
1758
+ continue;
1759
+ let sequence = task.sequence;
1760
+ let path = task.path;
1761
+ if (fork != null) {
1762
+ sequence = { type: 'sequence', items: [] };
1763
+ fork.items.unshift(sequence);
1764
+ path = [...task.path, id];
1765
+ }
1766
+ sequence.items.push({ type: 'node', value, path });
1767
+ queue.push({ id, sequence, path });
1768
+ }
1769
+ }
1770
+ return rootSequence;
1771
+ }
1772
+ function findLatestOrphanRoot(childrenMap, nodeIds) {
1773
+ if (childrenMap[ROOT_ID] != null)
1774
+ return undefined;
1775
+ return Object.keys(childrenMap)
1776
+ .filter(parentId => !nodeIds.has(parentId))
1777
+ .map(parentId => ({
1778
+ parentId,
1779
+ lastId: findLatestDescendantId(parentId, childrenMap),
1780
+ }))
1781
+ .sort((left, right) => left.lastId.localeCompare(right.lastId))
1782
+ .at(-1)?.parentId;
1783
+ }
1784
+ function findLatestDescendantId(parentId, childrenMap) {
1785
+ const queue = [parentId];
1786
+ const seen = new Set();
1787
+ let latestId = parentId;
1788
+ while (queue.length > 0) {
1789
+ const current = queue.shift();
1790
+ if (!current || seen.has(current))
1791
+ continue;
1792
+ seen.add(current);
1793
+ for (const child of childrenMap[current] ?? []) {
1794
+ const childId = child.checkpoint?.checkpoint_id;
1795
+ if (childId == null)
1796
+ continue;
1797
+ if (childId.localeCompare(latestId) > 0) {
1798
+ latestId = childId;
1799
+ }
1800
+ queue.push(childId);
1801
+ }
1802
+ }
1803
+ return latestId;
1804
+ }
1805
+
1806
+ function extractCitations(msg) {
1807
+ const raw = msg.additional_kwargs?.['citations'] ?? msg.additional_kwargs?.['sources'];
1808
+ if (!Array.isArray(raw) || raw.length === 0)
1809
+ return undefined;
1810
+ return raw.map((entry, i) => normalizeCitation(entry, i + 1));
1811
+ }
1812
+ function normalizeCitation(entry, fallbackIndex) {
1813
+ if (typeof entry === 'string') {
1814
+ return { id: `c${fallbackIndex}`, index: fallbackIndex, url: entry };
1815
+ }
1816
+ const e = (entry ?? {});
1817
+ const str = (key) => typeof e[key] === 'string' ? e[key] : undefined;
1818
+ const firstStr = (...keys) => {
1819
+ for (const k of keys) {
1820
+ const v = str(k);
1821
+ if (v !== undefined)
1822
+ return v;
1823
+ }
1824
+ return undefined;
1825
+ };
1826
+ return {
1827
+ id: str('id') ?? str('refId') ?? `c${fallbackIndex}`,
1828
+ index: typeof e['index'] === 'number' ? e['index'] : fallbackIndex,
1829
+ title: firstStr('title', 'name'),
1830
+ url: firstStr('url', 'href', 'source'),
1831
+ snippet: firstStr('snippet', 'content', 'excerpt'),
1832
+ extra: typeof e['extra'] === 'object' && e['extra'] !== null
1833
+ ? e['extra']
1834
+ : undefined,
1835
+ };
1836
+ }
1837
+
1838
+ // SPDX-License-Identifier: MIT
1839
+ /**
1840
+ * Walk LangGraph history (newest-first) and pair each AIMessage id with
1841
+ * the most recent checkpoint that contains it as the tail message in
1842
+ * `values.messages`.
1843
+ *
1844
+ * Implementation: iterate oldest → newest (i.e. reverse the input array)
1845
+ * so later writes overwrite earlier ones; the final map has each
1846
+ * AIMessage paired with the newest containing checkpoint where it is
1847
+ * still the tail. Checkpoints with no AIMessage in scope are skipped.
1848
+ * Checkpoints with no checkpoint_id are skipped.
1849
+ */
1850
+ function computeMessageCheckpoints(history) {
1851
+ const out = new Map();
1852
+ for (let i = history.length - 1; i >= 0; i--) {
1853
+ const state = history[i];
1854
+ const cpId = state.checkpoint?.checkpoint_id;
1855
+ if (typeof cpId !== 'string' || cpId.length === 0)
1856
+ continue;
1857
+ const values = state.values;
1858
+ const msgs = Array.isArray(values?.messages) ? values.messages : [];
1859
+ for (let j = msgs.length - 1; j >= 0; j--) {
1860
+ const m = msgs[j];
1861
+ const type = typeof m._getType === 'function' ? m._getType() : m.type;
1862
+ if (type === 'ai' && typeof m.id === 'string') {
1863
+ out.set(m.id, cpId);
1864
+ break;
1865
+ }
1866
+ }
1867
+ }
1868
+ return out;
1869
+ }
1870
+ /**
1871
+ * Creates a LangGraph-backed Angular agent.
1872
+ *
1873
+ * Must be called within an Angular injection context (component constructor,
1874
+ * field initializer, or `runInInjectionContext`). Returns a unified
1875
+ * {@link LangGraphAgent} whose properties are Angular Signals that update
1876
+ * in real time as LangGraph streams messages, values, tool calls, interrupts,
1877
+ * subagent state, and checkpoint history.
1878
+ *
1879
+ * @typeParam T - The state shape returned by the agent
1880
+ * @typeParam Bag - Optional bag template for typed interrupts and submit payloads
1881
+ * @param options - Configuration for the LangGraph agent
1882
+ * @returns A {@link LangGraphAgent} with reactive signals and action methods
1883
+ *
1884
+ * @example
1885
+ * ```typescript
1886
+ * // In a component field initializer
1887
+ * const chat = agent({
1888
+ * assistantId: 'chat_agent',
1889
+ * apiUrl: 'http://localhost:2024',
1890
+ * threadId: signal(this.savedThreadId),
1891
+ * onThreadId: (id) => localStorage.setItem('threadId', id),
1892
+ * });
1893
+ *
1894
+ * // Access signals in template
1895
+ * // chat.messages(), chat.status(), chat.error()
1896
+ * ```
1897
+ */
1898
+ function agent(options) {
1899
+ // Injection context required
1900
+ const destroyRef = inject(DestroyRef);
1901
+ const globalConfig = inject(AGENT_CONFIG, { optional: true });
1902
+ const destroy$ = new Subject();
1903
+ destroyRef.onDestroy(() => { destroy$.next(); destroy$.complete(); });
1904
+ // Merge: call-site options take precedence over global provider config
1905
+ const apiUrl = options.apiUrl ?? globalConfig?.apiUrl ?? '';
1906
+ const transport = options.transport ?? globalConfig?.transport;
1907
+ const init = (options.initialValues ?? {});
1908
+ // All subjects created before the bridge
1909
+ const status$ = new BehaviorSubject(ResourceStatus.Idle);
1910
+ const values$ = new BehaviorSubject(init);
1911
+ const messages$ = new BehaviorSubject([]);
1912
+ const error$ = new BehaviorSubject(undefined);
1913
+ const interrupt$ = new BehaviorSubject(undefined);
1914
+ const interrupts$ = new BehaviorSubject([]);
1915
+ const branch$ = new BehaviorSubject('');
1916
+ const history$ = new BehaviorSubject([]);
1917
+ const isThreadLoading$ = new BehaviorSubject(false);
1918
+ const toolProgress$ = new BehaviorSubject([]);
1919
+ const toolCalls$ = new BehaviorSubject([]);
1920
+ const messageMetadata$ = new BehaviorSubject(new Map());
1921
+ const subagents$ = new BehaviorSubject(new Map());
1922
+ const queue$ = new BehaviorSubject({
1923
+ entries: [],
1924
+ size: 0,
1925
+ cancel: async () => false,
1926
+ clear: async () => undefined,
1927
+ });
1928
+ const custom$ = new BehaviorSubject([]);
1929
+ const hasValue$ = new BehaviorSubject(false);
1930
+ function resetDerivedThreadState() {
1931
+ status$.next(ResourceStatus.Idle);
1932
+ error$.next(undefined);
1933
+ hasValue$.next(false);
1934
+ }
1935
+ // Track hasValue — becomes true once values or messages arrive
1936
+ values$.pipe(takeUntil$1(destroy$)).subscribe(v => {
1937
+ if (v != null && Object.keys(v).length > 0)
1938
+ hasValue$.next(true);
1939
+ });
1940
+ messages$.pipe(takeUntil$1(destroy$)).subscribe(m => { if (m.length > 0)
1941
+ hasValue$.next(true); });
1942
+ const subjects = {
1943
+ status$, values$, messages$, error$,
1944
+ interrupt$, interrupts$, branch$, history$,
1945
+ isThreadLoading$, toolProgress$, toolCalls$, messageMetadata$, subagents$, queue$, custom$,
1946
+ };
1947
+ // threadId$ — resolved before bridge creation (injection context required for toObservable)
1948
+ const threadId$ = isSignal(options.threadId)
1949
+ ? toObservable(options.threadId)
1950
+ : of(options.threadId ?? null);
1951
+ let hasSeenThreadId = false;
1952
+ let lastThreadId = null;
1953
+ threadId$.pipe(takeUntil$1(destroy$)).subscribe((id) => {
1954
+ if (hasSeenThreadId && lastThreadId !== id) {
1955
+ resetDerivedThreadState();
1956
+ }
1957
+ hasSeenThreadId = true;
1958
+ lastThreadId = id;
1959
+ });
1960
+ // ── Lifecycle instrumentation ─────────────────────────────────────────────
1961
+ // Eight signals tracking key transitions for telemetry/observability.
1962
+ // All reset together via resetLifecycle(); see switchThread() below.
1963
+ const lcStreamStartedAt = signal(null, ...(ngDevMode ? [{ debugName: "lcStreamStartedAt" }] : []));
1964
+ const lcStreamErrorAt = signal(null, ...(ngDevMode ? [{ debugName: "lcStreamErrorAt" }] : []));
1965
+ const lcInterruptReceivedAt = signal(null, ...(ngDevMode ? [{ debugName: "lcInterruptReceivedAt" }] : []));
1966
+ const lcInterruptResolvedAt = signal(null, ...(ngDevMode ? [{ debugName: "lcInterruptResolvedAt" }] : []));
1967
+ const lcThreadCreatedAt = signal(null, ...(ngDevMode ? [{ debugName: "lcThreadCreatedAt" }] : []));
1968
+ const lcThreadPersistedAt = signal(null, ...(ngDevMode ? [{ debugName: "lcThreadPersistedAt" }] : []));
1969
+ const lcToolCallStartedAt = signal(null, ...(ngDevMode ? [{ debugName: "lcToolCallStartedAt" }] : []));
1970
+ const lcToolCallCompletedAt = signal(null, ...(ngDevMode ? [{ debugName: "lcToolCallCompletedAt" }] : []));
1971
+ const lifecycle = {
1972
+ streamStartedAt: lcStreamStartedAt,
1973
+ streamErrorAt: lcStreamErrorAt,
1974
+ interruptReceivedAt: lcInterruptReceivedAt,
1975
+ interruptResolvedAt: lcInterruptResolvedAt,
1976
+ threadCreatedAt: lcThreadCreatedAt,
1977
+ threadPersistedAt: lcThreadPersistedAt,
1978
+ toolCallStartedAt: lcToolCallStartedAt,
1979
+ toolCallCompletedAt: lcToolCallCompletedAt,
1980
+ };
1981
+ // Register with optional lifecycle registry. External instrumentation
1982
+ // (e.g. cockpit-telemetry) provides AgentLifecycleRegistry to receive
1983
+ // per-agent lifecycles created within this injection context.
1984
+ const lifecycleRegistry = inject(AgentLifecycleRegistry, { optional: true });
1985
+ lifecycleRegistry?.register(lifecycle);
1986
+ function resetLifecycle() {
1987
+ lcStreamStartedAt.set(null);
1988
+ lcStreamErrorAt.set(null);
1989
+ lcInterruptReceivedAt.set(null);
1990
+ lcInterruptResolvedAt.set(null);
1991
+ lcThreadCreatedAt.set(null);
1992
+ lcThreadPersistedAt.set(null);
1993
+ lcToolCallStartedAt.set(null);
1994
+ lcToolCallCompletedAt.set(null);
1995
+ }
1996
+ // First chunk: first values$ or messages$ emission with content.
1997
+ values$.pipe(takeUntil$1(destroy$)).subscribe(v => {
1998
+ if (lcStreamStartedAt() === null && v != null && Object.keys(v).length > 0) {
1999
+ lcStreamStartedAt.set(Date.now());
2000
+ }
2001
+ });
2002
+ messages$.pipe(takeUntil$1(destroy$)).subscribe(m => {
2003
+ if (lcStreamStartedAt() === null && m.length > 0)
2004
+ lcStreamStartedAt.set(Date.now());
2005
+ });
2006
+ // Stream error: capture timestamp + classification (Error name or 'unknown').
2007
+ error$.pipe(takeUntil$1(destroy$)).subscribe(e => {
2008
+ if (e == null)
2009
+ return;
2010
+ const classification = e instanceof Error ? e.name : typeof e === 'string' ? 'string' : 'unknown';
2011
+ lcStreamErrorAt.set({ at: Date.now(), classification });
2012
+ });
2013
+ // First non-null interrupt within this thread.
2014
+ interrupt$.pipe(takeUntil$1(destroy$)).subscribe(ix => {
2015
+ if (ix != null && lcInterruptReceivedAt() === null)
2016
+ lcInterruptReceivedAt.set(Date.now());
2017
+ });
2018
+ // First tool call append; first completed/error result transition.
2019
+ const seenToolCallStates = new Map();
2020
+ toolCalls$.pipe(takeUntil$1(destroy$)).subscribe(tcs => {
2021
+ if (tcs.length > 0 && lcToolCallStartedAt() === null)
2022
+ lcToolCallStartedAt.set(Date.now());
2023
+ if (lcToolCallCompletedAt() !== null)
2024
+ return;
2025
+ for (const tc of tcs) {
2026
+ const prev = seenToolCallStates.get(tc.id);
2027
+ if (prev !== tc.state && (tc.state === 'completed' || tc.state === 'error')) {
2028
+ lcToolCallCompletedAt.set(Date.now());
2029
+ seenToolCallStates.set(tc.id, tc.state);
2030
+ break;
2031
+ }
2032
+ seenToolCallStates.set(tc.id, tc.state);
2033
+ }
2034
+ });
2035
+ // Thread restored from server: history$ populates with content for a
2036
+ // pre-existing threadId.
2037
+ history$.pipe(takeUntil$1(destroy$)).subscribe(h => {
2038
+ if (h.length > 0 && lcThreadPersistedAt() === null)
2039
+ lcThreadPersistedAt.set(Date.now());
2040
+ });
2041
+ const manager = createStreamManagerBridge({
2042
+ options: { ...options, apiUrl, transport },
2043
+ subjects,
2044
+ threadId$,
2045
+ destroy$: destroy$.asObservable(),
2046
+ });
2047
+ // Throttle helper — default 16ms (~60fps) to batch SSE token updates into
2048
+ // at most one signal update per frame, preventing change detection storms.
2049
+ const ms = typeof options.throttle === 'number' ? options.throttle : 16;
2050
+ const maybeThrottle = (obs) => ms > 0
2051
+ ? obs.pipe(throttleTime(ms, asyncScheduler, { leading: true, trailing: true }))
2052
+ : obs.asObservable();
2053
+ // Convert to Angular Signals (must happen in injection context)
2054
+ const value = toSignal(maybeThrottle(values$), { initialValue: init });
2055
+ // No throttle on messages$: we need every token emission to propagate to
2056
+ // Angular so streaming markdown actually streams. The bridge already
2057
+ // batches per-tuple at the SDK level; further throttling at the signal
2058
+ // boundary collapses tokens together and breaks visible token-by-token
2059
+ // rendering. Same-frame multiple emissions are coalesced by Angular's
2060
+ // CD anyway.
2061
+ const rawMessages = toSignal(messages$, { initialValue: [] });
2062
+ const statusSig = toSignal(status$, { initialValue: ResourceStatus.Idle });
2063
+ const errorSig = toSignal(error$, { initialValue: undefined });
2064
+ const hasValueSig = toSignal(hasValue$, { initialValue: false });
2065
+ const interruptSig = toSignal(interrupt$, { initialValue: undefined });
2066
+ const interruptsSig = toSignal(interrupts$, { initialValue: [] });
2067
+ const branchSig = toSignal(branch$, { initialValue: '' });
2068
+ const historySig = toSignal(history$, { initialValue: [] });
2069
+ const threadLoadSig = toSignal(isThreadLoading$, { initialValue: false });
2070
+ const toolProgSig = toSignal(toolProgress$, { initialValue: [] });
2071
+ const rawToolCalls = toSignal(toolCalls$, { initialValue: [] });
2072
+ const subagentsSig = toSignal(subagents$, { initialValue: new Map() });
2073
+ const queueSig = toSignal(queue$, { initialValue: queue$.value });
2074
+ const customSig = toSignal(custom$, { initialValue: [] });
2075
+ const isLoading = computed(() => statusSig() === ResourceStatus.Loading, ...(ngDevMode ? [{ debugName: "isLoading" }] : []));
2076
+ const activeSubagents = computed(() => [...subagentsSig().values()].filter(s => s.status() === 'running'), ...(ngDevMode ? [{ debugName: "activeSubagents" }] : []));
2077
+ // ── Runtime-neutral projections ───────────────────────────────────────────
2078
+ // Project BaseMessage → Message on every recompute. We deliberately do
2079
+ // NOT cache: the LangGraph SDK mutates the same AIMessage instance in
2080
+ // place during token streaming (appends content to the same object), so
2081
+ // any identity-based cache returns stale projections and Angular's
2082
+ // `@let content = messageContent(message)` short-circuits — DOM never
2083
+ // updates per token. DOM stability is provided by `track message.id`
2084
+ // in chat-message-list, not by Message identity.
2085
+ const messagesNeutral = computed(() => rawMessages().map((m) => toMessage(m, manager.getReasoningDurationMs)), ...(ngDevMode ? [{ debugName: "messagesNeutral" }] : []));
2086
+ const toolCallsNeutral = computed(() => rawToolCalls().map(toToolCall), ...(ngDevMode ? [{ debugName: "toolCallsNeutral" }] : []));
2087
+ const statusNeutral = computed(() => mapStatus(statusSig()), ...(ngDevMode ? [{ debugName: "statusNeutral" }] : []));
2088
+ const stateNeutral = computed(() => {
2089
+ const v = value();
2090
+ return v && typeof v === 'object' ? v : {};
2091
+ }, ...(ngDevMode ? [{ debugName: "stateNeutral" }] : []));
2092
+ const interruptNeutral = computed(() => {
2093
+ const ix = interruptSig();
2094
+ return ix ? toInterrupt(ix) : undefined;
2095
+ }, ...(ngDevMode ? [{ debugName: "interruptNeutral" }] : []));
2096
+ const subagentsNeutral = computed(() => {
2097
+ const out = new Map();
2098
+ subagentsSig().forEach((sa, key) => out.set(key, toSubagent(sa)));
2099
+ return out;
2100
+ }, ...(ngDevMode ? [{ debugName: "subagentsNeutral" }] : []));
2101
+ const historyNeutral = computed(() => historySig().map(toCheckpoint), ...(ngDevMode ? [{ debugName: "historyNeutral" }] : []));
2102
+ const messageCheckpointsSig = computed(() => computeMessageCheckpoints(historySig()), ...(ngDevMode ? [{ debugName: "messageCheckpointsSig" }] : []));
2103
+ const experimentalBranchTree = computed(() => buildBranchTree(historySig()), ...(ngDevMode ? [{ debugName: "experimentalBranchTree" }] : []));
2104
+ const events$ = buildEvents$(customSig);
2105
+ return {
2106
+ // ── Runtime-neutral surface (AgentWithHistory) ────────────────────────
2107
+ messages: messagesNeutral,
2108
+ status: statusNeutral,
2109
+ isLoading,
2110
+ error: errorSig,
2111
+ toolCalls: toolCallsNeutral,
2112
+ state: stateNeutral,
2113
+ interrupt: interruptNeutral,
2114
+ subagents: subagentsNeutral,
2115
+ events$,
2116
+ history: historyNeutral,
2117
+ messageCheckpoints: messageCheckpointsSig,
2118
+ submit: (input, opts) => {
2119
+ // Lifecycle: first submit with no existing threadId → thread create.
2120
+ if (lcThreadCreatedAt() === null && lastThreadId == null) {
2121
+ lcThreadCreatedAt.set(Date.now());
2122
+ }
2123
+ // Lifecycle: any resume submit marks an interrupt resolution.
2124
+ if (input?.resume !== undefined || opts?.resume !== undefined) {
2125
+ lcInterruptResolvedAt.set(Date.now());
2126
+ }
2127
+ const request = buildSubmitRequest(input, opts);
2128
+ return manager.submit(request.payload, request.options);
2129
+ },
2130
+ stop: () => manager.stop(),
2131
+ regenerate: async (assistantMessageIndex) => {
2132
+ if (isLoading()) {
2133
+ throw new Error('Cannot regenerate while agent is loading another response');
2134
+ }
2135
+ const msgs = messagesNeutral();
2136
+ const target = msgs[assistantMessageIndex];
2137
+ if (!target || target.role !== 'assistant') {
2138
+ throw new Error(`Message at index ${assistantMessageIndex} is not an assistant message`);
2139
+ }
2140
+ // Find the user message immediately preceding the target assistant message.
2141
+ const userIdx = msgs
2142
+ .slice(0, assistantMessageIndex)
2143
+ .map((m, i) => ({ m, i }))
2144
+ .reverse()
2145
+ .find(({ m }) => m.role === 'user')?.i;
2146
+ if (userIdx === undefined) {
2147
+ throw new Error('No user message found before the target assistant message');
2148
+ }
2149
+ // Snapshot the raw BaseMessages that will be REMOVED (everything after userIdx).
2150
+ const rawToRemove = messages$.value.slice(userIdx + 1);
2151
+ // Truncate local buffer INCLUSIVE of the user message. The computed
2152
+ // messagesNeutral signal immediately reflects this — user message is
2153
+ // preserved in the UI while the new response streams in.
2154
+ messages$.next(messages$.value.slice(0, userIdx + 1));
2155
+ // Build RemoveMessage wire-shape instructions for server-side rollback.
2156
+ // LangGraph's add_messages reducer recognises `{ type: 'remove', id }`
2157
+ // and removes those entries from the thread state — ensuring the
2158
+ // runtime re-runs against the same trimmed state rather than appending
2159
+ // new messages on top.
2160
+ const removeList = rawToRemove
2161
+ .map(m => {
2162
+ const raw = m;
2163
+ const id = typeof raw['id'] === 'string' ? raw['id'] : undefined;
2164
+ return id
2165
+ ? { type: 'remove', role: 'remove', id, content: '' }
2166
+ : null;
2167
+ })
2168
+ .filter((rm) => rm !== null);
2169
+ // RemoveMessage rollback + reposition the graph to the entry node
2170
+ // via `as_node: '__start__'`. After the original run, the thread is
2171
+ // at `__end__` with `next: []` — submitting `null` would be a no-op
2172
+ // because there is nothing pending to execute. Setting `asNode` to
2173
+ // the start node tells LangGraph to treat the update as if `__start__`
2174
+ // had just produced the values, so the next pull resumes at the entry
2175
+ // node and runs `generate` against the rolled-back state.
2176
+ //
2177
+ // We always pass `asNode` even when removeList is empty (rare, but
2178
+ // possible if the assistant message had no id) so the regenerate
2179
+ // submit below still runs the graph.
2180
+ await manager.updateState({ messages: removeList }, { asNode: '__start__' });
2181
+ // Re-run the graph with no new input. With the thread now repositioned
2182
+ // at `__start__`, this resumes at the entry node and produces a fresh
2183
+ // assistant message — the trailing user message becomes the active
2184
+ // prompt without being re-appended.
2185
+ await manager.submit(null, undefined);
2186
+ },
2187
+ // ── Raw LangGraph signals ─────────────────────────────────────────────
2188
+ langGraphMessages: rawMessages,
2189
+ langGraphInterrupts: interruptsSig,
2190
+ langGraphToolCalls: rawToolCalls,
2191
+ langGraphHistory: historySig,
2192
+ experimentalBranchTree,
2193
+ // ── Other LangGraph-specific fields ──────────────────────────────────
2194
+ value: value,
2195
+ hasValue: hasValueSig,
2196
+ reload: () => manager.resubmitLast(),
2197
+ toolProgress: toolProgSig,
2198
+ queue: queueSig,
2199
+ activeSubagents,
2200
+ getSubagent: (toolCallId) => subagentsSig().get(toolCallId),
2201
+ getSubagentsByType: (type) => [...subagentsSig().values()].filter(sa => sa.name === type),
2202
+ getSubagentsByMessage: (msg) => {
2203
+ const ids = getToolCallIds(msg);
2204
+ const subagents = subagentsSig();
2205
+ return ids
2206
+ .map(id => subagents.get(id))
2207
+ .filter((subagent) => subagent != null);
2208
+ },
2209
+ customEvents: customSig,
2210
+ branch: branchSig,
2211
+ setBranch: (b) => branch$.next(b),
2212
+ isThreadLoading: threadLoadSig,
2213
+ switchThread: (id) => {
2214
+ resetDerivedThreadState();
2215
+ resetLifecycle();
2216
+ seenToolCallStates.clear();
2217
+ manager.switchThread(id);
2218
+ },
2219
+ lifecycle,
2220
+ joinStream: (id, last) => manager.joinStream(id, last),
2221
+ getMessagesMetadata: (msg, idx) => {
2222
+ const id = msg['id'];
2223
+ const key = id != null ? String(id) : idx != null ? String(idx) : undefined;
2224
+ return key ? messageMetadata$.value.get(key) : undefined;
2225
+ },
2226
+ getToolCalls: (msg) => {
2227
+ const id = msg['id'];
2228
+ return id == null
2229
+ ? []
2230
+ : toolCalls$.value.filter(tc => tc.aiMessage['id'] === id);
2231
+ },
2232
+ };
2233
+ }
2234
+ // ── Private translation helpers (moved from to-agent.ts) ─────────────────────
2235
+ /**
2236
+ * Build an Observable<AgentEvent> that bridges LangGraph's
2237
+ * `Signal<CustomStreamEvent[]>` (append-only array) into a stream of newly
2238
+ * emitted events. Each effect firing compares against a cursor tracking the
2239
+ * previously-seen length and emits only the tail slice.
2240
+ */
2241
+ function buildEvents$(customSig) {
2242
+ const subject = new Subject();
2243
+ let seen = 0;
2244
+ effect(() => {
2245
+ const all = customSig();
2246
+ if (all.length < seen) {
2247
+ // Stream reset (new session, thread switch, etc.). Rewind cursor.
2248
+ seen = 0;
2249
+ }
2250
+ for (let i = seen; i < all.length; i++) {
2251
+ subject.next(toAgentEvent(all[i]));
2252
+ }
2253
+ seen = all.length;
2254
+ });
2255
+ return subject.asObservable();
2256
+ }
2257
+ function toAgentEvent(e) {
2258
+ if (e.name === 'state_update' && isRecord(e.data)) {
2259
+ return { type: 'state_update', data: e.data };
2260
+ }
2261
+ return { type: 'custom', name: e.name, data: e.data };
2262
+ }
2263
+ function mapStatus(s) {
2264
+ switch (s) {
2265
+ case ResourceStatus.Error: return 'error';
2266
+ case ResourceStatus.Loading:
2267
+ case ResourceStatus.Reloading:
2268
+ return 'running';
2269
+ default:
2270
+ return 'idle';
2271
+ }
2272
+ }
2273
+ function toMessage(m, getReasoningDurationMs) {
2274
+ const raw = m;
2275
+ const typeVal = typeof m._getType === 'function'
2276
+ ? m._getType()
2277
+ : raw['type'] ?? 'ai';
2278
+ const role = typeVal === 'human' ? 'user' :
2279
+ typeVal === 'tool' ? 'tool' :
2280
+ typeVal === 'system' ? 'system' :
2281
+ 'assistant';
2282
+ const id = m.id ?? raw['id'] ?? randomId();
2283
+ const reasoning = typeof raw['reasoning'] === 'string' && raw['reasoning'].length > 0
2284
+ ? raw['reasoning']
2285
+ : undefined;
2286
+ const reasoningDurationMs = reasoning && getReasoningDurationMs
2287
+ ? getReasoningDurationMs(id)
2288
+ : undefined;
2289
+ const result = {
2290
+ id,
2291
+ role,
2292
+ content: extractTextContent(m.content),
2293
+ toolCallId: raw['tool_call_id'],
2294
+ name: raw['name'],
2295
+ reasoning,
2296
+ reasoningDurationMs,
2297
+ extra: raw,
2298
+ };
2299
+ const citations = extractCitations(raw);
2300
+ if (citations)
2301
+ result.citations = citations;
2302
+ if (role === 'assistant') {
2303
+ const tcIds = getToolCallIds(m);
2304
+ if (tcIds.length > 0)
2305
+ result.toolCallIds = tcIds;
2306
+ }
2307
+ return result;
2308
+ }
2309
+ /**
2310
+ * Extract user-visible text from a `BaseMessage.content` value.
2311
+ *
2312
+ * LangChain's `BaseMessage.content` is `string | MessageContentComplex[]`.
2313
+ * Reasoning-capable models (OpenAI gpt-5/o-series, Anthropic) emit complex
2314
+ * arrays of typed blocks: `{type: 'text', text}`, `{type: 'reasoning', ...}`,
2315
+ * tool-use blocks, etc. We render only the visible text portions and skip
2316
+ * anything else. JSON-stringifying the whole array (the previous behaviour)
2317
+ * would dump raw `[{"type":"text",...}]` into the chat bubble.
2318
+ */
2319
+ function extractTextContent(content) {
2320
+ if (typeof content === 'string')
2321
+ return content;
2322
+ if (!Array.isArray(content))
2323
+ return '';
2324
+ let out = '';
2325
+ for (const block of content) {
2326
+ if (typeof block === 'string') {
2327
+ out += block;
2328
+ continue;
2329
+ }
2330
+ if (!isRecord(block))
2331
+ continue;
2332
+ const t = block['type'];
2333
+ // Common text-bearing block shapes across providers.
2334
+ if (t === 'text' || t === 'output_text' || t === undefined) {
2335
+ const text = block['text'];
2336
+ if (typeof text === 'string')
2337
+ out += text;
2338
+ }
2339
+ // Skip reasoning, tool_use, image, etc. — not chat-bubble content.
2340
+ }
2341
+ return out;
2342
+ }
2343
+ function toToolCall(tc) {
2344
+ const stateMap = {
2345
+ pending: 'pending',
2346
+ completed: 'complete',
2347
+ error: 'error',
2348
+ };
2349
+ const status = stateMap[tc.state] ?? 'running';
2350
+ const result = tc.result;
2351
+ return {
2352
+ id: tc.id,
2353
+ name: tc.call.name,
2354
+ args: tc.call.args,
2355
+ status,
2356
+ result: result?.['content'],
2357
+ error: tc.state === 'error' ? result?.['content'] : undefined,
2358
+ };
2359
+ }
2360
+ function toInterrupt(ix) {
2361
+ const raw = ix;
2362
+ return {
2363
+ id: raw['id'] ?? randomId(),
2364
+ value: raw['value'] ?? ix,
2365
+ resumable: true,
2366
+ };
2367
+ }
2368
+ function toSubagent(sa) {
2369
+ return {
2370
+ toolCallId: sa.toolCallId,
2371
+ name: sa.name,
2372
+ status: sa.status,
2373
+ messages: computed(() => sa.messages().map((m) => toMessage(m))),
2374
+ state: sa.values,
2375
+ };
2376
+ }
2377
+ function getToolCallIds(msg) {
2378
+ const raw = msg;
2379
+ const toolCalls = raw['tool_calls'];
2380
+ if (!Array.isArray(toolCalls))
2381
+ return [];
2382
+ return toolCalls
2383
+ .map(toolCall => isRecord(toolCall) && typeof toolCall['id'] === 'string' ? toolCall['id'] : undefined)
2384
+ .filter((id) => id != null);
2385
+ }
2386
+ function buildSubmitRequest(input, opts) {
2387
+ return {
2388
+ payload: buildSubmitPayload(input),
2389
+ options: normalizeSubmitOptions(input, opts),
2390
+ };
2391
+ }
2392
+ function buildSubmitPayload(input) {
2393
+ if (input == null)
2394
+ return null;
2395
+ if (input.resume !== undefined)
2396
+ return null;
2397
+ return buildSubmitUpdate(input) ?? {};
2398
+ }
2399
+ function normalizeSubmitOptions(input, opts) {
2400
+ const inputResume = input?.resume;
2401
+ const optionResume = opts?.resume;
2402
+ const resume = inputResume !== undefined ? inputResume : optionResume;
2403
+ if (resume === undefined)
2404
+ return opts;
2405
+ const next = { ...(opts ?? {}) };
2406
+ delete next.resume;
2407
+ const command = next.command;
2408
+ const update = buildSubmitUpdate(input);
2409
+ const commandUpdate = mergeCommandUpdate(command?.update, update);
2410
+ return {
2411
+ ...next,
2412
+ command: {
2413
+ ...command,
2414
+ resume,
2415
+ ...(commandUpdate === undefined ? {} : { update: commandUpdate }),
2416
+ },
2417
+ };
2418
+ }
2419
+ function buildSubmitUpdate(input) {
2420
+ if (input == null)
2421
+ return undefined;
2422
+ if (input.message !== undefined) {
2423
+ const content = typeof input.message === 'string'
2424
+ ? input.message
2425
+ : input.message.map((b) => (b.type === 'text' ? b.text : JSON.stringify(b))).join('');
2426
+ // `type: 'human'` is what `toMessage()` reads via `_getType` || raw['type'];
2427
+ // `role: 'human'` is what the LangGraph server expects in submit payloads.
2428
+ // Include both so the optimistic local copy projects as a 'user' bubble
2429
+ // (otherwise toMessage falls through to the 'ai' default and renders the
2430
+ // user's question as an assistant message).
2431
+ return { messages: [{ type: 'human', role: 'human', content }], ...(input.state ?? {}) };
2432
+ }
2433
+ return input.state;
2434
+ }
2435
+ function mergeCommandUpdate(existing, update) {
2436
+ if (update === undefined)
2437
+ return existing;
2438
+ if (existing == null)
2439
+ return update;
2440
+ if (isRecord(existing))
2441
+ return { ...existing, ...update };
2442
+ if (Array.isArray(existing))
2443
+ return [...existing, ...Object.entries(update)];
2444
+ return update;
2445
+ }
2446
+ function randomId() {
2447
+ return Math.random().toString(36).slice(2);
2448
+ }
2449
+ function toCheckpoint(state) {
2450
+ return {
2451
+ id: state.checkpoint?.checkpoint_id ?? undefined,
2452
+ label: state.next?.[0] ?? undefined,
2453
+ values: isRecord(state.values) ? state.values : {},
2454
+ };
2455
+ }
2456
+ function isRecord(v) {
2457
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
2458
+ }
2459
+
2460
+ // SPDX-License-Identifier: MIT
2461
+ const AGENT_LIFECYCLE = new InjectionToken('AGENT_LIFECYCLE');
2462
+
2463
+ /**
2464
+ * Test transport for deterministic agent testing without a real LangGraph server.
2465
+ *
2466
+ * Script event batches upfront, then emit them manually or step through them
2467
+ * in your test specs. Supports error injection and close control.
2468
+ *
2469
+ * @example
2470
+ * ```typescript
2471
+ * const transport = new MockAgentTransport([
2472
+ * [{ type: 'values', messages: [aiMsg('Hello')] }],
2473
+ * [{ type: 'values', messages: [aiMsg('Done')] }],
2474
+ * ]);
2475
+ * ```
2476
+ */
2477
+ class MockAgentTransport {
2478
+ history = [];
2479
+ historyCalls = [];
2480
+ streams = [];
2481
+ createdQueuedRuns = [];
2482
+ cancelledRuns = [];
2483
+ joinedRuns = [];
2484
+ script;
2485
+ scriptIndex = 0;
2486
+ streaming = false;
2487
+ eventQueue = [];
2488
+ // Each resolver simply wakes the stream loop to re-check state.
2489
+ resolvers = [];
2490
+ closed = false;
2491
+ pendingError = null;
2492
+ /** @param script - Array of event batches. Each batch is emitted as a group. */
2493
+ constructor(script = []) {
2494
+ this.script = script;
2495
+ }
2496
+ /** Advance to the next scripted batch. Pass the returned events to `emit()`. */
2497
+ nextBatch() {
2498
+ if (this.scriptIndex >= this.script.length)
2499
+ return [];
2500
+ return this.script[this.scriptIndex++];
2501
+ }
2502
+ /** Manually emit events into the stream. */
2503
+ emit(events) {
2504
+ this.eventQueue.push(...events);
2505
+ this.flush();
2506
+ }
2507
+ /** Inject an error into the stream. */
2508
+ emitError(err) {
2509
+ this.pendingError = err;
2510
+ this.flush();
2511
+ }
2512
+ /** Close the stream. Remaining queued events are drained before completion. */
2513
+ close() {
2514
+ this.closed = true;
2515
+ this.flush();
2516
+ }
2517
+ /** Returns true if a stream is currently active. */
2518
+ isStreaming() {
2519
+ return this.streaming;
2520
+ }
2521
+ async *stream(_assistantId, _threadId, _payload, signal, options) {
2522
+ this.streams.push({ threadId: _threadId, payload: _payload, options });
2523
+ this.streaming = true;
2524
+ try {
2525
+ while (!this.closed && !signal.aborted) {
2526
+ if (this.pendingError)
2527
+ throw this.pendingError;
2528
+ if (this.eventQueue.length > 0) {
2529
+ const event = this.eventQueue.shift();
2530
+ if (event)
2531
+ yield event;
2532
+ }
2533
+ else {
2534
+ // Wait until flush() wakes us, then loop again to check state.
2535
+ await new Promise((resolve) => {
2536
+ if (signal.aborted) {
2537
+ resolve();
2538
+ return;
2539
+ }
2540
+ this.resolvers.push(resolve);
2541
+ });
2542
+ }
2543
+ }
2544
+ if (signal.aborted)
2545
+ return;
2546
+ // Drain remaining events after close()
2547
+ while (this.eventQueue.length > 0) {
2548
+ const event = this.eventQueue.shift();
2549
+ if (event)
2550
+ yield event;
2551
+ }
2552
+ }
2553
+ finally {
2554
+ this.streaming = false;
2555
+ }
2556
+ }
2557
+ async createQueuedRun(_assistantId, threadId, payload, signal, options) {
2558
+ void signal;
2559
+ const entry = {
2560
+ id: `queued-run-${this.createdQueuedRuns.length + 1}`,
2561
+ threadId,
2562
+ values: payload,
2563
+ options: { ...options, multitaskStrategy: 'enqueue' },
2564
+ createdAt: new Date(),
2565
+ };
2566
+ this.createdQueuedRuns.push(entry);
2567
+ return entry;
2568
+ }
2569
+ async cancelRun(threadId, runId, signal) {
2570
+ void signal;
2571
+ this.cancelledRuns.push({ threadId, runId });
2572
+ }
2573
+ async getHistory(threadId, signal) {
2574
+ void signal;
2575
+ this.historyCalls.push(threadId);
2576
+ return this.history;
2577
+ }
2578
+ async *joinStream(threadId, runId, lastEventId, signal) {
2579
+ void lastEventId;
2580
+ void signal;
2581
+ this.joinedRuns.push({ threadId, runId });
2582
+ yield { type: 'values', values: { queued: true } };
2583
+ }
2584
+ flush() {
2585
+ const resolve = this.resolvers.shift();
2586
+ if (resolve)
2587
+ resolve();
2588
+ }
2589
+ }
2590
+
2591
+ // SPDX-License-Identifier: MIT
2592
+ /**
2593
+ * Creates a mock LangGraphAgent with writable signals for testing.
2594
+ * Control state by writing to the returned writable signals directly.
2595
+ */
2596
+ function mockLangGraphAgent(initial = {}) {
2597
+ const messages$ = signal(initial.messages ?? [], ...(ngDevMode ? [{ debugName: "messages$" }] : []));
2598
+ const langGraphMessages$ = signal(initial.langGraphMessages ?? [], ...(ngDevMode ? [{ debugName: "langGraphMessages$" }] : []));
2599
+ const status$ = signal(initial.status ?? 'idle', ...(ngDevMode ? [{ debugName: "status$" }] : []));
2600
+ const isLoading$ = signal(initial.isLoading ?? false, ...(ngDevMode ? [{ debugName: "isLoading$" }] : []));
2601
+ const error$ = signal(initial.error ?? null, ...(ngDevMode ? [{ debugName: "error$" }] : []));
2602
+ const hasValue$ = signal(initial.hasValue ?? false, ...(ngDevMode ? [{ debugName: "hasValue$" }] : []));
2603
+ const value$ = signal(null, ...(ngDevMode ? [{ debugName: "value$" }] : []));
2604
+ const interrupt$ = signal(undefined, ...(ngDevMode ? [{ debugName: "interrupt$" }] : []));
2605
+ const langGraphInterrupts$ = signal([], ...(ngDevMode ? [{ debugName: "langGraphInterrupts$" }] : []));
2606
+ const toolCalls$ = signal([], ...(ngDevMode ? [{ debugName: "toolCalls$" }] : []));
2607
+ const langGraphToolCalls$ = signal([], ...(ngDevMode ? [{ debugName: "langGraphToolCalls$" }] : []));
2608
+ const toolProgress$ = signal([], ...(ngDevMode ? [{ debugName: "toolProgress$" }] : []));
2609
+ const queue$ = signal({
2610
+ entries: [],
2611
+ size: 0,
2612
+ cancel: async () => false,
2613
+ clear: async () => undefined,
2614
+ }, ...(ngDevMode ? [{ debugName: "queue$" }] : []));
2615
+ const branch$ = signal('', ...(ngDevMode ? [{ debugName: "branch$" }] : []));
2616
+ const history$ = signal([], ...(ngDevMode ? [{ debugName: "history$" }] : []));
2617
+ const langGraphHistory$ = signal([], ...(ngDevMode ? [{ debugName: "langGraphHistory$" }] : []));
2618
+ const experimentalBranchTree$ = signal({ type: 'sequence', items: [] }, ...(ngDevMode ? [{ debugName: "experimentalBranchTree$" }] : []));
2619
+ const isThreadLoading$ = signal(initial.isThreadLoading ?? false, ...(ngDevMode ? [{ debugName: "isThreadLoading$" }] : []));
2620
+ const subagents$ = signal(new Map(), ...(ngDevMode ? [{ debugName: "subagents$" }] : []));
2621
+ const activeSubagents$ = signal([], ...(ngDevMode ? [{ debugName: "activeSubagents$" }] : []));
2622
+ const customEvents$ = signal([], ...(ngDevMode ? [{ debugName: "customEvents$" }] : []));
2623
+ const state$ = computed(() => {
2624
+ const v = value$();
2625
+ return v && typeof v === 'object' ? v : {};
2626
+ }, ...(ngDevMode ? [{ debugName: "state$" }] : []));
2627
+ const eventsSubject = new Subject();
2628
+ const mock = {
2629
+ // ── AgentWithHistory (runtime-neutral surface) ────────────────────────
2630
+ messages: messages$,
2631
+ status: status$,
2632
+ isLoading: isLoading$,
2633
+ error: error$,
2634
+ toolCalls: toolCalls$,
2635
+ state: state$,
2636
+ interrupt: interrupt$,
2637
+ subagents: subagents$,
2638
+ events$: eventsSubject.asObservable(),
2639
+ history: history$,
2640
+ submit: (_input, _opts) => Promise.resolve(),
2641
+ stop: () => Promise.resolve(),
2642
+ regenerate: (_assistantMessageIndex) => Promise.resolve(),
2643
+ // ── Raw LangGraph signals ─────────────────────────────────────────────
2644
+ langGraphMessages: langGraphMessages$,
2645
+ langGraphInterrupts: langGraphInterrupts$,
2646
+ langGraphToolCalls: langGraphToolCalls$,
2647
+ langGraphHistory: langGraphHistory$,
2648
+ experimentalBranchTree: experimentalBranchTree$,
2649
+ // ── Other AgentRef fields preserved ──────────────────────────────────
2650
+ value: value$,
2651
+ hasValue: hasValue$,
2652
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
2653
+ reload: () => { },
2654
+ toolProgress: toolProgress$,
2655
+ queue: queue$,
2656
+ activeSubagents: activeSubagents$,
2657
+ getSubagent: (toolCallId) => activeSubagents$().find(subagent => subagent.toolCallId === toolCallId),
2658
+ getSubagentsByType: (type) => activeSubagents$().filter(subagent => subagent.name === type),
2659
+ getSubagentsByMessage: (msg) => {
2660
+ const toolCalls = msg['tool_calls'];
2661
+ if (!Array.isArray(toolCalls))
2662
+ return [];
2663
+ const ids = toolCalls
2664
+ .map(toolCall => {
2665
+ if (toolCall == null || typeof toolCall !== 'object' || Array.isArray(toolCall))
2666
+ return undefined;
2667
+ const id = toolCall['id'];
2668
+ return typeof id === 'string' ? id : undefined;
2669
+ })
2670
+ .filter((id) => id != null);
2671
+ return activeSubagents$().filter(subagent => ids.includes(subagent.toolCallId));
2672
+ },
2673
+ customEvents: customEvents$,
2674
+ branch: branch$,
2675
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
2676
+ setBranch: (_branch) => { },
2677
+ isThreadLoading: isThreadLoading$,
2678
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
2679
+ switchThread: (_threadId) => { },
2680
+ joinStream: (_runId, _lastEventId) => Promise.resolve(),
2681
+ getMessagesMetadata: (_msg, _idx) => undefined,
2682
+ getToolCalls: (_msg) => [],
2683
+ lifecycle: {
2684
+ streamStartedAt: signal(null),
2685
+ streamErrorAt: signal(null),
2686
+ interruptReceivedAt: signal(null),
2687
+ interruptResolvedAt: signal(null),
2688
+ threadCreatedAt: signal(null),
2689
+ threadPersistedAt: signal(null),
2690
+ toolCallStartedAt: signal(null),
2691
+ toolCallCompletedAt: signal(null),
2692
+ },
2693
+ };
2694
+ return mock;
2695
+ }
2696
+
2697
+ // SPDX-License-Identifier: MIT
2698
+ const LANGGRAPH_THREADS_CONFIG = new InjectionToken('LANGGRAPH_THREADS_CONFIG');
2699
+ /** Optional adapter clients can pass an explicit Client (e.g. for
2700
+ * testing). When omitted, the adapter constructs one via
2701
+ * {@link createLangGraphClient}. */
2702
+ const LANGGRAPH_CLIENT = new InjectionToken('LANGGRAPH_CLIENT');
2703
+ /**
2704
+ * SDK-backed thread store. Wraps `client.threads.*` and maps SDK
2705
+ * threads to the framework's {@link Thread} type for direct use with
2706
+ * `<chat-thread-list>` / `<chat-sidenav>`.
2707
+ *
2708
+ * Consumers wire the framework's `ThreadActionAdapter` to instance
2709
+ * methods (rename/delete/archive/pin/...) so the right-click menu
2710
+ * round-trips through the LangGraph SDK without per-app boilerplate.
2711
+ *
2712
+ * @example
2713
+ * ```ts
2714
+ * const svc = inject(LangGraphThreadsAdapter);
2715
+ * const actions: ThreadActionAdapter = {
2716
+ * rename: (id, t) => svc.rename(id, t),
2717
+ * delete: (id) => svc.delete(id),
2718
+ * };
2719
+ * ```
2720
+ */
2721
+ class LangGraphThreadsAdapter {
2722
+ config = inject(LANGGRAPH_THREADS_CONFIG);
2723
+ client = inject(LANGGRAPH_CLIENT, { optional: true })
2724
+ ?? createLangGraphClient(this.config.apiUrl);
2725
+ fallback = this.config.titleFallback ?? 'Untitled';
2726
+ _threads = signal([], ...(ngDevMode ? [{ debugName: "_threads" }] : []));
2727
+ _archived = signal([], ...(ngDevMode ? [{ debugName: "_archived" }] : []));
2728
+ /** Active (non-archived) threads, sorted with pinned first. */
2729
+ threads = this._threads.asReadonly();
2730
+ /** Threads whose `metadata.archived === true`. */
2731
+ archivedThreads = this._archived.asReadonly();
2732
+ /** Fetch the latest thread list from the server. Failures are
2733
+ * logged via `console.error` (not swallowed silently — silent
2734
+ * catches have masked prod issues in the past).
2735
+ *
2736
+ * Invocation and resolution are logged at `console.debug` so prod
2737
+ * inspection can distinguish "never called" from "called but
2738
+ * resolved empty" from "called and threw." This was prompted by a
2739
+ * demo.threadplane.ai cold-load bug where the sidenav stayed empty
2740
+ * with no visible signal. Tighten the log volume if it becomes
2741
+ * noisy. */
2742
+ async refresh() {
2743
+ console.debug('[LangGraphThreadsAdapter.refresh] invoked');
2744
+ try {
2745
+ const list = await this.client.threads.search({ limit: 50 });
2746
+ console.debug('[LangGraphThreadsAdapter.refresh] resolved', list.length);
2747
+ const mapped = list.map((t) => this.toThread(t));
2748
+ this._threads.set(mapped
2749
+ .filter((t) => t.status !== 'archived')
2750
+ .sort((a, b) => {
2751
+ const aP = a.pinned === true;
2752
+ const bP = b.pinned === true;
2753
+ if (aP !== bP)
2754
+ return Number(bP) - Number(aP);
2755
+ if (aP && bP) {
2756
+ const aO = typeof a['pinnedOrder'] === 'number' ? a['pinnedOrder'] : Infinity;
2757
+ const bO = typeof b['pinnedOrder'] === 'number' ? b['pinnedOrder'] : Infinity;
2758
+ return aO - bO;
2759
+ }
2760
+ return 0;
2761
+ }));
2762
+ this._archived.set(mapped.filter((t) => t.status === 'archived'));
2763
+ }
2764
+ catch (e) {
2765
+ console.error('[LangGraphThreadsAdapter.refresh] failed:', e);
2766
+ }
2767
+ }
2768
+ /** Fetch a single thread by id. Returns `null` when the server
2769
+ * returns 404 (thread doesn't exist) so callers can distinguish
2770
+ * "missing" from "couldn't reach the server" — genuine network
2771
+ * errors rethrow. Used by URL-based thread routing to validate a
2772
+ * pasted/shared thread id before activating it. */
2773
+ async getThread(threadId) {
2774
+ try {
2775
+ const t = await this.client.threads.get(threadId);
2776
+ return this.toThread(t);
2777
+ }
2778
+ catch (e) {
2779
+ // SDK throws HTTPError-like objects without a typed error class;
2780
+ // sniff status on the error or its nested response. Treat both
2781
+ // 404 (server says "no such thread") and 422 (server says "id
2782
+ // isn't even a valid UUID") as "missing" — both warrant the
2783
+ // same caller behavior (redirect to a fresh chat).
2784
+ const status = e.status ??
2785
+ e.response?.status;
2786
+ if (status === 404 || status === 422)
2787
+ return null;
2788
+ throw e;
2789
+ }
2790
+ }
2791
+ async create(metadata = {}) {
2792
+ try {
2793
+ const t = await this.client.threads.create({ metadata });
2794
+ await this.refresh();
2795
+ return t.thread_id;
2796
+ }
2797
+ catch (e) {
2798
+ console.error('[LangGraphThreadsAdapter.create] failed:', e);
2799
+ return null;
2800
+ }
2801
+ }
2802
+ async delete(threadId) {
2803
+ await this.client.threads.delete(threadId);
2804
+ await this.refresh();
2805
+ }
2806
+ async rename(threadId, newTitle) {
2807
+ await this.client.threads.update(threadId, { metadata: { title: newTitle } });
2808
+ await this.refresh();
2809
+ }
2810
+ async archive(threadId) {
2811
+ await this.client.threads.update(threadId, { metadata: { archived: true } });
2812
+ await this.refresh();
2813
+ }
2814
+ async unarchive(threadId) {
2815
+ await this.client.threads.update(threadId, { metadata: { archived: false } });
2816
+ await this.refresh();
2817
+ }
2818
+ async pin(threadId) {
2819
+ await this.client.threads.update(threadId, { metadata: { pinned: true } });
2820
+ await this.refresh();
2821
+ }
2822
+ async unpin(threadId) {
2823
+ await this.client.threads.update(threadId, { metadata: { pinned: false } });
2824
+ await this.refresh();
2825
+ }
2826
+ async moveToProject(threadId, projectId) {
2827
+ await this.client.threads.update(threadId, { metadata: { projectId } });
2828
+ await this.refresh();
2829
+ }
2830
+ /** Re-stamp `metadata.pinnedOrder = 0,1,2,...` for the pinned slice
2831
+ * to reflect the new ordering. */
2832
+ async reorderPinned(threadId, beforeId) {
2833
+ const current = this._threads().filter((t) => t.pinned === true);
2834
+ const moved = current.find((t) => t.id === threadId);
2835
+ if (!moved)
2836
+ return;
2837
+ const rest = current.filter((t) => t.id !== threadId);
2838
+ const next = [];
2839
+ for (const t of rest) {
2840
+ if (t.id === beforeId)
2841
+ next.push(moved);
2842
+ next.push(t);
2843
+ }
2844
+ if (beforeId === null)
2845
+ next.push(moved);
2846
+ await Promise.all(next.map((t, idx) => this.client.threads.update(t.id, { metadata: { pinnedOrder: idx } })));
2847
+ await this.refresh();
2848
+ }
2849
+ toThread(t) {
2850
+ const meta = (t.metadata ?? {});
2851
+ const rawTitle = meta['title'];
2852
+ const archived = meta['archived'] === true;
2853
+ const pinned = meta['pinned'] === true;
2854
+ const projectId = typeof meta['projectId'] === 'string' && meta['projectId'].length > 0
2855
+ ? meta['projectId']
2856
+ : null;
2857
+ const pinnedOrder = typeof meta['pinnedOrder'] === 'number' ? meta['pinnedOrder'] : undefined;
2858
+ return {
2859
+ id: t.thread_id,
2860
+ title: typeof rawTitle === 'string' && rawTitle.length > 0 ? rawTitle : this.fallback,
2861
+ status: archived ? 'archived' : 'active',
2862
+ pinned,
2863
+ projectId,
2864
+ pinnedOrder,
2865
+ updatedAt: t.updated_at ? Date.parse(t.updated_at) : undefined,
2866
+ };
2867
+ }
2868
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: LangGraphThreadsAdapter, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2869
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: LangGraphThreadsAdapter, providedIn: 'root' });
2870
+ }
2871
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: LangGraphThreadsAdapter, decorators: [{
2872
+ type: Injectable,
2873
+ args: [{ providedIn: 'root' }]
2874
+ }] });
2875
+
2876
+ // SPDX-License-Identifier: MIT
2877
+ /**
2878
+ * Call `fn` whenever the agent's status transitions out of `'running'`
2879
+ * (i.e. when a run completes — success, error, or interrupt). Useful
2880
+ * for refreshing thread lists, telemetry, or any other state that
2881
+ * lags the agent.
2882
+ *
2883
+ * Must be called within an injection context (constructor or
2884
+ * `runInInjectionContext`) — uses Angular's `effect` under the hood.
2885
+ *
2886
+ * @example
2887
+ * ```ts
2888
+ * constructor() {
2889
+ * refreshOnRunEnd(this.agent, () => this.threads.refresh());
2890
+ * }
2891
+ * ```
2892
+ */
2893
+ function refreshOnRunEnd(agent, fn) {
2894
+ let lastStatus = agent.status();
2895
+ effect(() => {
2896
+ const status = agent.status();
2897
+ if (lastStatus === 'running' && status !== 'running') {
2898
+ void fn();
2899
+ }
2900
+ lastStatus = status;
2901
+ });
2902
+ }
2903
+ /**
2904
+ * Call `fn` whenever any of the watched signals transitions from a
2905
+ * truthy "active" value to a non-active value. Generic version of
2906
+ * {@link refreshOnRunEnd} for callers tracking custom state machines.
2907
+ *
2908
+ * Must be called within an injection context.
2909
+ */
2910
+ function refreshOnTransition(watch, isActive, fn) {
2911
+ let lastActive = isActive(watch());
2912
+ effect(() => {
2913
+ const active = isActive(watch());
2914
+ if (lastActive && !active)
2915
+ void fn();
2916
+ lastActive = active;
2917
+ });
2918
+ }
2919
+
2920
+ // SPDX-License-Identifier: MIT
2921
+ // Primary function
2922
+
2923
+ /**
2924
+ * Generated bundle index. Do not edit.
2925
+ */
2926
+
2927
+ export { AGENT_CONFIG, AGENT_LIFECYCLE, AgentLifecycleRegistry, FetchStreamTransport, LANGGRAPH_CLIENT, LANGGRAPH_THREADS_CONFIG, LangGraphThreadsAdapter, MockAgentTransport, ResourceStatus, agent, createLangGraphClient, extractCitations, mockLangGraphAgent, provideAgent, refreshOnRunEnd, refreshOnTransition, toAbsoluteApiUrl };
2928
+ //# sourceMappingURL=threadplane-langgraph.mjs.map