@yeaft/webchat-agent 0.1.734 → 0.1.738

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.
@@ -50,9 +50,8 @@ import {
50
50
  compactHistory,
51
51
  trimSnapshotForBudget,
52
52
  } from './history-compact.js';
53
- import { createFeatureArc } from './feature-arc.js';
54
- import { getFeatureStore } from './tools/feature-tools.js';
55
53
  import { persistUnifyAttachments, attachmentsForPersistence } from './attachments.js';
54
+ import { parseSeqFromId } from './conversation/persist.js';
56
55
 
57
56
  /** @type {import('./session.js').Session | null} */
58
57
  let session = null;
@@ -193,6 +192,42 @@ export function broadcastLanguageChange(language) {
193
192
  /** Query timeout in ms — abort if LLM doesn't respond within this window */
194
193
  const QUERY_TIMEOUT_MS = 120_000;
195
194
 
195
+ /**
196
+ * Secondary watchdog grace period (ms).
197
+ *
198
+ * After {@link QUERY_TIMEOUT_MS} of silence the per-VP `vpAbort` is fired.
199
+ * That's enough on its own when adapters / tools cooperate with the
200
+ * AbortSignal — the engine throws `AbortError`, runVpTurn's catch emits
201
+ * `result{stopped:true}`, the driver `finally` emits `vp_typing_end`, and
202
+ * the user is unstuck.
203
+ *
204
+ * If a tool ignores `signal` and never resolves, the engine generator's
205
+ * `await tool.execute(...)` is permanently blocked: the abort fires on a
206
+ * controller it never observes, and runVpTurn never returns. The same
207
+ * applies to an adapter `stream()` that ignores `signal` (e.g. a stuck
208
+ * SSE connection) or to tools that legitimately opt out of the per-tool
209
+ * timeout via `timeoutMs <= 0`. The per-tool timeout in
210
+ * {@link import('./tools/registry.js').DEFAULT_TOOL_TIMEOUT_MS}
211
+ * is the primary cure for the tool-ignore-signal case; this bridge-level
212
+ * escalation strictly extends it to cover the adapter and opt-out cases.
213
+ * Without a second-stage escalation the typing dots hang forever —
214
+ * exactly the "halts mid-execution with no turn_end" symptom.
215
+ *
216
+ * The driver loop wraps `await runVpTurn(...)` in a Promise.race against
217
+ * this grace-window timer. If runVpTurn doesn't return within
218
+ * QUERY_TIMEOUT_MS + ESCALATE_AFTER_ABORT_MS, the driver forces its
219
+ * `finally` block (vp_typing_end + group_message), emits a synthetic
220
+ * `result{stopped:true}` so the frontend leaves its in-flight state,
221
+ * and moves on. The hung tool promise leaks (JS lacks cooperative
222
+ * promise cancellation) but the user-facing turn is closed.
223
+ *
224
+ * 15s is wide enough that legitimate "abort took a moment to propagate"
225
+ * paths (network teardown, finally cleanup) finish first; tight enough
226
+ * that a truly stuck tool doesn't stretch the user-visible stall to
227
+ * minutes.
228
+ */
229
+ const ESCALATE_AFTER_ABORT_MS = 15_000;
230
+
196
231
  /** Virtual conversationId for the Unify session */
197
232
  let unifyConversationId = null;
198
233
 
@@ -423,7 +458,7 @@ function ensureDriverRunning(groupId, vpId) {
423
458
  } catch { /* never crash WS pipeline */ }
424
459
 
425
460
  try {
426
- await runVpTurn({
461
+ await runVpTurnWithEscalation({
427
462
  prompt,
428
463
  promptParts,
429
464
  groupId,
@@ -909,15 +944,18 @@ export function installUnifyRuntimeBridge(s) {
909
944
  */
910
945
  function handleEngineEvent(event, hctx) {
911
946
  hctx.resetQueryTimer();
912
- // featureId may have just been published mid-turn by the FeatureArc
913
- // (the arc's observeEvent runs before this dispatch); pull it fresh
914
- // so the wire envelope tags every subsequent emit with the right id.
915
- const featureId = typeof hctx.getFeatureId === 'function' ? hctx.getFeatureId() : null;
947
+ // Sub-agent events may carry their own `featureId` (stamped by the
948
+ // sub-agent runner from the parent's inbound feature scope). Plain
949
+ // VP-turn events have no featureId auto-feature creation was
950
+ // removed when Track-A / FeatureArc was deleted (2026-05-08).
951
+ const eventFeatureId = typeof event === 'object' && event && typeof event.featureId === 'string'
952
+ ? event.featureId
953
+ : null;
916
954
  const envelope = {
917
955
  groupId: hctx.groupId,
918
956
  vpId: hctx.vpId,
919
957
  turnId: hctx.turnId,
920
- ...(featureId ? { featureId } : {}),
958
+ ...(eventFeatureId ? { featureId: eventFeatureId } : {}),
921
959
  };
922
960
 
923
961
  switch (event.type) {
@@ -1671,6 +1709,94 @@ async function ensureSessionLoaded() {
1671
1709
  sendGroupSnapshotBroadcast();
1672
1710
  }
1673
1711
 
1712
+ /**
1713
+ * Wrap {@link runVpTurn} with a hard escalation deadline.
1714
+ *
1715
+ * The first-line defense is the in-turn watchdog inside runVpTurn: at
1716
+ * {@link QUERY_TIMEOUT_MS} of silence it calls `vpAbort.abort()`. When
1717
+ * adapters and tools cooperate with AbortSignal that's enough — the
1718
+ * engine throws AbortError, the catch handler emits `result{stopped:true}`,
1719
+ * and the driver's `finally` emits `vp_typing_end`.
1720
+ *
1721
+ * This wrapper is the second-line defense for the "tool ignores signal"
1722
+ * failure mode. If runVpTurn doesn't return within
1723
+ * QUERY_TIMEOUT_MS + ESCALATE_AFTER_ABORT_MS we synthesize a clean exit:
1724
+ * emit a synthetic `result{stopped:true}` so the frontend leaves its
1725
+ * in-flight state, log loudly so operators know a tool is stuck, and
1726
+ * resolve. The hung promise leaks (the engine generator is permanently
1727
+ * blocked on a tool that ignores cancellation) but the user-facing turn
1728
+ * is closed and the next message can flow. Resolving the wrapper is
1729
+ * preferred over rejecting because the driver's catch already logs a
1730
+ * warning — we want a single, unambiguous "watchdog escalated" line in
1731
+ * the log instead of layered noise.
1732
+ *
1733
+ * Tool-level timeouts (see registry.js DEFAULT_TOOL_TIMEOUT_MS) are the
1734
+ * real cure: this wrapper should rarely fire because no tool should be
1735
+ * able to block longer than its budget. It exists as belt-and-suspenders
1736
+ * for tools that legitimately disable timeouts (long-running internal
1737
+ * helpers) or for adapter implementations that ignore signal.
1738
+ */
1739
+ async function runVpTurnWithEscalation(args) {
1740
+ const { groupId, vpId, turnId } = args;
1741
+ const deadlineMs = QUERY_TIMEOUT_MS + ESCALATE_AFTER_ABORT_MS;
1742
+ await raceWithEscalation(runVpTurn(args), {
1743
+ deadlineMs,
1744
+ onEscalate: () => {
1745
+ console.error(
1746
+ `[Unify] runVpTurn watchdog escalation: VP ${vpId} did not return ${deadlineMs}ms after enqueue — emitting synthetic stop and unblocking driver`,
1747
+ );
1748
+ try {
1749
+ sendUnifyOutput(
1750
+ { type: 'result', result_text: '', stopped: true },
1751
+ { groupId, vpId, turnId },
1752
+ );
1753
+ } catch { /* never crash WS pipeline */ }
1754
+ },
1755
+ });
1756
+ }
1757
+
1758
+ /**
1759
+ * Race `inner` against a deadline timer. If `inner` resolves/rejects first,
1760
+ * the timer is cleared and the result of `inner` is returned. If the timer
1761
+ * wins, `onEscalate` is called and the wrapper resolves cleanly — the inner
1762
+ * promise is left dangling (JS has no promise cancellation) but the caller
1763
+ * is unblocked.
1764
+ *
1765
+ * `onEscalate` MUST be synchronous. We swallow synchronous throws so a
1766
+ * torn-down WS pipeline can't crash the watchdog, but a Promise rejection
1767
+ * from an async `onEscalate` would leak past this `catch`.
1768
+ *
1769
+ * Pure helper, no module-level state, exported as `__testRaceWithEscalation`
1770
+ * so the contract can be unit-tested in isolation. Inner errors propagate
1771
+ * (a tool that throws still surfaces through `runVpTurn`'s normal catch).
1772
+ *
1773
+ * @template T
1774
+ * @param {Promise<T>} inner
1775
+ * @param {{ deadlineMs: number, onEscalate: () => void }} opts
1776
+ * @returns {Promise<T|void>}
1777
+ */
1778
+ async function raceWithEscalation(inner, { deadlineMs, onEscalate }) {
1779
+ let escalateTimer = null;
1780
+ const escalation = new Promise((resolve) => {
1781
+ escalateTimer = setTimeout(() => {
1782
+ try { onEscalate(); } catch { /* never throw out of the watchdog */ }
1783
+ resolve();
1784
+ }, deadlineMs);
1785
+ // `unref()` lets a pending escalation timer not hold the Node event
1786
+ // loop open (e.g. during graceful shutdown). Browsers / non-Node
1787
+ // runtimes don't expose it, hence the typeof guard. Node's own
1788
+ // `Timeout.unref()` does not throw, so no try/catch is needed.
1789
+ if (escalateTimer && typeof escalateTimer.unref === 'function') {
1790
+ escalateTimer.unref();
1791
+ }
1792
+ });
1793
+ try {
1794
+ return await Promise.race([inner, escalation]);
1795
+ } finally {
1796
+ clearTimeout(escalateTimer);
1797
+ }
1798
+ }
1799
+
1674
1800
  /**
1675
1801
  * Run a single VP's turn: call engine.query() with the supplied prompt and
1676
1802
  * coordinator-bound router, stream events to the frontend, and append the
@@ -1696,11 +1822,6 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
1696
1822
  if (!prompt?.trim()) return;
1697
1823
 
1698
1824
  const envelope = { groupId, vpId, turnId };
1699
- // Arc is declared at outer-try scope so the catch / finally branches
1700
- // below can call `arc.finalize({status:'aborted'|'error'})` after a
1701
- // throw escaping the inner try. It's null until the inner try
1702
- // populates it; all catch-side calls guard with `arc?.finalize?.`.
1703
- let arc = null;
1704
1825
 
1705
1826
  try {
1706
1827
  if (session?.dreamScheduler) {
@@ -1726,12 +1847,6 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
1726
1847
  const assistantTextParts = [];
1727
1848
  const toolCallsAccum = [];
1728
1849
  const toolResultsAccum = [];
1729
- // PR-4 (review fix): hoist `vpEngine` so the `finally` can clear
1730
- // the per-turn featureId accessor on the SAME engine instance
1731
- // we installed it on — even if the VP was kicked or its group
1732
- // deleted mid-turn (both code paths call `vpEngines.delete(...)`).
1733
- // Calling `getOrCreateVpEngine` again from `finally` would
1734
- // resurrect a zombie engine for a VP that no longer exists.
1735
1850
  let vpEngine = null;
1736
1851
 
1737
1852
  // task-707: per-VP engine + persistent group coord. The coord is
@@ -1748,74 +1863,7 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
1748
1863
  envelope: inboundEnvelope,
1749
1864
  });
1750
1865
 
1751
- // ── Dual-track feature arc ──
1752
- // Track A (quick-response) runs concurrently against the same
1753
- // primary model with a non-looping single call; its preview is
1754
- // surfaced to the user immediately via `quick_preview` so they
1755
- // see *something* within ~1s. Three signals (Track A intent,
1756
- // ≥3 engine loops, key tool call) auto-create a Feature record
1757
- // and the wire envelope starts tagging emits with `featureId`,
1758
- // letting the frontend fold subsequent messages into a pill.
1759
- arc = createFeatureArc({
1760
- adapter: session?.adapter || null,
1761
- model: session?.config?.model || null,
1762
- featureStore: getFeatureStore(),
1763
- prompt,
1764
- vpId,
1765
- groupId: groupId || null,
1766
- turnId,
1767
- vpDisplayName: queryOpts?.vpPersona?.displayName || vpId,
1768
- language: session?.config?.language || 'en',
1769
- signal: vpAbort.signal,
1770
- emit: {
1771
- quickPreview: ({ intent, preview }) => {
1772
- sendUnifyEvent({
1773
- type: 'quick_preview',
1774
- intent,
1775
- preview,
1776
- vpId,
1777
- turnId,
1778
- }, envelope);
1779
- },
1780
- featureStarted: ({ featureId, title, trigger, toolName }) => {
1781
- sendUnifyEvent({
1782
- type: 'feature_started',
1783
- featureId,
1784
- title,
1785
- trigger, // 'quick' | 'turns' | 'tool'
1786
- toolName: toolName || null,
1787
- vpId,
1788
- turnId,
1789
- }, { ...envelope, featureId });
1790
- },
1791
- featureCompleted: ({ featureId, summary, status }) => {
1792
- sendUnifyEvent({
1793
- type: 'feature_completed',
1794
- featureId,
1795
- summary,
1796
- status, // 'completed' | 'aborted' | 'error'
1797
- vpId,
1798
- turnId,
1799
- }, { ...envelope, featureId });
1800
- },
1801
- },
1802
- });
1803
- // Fire-and-forget — Track A produces its preview / decision when
1804
- // ready; the main engine loop must not be held back waiting for it.
1805
- arc.startTrackA();
1806
-
1807
- // PR-4: let sub-agents spawned during this turn inherit the
1808
- // parent's active featureId. Read lazily inside the engine's
1809
- // parentEngineDeps so a feature that opens AFTER a sub-agent
1810
- // spawns still tags the sub-agent's later events. Cleared in the
1811
- // `finally` below so a stale `arc` reference doesn't leak into
1812
- // the next turn.
1813
1866
  vpEngine = getOrCreateVpEngine(groupId, vpId);
1814
- if (typeof vpEngine.setCurrentFeatureIdAccessor === 'function') {
1815
- vpEngine.setCurrentFeatureIdAccessor(() => {
1816
- try { return arc?.getFeatureId?.() || null; } catch { return null; }
1817
- });
1818
- }
1819
1867
 
1820
1868
  const handlerCtx = {
1821
1869
  assistantTextParts,
@@ -1825,9 +1873,6 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
1825
1873
  groupId,
1826
1874
  vpId,
1827
1875
  turnId,
1828
- // Lets handleEngineEvent stamp the latest featureId on each
1829
- // outgoing envelope; the arc may publish it mid-turn.
1830
- getFeatureId: () => arc.getFeatureId(),
1831
1876
  };
1832
1877
  // Always trim the snapshot before passing to engine.query. This is
1833
1878
  // the second-line defense (history-compact only fires above 30K
@@ -1852,26 +1897,12 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
1852
1897
  ...queryOpts,
1853
1898
  })) {
1854
1899
  resetQueryTimer();
1855
- // Arc observes BEFORE dispatch so featureId (if just published)
1856
- // is available when handleEngineEvent stamps the envelope.
1857
- try { arc.observeEvent(event); } catch (err) {
1858
- console.warn('[FeatureArc] observe failed:', err?.message || err);
1859
- }
1860
1900
  handleEngineEvent(event, handlerCtx);
1861
1901
  }
1862
1902
 
1863
1903
  // Turn completed — atomically append this VP's output to shared history.
1864
1904
  appendTurnToHistory(prompt, assistantTextParts, toolCallsAccum, toolResultsAccum);
1865
1905
 
1866
- // Close the arc: if a feature was opened during the turn, run the
1867
- // summary call and write status='completed' back to FeatureStore.
1868
- // Awaited so the `feature_completed` event reaches the frontend
1869
- // before the final 'result' bubble (UI ordering matters: the pill
1870
- // should reach its done state before the turn is marked done).
1871
- try { await arc.finalize({ status: 'completed' }); } catch (err) {
1872
- console.warn('[FeatureArc] finalize failed:', err?.message || err);
1873
- }
1874
-
1875
1906
  sendUnifyOutput({
1876
1907
  type: 'assistant',
1877
1908
  message: { content: [] },
@@ -1882,25 +1913,10 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
1882
1913
  }, envelope);
1883
1914
  } finally {
1884
1915
  if (queryTimer) clearTimeout(queryTimer);
1885
- // PR-4 (review fix): clear the accessor on the SAME engine
1886
- // instance we installed it on. Reusing the captured reference
1887
- // (instead of calling getOrCreateVpEngine again) avoids
1888
- // resurrecting a zombie engine if the VP/group was torn down
1889
- // mid-turn. `vpEngine` is null only when the install path threw
1890
- // before the engine lookup (very early failure) — in that case
1891
- // there's nothing to clear.
1892
- try {
1893
- if (vpEngine && typeof vpEngine.setCurrentFeatureIdAccessor === 'function') {
1894
- vpEngine.setCurrentFeatureIdAccessor(null);
1895
- }
1896
- } catch { /* best-effort */ }
1897
1916
  }
1898
1917
  } catch (err) {
1899
1918
  const isAbort = err && (err.name === 'AbortError' || err.name === 'LLMAbortError');
1900
1919
  if (isAbort) {
1901
- // Best-effort close: mark the feature aborted so the frontend pill
1902
- // settles into the right terminal state instead of staying active.
1903
- try { await arc?.finalize?.({ status: 'aborted' }); } catch { /* ignore */ }
1904
1920
  sendUnifyOutput({
1905
1921
  type: 'result',
1906
1922
  result_text: '',
@@ -1910,7 +1926,6 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
1910
1926
  }
1911
1927
 
1912
1928
  console.error('[Unify] query error:', err);
1913
- try { await arc?.finalize?.({ status: 'error' }); } catch { /* ignore */ }
1914
1929
 
1915
1930
  if (isPermissionErrorMsg(err.message)) {
1916
1931
  if (!_permissionDiagnosticSent) {
@@ -2321,6 +2336,13 @@ export function __testGetRegisteredThreadIds() {
2321
2336
  return currentAbortCtrl && !currentAbortCtrl.signal.aborted ? ['main'] : [];
2322
2337
  }
2323
2338
 
2339
+ /**
2340
+ * Test-only: expose the bridge-level escalation helper. Lets tests verify
2341
+ * the "tool ignored signal → wrapper escalates" contract without booting a
2342
+ * full session. See `test/agent/unify/web-bridge-escalation.test.js`.
2343
+ */
2344
+ export const __testRaceWithEscalation = raceWithEscalation;
2345
+
2324
2346
  /**
2325
2347
  * Manual dream trigger from VP detail page.
2326
2348
  */
@@ -2594,6 +2616,32 @@ export async function handleUnifyLoadHistory(msg) {
2594
2616
  }
2595
2617
  }
2596
2618
 
2619
+ // Compute the pagination cursor for the bootstrap load so the frontend
2620
+ // knows whether a "Load older messages" hint should be shown and where
2621
+ // to start the next page. The cursor is the seq of the oldest replayed
2622
+ // message; `hasMore` is true iff there's an earlier message in the
2623
+ // group that we did NOT replay.
2624
+ let hasMore = false;
2625
+ let oldestSeq = null;
2626
+ if (groupId && messages.length > 0) {
2627
+ const firstId = messages[0].id;
2628
+ const seq = parseSeqFromId(firstId);
2629
+ // Defend against malformed ids: a NaN cursor would round-trip back as
2630
+ // a poison `beforeSeq` and degrade subsequent paginations to "give me
2631
+ // the newest page again". Surface as null instead.
2632
+ oldestSeq = Number.isFinite(seq) ? seq : null;
2633
+ if (oldestSeq != null) {
2634
+ // Consult the store for whether anything older exists in the same
2635
+ // group. Cheap: a single extra `loadOlderByGroup` with turns=1.
2636
+ try {
2637
+ const probe = session.conversationStore.loadOlderByGroup(groupId, oldestSeq, 1);
2638
+ hasMore = probe.messages.length > 0;
2639
+ } catch (err) {
2640
+ console.error('[Unify] history-load probe failed:', err.message);
2641
+ }
2642
+ }
2643
+ }
2644
+
2597
2645
  sendUnifyEvent({
2598
2646
  type: 'history_loaded',
2599
2647
  count: messages.length,
@@ -2601,6 +2649,71 @@ export async function handleUnifyLoadHistory(msg) {
2601
2649
  totalHot: session.conversationStore.countHot(),
2602
2650
  totalCold: session.conversationStore.countCold(),
2603
2651
  groupId,
2652
+ hasMore,
2653
+ oldestSeq,
2654
+ });
2655
+ }
2656
+
2657
+ /**
2658
+ * Handle a "load older messages" pagination request. Reads `turns` more
2659
+ * turns of history strictly older than `beforeSeq` for `groupId`, and
2660
+ * emits them in a single `unify_history_chunk` envelope (NOT a
2661
+ * `unify_output` — that pipeline appends, but the frontend needs to
2662
+ * PREPEND these older messages above what it already has).
2663
+ *
2664
+ * Tool replay is NOT included in this PR — same projection as
2665
+ * `handleUnifyLoadHistory` (user / assistant text only). On any internal
2666
+ * failure we still emit an empty chunk so the spinner clears.
2667
+ *
2668
+ * @param {object} msg — { groupId, beforeSeq, turns }
2669
+ */
2670
+ export async function handleUnifyLoadMoreHistory(msg) {
2671
+ const groupId = (msg && typeof msg.groupId === 'string' && msg.groupId) || null;
2672
+ const emit = (payload) => sendToServer({
2673
+ type: 'unify_history_chunk',
2674
+ conversationId: unifyConversationId,
2675
+ groupId,
2676
+ ...payload,
2677
+ });
2678
+
2679
+ if (!session || !groupId) {
2680
+ emit({ messages: [], oldestSeq: null, hasMore: false });
2681
+ return;
2682
+ }
2683
+
2684
+ const beforeSeq = (typeof msg.beforeSeq === 'number') ? msg.beforeSeq : null;
2685
+ const turns = (typeof msg.turns === 'number' && msg.turns > 0) ? msg.turns : 20;
2686
+
2687
+ let result;
2688
+ try {
2689
+ result = session.conversationStore.loadOlderByGroup(groupId, beforeSeq, turns);
2690
+ } catch (err) {
2691
+ console.error('[Unify] loadOlderByGroup failed:', err.message);
2692
+ result = { messages: [], oldestSeq: null, hasMore: false };
2693
+ }
2694
+
2695
+ // Wire shape mirrors handleUnifyLoadHistory's projection: only user /
2696
+ // assistant text rows. Tool_use / tool_result replay is out of scope
2697
+ // for this PR (today's bootstrap path drops them too).
2698
+ //
2699
+ // We intentionally do NOT ship `id` or `time` over the wire:
2700
+ // `handleUnifyHistoryChunk` reads only role / content / groupId. The
2701
+ // pagination cursor (`oldestSeq`) is sent once at the envelope level,
2702
+ // so per-row ids are dead weight. `time` would be useful if we render
2703
+ // "5 days ago" stamps on older history rows — when that ships, add it
2704
+ // back here and consume it in conversationHandler.
2705
+ const projected = (result.messages || [])
2706
+ .filter(m => m && (m.role === 'user' || m.role === 'assistant'))
2707
+ .map(m => ({
2708
+ role: m.role,
2709
+ content: m.content,
2710
+ groupId: m.groupId || null,
2711
+ }));
2712
+
2713
+ emit({
2714
+ messages: projected,
2715
+ oldestSeq: result.oldestSeq,
2716
+ hasMore: !!result.hasMore,
2604
2717
  });
2605
2718
  }
2606
2719