@yeaft/webchat-agent 0.1.674 → 0.1.676

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.
@@ -36,7 +36,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
36
36
  import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
37
37
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
38
38
  import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings, getSearchSettings, updateSearchSettings, fetchTavilyUsage } from '../unify/config-api.js';
39
- import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyFeatureMessage, handleUnifyFetchSummaryHistory, handleUnifyFeatureCrud, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger } from '../unify/web-bridge.js';
39
+ import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyFeatureMessage, handleUnifyFetchSummaryHistory, handleUnifyFeatureCrud, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger } from '../unify/web-bridge.js';
40
40
 
41
41
  export async function handleMessage(msg) {
42
42
  switch (msg.type) {
@@ -412,6 +412,11 @@ export async function handleMessage(msg) {
412
412
  handleUnifyAbortAll();
413
413
  break;
414
414
 
415
+ case 'unify_abort_turn':
416
+ // Per-VP stop: abort a single VP turn by turnId.
417
+ handleUnifyAbortTurn(msg);
418
+ break;
419
+
415
420
  // task-334-ui-a: VP library subscribe — replies with one-shot
416
421
  // vp_snapshot event. Live diff (vp_updated/vp_removed) deferred to 334h.
417
422
  case 'unify_vp_subscribe':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.674",
3
+ "version": "0.1.676",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { join } from 'node:path';
22
22
  import { existsSync } from 'node:fs';
23
+ import { randomUUID } from 'node:crypto';
23
24
  import { loadSession } from './session.js';
24
25
  import { sendToServer } from '../connection/buffer.js';
25
26
  import ctx from '../context.js';
@@ -54,6 +55,15 @@ let session = null;
54
55
  */
55
56
  let currentAbortCtrl = null;
56
57
 
58
+ /**
59
+ * Per-VP-turn AbortControllers. Maps `turnId` → `AbortController`.
60
+ * Each VP-turn in a fan-out gets its own controller so it can be stopped
61
+ * independently (per-VP Stop button). `handleUnifyAbortTurn` looks up by
62
+ * turnId to abort a single VP. `handleUnifyAbortAll` iterates and aborts all.
63
+ * @type {Map<string, AbortController>}
64
+ */
65
+ const turnAbortCtrls = new Map();
66
+
57
67
  /** Query timeout in ms — abort if LLM doesn't respond within this window */
58
68
  const QUERY_TIMEOUT_MS = 120_000;
59
69
 
@@ -108,25 +118,28 @@ function isPermissionErrorMsg(msg) {
108
118
 
109
119
  /**
110
120
  * Send a unify_output message carrying claude_output-format data.
111
- * Optional `groupId` tags every emitted assistant/tool/user mirror with
112
- * the originating group so the frontend can stamp arriving messages with
113
- * the SEND-context group.
121
+ * Envelope fields: conversationId, groupId, vpId, turnId the last two
122
+ * let the frontend route incremental deltas to the correct per-VP message block.
114
123
  */
115
- function sendUnifyOutput(data, groupId) {
124
+ function sendUnifyOutput(data, { groupId, vpId, turnId } = {}) {
116
125
  sendToServer({
117
126
  type: 'unify_output',
118
127
  conversationId: unifyConversationId,
119
128
  ...(groupId ? { groupId } : {}),
129
+ ...(vpId ? { vpId } : {}),
130
+ ...(turnId ? { turnId } : {}),
120
131
  data,
121
132
  });
122
133
  }
123
134
 
124
135
  /** Send a unify_output event (non-claude_output metadata). */
125
- function sendUnifyEvent(event, groupId) {
136
+ function sendUnifyEvent(event, { groupId, vpId, turnId } = {}) {
126
137
  sendToServer({
127
138
  type: 'unify_output',
128
139
  conversationId: unifyConversationId,
129
140
  ...(groupId ? { groupId } : {}),
141
+ ...(vpId ? { vpId } : {}),
142
+ ...(turnId ? { turnId } : {}),
130
143
  event,
131
144
  });
132
145
  }
@@ -402,11 +415,11 @@ export function installUnifyRuntimeBridge(s) {
402
415
  * H2.f.2: no longer stamps a threadId on outgoing claude_output frames.
403
416
  *
404
417
  * @param {object} event — engine event (text_delta / tool_call / …)
405
- * @param {{assistantTextParts:string[], toolCallsAccum:Array, toolResultsAccum:Array, resetQueryTimer:Function, groupId?:string}} hctx
418
+ * @param {{assistantTextParts:string[], toolCallsAccum:Array, toolResultsAccum:Array, resetQueryTimer:Function, groupId?:string, vpId?:string, turnId?:string}} hctx
406
419
  */
407
420
  function handleEngineEvent(event, hctx) {
408
421
  hctx.resetQueryTimer();
409
- const gid = hctx && hctx.groupId;
422
+ const envelope = { groupId: hctx.groupId, vpId: hctx.vpId, turnId: hctx.turnId };
410
423
 
411
424
  switch (event.type) {
412
425
  case 'text_delta':
@@ -414,11 +427,11 @@ function handleEngineEvent(event, hctx) {
414
427
  sendUnifyOutput({
415
428
  type: 'assistant',
416
429
  message: { content: [{ type: 'text', text: event.text }] },
417
- }, gid);
430
+ }, envelope);
418
431
  break;
419
432
 
420
433
  case 'thinking_delta':
421
- sendUnifyEvent({ type: 'thinking_delta', text: event.text }, gid);
434
+ sendUnifyEvent({ type: 'thinking_delta', text: event.text }, envelope);
422
435
  break;
423
436
 
424
437
  case 'tool_call':
@@ -436,7 +449,7 @@ function handleEngineEvent(event, hctx) {
436
449
  sendUnifyOutput({
437
450
  type: 'assistant',
438
451
  message: { content: [] },
439
- }, gid);
452
+ }, envelope);
440
453
  sendUnifyOutput({
441
454
  type: 'assistant',
442
455
  message: {
@@ -447,7 +460,7 @@ function handleEngineEvent(event, hctx) {
447
460
  input: event.input,
448
461
  }],
449
462
  },
450
- }, gid);
463
+ }, envelope);
451
464
  break;
452
465
 
453
466
  case 'tool_start':
@@ -455,7 +468,7 @@ function handleEngineEvent(event, hctx) {
455
468
  type: 'tool_start',
456
469
  id: event.id,
457
470
  name: event.name,
458
- }, gid);
471
+ }, envelope);
459
472
  break;
460
473
 
461
474
  case 'tool_end':
@@ -475,7 +488,7 @@ function handleEngineEvent(event, hctx) {
475
488
  content: event.output || '',
476
489
  is_error: event.isError || false,
477
490
  }],
478
- }, gid);
491
+ }, envelope);
479
492
  break;
480
493
 
481
494
  case 'turn_start':
@@ -489,7 +502,7 @@ function handleEngineEvent(event, hctx) {
489
502
  type: 'context_usage',
490
503
  inputTokens: event.inputTokens,
491
504
  outputTokens: event.outputTokens,
492
- }, gid);
505
+ }, envelope);
493
506
  break;
494
507
 
495
508
  case 'recall':
@@ -497,7 +510,7 @@ function handleEngineEvent(event, hctx) {
497
510
  type: 'recall',
498
511
  entryCount: event.entryCount,
499
512
  cached: event.cached,
500
- }, gid);
513
+ }, envelope);
501
514
  break;
502
515
 
503
516
  case 'consolidate':
@@ -507,7 +520,7 @@ function handleEngineEvent(event, hctx) {
507
520
  type: 'consolidate',
508
521
  archivedCount: event.archivedCount,
509
522
  extractedCount: event.extractedCount,
510
- }, gid);
523
+ }, envelope);
511
524
  break;
512
525
 
513
526
  case 'fallback':
@@ -516,7 +529,7 @@ function handleEngineEvent(event, hctx) {
516
529
  from: event.from,
517
530
  to: event.to,
518
531
  reason: event.reason,
519
- }, gid);
532
+ }, envelope);
520
533
  break;
521
534
 
522
535
  case 'reflection':
@@ -529,7 +542,7 @@ function handleEngineEvent(event, hctx) {
529
542
  content: event.content,
530
543
  durationMs: event.durationMs,
531
544
  error: event.error,
532
- }, gid);
545
+ }, envelope);
533
546
  break;
534
547
 
535
548
  case 'debug_turn':
@@ -547,7 +560,7 @@ function handleEngineEvent(event, hctx) {
547
560
  stopReason: event.stopReason,
548
561
  rawRequest: event.rawRequest,
549
562
  rawResponse: event.rawResponse,
550
- }, gid);
563
+ }, envelope);
551
564
  break;
552
565
 
553
566
  case 'error': {
@@ -563,7 +576,7 @@ function handleEngineEvent(event, hctx) {
563
576
  text: '⚠️ Cannot write to ~/.yeaft/ directory — some features (memory, history) are unavailable. Please check directory permissions: `chmod -R u+rw ~/.yeaft/`',
564
577
  }],
565
578
  },
566
- }, gid);
579
+ }, envelope);
567
580
  }
568
581
  } else {
569
582
  sendUnifyOutput({
@@ -571,7 +584,7 @@ function handleEngineEvent(event, hctx) {
571
584
  message: {
572
585
  content: [{ type: 'text', text: `⚠️ Error: ${errMsg}` }],
573
586
  },
574
- }, gid);
587
+ }, envelope);
575
588
  }
576
589
  break;
577
590
  }
@@ -616,21 +629,23 @@ export async function handleUnifyGroupChat(msg) {
616
629
  sendUnifyOutput({
617
630
  type: 'assistant',
618
631
  message: { content: [{ type: 'text', text: '⚠️ Unify session error: no yeaft directory configured.' }] },
619
- }, groupId);
620
- sendUnifyOutput({ type: 'result', result_text: '' }, groupId);
632
+ }, { groupId });
633
+ sendUnifyOutput({ type: 'result', result_text: '' }, { groupId });
621
634
  return;
622
635
  }
623
636
 
624
637
  await ensureSessionLoaded();
625
638
 
626
- // Cancel any prior in-flight dispatch BEFORE we fan out. One dispatch =
627
- // one cancellation domain. Each per-VP runVpTurn shares this signal, so
628
- // siblings within the same dispatch never abort each other (the bug
629
- // before this fix: each runVpTurn replaced currentAbortCtrl, causing
630
- // VP-B's start to silently kill VP-A's in-flight LLM call).
639
+ // Cancel any prior in-flight dispatch BEFORE we fan out.
631
640
  if (currentAbortCtrl && !currentAbortCtrl.signal.aborted) {
632
641
  try { currentAbortCtrl.abort(); } catch { /* best-effort */ }
633
642
  }
643
+ // Also abort any lingering per-VP controllers from the prior dispatch.
644
+ for (const ctrl of turnAbortCtrls.values()) {
645
+ try { if (!ctrl.signal.aborted) ctrl.abort(); } catch { /* best-effort */ }
646
+ }
647
+ turnAbortCtrls.clear();
648
+
634
649
  const dispatchAbortCtrl = new AbortController();
635
650
  currentAbortCtrl = dispatchAbortCtrl;
636
651
 
@@ -666,8 +681,8 @@ export async function handleUnifyGroupChat(msg) {
666
681
  sendUnifyOutput({
667
682
  type: 'assistant',
668
683
  message: { content: [{ type: 'text', text: errText }] },
669
- }, groupId);
670
- sendUnifyOutput({ type: 'result', result_text: '' }, groupId);
684
+ }, { groupId });
685
+ sendUnifyOutput({ type: 'result', result_text: '' }, { groupId });
671
686
  return;
672
687
  }
673
688
 
@@ -724,8 +739,8 @@ export async function handleUnifyGroupChat(msg) {
724
739
  sendUnifyOutput({
725
740
  type: 'assistant',
726
741
  message: { content: [{ type: 'text', text: `⚠️ Group dispatch error: ${err?.message || err}` }] },
727
- }, groupId);
728
- sendUnifyOutput({ type: 'result', result_text: '' }, groupId);
742
+ }, { groupId });
743
+ sendUnifyOutput({ type: 'result', result_text: '' }, { groupId });
729
744
  return;
730
745
  }
731
746
 
@@ -738,12 +753,20 @@ export async function handleUnifyGroupChat(msg) {
738
753
  sendUnifyOutput({
739
754
  type: 'assistant',
740
755
  message: { content: [{ type: 'text', text: '⚠️ No VP available to respond — check the group roster.' }] },
741
- }, groupId);
742
- sendUnifyOutput({ type: 'result', result_text: '' }, groupId);
756
+ }, { groupId });
757
+ sendUnifyOutput({ type: 'result', result_text: '' }, { groupId });
743
758
  return;
744
759
  }
745
760
 
761
+ // Mint a dispatch ID for this fan-out — each VP gets a unique turnId.
762
+ const dispatchId = randomUUID().slice(0, 8);
763
+
764
+ // Snapshot conversation history BEFORE fan-out starts. Each VP reads
765
+ // from this consistent point; no VP sees another VP's in-flight output.
766
+ const baseSnapshot = [...conversationMessages];
767
+
746
768
  for (const { vpId, envelope } of captured) {
769
+ const turnId = `${dispatchId}:${vpId}`;
747
770
  try {
748
771
  sendUnifyEvent({
749
772
  type: 'group_message',
@@ -754,7 +777,7 @@ export async function handleUnifyGroupChat(msg) {
754
777
  mentions,
755
778
  trigger: envelope?.trigger || 'fallback',
756
779
  ts: Date.now(),
757
- });
780
+ }, { groupId, vpId, turnId });
758
781
  } catch { /* never crash WS pipeline */ }
759
782
 
760
783
  try {
@@ -762,40 +785,43 @@ export async function handleUnifyGroupChat(msg) {
762
785
  type: 'vp_typing_start',
763
786
  groupId,
764
787
  vpId,
788
+ turnId,
765
789
  ts: Date.now(),
766
- });
790
+ }, { groupId, vpId, turnId });
767
791
  } catch { /* never crash WS pipeline */ }
768
792
  }
769
793
 
770
- // GC.1 Commit C: VP-level parallelism. Each selected VP runs its
771
- // turn concurrently via Promise.all. Intra-VP loops (LLM tool
772
- // LLM) stay serial inside each runVpTurn call. All siblings share
773
- // the dispatch-level AbortSignal so a single user-initiated abort
774
- // (or a timeout in any one VP) cancels the whole fan-out.
775
- //
776
- // Side-effect: VP-B's transcript no longer contains VP-A's reply
777
- // (they're concurrent). Cross-VP visibility moves to the explicit
778
- // route_forward tool. The group jsonl log remains the source of
779
- // truth for full-fidelity replay.
794
+ // Per-VP parallel fan-out. Each VP gets its own AbortController
795
+ // (stoppable individually via per-VP Stop button) and reads from
796
+ // the shared baseSnapshot. On completion (or abort), results are
797
+ // atomically appended to conversationMessages.
780
798
  await Promise.all(captured.map(async ({ vpId }) => {
799
+ const turnId = `${dispatchId}:${vpId}`;
800
+ const vpAbort = new AbortController();
801
+ turnAbortCtrls.set(turnId, vpAbort);
802
+
781
803
  try {
782
804
  await runVpTurn({
783
805
  prompt: `@vp-${vpId} ${text}`,
784
806
  groupId,
785
807
  vpId,
808
+ turnId,
786
809
  groupCoordinator: coord,
787
- abortCtrl: dispatchAbortCtrl,
810
+ vpAbort,
811
+ baseSnapshot,
788
812
  });
789
813
  } catch (err) {
790
814
  console.warn('[Unify] unify_group_chat: per-vp dispatch failed', vpId, err?.message || err);
791
815
  } finally {
816
+ turnAbortCtrls.delete(turnId);
792
817
  try {
793
818
  sendUnifyEvent({
794
819
  type: 'vp_typing_end',
795
820
  groupId,
796
821
  vpId,
822
+ turnId,
797
823
  ts: Date.now(),
798
- });
824
+ }, { groupId, vpId, turnId });
799
825
  } catch { /* never crash WS pipeline */ }
800
826
  }
801
827
  }));
@@ -919,19 +945,19 @@ async function ensureSessionLoaded() {
919
945
  * coordinator-bound router, stream events to the frontend, and append the
920
946
  * result to the flat conversation history.
921
947
  *
922
- * Private — only `handleUnifyGroupChat` calls this. Coordinator and groupId
923
- * are mandatory; callers MUST resolve a default group before invoking. The
924
- * caller also owns the AbortController; we receive the whole controller
925
- * (not just its signal) so the per-VP query-timeout can abort exactly the
926
- * dispatch it belongs to and only if that controller is still the
927
- * module-active one. This avoids a stale-timer-from-an-aborted-dispatch
928
- * killing the next dispatch.
948
+ * Private — only `handleUnifyGroupChat` calls this. Each VP-turn gets its
949
+ * own AbortController (`vpAbort`) so it can be stopped individually. The
950
+ * shared `baseSnapshot` is the conversation history at fan-out start — no
951
+ * VP sees another VP's in-flight output. After the turn finishes (or is
952
+ * aborted), the VP's output is atomically appended to `conversationMessages`.
929
953
  *
930
- * @param {{ prompt: string, groupId: string, vpId: string|null, groupCoordinator: object, abortCtrl: AbortController }} args
954
+ * @param {{ prompt: string, groupId: string, vpId: string, turnId: string, groupCoordinator: object, vpAbort: AbortController, baseSnapshot: Array }} args
931
955
  */
932
- async function runVpTurn({ prompt, groupId, vpId, groupCoordinator, abortCtrl }) {
956
+ async function runVpTurn({ prompt, groupId, vpId, turnId, groupCoordinator, vpAbort, baseSnapshot }) {
933
957
  if (!prompt?.trim()) return;
934
958
 
959
+ const envelope = { groupId, vpId, turnId };
960
+
935
961
  try {
936
962
  if (session?.dreamScheduler) {
937
963
  session.dreamScheduler.noteUserMessage();
@@ -941,25 +967,22 @@ async function runVpTurn({ prompt, groupId, vpId, groupCoordinator, abortCtrl })
941
967
  const resetQueryTimer = () => {
942
968
  if (queryTimer) clearTimeout(queryTimer);
943
969
  queryTimer = setTimeout(() => {
944
- // Abort the captured dispatch controller — but only if it is
945
- // still the module-active one. If a later dispatch already
946
- // replaced it, our timer is stale and must NOT abort whatever
947
- // the new dispatch installed (that was the original race).
948
- if (abortCtrl === currentAbortCtrl && !abortCtrl.signal.aborted) {
949
- console.error(`[Unify] query timeout after ${QUERY_TIMEOUT_MS / 1000}s of silence — aborting`);
950
- try { abortCtrl.abort(); } catch { /* best-effort */ }
970
+ if (!vpAbort.signal.aborted) {
971
+ console.error(`[Unify] query timeout after ${QUERY_TIMEOUT_MS / 1000}s of silence — aborting VP ${vpId}`);
972
+ try { vpAbort.abort(); } catch { /* best-effort */ }
951
973
  }
952
974
  }, QUERY_TIMEOUT_MS);
953
975
  };
954
976
  resetQueryTimer();
955
977
 
978
+ // Emit turn_start so frontend can create the message block.
979
+ sendUnifyEvent({ type: 'vp_turn_start', vpId, turnId, groupId }, envelope);
980
+
956
981
  try {
957
982
  const assistantTextParts = [];
958
983
  const toolCallsAccum = [];
959
984
  const toolResultsAccum = [];
960
985
 
961
- // H2.f.5: dispatcher + InputQueue retired. Call engine.query() directly,
962
- // passing the flat conversation history as `messages` for context continuity.
963
986
  const queryOpts = buildVpQueryOpts({ vpId, groupCoordinator, groupId });
964
987
  const handlerCtx = {
965
988
  assistantTextParts,
@@ -967,50 +990,30 @@ async function runVpTurn({ prompt, groupId, vpId, groupCoordinator, abortCtrl })
967
990
  toolResultsAccum,
968
991
  resetQueryTimer,
969
992
  groupId,
993
+ vpId,
994
+ turnId,
970
995
  };
971
996
  for await (const event of session.engine.query({
972
997
  prompt,
973
- messages: [...conversationMessages],
974
- signal: abortCtrl.signal,
998
+ messages: baseSnapshot,
999
+ signal: vpAbort.signal,
975
1000
  ...queryOpts,
976
1001
  })) {
977
1002
  resetQueryTimer();
978
1003
  handleEngineEvent(event, handlerCtx);
979
1004
  }
980
1005
 
981
- // Accumulate messages for context continuity.
982
- conversationMessages.push({ role: 'user', content: prompt });
983
-
984
- const fullText = assistantTextParts.join('');
985
- if (fullText || toolCallsAccum.length > 0) {
986
- const assistantMsg = { role: 'assistant', content: fullText };
987
- if (toolCallsAccum.length > 0) {
988
- assistantMsg.toolCalls = toolCallsAccum.map(tc => ({
989
- id: tc.id,
990
- name: tc.name,
991
- input: tc.input,
992
- }));
993
- }
994
- conversationMessages.push(assistantMsg);
995
-
996
- for (const tr of toolResultsAccum) {
997
- conversationMessages.push({
998
- role: 'tool',
999
- toolCallId: tr.toolCallId,
1000
- content: tr.content,
1001
- isError: tr.isError,
1002
- });
1003
- }
1004
- }
1006
+ // Turn completed atomically append this VP's output to shared history.
1007
+ appendTurnToHistory(prompt, assistantTextParts, toolCallsAccum, toolResultsAccum);
1005
1008
 
1006
1009
  sendUnifyOutput({
1007
1010
  type: 'assistant',
1008
1011
  message: { content: [] },
1009
- }, groupId);
1012
+ }, envelope);
1010
1013
  sendUnifyOutput({
1011
1014
  type: 'result',
1012
1015
  result_text: '',
1013
- }, groupId);
1016
+ }, envelope);
1014
1017
  } finally {
1015
1018
  if (queryTimer) clearTimeout(queryTimer);
1016
1019
  }
@@ -1020,7 +1023,8 @@ async function runVpTurn({ prompt, groupId, vpId, groupCoordinator, abortCtrl })
1020
1023
  sendUnifyOutput({
1021
1024
  type: 'result',
1022
1025
  result_text: '',
1023
- }, groupId);
1026
+ stopped: true,
1027
+ }, envelope);
1024
1028
  return;
1025
1029
  }
1026
1030
 
@@ -1037,7 +1041,7 @@ async function runVpTurn({ prompt, groupId, vpId, groupCoordinator, abortCtrl })
1037
1041
  text: '⚠️ Cannot write to ~/.yeaft/ directory — some features (memory, history) are unavailable. Please check directory permissions: `chmod -R u+rw ~/.yeaft/`',
1038
1042
  }],
1039
1043
  },
1040
- }, groupId);
1044
+ }, envelope);
1041
1045
  }
1042
1046
  } else {
1043
1047
  sendUnifyOutput({
@@ -1048,12 +1052,42 @@ async function runVpTurn({ prompt, groupId, vpId, groupCoordinator, abortCtrl })
1048
1052
  text: `⚠️ Session error: ${err.message}`,
1049
1053
  }],
1050
1054
  },
1051
- }, groupId);
1055
+ }, envelope);
1052
1056
  }
1053
1057
  sendUnifyOutput({
1054
1058
  type: 'result',
1055
1059
  result_text: '',
1056
- }, groupId);
1060
+ }, envelope);
1061
+ }
1062
+ }
1063
+
1064
+ /**
1065
+ * Atomically append a completed VP-turn's messages to the shared
1066
+ * conversation history. Called once at turn end (not during streaming).
1067
+ */
1068
+ function appendTurnToHistory(prompt, assistantTextParts, toolCallsAccum, toolResultsAccum) {
1069
+ conversationMessages.push({ role: 'user', content: prompt });
1070
+
1071
+ const fullText = assistantTextParts.join('');
1072
+ if (fullText || toolCallsAccum.length > 0) {
1073
+ const assistantMsg = { role: 'assistant', content: fullText };
1074
+ if (toolCallsAccum.length > 0) {
1075
+ assistantMsg.toolCalls = toolCallsAccum.map(tc => ({
1076
+ id: tc.id,
1077
+ name: tc.name,
1078
+ input: tc.input,
1079
+ }));
1080
+ }
1081
+ conversationMessages.push(assistantMsg);
1082
+
1083
+ for (const tr of toolResultsAccum) {
1084
+ conversationMessages.push({
1085
+ role: 'tool',
1086
+ toolCallId: tr.toolCallId,
1087
+ content: tr.content,
1088
+ isError: tr.isError,
1089
+ });
1090
+ }
1057
1091
  }
1058
1092
  }
1059
1093
 
@@ -1072,6 +1106,11 @@ export function handleUnifyAbortThread(_msg = {}) {
1072
1106
  try { currentAbortCtrl.abort(); aborted.push('main'); } catch { /* best-effort */ }
1073
1107
  }
1074
1108
  currentAbortCtrl = null;
1109
+ // Also abort all per-VP turn controllers.
1110
+ for (const [turnId, ctrl] of turnAbortCtrls) {
1111
+ try { if (!ctrl.signal.aborted) { ctrl.abort(); aborted.push(turnId); } } catch { /* best-effort */ }
1112
+ }
1113
+ turnAbortCtrls.clear();
1075
1114
  sendUnifyEvent({ type: 'unify_aborted', aborted, all: false });
1076
1115
  return { aborted, all: false };
1077
1116
  }
@@ -1086,10 +1125,37 @@ export function handleUnifyAbortAll() {
1086
1125
  try { currentAbortCtrl.abort(); aborted.push('main'); } catch { /* best-effort */ }
1087
1126
  }
1088
1127
  currentAbortCtrl = null;
1128
+ // Also abort all per-VP turn controllers.
1129
+ for (const [turnId, ctrl] of turnAbortCtrls) {
1130
+ try { if (!ctrl.signal.aborted) { ctrl.abort(); aborted.push(turnId); } } catch { /* best-effort */ }
1131
+ }
1132
+ turnAbortCtrls.clear();
1089
1133
  sendUnifyEvent({ type: 'unify_aborted', aborted, all: true });
1090
1134
  return { aborted, all: true };
1091
1135
  }
1092
1136
 
1137
+ /**
1138
+ * Per-VP abort: stops a single VP turn by turnId without affecting siblings.
1139
+ * Frontend sends `{ type: 'unify_abort_turn', turnId }`.
1140
+ * @param {{ turnId?: string }} msg
1141
+ */
1142
+ export function handleUnifyAbortTurn(msg = {}) {
1143
+ const { turnId } = msg;
1144
+ if (!turnId) {
1145
+ sendUnifyEvent({ type: 'unify_turn_aborted', turnId: null, success: false });
1146
+ return;
1147
+ }
1148
+ const ctrl = turnAbortCtrls.get(turnId);
1149
+ if (ctrl && !ctrl.signal.aborted) {
1150
+ try { ctrl.abort(); } catch { /* best-effort */ }
1151
+ turnAbortCtrls.delete(turnId);
1152
+ sendUnifyEvent({ type: 'unify_turn_aborted', turnId, success: true });
1153
+ } else {
1154
+ turnAbortCtrls.delete(turnId);
1155
+ sendUnifyEvent({ type: 'unify_turn_aborted', turnId, success: false });
1156
+ }
1157
+ }
1158
+
1093
1159
  /**
1094
1160
  * Unified abort entry: routes by payload shape.
1095
1161
  * @param {{ threadId?: string, all?: boolean }} [opts]
@@ -1352,13 +1418,13 @@ export async function handleUnifyLoadHistory(msg) {
1352
1418
 
1353
1419
  for (const m of messages) {
1354
1420
  if (m.role === 'user') {
1355
- sendUnifyOutput({ type: 'user', message: { content: m.content } }, m.groupId || null);
1421
+ sendUnifyOutput({ type: 'user', message: { content: m.content } }, { groupId: m.groupId || null });
1356
1422
  } else if (m.role === 'assistant') {
1357
1423
  sendUnifyOutput({
1358
1424
  type: 'assistant',
1359
1425
  message: { content: [{ type: 'text', text: m.content }] },
1360
- }, m.groupId || null);
1361
- sendUnifyOutput({ type: 'result', result_text: '' }, m.groupId || null);
1426
+ }, { groupId: m.groupId || null });
1427
+ sendUnifyOutput({ type: 'result', result_text: '' }, { groupId: m.groupId || null });
1362
1428
  }
1363
1429
  }
1364
1430