@yeaft/webchat-agent 0.1.497 → 0.1.498

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/unify/web-bridge.js +107 -49
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.497",
3
+ "version": "0.1.498",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -16,13 +16,22 @@
16
16
  import { loadSession } from './session.js';
17
17
  import { sendToServer } from '../connection/buffer.js';
18
18
  import ctx from '../context.js';
19
- import { getThreadStore } from './threads/store.js';
19
+ import { getThreadStore, MAIN_THREAD_ID } from './threads/store.js';
20
20
 
21
21
  /** @type {import('./session.js').Session | null} */
22
22
  let session = null;
23
23
 
24
- /** @type {AbortController | null} */
25
- let currentAbort = null;
24
+ /**
25
+ * task-320: per-thread in-flight AbortController registry.
26
+ *
27
+ * A new message only cancels the prior round on the SAME thread; a message
28
+ * routed to a different thread runs concurrently without aliasing. Keyed by
29
+ * the resolved `targetThreadId` from the dispatcher's `routing_decision`
30
+ * event (we don't know the thread until the router has classified).
31
+ *
32
+ * @type {Map<string, AbortController>}
33
+ */
34
+ const abortByThread = new Map();
26
35
 
27
36
  /** Query timeout in ms — abort if LLM doesn't respond within this window */
28
37
  const QUERY_TIMEOUT_MS = 120_000;
@@ -30,10 +39,22 @@ const QUERY_TIMEOUT_MS = 120_000;
30
39
  /** Virtual conversationId for the Unify session */
31
40
  let unifyConversationId = null;
32
41
 
33
- /** Accumulated conversation messages for context continuity across queries.
34
- * Each entry is { role: 'user'|'assistant', content: string|Array }.
35
- * Cleared on session reset or consolidation. */
36
- let conversationMessages = [];
42
+ /**
43
+ * task-320: per-thread accumulated conversation messages for context
44
+ * continuity. Previously a single flat array — which cross-contaminated
45
+ * history across threads. Keyed by threadId. Cleared on session reset or
46
+ * by a `consolidate` event for that thread only.
47
+ *
48
+ * @type {Map<string, Array<{role: 'user'|'assistant', content: string|Array}>>}
49
+ */
50
+ const messagesByThread = new Map();
51
+
52
+ function getThreadMessages(threadId) {
53
+ if (!threadId) return [];
54
+ let arr = messagesByThread.get(threadId);
55
+ if (!arr) { arr = []; messagesByThread.set(threadId, arr); }
56
+ return arr;
57
+ }
37
58
 
38
59
  /** Whether we've already sent a permission warning to the UI */
39
60
  let _permissionDiagnosticSent = false;
@@ -395,8 +416,13 @@ function handleEngineEvent(event, threadId, hctx) {
395
416
  break;
396
417
 
397
418
  case 'consolidate':
398
- // Engine compressed the context — clear our accumulated history.
399
- conversationMessages = [];
419
+ // Engine compressed the context — clear our accumulated history for
420
+ // THIS thread only (task-320: per-thread history map).
421
+ if (threadId) {
422
+ messagesByThread.set(threadId, []);
423
+ } else {
424
+ messagesByThread.clear();
425
+ }
400
426
  sendUnifyEvent({
401
427
  type: 'consolidate',
402
428
  archivedCount: event.archivedCount,
@@ -512,11 +538,16 @@ export async function handleUnifyChat(msg) {
512
538
  // Create a stable conversationId for the Unify session
513
539
  unifyConversationId = `unify-${Date.now()}`;
514
540
 
515
- // Restore conversationMessages from persisted history for LLM context
541
+ // Restore per-thread history from persisted conversation store.
542
+ // task-320: bucket by threadId so each thread keeps its own context.
543
+ messagesByThread.clear();
516
544
  const recent = session.conversationStore.loadRecent(50);
517
- conversationMessages = recent
518
- .filter(m => m.role === 'user' || m.role === 'assistant')
519
- .map(m => ({ role: m.role, content: m.content }));
545
+ for (const m of recent) {
546
+ if (m.role !== 'user' && m.role !== 'assistant') continue;
547
+ const tid = m.threadId || MAIN_THREAD_ID;
548
+ const bucket = getThreadMessages(tid);
549
+ bucket.push({ role: m.role, content: m.content });
550
+ }
520
551
 
521
552
  // Notify UI: session is ready with model info + conversationId
522
553
  sendUnifyEvent({
@@ -533,13 +564,14 @@ export async function handleUnifyChat(msg) {
533
564
  sendThreadListUpdate();
534
565
  }
535
566
 
536
- // ─── Cancel any in-flight query ──
537
- if (currentAbort) {
538
- currentAbort.abort();
539
- currentAbort = null;
540
- }
541
-
542
- currentAbort = new AbortController();
567
+ // ─── Per-call AbortController (task-320) ──
568
+ // Each call owns its own controller. Only once the router resolves the
569
+ // target thread do we register it into `abortByThread` and abort any
570
+ // prior controller on THAT same thread. Messages routed to different
571
+ // threads never alias each other's signals.
572
+ const abortCtrl = new AbortController();
573
+ /** @type {string | null} — set on routing_decision */
574
+ let resolvedThreadId = null;
543
575
 
544
576
  // ─── Timeout guard: abort query if LLM hangs beyond threshold ──
545
577
  // Resets on every event — fires only after prolonged silence.
@@ -547,9 +579,9 @@ export async function handleUnifyChat(msg) {
547
579
  const resetQueryTimer = () => {
548
580
  if (queryTimer) clearTimeout(queryTimer);
549
581
  queryTimer = setTimeout(() => {
550
- if (currentAbort) {
582
+ if (!abortCtrl.signal.aborted) {
551
583
  console.error(`[Unify] query timeout after ${QUERY_TIMEOUT_MS / 1000}s of silence — aborting`);
552
- currentAbort.abort();
584
+ abortCtrl.abort();
553
585
  }
554
586
  }, QUERY_TIMEOUT_MS);
555
587
  };
@@ -590,17 +622,31 @@ export async function handleUnifyChat(msg) {
590
622
  onError: (err) => { throw err; },
591
623
  };
592
624
 
593
- for await (const pev of session.dispatcher.drain({ signal: currentAbort.signal })) {
625
+ for await (const pev of session.dispatcher.drain({ signal: abortCtrl.signal })) {
594
626
  resetQueryTimer();
627
+ // task-320: on routing_decision, bind this abort controller to the
628
+ // resolved target thread and abort any prior in-flight controller
629
+ // owned by that thread. Different threads don't alias.
630
+ if (pev && pev.type === 'routing_decision' && pev.targetThreadId && !resolvedThreadId) {
631
+ resolvedThreadId = pev.targetThreadId;
632
+ const prior = abortByThread.get(resolvedThreadId);
633
+ if (prior && prior !== abortCtrl) {
634
+ prior.abort();
635
+ }
636
+ abortByThread.set(resolvedThreadId, abortCtrl);
637
+ }
595
638
  forwardPipelineEvent(pev, pipelineCtx);
596
639
  }
597
640
 
598
641
  // ─── Query complete — accumulate messages for context continuity ──
599
- conversationMessages.push({ role: 'user', content: cleanedPrompt });
642
+ // task-320: per-thread history (no cross-thread contamination).
643
+ const historyThread = resolvedThreadId || MAIN_THREAD_ID;
644
+ const threadMessages = getThreadMessages(historyThread);
645
+ threadMessages.push({ role: 'user', content: cleanedPrompt });
600
646
 
601
647
  const fullText = assistantTextParts.join('');
602
648
  if (fullText) {
603
- conversationMessages.push({ role: 'assistant', content: fullText });
649
+ threadMessages.push({ role: 'assistant', content: fullText });
604
650
  }
605
651
 
606
652
  // ─── Signal turn end to UI ──
@@ -621,17 +667,14 @@ export async function handleUnifyChat(msg) {
621
667
  }
622
668
 
623
669
  } catch (err) {
624
- // Don't report abort errors but still send result to unblock frontend
625
- if (err.name === 'AbortError') {
626
- sendUnifyOutput({
627
- type: 'assistant',
628
- message: {
629
- content: [{
630
- type: 'text',
631
- text: '⚠️ Query timed out no response from LLM. Please try again.',
632
- }],
633
- },
634
- });
670
+ // task-320: classify both DOM AbortError and LLMAbortError as
671
+ // "aborted" LLMAbortError is thrown by the LLM adapters when the
672
+ // signal trips and must NOT render as a session error bubble.
673
+ const isAbort = err && (err.name === 'AbortError' || err.name === 'LLMAbortError');
674
+ if (isAbort) {
675
+ // Silent abort — the new in-flight round (on the same thread) will
676
+ // produce its own output. Still send `result` so the frontend's
677
+ // processing spinner for this exact send clears.
635
678
  sendUnifyOutput({
636
679
  type: 'result',
637
680
  result_text: '',
@@ -672,7 +715,12 @@ export async function handleUnifyChat(msg) {
672
715
  result_text: '',
673
716
  });
674
717
  } finally {
675
- currentAbort = null;
718
+ // task-320: only clear the per-thread slot if THIS controller is still
719
+ // the registered one. If a newer message already overwrote it, leaving
720
+ // the newer controller in the map is the correct state.
721
+ if (resolvedThreadId && abortByThread.get(resolvedThreadId) === abortCtrl) {
722
+ abortByThread.delete(resolvedThreadId);
723
+ }
676
724
  }
677
725
  }
678
726
 
@@ -863,11 +911,16 @@ export async function handleUnifyLoadHistory(msg) {
863
911
 
864
912
  unifyConversationId = `unify-${Date.now()}`;
865
913
 
866
- // Restore conversationMessages from persisted history for LLM context
914
+ // Restore per-thread history from persisted conversation store.
915
+ // task-320: bucket by threadId so each thread keeps its own context.
916
+ messagesByThread.clear();
867
917
  const recent = session.conversationStore.loadRecent(50);
868
- conversationMessages = recent
869
- .filter(m => m.role === 'user' || m.role === 'assistant')
870
- .map(m => ({ role: m.role, content: m.content }));
918
+ for (const m of recent) {
919
+ if (m.role !== 'user' && m.role !== 'assistant') continue;
920
+ const tid = m.threadId || MAIN_THREAD_ID;
921
+ const bucket = getThreadMessages(tid);
922
+ bucket.push({ role: m.role, content: m.content });
923
+ }
871
924
 
872
925
  sendUnifyEvent({
873
926
  type: 'session_ready',
@@ -915,16 +968,19 @@ export async function handleUnifyLoadHistory(msg) {
915
968
  * session_ready so the frontend picks up updated models/config.
916
969
  */
917
970
  export async function resetUnifySession() {
918
- if (currentAbort) {
919
- currentAbort.abort();
920
- currentAbort = null;
971
+ // task-320: abort ALL in-flight controllers (every thread) before
972
+ // tearing down the session. Leaves no dangling round still writing
973
+ // to stdout after shutdown.
974
+ for (const ctrl of abortByThread.values()) {
975
+ try { ctrl.abort(); } catch { /* ignore */ }
921
976
  }
977
+ abortByThread.clear();
922
978
  if (session) {
923
979
  await session.shutdown();
924
980
  session = null;
925
981
  }
926
982
  unifyConversationId = null;
927
- conversationMessages = [];
983
+ messagesByThread.clear();
928
984
 
929
985
  // Re-initialize session immediately so frontend gets updated config
930
986
  try {
@@ -944,11 +1000,13 @@ export async function resetUnifySession() {
944
1000
 
945
1001
  unifyConversationId = `unify-${Date.now()}`;
946
1002
 
947
- // Restore conversation history for LLM context
1003
+ // Restore per-thread history for LLM context (task-320).
948
1004
  const recent = session.conversationStore.loadRecent(50);
949
- conversationMessages = recent
950
- .filter(m => m.role === 'user' || m.role === 'assistant')
951
- .map(m => ({ role: m.role, content: m.content }));
1005
+ for (const m of recent) {
1006
+ if (m.role !== 'user' && m.role !== 'assistant') continue;
1007
+ const tid = m.threadId || MAIN_THREAD_ID;
1008
+ getThreadMessages(tid).push({ role: m.role, content: m.content });
1009
+ }
952
1010
 
953
1011
  sendUnifyEvent({
954
1012
  type: 'session_ready',