@yeaft/webchat-agent 0.1.890 → 0.1.892

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.890",
3
+ "version": "0.1.892",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -966,6 +966,46 @@ export class ConversationStore {
966
966
  };
967
967
  }
968
968
 
969
+ /**
970
+ * Load messages strictly after a seq cursor, ordered by seq ascending.
971
+ * Used by the web client to fetch "everything new since my latest known
972
+ * message" when re-entering a session — the delta path.
973
+ *
974
+ * @param {string} sessionId
975
+ * @param {number|null} afterSeq — exclusive lower bound
976
+ * @param {{ limit?: number }} [opts]
977
+ * @returns {{ messages: object[], latestSeq: number|null }}
978
+ */
979
+ loadAfterSeqByGroup(sessionId, afterSeq, opts = {}) {
980
+ if (!sessionId) return { messages: [], latestSeq: null };
981
+ const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? opts.limit : 500;
982
+ const cutoff = Number.isFinite(afterSeq) && afterSeq >= 0 ? afterSeq : null;
983
+ if (cutoff === null) return { messages: [], latestSeq: null };
984
+ const hot = this.#loadGroupHotMessages(sessionId);
985
+ const cold = this.#loadGroupColdMessages(sessionId);
986
+ const all = [...cold, ...hot].sort(compareMessagesBySeq);
987
+ const after = all.filter((m) => {
988
+ if (!m || m.sessionId !== sessionId) return false;
989
+ const seq = parseSeqFromId(m.id);
990
+ return Number.isFinite(seq) && seq > cutoff;
991
+ });
992
+ const sliced = pairSanitize(after.slice(0, limit));
993
+ const lastSeq = sliced.length ? parseSeqFromId(sliced[sliced.length - 1].id) : null;
994
+ return { messages: sliced, latestSeq: Number.isFinite(lastSeq) ? lastSeq : null };
995
+ }
996
+
997
+ /**
998
+ * Convenience: extract the numeric seq embedded in a message id.
999
+ *
1000
+ * @param {string} messageId
1001
+ * @returns {number|null}
1002
+ */
1003
+ getMessageSeqById(messageId) {
1004
+ if (!messageId || typeof messageId !== 'string') return null;
1005
+ const seq = parseSeqFromId(messageId);
1006
+ return Number.isFinite(seq) ? seq : null;
1007
+ }
1008
+
969
1009
  /**
970
1010
  * Count hot messages.
971
1011
  *
@@ -1928,12 +1928,12 @@ function handleEngineEvent(event, hctx) {
1928
1928
  is_error: event.isError || false,
1929
1929
  }],
1930
1930
  }, envelope);
1931
- // Tool finished. The engine may either (a) emit more text-deltas
1932
- // before end_turn, or (b) go straight to end_turn. Settle the
1933
- // row back to 'thinking' if (a), the next text_delta will flip
1934
- // it to 'streaming'; if (b), runVpTurn's finally will flip it to
1935
- // 'idle'. Either way we never strand the row in 'tool'.
1936
- maybeTransitionVpStatus(hctx, 'thinking');
1931
+ // Tool finished. Do NOT speculatively flip to 'thinking' the
1932
+ // engine may emit more text-deltas (→ 'streaming') OR go straight
1933
+ // to end_turn (→ 'idle' via runVpTurn's finally). The old
1934
+ // speculative transition caused a visible 'tool thinking
1935
+ // streaming' flicker on every tool call. Hold the 'tool' state
1936
+ // until the next real event arrives.
1937
1937
  break;
1938
1938
 
1939
1939
  case 'turn_start':
@@ -1971,9 +1971,11 @@ function handleEngineEvent(event, hctx) {
1971
1971
  threadId: hctx.threadId || event.threadId || 'main',
1972
1972
  turnId: hctx.turnId,
1973
1973
  stopReason: event.stopReason,
1974
+ reason: 'route_forward',
1974
1975
  detail: event.detail || null,
1975
1976
  ts: Date.now(),
1976
1977
  }, envelope);
1978
+ if (typeof hctx.markTurnEnd === 'function') hctx.markTurnEnd('route_forward');
1977
1979
  }
1978
1980
  break;
1979
1981
 
@@ -2709,6 +2711,38 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
2709
2711
 
2710
2712
  const envelope = { sessionId, vpId, threadId, turnId };
2711
2713
 
2714
+ // Per-message turn lifecycle: track start ts + which terminal reason
2715
+ // we'll emit. `emitVpTurnEnd` is idempotent (route_forward emits inside
2716
+ // the engine loop; normal end_turn / abort / error emit at runVpTurn
2717
+ // boundaries — without idempotency a route_forward turn would emit
2718
+ // twice). `markTurnEnd` lets the engine-event handler tell us that
2719
+ // it already emitted, so we don't emit a duplicate at the runVpTurn
2720
+ // normal-completion path.
2721
+ const turnStartAt = Date.now();
2722
+ let turnEndReason = 'end_turn';
2723
+ let turnEndEmitted = false;
2724
+ let turnEndDetail = null;
2725
+ const markTurnEnd = (reason) => { turnEndEmitted = true; turnEndReason = reason; };
2726
+ const emitVpTurnEnd = (reason, detail = null) => {
2727
+ if (turnEndEmitted) return;
2728
+ turnEndEmitted = true;
2729
+ try {
2730
+ sendYeaftEvent({
2731
+ type: 'vp_turn_end',
2732
+ sessionId,
2733
+ vpId,
2734
+ threadId: threadId || 'main',
2735
+ turnId,
2736
+ reason,
2737
+ durationMs: Date.now() - turnStartAt,
2738
+ detail: detail || null,
2739
+ ts: Date.now(),
2740
+ }, envelope);
2741
+ } catch (err) {
2742
+ console.warn('[Yeaft] vp_turn_end emit failed:', err?.message || err);
2743
+ }
2744
+ };
2745
+
2712
2746
  try {
2713
2747
  if (session?.dreamScheduler) {
2714
2748
  session.dreamScheduler.noteUserMessage();
@@ -2773,6 +2807,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
2773
2807
  threadId,
2774
2808
  thread,
2775
2809
  appendedUserPrompts,
2810
+ markTurnEnd,
2776
2811
  };
2777
2812
  // Always trim the snapshot before passing to engine.query. This is
2778
2813
  // the second-line defense (history-compact only fires above 30K
@@ -2816,6 +2851,10 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
2816
2851
  type: 'result',
2817
2852
  result_text: '',
2818
2853
  }, envelope);
2854
+ // Normal end-of-turn (no route_forward, no abort, no error). Emit
2855
+ // the message-status terminal so the web client can flip the
2856
+ // assistant message status from 'pending' → 'completed'.
2857
+ emitVpTurnEnd('end_turn');
2819
2858
  } finally {
2820
2859
  if (queryTimer) clearTimeout(queryTimer);
2821
2860
  }
@@ -2827,10 +2866,13 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
2827
2866
  result_text: '',
2828
2867
  stopped: true,
2829
2868
  }, envelope);
2869
+ emitVpTurnEnd('aborted');
2830
2870
  return;
2831
2871
  }
2832
2872
 
2833
2873
  console.error('[Yeaft] query error:', err);
2874
+ turnEndReason = 'errored';
2875
+ turnEndDetail = { message: err?.message || String(err) };
2834
2876
 
2835
2877
  // vp-status: surface a transient `error` state so the row's status
2836
2878
  // label flips red for the brief window before the outer finally
@@ -2872,14 +2914,23 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
2872
2914
  result_text: '',
2873
2915
  }, envelope);
2874
2916
  } finally {
2917
+ // Emit terminal vp_turn_end for the error path (normal + abort + route
2918
+ // already emitted above). Done before settleIdle so the web client
2919
+ // sees status flip BEFORE the broker's idle event lands.
2920
+ if (turnEndReason === 'errored') emitVpTurnEnd('errored', turnEndDetail);
2875
2921
  // vp-status: guaranteed-settle. Regardless of how the turn exited
2876
2922
  // (normal completion, AbortError early-return, caught exception),
2877
- // the row must drop back to 'idle'. Wrapped in its own try so a
2878
- // broker bug can't mask the original error.
2879
- try {
2880
- getVpStatusBroker().settleIdle({ sessionId, vpId, threadId: threadId || 'main', title: thread?.title || '' });
2881
- } catch (err) {
2882
- console.warn('[Yeaft] vp-status settleIdle failed:', err?.message || err);
2923
+ // the row must drop back to 'idle'. EXCEPTION: when the turn errored,
2924
+ // we keep the broker's 'error' state visible until the next turn
2925
+ // starts, so the user can see something failed instead of a silent
2926
+ // green-state turn end. Wrapped in its own try so a broker bug
2927
+ // can't mask the original error.
2928
+ if (turnEndReason !== 'errored') {
2929
+ try {
2930
+ getVpStatusBroker().settleIdle({ sessionId, vpId, threadId: threadId || 'main', title: thread?.title || '' });
2931
+ } catch (err) {
2932
+ console.warn('[Yeaft] vp-status settleIdle failed:', err?.message || err);
2933
+ }
2883
2934
  }
2884
2935
  // fix-vp-multi-thread (bug 2): the bridge tracks per-thread status
2885
2936
  // on `thread.status` separately from the broker. Multiple sites
@@ -3614,6 +3665,55 @@ export async function handleYeaftLoadHistory(msg) {
3614
3665
  console.warn('[Yeaft] vp-status snapshot broadcast (replay) failed:', err?.message || err);
3615
3666
  }
3616
3667
 
3668
+ // Delta path: caller knows the latest seq (or message id) it has cached
3669
+ // and wants only the messages that arrived after that cursor. Returns
3670
+ // early with mode:'delta' so the frontend can append+dedupe instead of
3671
+ // replacing the pane.
3672
+ const afterSeqRaw = (msg && Number.isFinite(msg.afterSeq)) ? msg.afterSeq : null;
3673
+ const afterMessageId = (msg && typeof msg.afterMessageId === 'string') ? msg.afterMessageId : null;
3674
+ let afterSeq = afterSeqRaw;
3675
+ if (afterSeq === null && afterMessageId && typeof session.conversationStore.getMessageSeqById === 'function') {
3676
+ afterSeq = session.conversationStore.getMessageSeqById(afterMessageId);
3677
+ }
3678
+ if (sessionId && afterSeq !== null && typeof session.conversationStore.loadAfterSeqByGroup === 'function') {
3679
+ const delta = session.conversationStore.loadAfterSeqByGroup(sessionId, afterSeq);
3680
+ for (const entry of delta.messages) {
3681
+ if (entry.role === 'user') {
3682
+ sendYeaftOutput({
3683
+ type: 'user',
3684
+ message: {
3685
+ content: entry.content,
3686
+ id: entry.id || null,
3687
+ ...(Array.isArray(entry.attachments) && entry.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(entry.attachments) } : {}),
3688
+ },
3689
+ ts: entry.ts || null,
3690
+ }, { sessionId: entry.sessionId || null, threadId: entry.threadId || 'main', turnId: entry.turnId || entry.threadId || 'main' });
3691
+ } else if (entry.role === 'assistant') {
3692
+ const envelopeOpts = {
3693
+ sessionId: entry.sessionId || null,
3694
+ threadId: entry.threadId || 'main',
3695
+ turnId: entry.turnId || entry.threadId || 'main',
3696
+ };
3697
+ if (entry.speakerVpId) envelopeOpts.vpId = entry.speakerVpId;
3698
+ sendYeaftOutput({
3699
+ type: 'assistant',
3700
+ message: { id: entry.id || null, content: [{ type: 'text', text: entry.content }] },
3701
+ ts: entry.ts || null,
3702
+ }, envelopeOpts);
3703
+ sendYeaftOutput({ type: 'result', result_text: '' }, envelopeOpts);
3704
+ }
3705
+ }
3706
+ sendYeaftEvent({
3707
+ type: 'history_loaded',
3708
+ mode: 'delta',
3709
+ count: delta.messages.length,
3710
+ sessionId,
3711
+ latestSeq: delta.latestSeq,
3712
+ afterSeq,
3713
+ });
3714
+ return;
3715
+ }
3716
+
3617
3717
  // `msg.limit` is the replay-scrollback request from the frontend (UI
3618
3718
  // history pane, not engine context). Keep the bootstrap window small so
3619
3719
  // opening a group can paint the latest messages quickly; older rows are
@@ -3684,8 +3784,18 @@ export async function handleYeaftLoadHistory(msg) {
3684
3784
  hasCompactSummaryFlag = session.conversationStore.hasAnyCompactSummaryForGroup(sessionId);
3685
3785
  }
3686
3786
 
3787
+ // Latest seq cursor in the recent-mode reply lets the frontend stamp its
3788
+ // delta cursor on first paint, so the next session-switch can ask for
3789
+ // afterSeq instead of a full recent-N replay.
3790
+ let latestSeq = null;
3791
+ if (replayEntries.length > 0 && typeof session.conversationStore.getMessageSeqById === 'function') {
3792
+ const last = replayEntries[replayEntries.length - 1];
3793
+ if (last && last.id) latestSeq = session.conversationStore.getMessageSeqById(last.id);
3794
+ }
3795
+
3687
3796
  sendYeaftEvent({
3688
3797
  type: 'history_loaded',
3798
+ mode: 'recent',
3689
3799
  count: replayEntries.length,
3690
3800
  hasCompactSummary: hasCompactSummaryFlag,
3691
3801
  totalHot: session.conversationStore.countHot(),
@@ -3693,6 +3803,7 @@ export async function handleYeaftLoadHistory(msg) {
3693
3803
  sessionId,
3694
3804
  hasMore,
3695
3805
  oldestSeq,
3806
+ latestSeq,
3696
3807
  });
3697
3808
  }
3698
3809