@yeaft/webchat-agent 0.1.903 → 0.1.904

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.
@@ -55,6 +55,8 @@ import {
55
55
  import { persistYeaftAttachments, attachmentsForPersistence, persistedAttachmentPreviewPayload } from './attachments.js';
56
56
  import { parseSeqFromId } from './conversation/persist.js';
57
57
  import { sliceLastNTurns } from './turn-utils.js';
58
+ import { pairSanitize } from './pair-sanitize.js';
59
+ import { filterSnapshotForVp } from './snapshot-filter.js';
58
60
  import { createVpStatusBroker } from './vp-status-broker.js';
59
61
  import { classifyThread as defaultClassifyThread, fallbackTitle } from './vp/thread-classifier.js';
60
62
 
@@ -221,7 +223,7 @@ const vpCurrentTodos = new Map();
221
223
  * router: ReturnType<typeof createRouter>,
222
224
  * sessionHandle: object }>}
223
225
  */
224
- const groupContexts = new Map();
226
+ const sessionContexts = new Map();
225
227
 
226
228
  function vpKey(sessionId, vpId) {
227
229
  return `${sessionId}::${vpId}`;
@@ -356,7 +358,7 @@ async function waitForRoutePromises(msgId) {
356
358
  */
357
359
  function invalidateGroupContext(sessionId) {
358
360
  if (!sessionId) return;
359
- groupContexts.delete(sessionId);
361
+ sessionContexts.delete(sessionId);
360
362
  const prefix = `${sessionId}::`;
361
363
  for (const [k, ctrl] of vpAborts) {
362
364
  if (!k.startsWith(prefix)) continue;
@@ -458,7 +460,7 @@ let _vpUnsubscribe = null;
458
460
 
459
461
  /**
460
462
  * Per-group conversation history lives on the GroupContext entry
461
- * (`groupContexts.get(sessionId).history`). The pre-refactor module-level
463
+ * (`sessionContexts.get(sessionId).history`). The pre-refactor module-level
462
464
  * `conversationMessages` was a single array shared across every group —
463
465
  * a user prompt in group-A would leak into group-B's next-turn snapshot
464
466
  * because the bridge appended every turn to the same array regardless
@@ -466,7 +468,7 @@ let _vpUnsubscribe = null;
466
468
  * the in-memory tape was unified.
467
469
  *
468
470
  * Post-refactor: each GroupContext owns its own `history`, lazily
469
- * hydrated from `conversationStore.loadRecentByGroup(sessionId)` on first
471
+ * hydrated from `conversationStore.loadRecentBySession(sessionId)` on first
470
472
  * access. Group-A and group-B are isolated.
471
473
  *
472
474
  * @typedef {Array<{role:'user'|'assistant'|'tool', content:string|Array, toolCalls?:Array, toolCallId?:string, isError?:boolean}>} GroupHistory
@@ -482,7 +484,7 @@ let _vpUnsubscribe = null;
482
484
  * from disk (or explicitly assigned). The flag is required because an
483
485
  * empty array is legitimate post-consolidate / post-clear state and
484
486
  * MUST NOT trigger a re-hydrate. Without the flag, a partial entry
485
- * would short-circuit `getOrCreateGroupHistory` on truthy `[]` and skip
487
+ * would short-circuit `getOrCreateSessionHistory` on truthy `[]` and skip
486
488
  * the disk load.
487
489
  */
488
490
 
@@ -602,26 +604,26 @@ function loadVisibleGroupHistoryPage(store, sessionId, limit, beforeSeq = null)
602
604
 
603
605
  let rows = [];
604
606
  try {
605
- if (typeof store.loadVisibleByGroup === 'function') {
606
- const page = store.loadVisibleByGroup(sessionId, beforeSeq, limit);
607
+ if (typeof store.loadVisibleBySession === 'function') {
608
+ const page = store.loadVisibleBySession(sessionId, beforeSeq, limit);
607
609
  return {
608
610
  messages: (page.messages || []).map(projectPersistedToVisibleHistoryEntry).filter(Boolean),
609
611
  oldestSeq: (typeof page.oldestSeq === 'number') ? page.oldestSeq : null,
610
612
  hasMore: !!page.hasMore,
611
613
  };
612
- } else if (typeof store.loadOlderByGroup === 'function') {
614
+ } else if (typeof store.loadOlderBySession === 'function') {
613
615
  // Compatibility fallback for older test doubles: use an unbounded raw
614
616
  // prefix, then project/slice visible rows below.
615
- rows = store.loadOlderByGroup(sessionId, beforeSeq, Infinity).messages || [];
617
+ rows = store.loadOlderBySession(sessionId, beforeSeq, Infinity).messages || [];
616
618
  } else if (Number.isFinite(beforeSeq)) {
617
- const all = typeof store.loadAllByGroup === 'function'
618
- ? store.loadAllByGroup(sessionId)
619
- : store.loadRecentByGroup(sessionId, Infinity);
619
+ const all = typeof store.loadAllBySession === 'function'
620
+ ? store.loadAllBySession(sessionId)
621
+ : store.loadRecentBySession(sessionId, Infinity);
620
622
  rows = all.filter(m => parseSeqFromId(m?.id) < beforeSeq);
621
- } else if (typeof store.loadAllByGroup === 'function') {
622
- rows = store.loadAllByGroup(sessionId);
623
+ } else if (typeof store.loadAllBySession === 'function') {
624
+ rows = store.loadAllBySession(sessionId);
623
625
  } else {
624
- rows = store.loadRecentByGroup(sessionId, Infinity);
626
+ rows = store.loadRecentBySession(sessionId, Infinity);
625
627
  }
626
628
  } catch (err) {
627
629
  console.error('[Yeaft] visible history page load failed:', err?.message || err);
@@ -658,7 +660,7 @@ function hydrateGroupHistory(sessionId) {
658
660
  if (!session?.conversationStore || !sessionId) return [];
659
661
  let recent;
660
662
  try {
661
- recent = session.conversationStore.loadRecentByGroup(sessionId);
663
+ recent = session.conversationStore.loadRecentBySession(sessionId);
662
664
  } catch (err) {
663
665
  console.warn('[Yeaft] hydrateGroupHistory failed (sessionId=%s):', sessionId, err?.message || err);
664
666
  return [];
@@ -674,7 +676,7 @@ function hydrateGroupHistory(sessionId) {
674
676
  /**
675
677
  * Get-or-create the per-group history array. Used everywhere the bridge
676
678
  * needs to read/append/snapshot a group's conversation tape. Lazily
677
- * inserts an entry into `groupContexts` on first access — no
679
+ * inserts an entry into `sessionContexts` on first access — no
678
680
  * `sessionHandle` required (history is independent of coord/router
679
681
  * lifecycle, so a sub-agent / route_forward path that hasn't yet
680
682
  * opened the group can still read history).
@@ -687,9 +689,9 @@ function hydrateGroupHistory(sessionId) {
687
689
  * @param {string} sessionId
688
690
  * @returns {GroupHistory}
689
691
  */
690
- function getOrCreateGroupHistory(sessionId) {
692
+ function getOrCreateSessionHistory(sessionId) {
691
693
  if (!sessionId) return [];
692
- let entry = groupContexts.get(sessionId);
694
+ let entry = sessionContexts.get(sessionId);
693
695
  // Use `historyHydrated` rather than truthiness on `history` itself —
694
696
  // an empty array (post-consolidate, post-clear, or a partial entry
695
697
  // seeded by an early `getOrCreateSessionContext` call before data was
@@ -699,7 +701,7 @@ function getOrCreateGroupHistory(sessionId) {
699
701
  if (entry && entry.historyHydrated) return entry.history;
700
702
  if (!entry) {
701
703
  entry = makeGroupContextStub();
702
- groupContexts.set(sessionId, entry);
704
+ sessionContexts.set(sessionId, entry);
703
705
  }
704
706
  entry.history = hydrateGroupHistory(sessionId);
705
707
  entry.historyHydrated = true;
@@ -720,10 +722,10 @@ function getOrCreateGroupHistory(sessionId) {
720
722
  */
721
723
  function setGroupHistory(sessionId, next) {
722
724
  if (!sessionId) return;
723
- let entry = groupContexts.get(sessionId);
725
+ let entry = sessionContexts.get(sessionId);
724
726
  if (!entry) {
725
727
  entry = makeGroupContextStub();
726
- groupContexts.set(sessionId, entry);
728
+ sessionContexts.set(sessionId, entry);
727
729
  }
728
730
  entry.history = next;
729
731
  entry.historyHydrated = true;
@@ -737,7 +739,7 @@ function setGroupHistory(sessionId, next) {
737
739
  * @param {string} sessionId
738
740
  */
739
741
  export function __testGroupHistory(sessionId) {
740
- return getOrCreateGroupHistory(sessionId);
742
+ return getOrCreateSessionHistory(sessionId);
741
743
  }
742
744
 
743
745
  /**
@@ -758,12 +760,12 @@ export function __testSetSession(sessionLike) {
758
760
  /**
759
761
  * Test-only: peek at the GroupContext entry for a group (or undefined
760
762
  * if never seeded). Lets tests assert the `historyHydrated` flag without
761
- * exporting the entire `groupContexts` Map.
763
+ * exporting the entire `sessionContexts` Map.
762
764
  *
763
765
  * @param {string} sessionId
764
766
  */
765
767
  export function __testGroupContextEntry(sessionId) {
766
- return groupContexts.get(sessionId);
768
+ return sessionContexts.get(sessionId);
767
769
  }
768
770
 
769
771
  /**
@@ -887,9 +889,9 @@ function getOrCreateVpEngine(sessionId, vpId, threadId = 'main') {
887
889
  * @returns {{ coord: object, router: object, sessionHandle: object }}
888
890
  */
889
891
  function getOrCreateSessionContext(sessionId, sessionHandle) {
890
- let entry = groupContexts.get(sessionId);
892
+ let entry = sessionContexts.get(sessionId);
891
893
  if (entry && entry.coord && entry.router) return entry;
892
- // Either no entry, or a partial entry seeded by `getOrCreateGroupHistory`
894
+ // Either no entry, or a partial entry seeded by `getOrCreateSessionHistory`
893
895
  // (no coord/router yet). Build the coord/router and merge into the
894
896
  // existing record so the per-group history reference and hydration
895
897
  // flag are preserved.
@@ -899,13 +901,13 @@ function getOrCreateSessionContext(sessionId, sessionHandle) {
899
901
  const router = createRouter({ coordinator: coord });
900
902
  if (!entry) {
901
903
  entry = makeGroupContextStub();
902
- groupContexts.set(sessionId, entry);
904
+ sessionContexts.set(sessionId, entry);
903
905
  }
904
906
  entry.coord = coord;
905
907
  entry.router = router;
906
908
  entry.sessionHandle = sessionHandle;
907
909
  // Defend against a future caller that builds a coord/router without
908
- // having gone through `getOrCreateGroupHistory` first: a partial entry
910
+ // having gone through `getOrCreateSessionHistory` first: a partial entry
909
911
  // could exist with `historyHydrated:false`, so do the load now.
910
912
  if (!entry.historyHydrated) {
911
913
  entry.history = hydrateGroupHistory(sessionId);
@@ -1048,8 +1050,13 @@ function ensureDriverRunning(sessionId, vpId, threadId = 'main') {
1048
1050
  vpAborts.set(key, vpAbort);
1049
1051
  turnAbortCtrls.set(turnId, vpAbort);
1050
1052
  turnAbortMeta.set(turnId, { sessionId, vpId, threadId: thread.threadId, key });
1051
- const baseSnapshot = getOrCreateGroupHistory(sessionId)
1052
- .filter((m) => !m.threadId || m.threadId === 'main' || m.threadId === thread.threadId);
1053
+ const baseSnapshot = pairSanitize(
1054
+ filterSnapshotForVp(
1055
+ getOrCreateSessionHistory(sessionId)
1056
+ .filter((m) => !m.threadId || m.threadId === 'main' || m.threadId === thread.threadId),
1057
+ vpId,
1058
+ ),
1059
+ );
1053
1060
  const trigger = envelope?.trigger || 'fallback';
1054
1061
  const { text, prompt, promptParts } = buildVpPromptPayload(vpId, envelope);
1055
1062
 
@@ -1104,7 +1111,7 @@ function ensureDriverRunning(sessionId, vpId, threadId = 'main') {
1104
1111
  try {
1105
1112
  if (text && envelope?.msg) {
1106
1113
  sendYeaftEvent({
1107
- type: 'group_message',
1114
+ type: 'session_message',
1108
1115
  sessionId,
1109
1116
  vpId,
1110
1117
  threadId: thread.threadId,
@@ -1226,7 +1233,7 @@ export async function __testResetVpState() {
1226
1233
  vpDrivers.clear();
1227
1234
  vpEngines.clear();
1228
1235
  vpAborts.clear();
1229
- groupContexts.clear();
1236
+ sessionContexts.clear();
1230
1237
  vpCurrentTodos.clear();
1231
1238
  threadClassifier = defaultClassifyThread;
1232
1239
  // Per-group compact in-flight + pending state lives on the session's
@@ -1413,31 +1420,20 @@ export function handleYeaftVpRead(msg) {
1413
1420
  }
1414
1421
 
1415
1422
  /**
1416
- * Group CRUD wired to WS events.
1423
+ * Session CRUD wired to WS events.
1417
1424
  */
1418
- function sendGroupCrudResult(payload) {
1419
- // Wire-compat: emit BOTH legacy `group_crud_result` (old web bundles)
1420
- // and the new `session_crud_result` alias. Payload includes both
1421
- // `groupId` and `sessionId` for the same reason — see below.
1422
- const out = { ...payload };
1423
- if (out.sessionId != null && out.groupId == null) out.groupId = out.sessionId;
1424
- if (out.groupId != null && out.sessionId == null) out.sessionId = out.groupId;
1425
- sendYeaftEvent({ type: 'group_crud_result', ...out });
1426
- sendYeaftEvent({ type: 'session_crud_result', ...out });
1427
- }
1428
-
1429
- function sendGroupSnapshotBroadcast() {
1425
+ function sendSessionCrudResult(payload) {
1426
+ sendYeaftEvent({ type: 'session_crud_result', ...payload });
1427
+ }
1428
+
1429
+ function sendSessionSnapshotBroadcast() {
1430
1430
  try {
1431
1431
  const yeaftDir = ctx.CONFIG?.yeaftDir;
1432
1432
  if (!yeaftDir) return;
1433
1433
  const sessions = snapshotSessions(yeaftDir);
1434
- // Wire-compat: emit both legacy `group_list_updated` (groups field)
1435
- // and new `session_list_updated` (sessions field). Old web bundles
1436
- // listen on the former.
1437
- sendYeaftEvent({ type: 'group_list_updated', groups: sessions });
1438
1434
  sendYeaftEvent({ type: 'session_list_updated', sessions });
1439
1435
  } catch (err) {
1440
- console.warn('[Yeaft] sendGroupSnapshotBroadcast failed:', err?.message || err);
1436
+ console.warn('[Yeaft] sendSessionSnapshotBroadcast failed:', err?.message || err);
1441
1437
  }
1442
1438
  }
1443
1439
 
@@ -1451,28 +1447,26 @@ function sendGroupSnapshotBroadcast() {
1451
1447
  *
1452
1448
  * fix-yeaft-session-per-agent: previously, Agent B's sessions were
1453
1449
  * invisible in the unified sidebar until the user clicked into B's
1454
- * Yeaft view, because `sendGroupSnapshotBroadcast` only fired from
1450
+ * Yeaft view, because `sendSessionSnapshotBroadcast` only fired from
1455
1451
  * `ensureSessionLoaded`. That made the cross-agent list look broken
1456
1452
  * ("I see A but not B even though B is online") and was a major
1457
1453
  * contributor to the "session list disappears on switch" symptom.
1458
1454
  */
1459
- export { sendGroupSnapshotBroadcast as broadcastYeaftSessionSnapshotEager };
1455
+ export { sendSessionSnapshotBroadcast as broadcastYeaftSessionSnapshotEager };
1460
1456
 
1461
- function sendGroupRosterChanged(session) {
1457
+ function sendSessionRosterChanged(session) {
1462
1458
  if (!session) return;
1463
1459
  const payload = {
1464
1460
  sessionId: session.id,
1465
- groupId: session.id, // wire-compat for old web bundles
1466
1461
  name: session.name,
1467
1462
  roster: session.roster,
1468
1463
  defaultVpId: session.defaultVpId,
1469
1464
  workDir: session.workDir || '',
1470
1465
  };
1471
- sendYeaftEvent({ type: 'group_roster_changed', ...payload });
1472
1466
  sendYeaftEvent({ type: 'session_roster_changed', ...payload });
1473
1467
  }
1474
1468
 
1475
- function groupErrorPayload(err) {
1469
+ function sessionErrorPayload(err) {
1476
1470
  let code = 'unknown';
1477
1471
  if (err instanceof SessionCrudError) code = err.code;
1478
1472
  else if (err instanceof SessionConfigError) code = err.code;
@@ -1488,9 +1482,9 @@ export function handleYeaftListSessions(msg) {
1488
1482
  try {
1489
1483
  const yeaftDir = ctx.CONFIG?.yeaftDir;
1490
1484
  const groups = snapshotSessions(yeaftDir);
1491
- sendGroupCrudResult({ op: 'list', requestId, ok: true, groups });
1485
+ sendSessionCrudResult({ op: 'list', requestId, ok: true, sessions: groups });
1492
1486
  } catch (err) {
1493
- sendGroupCrudResult({ op: 'list', requestId, ok: false, error: groupErrorPayload(err) });
1487
+ sendSessionCrudResult({ op: 'list', requestId, ok: false, error: sessionErrorPayload(err) });
1494
1488
  }
1495
1489
  }
1496
1490
 
@@ -1500,10 +1494,10 @@ export function handleYeaftCreateSession(msg) {
1500
1494
  try {
1501
1495
  const yeaftDir = ctx.CONFIG?.yeaftDir;
1502
1496
  const group = createSessionFromSpec(yeaftDir, payload);
1503
- sendGroupCrudResult({ op: 'create', requestId, ok: true, group });
1504
- sendGroupSnapshotBroadcast();
1497
+ sendSessionCrudResult({ op: 'create', requestId, ok: true, session: group });
1498
+ sendSessionSnapshotBroadcast();
1505
1499
  } catch (err) {
1506
- sendGroupCrudResult({ op: 'create', requestId, ok: false, error: groupErrorPayload(err) });
1500
+ sendSessionCrudResult({ op: 'create', requestId, ok: false, error: sessionErrorPayload(err) });
1507
1501
  }
1508
1502
  }
1509
1503
 
@@ -1515,10 +1509,10 @@ export function handleYeaftRenameSession(msg) {
1515
1509
  const yeaftDir = ctx.CONFIG?.yeaftDir;
1516
1510
  const group = renameSession(yeaftDir, sessionId, name);
1517
1511
  invalidateGroupContext(sessionId);
1518
- sendGroupCrudResult({ op: 'rename', requestId, ok: true, group });
1519
- sendGroupSnapshotBroadcast();
1512
+ sendSessionCrudResult({ op: 'rename', requestId, ok: true, session: group });
1513
+ sendSessionSnapshotBroadcast();
1520
1514
  } catch (err) {
1521
- sendGroupCrudResult({ op: 'rename', requestId, ok: false, error: groupErrorPayload(err) });
1515
+ sendSessionCrudResult({ op: 'rename', requestId, ok: false, error: sessionErrorPayload(err) });
1522
1516
  }
1523
1517
  }
1524
1518
 
@@ -1555,10 +1549,10 @@ export function handleYeaftUpdateSession(msg) {
1555
1549
  group = updateSessionAnnouncement(yeaftDir, sessionId, patch.announcement);
1556
1550
  }
1557
1551
  invalidateGroupContext(sessionId);
1558
- sendGroupCrudResult({ op: 'update', requestId, ok: true, group });
1559
- sendGroupSnapshotBroadcast();
1552
+ sendSessionCrudResult({ op: 'update', requestId, ok: true, session: group });
1553
+ sendSessionSnapshotBroadcast();
1560
1554
  } catch (err) {
1561
- sendGroupCrudResult({ op: 'update', requestId, ok: false, error: groupErrorPayload(err) });
1555
+ sendSessionCrudResult({ op: 'update', requestId, ok: false, error: sessionErrorPayload(err) });
1562
1556
  }
1563
1557
  }
1564
1558
 
@@ -1585,10 +1579,10 @@ export function handleYeaftUpdateSessionConfig(msg) {
1585
1579
  if (k.startsWith(prefix)) vpEngines.delete(k);
1586
1580
  }
1587
1581
  invalidateGroupContext(sessionId);
1588
- sendGroupCrudResult({ op: 'update_config', requestId, ok: true, sessionId, config: savedConfig });
1589
- sendGroupSnapshotBroadcast();
1582
+ sendSessionCrudResult({ op: 'update_config', requestId, ok: true, sessionId, config: savedConfig });
1583
+ sendSessionSnapshotBroadcast();
1590
1584
  } catch (err) {
1591
- sendGroupCrudResult({ op: 'update_config', requestId, ok: false, error: groupErrorPayload(err) });
1585
+ sendSessionCrudResult({ op: 'update_config', requestId, ok: false, error: sessionErrorPayload(err) });
1592
1586
  }
1593
1587
  }
1594
1588
 
@@ -1599,10 +1593,10 @@ export function handleYeaftArchiveSession(msg) {
1599
1593
  const yeaftDir = ctx.CONFIG?.yeaftDir;
1600
1594
  const result = archiveSession(yeaftDir, sessionId);
1601
1595
  invalidateGroupContext(sessionId);
1602
- sendGroupCrudResult({ op: 'archive', requestId, ok: true, sessionId: result.sessionId });
1603
- sendGroupSnapshotBroadcast();
1596
+ sendSessionCrudResult({ op: 'archive', requestId, ok: true, sessionId: result.sessionId });
1597
+ sendSessionSnapshotBroadcast();
1604
1598
  } catch (err) {
1605
- sendGroupCrudResult({ op: 'archive', requestId, ok: false, error: groupErrorPayload(err) });
1599
+ sendSessionCrudResult({ op: 'archive', requestId, ok: false, error: sessionErrorPayload(err) });
1606
1600
  }
1607
1601
  }
1608
1602
 
@@ -1633,16 +1627,16 @@ export function handleYeaftDeleteSession(msg) {
1633
1627
  for (const k of Array.from(vpEngines.keys())) {
1634
1628
  if (k.startsWith(prefix)) vpEngines.delete(k);
1635
1629
  }
1636
- sendGroupCrudResult({
1630
+ sendSessionCrudResult({
1637
1631
  op: 'delete',
1638
1632
  requestId,
1639
1633
  ok: true,
1640
1634
  sessionId: result.sessionId,
1641
1635
  messagesRemoved,
1642
1636
  });
1643
- sendGroupSnapshotBroadcast();
1637
+ sendSessionSnapshotBroadcast();
1644
1638
  } catch (err) {
1645
- sendGroupCrudResult({ op: 'delete', requestId, ok: false, error: groupErrorPayload(err) });
1639
+ sendSessionCrudResult({ op: 'delete', requestId, ok: false, error: sessionErrorPayload(err) });
1646
1640
  }
1647
1641
  }
1648
1642
 
@@ -1654,10 +1648,10 @@ export function handleYeaftSessionAddMember(msg) {
1654
1648
  const yeaftDir = ctx.CONFIG?.yeaftDir;
1655
1649
  const group = addMember(yeaftDir, sessionId, vpId);
1656
1650
  invalidateGroupContext(sessionId);
1657
- sendGroupCrudResult({ op: 'add_member', requestId, ok: true, group });
1658
- sendGroupRosterChanged(group);
1651
+ sendSessionCrudResult({ op: 'add_member', requestId, ok: true, session: group });
1652
+ sendSessionRosterChanged(group);
1659
1653
  } catch (err) {
1660
- sendGroupCrudResult({ op: 'add_member', requestId, ok: false, error: groupErrorPayload(err) });
1654
+ sendSessionCrudResult({ op: 'add_member', requestId, ok: false, error: sessionErrorPayload(err) });
1661
1655
  }
1662
1656
  }
1663
1657
 
@@ -1675,10 +1669,10 @@ export function handleYeaftSessionRemoveMember(msg) {
1675
1669
  for (const key of Array.from(vpEngines.keys())) {
1676
1670
  if (key.startsWith(removedPrefix)) vpEngines.delete(key);
1677
1671
  }
1678
- sendGroupCrudResult({ op: 'remove_member', requestId, ok: true, group });
1679
- sendGroupRosterChanged(group);
1672
+ sendSessionCrudResult({ op: 'remove_member', requestId, ok: true, session: group });
1673
+ sendSessionRosterChanged(group);
1680
1674
  } catch (err) {
1681
- sendGroupCrudResult({ op: 'remove_member', requestId, ok: false, error: groupErrorPayload(err) });
1675
+ sendSessionCrudResult({ op: 'remove_member', requestId, ok: false, error: sessionErrorPayload(err) });
1682
1676
  }
1683
1677
  }
1684
1678
 
@@ -1690,10 +1684,10 @@ export function handleYeaftSessionSetDefaultVp(msg) {
1690
1684
  const yeaftDir = ctx.CONFIG?.yeaftDir;
1691
1685
  const group = setSessionDefaultVp(yeaftDir, sessionId, vpId);
1692
1686
  invalidateGroupContext(sessionId);
1693
- sendGroupCrudResult({ op: 'set_default_vp', requestId, ok: true, group });
1694
- sendGroupRosterChanged(group);
1687
+ sendSessionCrudResult({ op: 'set_default_vp', requestId, ok: true, session: group });
1688
+ sendSessionRosterChanged(group);
1695
1689
  } catch (err) {
1696
- sendGroupCrudResult({ op: 'set_default_vp', requestId, ok: false, error: groupErrorPayload(err) });
1690
+ sendSessionCrudResult({ op: 'set_default_vp', requestId, ok: false, error: sessionErrorPayload(err) });
1697
1691
  }
1698
1692
  }
1699
1693
 
@@ -2170,20 +2164,21 @@ function handleEngineEvent(event, hctx) {
2170
2164
  }
2171
2165
 
2172
2166
  /**
2173
- * Handle a yeaft_group_chat message from the web UI — the SOLE Yeaft
2167
+ * Handle a yeaft_session_chat message from the web UI — the SOLE Yeaft
2174
2168
  * conversation entry point.
2175
2169
  *
2176
2170
  * Contract (post-consolidation, was previously split between handleYeaftChat
2177
2171
  * and handleYeaftSessionSend):
2178
- * - Frontend ALWAYS sends `yeaft_group_chat`. There is no `yeaft_chat`.
2172
+ * - Frontend ALWAYS sends `yeaft_session_chat`. There is no `yeaft_chat`.
2179
2173
  * - `sessionId` defaults to `'grp_default'` if missing — Yeaft is a single
2180
- * conversation backed by the default group; the user is never "outside"
2181
- * a group.
2182
- * - If the group dir doesn't exist and the resolved id is `'grp_default'`,
2183
- * it is seeded on the fly. Any other unknown sessionId surfaces an error.
2174
+ * conversation backed by the default session; the user is never "outside"
2175
+ * a session.
2176
+ * - Sessions are created up-front via `handleYeaftCreateSession`; this
2177
+ * handler does NOT seed missing sessions on the fly. An unknown
2178
+ * sessionId surfaces a clear "session not found" error to the UI.
2184
2179
  * - Coordinator is MANDATORY (this is what guarantees ctx.router is wired
2185
2180
  * so the `route_forward` tool can never trip `router_unavailable`).
2186
- * - No legacy "no-group" fallback paths — they were the source of the
2181
+ * - No legacy "no-session" fallback paths — they were the source of the
2187
2182
  * router_unavailable bug fixed in v0.1.671.
2188
2183
  */
2189
2184
  export async function handleYeaftSessionSend(msg) {
@@ -2228,12 +2223,11 @@ export async function handleYeaftSessionSend(msg) {
2228
2223
 
2229
2224
  await ensureSessionLoaded();
2230
2225
 
2231
- // Open the group; seed grp_default on the fly if absent. Track
2232
- // seedFailed separately so a seed crash surfaces a different message
2233
- // than a genuinely-missing group.
2226
+ // Open the session. The default `grp_default` no longer self-seeds
2227
+ // here session creation happens up-front via `handleYeaftCreateSession`,
2228
+ // so a missing dir surfaces a clear error rather than masking it.
2234
2229
  let sessionHandle = null;
2235
2230
  let sessionRoot = null;
2236
- let seedFailed = false;
2237
2231
  try {
2238
2232
  const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
2239
2233
  sessionRoot = sessionsRoot(groupYeaftDir);
@@ -2247,19 +2241,16 @@ export async function handleYeaftSessionSend(msg) {
2247
2241
  // default-group row across agents. Now we surface the not-found
2248
2242
  // case so the web can show an "agent offline / session missing"
2249
2243
  // hint instead of silently creating a different session.
2250
- console.warn('[Yeaft] yeaft_group_chat: sessionId %s not found', sessionId);
2244
+ console.warn('[Yeaft] yeaft_session_chat: sessionId %s not found', sessionId);
2251
2245
  }
2252
2246
  } catch (err) {
2253
- console.warn('[Yeaft] yeaft_group_chat: group open failed', err?.message || err);
2247
+ console.warn('[Yeaft] yeaft_session_chat: session open failed', err?.message || err);
2254
2248
  }
2255
2249
 
2256
2250
  if (!sessionHandle) {
2257
- const errText = seedFailed
2258
- ? `⚠️ Failed to seed default group ${sessionId} — check group .yeaft permissions.`
2259
- : `⚠️ Group ${sessionId} not found.`;
2260
2251
  sendYeaftOutput({
2261
2252
  type: 'assistant',
2262
- message: { content: [{ type: 'text', text: errText }] },
2253
+ message: { content: [{ type: 'text', text: `⚠️ Session ${sessionId} not found.` }] },
2263
2254
  }, { sessionId });
2264
2255
  sendYeaftOutput({ type: 'result', result_text: '' }, { sessionId });
2265
2256
  return;
@@ -2284,7 +2275,7 @@ export async function handleYeaftSessionSend(msg) {
2284
2275
  if (rosterMutated) {
2285
2276
  try { sessionHandle.close && sessionHandle.close(); } catch { /* best-effort */ }
2286
2277
  sessionHandle = openSession(sessionRoot, sessionId);
2287
- sendGroupRosterChanged(sessionHandle.getMeta());
2278
+ sendSessionRosterChanged(sessionHandle.getMeta());
2288
2279
  }
2289
2280
  }
2290
2281
  const meta2 = sessionHandle.getMeta();
@@ -2293,12 +2284,12 @@ export async function handleYeaftSessionSend(msg) {
2293
2284
  setSessionDefaultVp(yeaftDir, sessionId, meta2.roster[0]);
2294
2285
  try { sessionHandle.close && sessionHandle.close(); } catch { /* best-effort */ }
2295
2286
  sessionHandle = openSession(sessionRoot, sessionId);
2296
- sendGroupRosterChanged(sessionHandle.getMeta());
2287
+ sendSessionRosterChanged(sessionHandle.getMeta());
2297
2288
  rosterMutated = true;
2298
2289
  } catch { /* best-effort */ }
2299
2290
  }
2300
2291
  } catch (err) {
2301
- console.warn('[Yeaft] yeaft_group_chat: auto-roster heal failed', err?.message || err);
2292
+ console.warn('[Yeaft] yeaft_session_chat: auto-roster heal failed', err?.message || err);
2302
2293
  }
2303
2294
 
2304
2295
  // task-707: per-group persistent coordinator/router. Created once per
@@ -2307,7 +2298,7 @@ export async function handleYeaftSessionSend(msg) {
2307
2298
  // we replace the cached coord so it points at the freshly-opened
2308
2299
  // sessionHandle.
2309
2300
  if (rosterMutated) {
2310
- groupContexts.delete(sessionId);
2301
+ sessionContexts.delete(sessionId);
2311
2302
  }
2312
2303
  const sessionCtx = getOrCreateSessionContext(sessionId, sessionHandle);
2313
2304
  const coord = sessionCtx.coord;
@@ -2331,7 +2322,7 @@ export async function handleYeaftSessionSend(msg) {
2331
2322
  try {
2332
2323
  attachmentBundle = persistYeaftAttachments(inboundFiles, { subdir: sessionId });
2333
2324
  } catch (err) {
2334
- console.warn('[Yeaft] yeaft_group_chat: attachment persist failed', err?.message || err);
2325
+ console.warn('[Yeaft] yeaft_session_chat: attachment persist failed', err?.message || err);
2335
2326
  }
2336
2327
  }
2337
2328
  // Surface partial / total upload failures to the user. We don't abort
@@ -2370,10 +2361,10 @@ export async function handleYeaftSessionSend(msg) {
2370
2361
  _promptSuffix: attachmentBundle.promptSuffix,
2371
2362
  });
2372
2363
  } catch (err) {
2373
- console.warn('[Yeaft] yeaft_group_chat: coord.ingest failed', err?.message || err);
2364
+ console.warn('[Yeaft] yeaft_session_chat: coord.ingest failed', err?.message || err);
2374
2365
  sendYeaftOutput({
2375
2366
  type: 'assistant',
2376
- message: { content: [{ type: 'text', text: `⚠️ Group dispatch error: ${err?.message || err}` }] },
2367
+ message: { content: [{ type: 'text', text: `⚠️ Session dispatch error: ${err?.message || err}` }] },
2377
2368
  }, { sessionId });
2378
2369
  sendYeaftOutput({ type: 'result', result_text: '' }, { sessionId });
2379
2370
  return;
@@ -2574,7 +2565,7 @@ async function ensureSessionLoaded() {
2574
2565
 
2575
2566
  yeaftConversationId = `yeaft-${Date.now()}`;
2576
2567
 
2577
- // Per-group history is hydrated lazily on first `getOrCreateGroupHistory`
2568
+ // Per-group history is hydrated lazily on first `getOrCreateSessionHistory`
2578
2569
  // — there's no global "all conversations" tape any more.
2579
2570
 
2580
2571
  sendYeaftEvent({
@@ -2587,7 +2578,7 @@ async function ensureSessionLoaded() {
2587
2578
  tools: session.status.tools,
2588
2579
  yeaftDir: ctx.CONFIG?.yeaftDir || null,
2589
2580
  });
2590
- sendGroupSnapshotBroadcast();
2581
+ sendSessionSnapshotBroadcast();
2591
2582
  // vp-status: rebuild frontend status table from authoritative agent
2592
2583
  // memory. Sent unconditionally so reconnect/refresh paths get the same
2593
2584
  // bootstrap as first-load (the broker dedup logic makes a redundant
@@ -2713,7 +2704,7 @@ async function raceWithEscalation(inner, { deadlineMs, onEscalate }) {
2713
2704
  * appended to `conversationMessages`.
2714
2705
  *
2715
2706
  * task-707: takes a coordinator `envelope` rather than the coordinator
2716
- * itself; the persistent coord lives in `groupContexts[sessionId]`. Uses
2707
+ * itself; the persistent coord lives in `sessionContexts[sessionId]`. Uses
2717
2708
  * `getOrCreateVpEngine(sessionId, vpId)` so each VP runs against its own
2718
2709
  * Engine instance — private state (`#currentAbortCtrl`, `#__queryCounter`,
2719
2710
  * `#pendingT2`, `#abortReason`, `#adjustRanByGroup`, `#execLog`) does not
@@ -2794,10 +2785,10 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
2794
2785
 
2795
2786
  // task-707: per-VP engine + persistent group coord. The coord is
2796
2787
  // created in handleYeaftSessionSend via getOrCreateSessionContext and
2797
- // cached on `groupContexts`; we pull it here so route_forward
2788
+ // cached on `sessionContexts`; we pull it here so route_forward
2798
2789
  // (router built from this same coord) lands envelopes back on the
2799
2790
  // right inbox set.
2800
- const sessionCtx = groupContexts.get(sessionId);
2791
+ const sessionCtx = sessionContexts.get(sessionId);
2801
2792
  const sessionCoordinator = sessionCtx?.coord || null;
2802
2793
  const queryOpts = buildVpQueryOpts({
2803
2794
  vpId,
@@ -2856,7 +2847,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
2856
2847
  }
2857
2848
 
2858
2849
  // Turn completed — atomically append this VP's output to shared history.
2859
- appendTurnToGroupHistory(sessionId, threadId, [prompt, ...appendedUserPrompts], assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum);
2850
+ appendTurnToSessionHistory(sessionId, threadId, vpId, [prompt, ...appendedUserPrompts], assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum);
2860
2851
 
2861
2852
  sendYeaftOutput({
2862
2853
  type: 'assistant',
@@ -2974,23 +2965,31 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
2974
2965
  * exactly once, with the same threadId as the running thread. The
2975
2966
  * engine's own `conversationMessages` (with T1/T2 collapse applied)
2976
2967
  * is persisted to disk via stop-hooks, so the next turn's history is
2977
- * read from disk via `loadRecentByGroup` on next session boot. Within
2968
+ * read from disk via `loadRecentBySession` on next session boot. Within
2978
2969
  * a session, this in-memory tape carries the un-collapsed form — which
2979
2970
  * is fine because each VP turn's `engine.query` re-collapses on the fly.
2980
2971
  */
2981
- function appendTurnToGroupHistory(sessionId, threadId, prompts, assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum) {
2972
+ function appendTurnToSessionHistory(sessionId, threadId, vpId, prompts, assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum) {
2982
2973
  if (!sessionId) return;
2983
- const history = getOrCreateGroupHistory(sessionId);
2974
+ const history = getOrCreateSessionHistory(sessionId);
2984
2975
  const promptList = Array.isArray(prompts) ? prompts : [prompts];
2985
2976
  for (const prompt of promptList) {
2986
2977
  if (typeof prompt === 'string' && prompt.trim()) {
2978
+ // user rows intentionally carry NO speakerVpId — every VP in the
2979
+ // session should see the prompt in their history.
2987
2980
  history.push({ role: 'user', content: prompt, threadId: threadId || 'main' });
2988
2981
  }
2989
2982
  }
2990
2983
 
2991
2984
  const fullText = assistantTextParts.join('');
2992
2985
  if (fullText || toolCallsAccum.length > 0) {
2986
+ // Stamp speakerVpId on assistant + tool rows so the in-memory
2987
+ // baseSnapshot filter (filterSnapshotForVp) can mirror the disk
2988
+ // replay's per-VP isolation rules. Without this stamp, the next
2989
+ // VP turn would inherit the previous VP's tool_use ids without
2990
+ // matching tool_result rows → Anthropic API 422.
2993
2991
  const assistantMsg = { role: 'assistant', content: fullText, threadId: threadId || 'main' };
2992
+ if (vpId) assistantMsg.speakerVpId = vpId;
2994
2993
  if (toolCallsAccum.length > 0) {
2995
2994
  assistantMsg.toolCalls = toolCallsAccum.map(tc => ({
2996
2995
  id: tc.id,
@@ -3002,7 +3001,9 @@ function appendTurnToGroupHistory(sessionId, threadId, prompts, assistantTextPar
3002
3001
  // requires us to echo them back on the next request or the API
3003
3002
  // returns "content[].thinking in the thinking mode must be passed
3004
3003
  // back to the API". The signature is server-private — it stays in
3005
- // this in-memory history and in agent-side persistence only.
3004
+ // this in-memory history and in agent-side persistence only. The
3005
+ // signature is also VP-private; filterSnapshotForVp drops it from
3006
+ // OTHER VPs' rows before each turn's payload is built.
3006
3007
  if (Array.isArray(thinkingBlocksAccum) && thinkingBlocksAccum.length > 0) {
3007
3008
  assistantMsg.thinkingBlocks = thinkingBlocksAccum.map(tb => (
3008
3009
  tb.redacted
@@ -3013,13 +3014,15 @@ function appendTurnToGroupHistory(sessionId, threadId, prompts, assistantTextPar
3013
3014
  history.push(assistantMsg);
3014
3015
 
3015
3016
  for (const tr of toolResultsAccum) {
3016
- history.push({
3017
+ const toolMsg = {
3017
3018
  role: 'tool',
3018
3019
  toolCallId: tr.toolCallId,
3019
3020
  content: tr.content,
3020
3021
  isError: tr.isError,
3021
3022
  threadId: threadId || 'main',
3022
- });
3023
+ };
3024
+ if (vpId) toolMsg.speakerVpId = vpId;
3025
+ history.push(toolMsg);
3023
3026
  }
3024
3027
  }
3025
3028
  }
@@ -3303,6 +3306,16 @@ export function __testGetRegisteredThreadIds() {
3303
3306
  */
3304
3307
  export const __testRaceWithEscalation = raceWithEscalation;
3305
3308
 
3309
+ /**
3310
+ * Test-only: invoke `appendTurnToSessionHistory` directly. Lets the VP
3311
+ * stamp contract (`speakerVpId` on assistant + tool rows, none on user
3312
+ * rows) be pinned with a table-driven test instead of booting a full
3313
+ * session. See `test/agent/yeaft/web-bridge-append-turn-vp-stamp.test.js`.
3314
+ */
3315
+ export function __testAppendTurnToSessionHistory(...args) {
3316
+ return appendTurnToSessionHistory(...args);
3317
+ }
3318
+
3306
3319
  /**
3307
3320
  * Manual dream trigger.
3308
3321
  *
@@ -3628,11 +3641,11 @@ export function handleYeaftModelSwitch(msg) {
3628
3641
  export async function handleYeaftLoadHistory(msg) {
3629
3642
  const sessionId = (msg && typeof msg.sessionId === 'string' && msg.sessionId) || null;
3630
3643
  // `lim` is now expressed in TURNS, not raw messages. `loadRecent` and
3631
- // `loadRecentByGroup` use turn-based slicing so the cut never lands
3644
+ // `loadRecentBySession` use turn-based slicing so the cut never lands
3632
3645
  // mid-tool-arc. Pass `undefined` to use the persistence-layer default
3633
3646
  // (DEFAULT_RECENT_TURNS = 20 turns).
3634
3647
  const pickRecent = (store, lim) =>
3635
- sessionId ? store.loadRecentByGroup(sessionId, lim) : store.loadRecent(lim);
3648
+ sessionId ? store.loadRecentBySession(sessionId, lim) : store.loadRecent(lim);
3636
3649
 
3637
3650
  if (!session) {
3638
3651
  const yeaftDir = ctx.CONFIG?.yeaftDir;
@@ -3646,7 +3659,7 @@ export async function handleYeaftLoadHistory(msg) {
3646
3659
 
3647
3660
  yeaftConversationId = `yeaft-${Date.now()}`;
3648
3661
 
3649
- // Per-group history hydrates lazily via getOrCreateGroupHistory.
3662
+ // Per-group history hydrates lazily via getOrCreateSessionHistory.
3650
3663
  // When the load-history call carries a sessionId, force-refresh THAT
3651
3664
  // group's tape so the next user message sees on-disk state. When
3652
3665
  // it doesn't (legacy callers), do nothing — the per-group lazy
@@ -3670,7 +3683,7 @@ export async function handleYeaftLoadHistory(msg) {
3670
3683
  tools: session.status.tools,
3671
3684
  yeaftDir: ctx.CONFIG?.yeaftDir || null,
3672
3685
  });
3673
- sendGroupSnapshotBroadcast();
3686
+ sendSessionSnapshotBroadcast();
3674
3687
  // vp-status: replay the authoritative table on reconnect so a refreshed
3675
3688
  // frontend doesn't have to wait for the next transition to learn each
3676
3689
  // VP's current state.
@@ -3795,8 +3808,8 @@ export async function handleYeaftLoadHistory(msg) {
3795
3808
  // replay, only scoped per-(group, vp) summaries count; legacy compact.md is
3796
3809
  // reserved for non-group / pre-scoped 1:1 callers.
3797
3810
  let hasCompactSummaryFlag = !!compactSummary;
3798
- if (sessionId && typeof session.conversationStore.hasAnyCompactSummaryForGroup === 'function') {
3799
- hasCompactSummaryFlag = session.conversationStore.hasAnyCompactSummaryForGroup(sessionId);
3811
+ if (sessionId && typeof session.conversationStore.hasAnyCompactSummaryForSession === 'function') {
3812
+ hasCompactSummaryFlag = session.conversationStore.hasAnyCompactSummaryForSession(sessionId);
3800
3813
  }
3801
3814
 
3802
3815
  // Latest seq cursor in the recent-mode reply lets the frontend stamp its
@@ -3856,7 +3869,7 @@ export async function handleYeaftLoadMoreHistory(msg) {
3856
3869
  try {
3857
3870
  result = loadVisibleGroupHistoryPage(session.conversationStore, sessionId, turns, beforeSeq);
3858
3871
  } catch (err) {
3859
- console.error('[Yeaft] loadOlderByGroup failed:', err.message);
3872
+ console.error('[Yeaft] loadOlderBySession failed:', err.message);
3860
3873
  result = { messages: [], oldestSeq: null, hasMore: false };
3861
3874
  }
3862
3875
 
@@ -3902,7 +3915,7 @@ export async function resetYeaftSession() {
3902
3915
  session = null;
3903
3916
  }
3904
3917
  yeaftConversationId = null;
3905
- // Per-group histories live on groupContexts entries — clearing the
3918
+ // Per-group histories live on sessionContexts entries — clearing the
3906
3919
  // map (a few lines below) drops every group's history with it. No
3907
3920
  // separate global tape to clear.
3908
3921
  // Re-arm the permission warning. The user might have fixed the
@@ -3922,7 +3935,7 @@ export async function resetYeaftSession() {
3922
3935
  vpInboxes.clear();
3923
3936
  vpDrivers.clear();
3924
3937
  vpEngines.clear();
3925
- groupContexts.clear();
3938
+ sessionContexts.clear();
3926
3939
  vpCurrentTodos.clear();
3927
3940
  threadClassifier = defaultClassifyThread;
3928
3941
  // History-dedup cache is keyed by per-session coordinator msg ids;
@@ -3951,7 +3964,7 @@ export async function resetYeaftSession() {
3951
3964
 
3952
3965
  yeaftConversationId = `yeaft-${Date.now()}`;
3953
3966
 
3954
- // Per-group history hydrates lazily via getOrCreateGroupHistory on
3967
+ // Per-group history hydrates lazily via getOrCreateSessionHistory on
3955
3968
  // first read. Nothing to seed here.
3956
3969
 
3957
3970
  sendYeaftEvent({