@yeaft/webchat-agent 1.0.39 → 1.0.41

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.
@@ -5,6 +5,7 @@ import { encrypt, decrypt, isEncrypted } from '../encryption.js';
5
5
  // 需要在断连期间缓冲的消息类型(CLI / Session 输出相关的关键消息)
6
6
  export const BUFFERABLE_TYPES = new Set([
7
7
  'claude_output', 'yeaft_output', 'yeaft_session_output', 'session_output',
8
+ 'yeaft_history_chunk',
8
9
  'turn_completed', 'conversation_closed',
9
10
  'session_id_update', 'compact_status', 'slash_commands_update',
10
11
  'background_task_started', 'background_task_output',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.39",
3
+ "version": "1.0.41",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -206,6 +206,7 @@ function serializeMessage(msg) {
206
206
  // routing can filter/replay by thread without rescanning JSON blobs.
207
207
  // Defaults to 'main' for legacy messages (see migrate-messages-threadid.js).
208
208
  fm.push(`threadId: ${msg.threadId || 'main'}`);
209
+ if (msg.turnId) fm.push(`turnId: ${msg.turnId}`);
209
210
  // task-313: when a thread is merged into another, the messages keep
210
211
  // their original thread id in `sourceThreadId` so the UI can still
211
212
  // render a small "#source" pill next to each bubble.
@@ -216,6 +217,7 @@ function serializeMessage(msg) {
216
217
  // shows an empty pane.
217
218
  if (msg.sessionId) fm.push(`sessionId: ${msg.sessionId}`);
218
219
  if (msg.chatId) fm.push(`chatId: ${msg.chatId}`);
220
+ if (msg.clientMessageId) fm.push(`clientMessageId: ${msg.clientMessageId}`);
219
221
  // Session attribution: when a VP authors an assistant turn (either
220
222
  // its own reply or a route_forward injection from another VP), stamp
221
223
  // the speaker so the UI can render the message on the correct VP track.
@@ -333,9 +335,11 @@ export function parseMessage(raw) {
333
335
  case 'isError': msg.isError = value === 'true'; break;
334
336
  case 'tokens_est': msg.tokens_est = parseInt(value, 10); break;
335
337
  case 'threadId': msg.threadId = value; break;
338
+ case 'turnId': msg.turnId = value; break;
336
339
  case 'sourceThreadId': msg.sourceThreadId = value; break;
337
340
  case 'sessionId': msg.sessionId = value; break;
338
341
  case 'chatId': msg.chatId = value; break;
342
+ case 'clientMessageId': msg.clientMessageId = value; break;
339
343
  case 'speakerVpId': msg.speakerVpId = value; break;
340
344
  case 'attachmentsB64':
341
345
  try {
package/yeaft/engine.js CHANGED
@@ -1597,7 +1597,7 @@ export class Engine {
1597
1597
  * string-prompt shape (no regression for existing callers).
1598
1598
  * @yields {EngineEvent}
1599
1599
  */
1600
- async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, drainPendingUserMessages = null, collabToolPolicy = null } = {}) {
1600
+ async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, collabToolPolicy = null } = {}) {
1601
1601
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
1602
1602
  yield {
1603
1603
  type: 'error',
@@ -2833,6 +2833,7 @@ export class Engine {
2833
2833
  // history replay can re-stamp them on reload.
2834
2834
  sessionId,
2835
2835
  threadId,
2836
+ turnId: vpTurnId || queryTurnId,
2836
2837
  vpId: this.#vpId,
2837
2838
  // Multi-VP fan-out (history-dedup): skip the user-row append
2838
2839
  // in stop-hooks when the orchestrator already wrote it once
@@ -65,6 +65,7 @@ export async function runStopHooks(context) {
65
65
  // history replay can route messages back into the originating group.
66
66
  sessionId,
67
67
  threadId,
68
+ turnId,
68
69
  // Group VP attribution: persist the engine-bound VP id on assistant/tool
69
70
  // rows so history replay can route replies back to the same visible VP.
70
71
  vpId,
@@ -167,6 +168,9 @@ export async function runStopHooks(context) {
167
168
  // Bug 6: stamp sessionId / threadId so replay can re-route by group.
168
169
  if (sessionId) record.sessionId = sessionId;
169
170
  if (threadId) record.threadId = threadId;
171
+ if (turnId && (msg.role === 'assistant' || msg.role === 'tool')) {
172
+ record.turnId = turnId;
173
+ }
170
174
  if (vpId && (msg.role === 'assistant' || msg.role === 'tool')) {
171
175
  record.speakerVpId = vpId;
172
176
  }
@@ -712,8 +712,9 @@ function projectPersistedToHistoryEntry(m) {
712
712
  const entry = { role: m.role, content: m.role === 'tool' ? m.content : __testNormalizePersistedVisibleContent(m.content) };
713
713
  if (m.id) entry.id = m.id;
714
714
  entry.threadId = m.threadId || m.turnId || 'main';
715
- entry.turnId = m.turnId || entry.threadId;
715
+ if (m.turnId) entry.turnId = m.turnId;
716
716
  if (m.sessionId) entry.sessionId = m.sessionId;
717
+ if (m.clientMessageId) entry.clientMessageId = m.clientMessageId;
717
718
  if (m.speakerVpId) entry.speakerVpId = m.speakerVpId;
718
719
  if (m.toolCallId) entry.toolCallId = m.toolCallId;
719
720
  if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
@@ -813,8 +814,9 @@ function projectVisibleHistoryChunkMessages(messages = []) {
813
814
  content: m.content,
814
815
  ts: m.ts || null,
815
816
  sessionId: m.sessionId || null,
817
+ ...(m.clientMessageId ? { clientMessageId: m.clientMessageId } : {}),
816
818
  threadId: m.threadId || m.turnId || 'main',
817
- turnId: m.turnId || m.threadId || 'main',
819
+ ...(m.turnId ? { turnId: m.turnId } : {}),
818
820
  ...(Array.isArray(m.attachments) && m.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(m.attachments) } : {}),
819
821
  ...(m.speakerVpId ? { speakerVpId: m.speakerVpId } : {}),
820
822
  ...(Number.isFinite(m.toolSummaryCount) && m.toolSummaryCount > 0
@@ -1392,6 +1394,7 @@ async function routeEnvelopeToVpThread(sessionId, vpId, envelope) {
1392
1394
  attachments: Array.isArray(envelope?.msg?.meta?.attachments) ? envelope.msg.meta.attachments : [],
1393
1395
  internal: isInternalAppend,
1394
1396
  ts: envelope?.msg?.ts || null,
1397
+ clientMessageId: envelope?.msg?.meta?.clientMessageId || null,
1395
1398
  });
1396
1399
  thread.updatedAt = Date.now();
1397
1400
  try {
@@ -1489,6 +1492,7 @@ function ensureDriverRunning(sessionId, vpId, threadId = 'main') {
1489
1492
  attachments: Array.isArray(meta.attachments) ? meta.attachments : [],
1490
1493
  internal: isInternal,
1491
1494
  ts: envelope?.msg?.ts || null,
1495
+ clientMessageId: meta.clientMessageId || null,
1492
1496
  });
1493
1497
  }
1494
1498
  } catch { /* never crash WS pipeline */ }
@@ -3152,6 +3156,7 @@ export async function handleYeaftSessionSend(msg) {
3152
3156
  mentions,
3153
3157
  // Persisted form (no base64) — safe for jsonl-log.
3154
3158
  attachments: persistedAttachments,
3159
+ clientMessageId: typeof msg.id === 'string' && msg.id ? msg.id : null,
3155
3160
  },
3156
3161
  // Live form — adapters need the base64 image blocks; runVpTurn
3157
3162
  // reads `_promptParts` off the envelope rather than going
@@ -3691,6 +3696,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3691
3696
  // would render the user's prompt N times.
3692
3697
  userAlreadyPersisted: true,
3693
3698
  threadId,
3699
+ vpTurnId: turnId,
3694
3700
  drainPendingUserMessages: () => {
3695
3701
  if (!thread || !Array.isArray(thread.pendingQueries) || thread.pendingQueries.length === 0) return [];
3696
3702
  return thread.pendingQueries.splice(0);
@@ -3945,11 +3951,11 @@ function appendTurnToSessionHistory(sessionId, threadId, vpId, prompts, assistan
3945
3951
  * refresh replay can render chips without leaking image source data into
3946
3952
  * the message body.
3947
3953
  *
3948
- * @param {{ msgId:string, text:string, sessionId:string, role?:string, speakerVpId?:string|null, attachments?:Array<object>, internal?:boolean, ts?:string|null }} args
3954
+ * @param {{ msgId:string, text:string, sessionId:string, role?:string, speakerVpId?:string|null, attachments?:Array<object>, internal?:boolean, ts?:string|null, clientMessageId?:string|null }} args
3949
3955
  * @returns {boolean} true if this call wrote the row, false if a prior
3950
3956
  * call already wrote it (dedup hit).
3951
3957
  */
3952
- function persistInboundMessageOnceByMsgId({ msgId, text, sessionId, threadId = 'main', role, speakerVpId, attachments, internal = false, ts = null }) {
3958
+ function persistInboundMessageOnceByMsgId({ msgId, text, sessionId, threadId = 'main', role, speakerVpId, attachments, internal = false, ts = null, clientMessageId = null }) {
3953
3959
  if (!session?.conversationStore) return false;
3954
3960
  // No msgId means no dedup key — caller is responsible for guarding.
3955
3961
  // Both call sites already do (`if (envMsgId && text)` and
@@ -3993,6 +3999,9 @@ function persistInboundMessageOnceByMsgId({ msgId, text, sessionId, threadId = '
3993
3999
  threadId: threadId || 'main',
3994
4000
  };
3995
4001
  if (sessionId) record.sessionId = sessionId;
4002
+ if (persistRole === 'user' && clientMessageId && typeof clientMessageId === 'string') {
4003
+ record.clientMessageId = clientMessageId;
4004
+ }
3996
4005
  // Stamp speakerVpId so the UI's loadHistory replay can route the row
3997
4006
  // to the correct VP block. Only meaningful when role='assistant'; for
3998
4007
  // a real user message we leave it unset (the UI's user track is
@@ -4818,6 +4827,110 @@ export async function handleYeaftLoadHistory(msg) {
4818
4827
  sessionId ? store.loadRecentBySession(sessionId, lim) : store.loadRecent(lim);
4819
4828
  let historyAlreadyReplayed = false;
4820
4829
 
4830
+ const replayHistoryFromStore = () => {
4831
+ // Delta path: caller knows the latest seq (or message id) it has cached
4832
+ // and wants only the messages that arrived after that cursor. Returns
4833
+ // mode:'delta' so the frontend can append+dedupe instead of replacing
4834
+ // the pane.
4835
+ const afterSeqRaw = (msg && Number.isFinite(msg.afterSeq)) ? msg.afterSeq : null;
4836
+ const afterMessageId = (msg && typeof msg.afterMessageId === 'string') ? msg.afterMessageId : null;
4837
+ let afterSeq = afterSeqRaw;
4838
+ if (afterSeq === null && afterMessageId && typeof session.conversationStore.getMessageSeqById === 'function') {
4839
+ afterSeq = session.conversationStore.getMessageSeqById(afterMessageId);
4840
+ }
4841
+ if (sessionId && afterSeq !== null && typeof session.conversationStore.loadAfterSeqByGroup === 'function') {
4842
+ const delta = session.conversationStore.loadAfterSeqByGroup(sessionId, afterSeq);
4843
+ const projectedMessages = emitHistoryChunk({
4844
+ sessionId,
4845
+ messages: delta.messages,
4846
+ mode: 'delta',
4847
+ latestSeq: delta.latestSeq,
4848
+ afterSeq,
4849
+ });
4850
+ sendSessionEvent({
4851
+ type: 'history_loaded',
4852
+ mode: 'delta',
4853
+ count: projectedMessages.length,
4854
+ sessionId,
4855
+ latestSeq: delta.latestSeq,
4856
+ afterSeq,
4857
+ });
4858
+ return;
4859
+ }
4860
+
4861
+ // `msg.limit` is the replay-scrollback request from the frontend (UI
4862
+ // history pane, not engine context). Keep the bootstrap window small so
4863
+ // opening a group can paint the latest messages quickly; older rows are
4864
+ // paged via `yeaft_load_more_history` when the user scrolls upward.
4865
+ const limit = (typeof msg.limit === 'number') ? msg.limit : 10;
4866
+ const visiblePage = sessionId
4867
+ ? loadVisibleGroupHistoryPage(session.conversationStore, sessionId, limit)
4868
+ : { messages: limit > 0 ? pickRecent(session.conversationStore, limit) : [], oldestSeq: null, hasMore: false };
4869
+ // Legacy compact.md is a non-group fallback only. For group replay, reading
4870
+ // it makes every group show "has compact" once any legacy/non-scoped compact
4871
+ // exists, even when this group has no scoped summary.
4872
+ const compactSummary = sessionId ? '' : session.conversationStore.readCompactSummary();
4873
+ const replayEntries = sessionId
4874
+ ? visiblePage.messages
4875
+ : visiblePage.messages
4876
+ .map(projectPersistedToVisibleHistoryEntry)
4877
+ .filter(Boolean);
4878
+
4879
+ let latestSeq = null;
4880
+ if (replayEntries.length > 0 && typeof session.conversationStore.getMessageSeqById === 'function') {
4881
+ const last = replayEntries[replayEntries.length - 1];
4882
+ if (last && last.id) latestSeq = session.conversationStore.getMessageSeqById(last.id);
4883
+ }
4884
+
4885
+ if (sessionId) {
4886
+ emitHistoryChunk({
4887
+ sessionId,
4888
+ messages: replayEntries,
4889
+ mode: 'recent',
4890
+ oldestSeq: visiblePage.oldestSeq,
4891
+ hasMore: visiblePage.hasMore,
4892
+ latestSeq,
4893
+ turns: limit,
4894
+ });
4895
+ } else {
4896
+ emitLegacyHistoryOutputFrames(replayEntries);
4897
+ }
4898
+
4899
+ // Compute the pagination cursor for the bootstrap load so the frontend
4900
+ // knows whether a "Load older messages" hint should be shown and where
4901
+ // to start the next page. For group history, this is computed from the
4902
+ // visible projected page, not raw persisted rows, so reflection/internal
4903
+ // tail rows cannot consume the bootstrap window or create false hasMore.
4904
+ let hasMore = false;
4905
+ let oldestSeq = null;
4906
+ if (sessionId) {
4907
+ hasMore = visiblePage.hasMore;
4908
+ oldestSeq = visiblePage.oldestSeq;
4909
+ }
4910
+
4911
+ // hasCompactSummary used to read a single session-global file, so it was
4912
+ // always true once ANY group/VP in the session had compacted. For group
4913
+ // replay, only scoped per-(group, vp) summaries count; legacy compact.md is
4914
+ // reserved for non-group / pre-scoped 1:1 callers.
4915
+ let hasCompactSummaryFlag = !!compactSummary;
4916
+ if (sessionId && typeof session.conversationStore.hasAnyCompactSummaryForSession === 'function') {
4917
+ hasCompactSummaryFlag = session.conversationStore.hasAnyCompactSummaryForSession(sessionId);
4918
+ }
4919
+
4920
+ sendSessionEvent({
4921
+ type: 'history_loaded',
4922
+ mode: 'recent',
4923
+ count: replayEntries.length,
4924
+ hasCompactSummary: hasCompactSummaryFlag,
4925
+ totalHot: session.conversationStore.countHot(),
4926
+ totalCold: session.conversationStore.countCold(),
4927
+ sessionId,
4928
+ hasMore,
4929
+ oldestSeq,
4930
+ latestSeq,
4931
+ });
4932
+ };
4933
+
4821
4934
  if (!session) {
4822
4935
  const yeaftDir = ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR;
4823
4936
  const afterSeqRaw = (msg && Number.isFinite(msg.afterSeq)) ? msg.afterSeq : null;
@@ -4869,6 +4982,8 @@ export async function handleYeaftLoadHistory(msg) {
4869
4982
  // hydration handles it.
4870
4983
  if (sessionId) setGroupHistory(sessionId, hydrateGroupHistory(sessionId));
4871
4984
  } else {
4985
+ replayHistoryFromStore();
4986
+ historyAlreadyReplayed = true;
4872
4987
  refreshLiveSessionConfig();
4873
4988
  hydrateYeaftStatusFromSession(session, { reason: 'history_load', emitEvent: true });
4874
4989
  }
@@ -4908,107 +5023,7 @@ export async function handleYeaftLoadHistory(msg) {
4908
5023
 
4909
5024
  if (historyAlreadyReplayed) return;
4910
5025
 
4911
- // Delta path: caller knows the latest seq (or message id) it has cached
4912
- // and wants only the messages that arrived after that cursor. Returns
4913
- // early with mode:'delta' so the frontend can append+dedupe instead of
4914
- // replacing the pane.
4915
- const afterSeqRaw = (msg && Number.isFinite(msg.afterSeq)) ? msg.afterSeq : null;
4916
- const afterMessageId = (msg && typeof msg.afterMessageId === 'string') ? msg.afterMessageId : null;
4917
- let afterSeq = afterSeqRaw;
4918
- if (afterSeq === null && afterMessageId && typeof session.conversationStore.getMessageSeqById === 'function') {
4919
- afterSeq = session.conversationStore.getMessageSeqById(afterMessageId);
4920
- }
4921
- if (sessionId && afterSeq !== null && typeof session.conversationStore.loadAfterSeqByGroup === 'function') {
4922
- const delta = session.conversationStore.loadAfterSeqByGroup(sessionId, afterSeq);
4923
- const projectedMessages = emitHistoryChunk({
4924
- sessionId,
4925
- messages: delta.messages,
4926
- mode: 'delta',
4927
- latestSeq: delta.latestSeq,
4928
- afterSeq,
4929
- });
4930
- sendSessionEvent({
4931
- type: 'history_loaded',
4932
- mode: 'delta',
4933
- count: projectedMessages.length,
4934
- sessionId,
4935
- latestSeq: delta.latestSeq,
4936
- afterSeq,
4937
- });
4938
- return;
4939
- }
4940
-
4941
- // `msg.limit` is the replay-scrollback request from the frontend (UI
4942
- // history pane, not engine context). Keep the bootstrap window small so
4943
- // opening a group can paint the latest messages quickly; older rows are
4944
- // paged via `yeaft_load_more_history` when the user scrolls upward.
4945
- const limit = (typeof msg.limit === 'number') ? msg.limit : 10;
4946
- const visiblePage = sessionId
4947
- ? loadVisibleGroupHistoryPage(session.conversationStore, sessionId, limit)
4948
- : { messages: limit > 0 ? pickRecent(session.conversationStore, limit) : [], oldestSeq: null, hasMore: false };
4949
- // Legacy compact.md is a non-group fallback only. For group replay, reading
4950
- // it makes every group show "has compact" once any legacy/non-scoped compact
4951
- // exists, even when this group has no scoped summary.
4952
- const compactSummary = sessionId ? '' : session.conversationStore.readCompactSummary();
4953
- const replayEntries = sessionId
4954
- ? visiblePage.messages
4955
- : visiblePage.messages
4956
- .map(projectPersistedToVisibleHistoryEntry)
4957
- .filter(Boolean);
4958
-
4959
- let latestSeq = null;
4960
- if (replayEntries.length > 0 && typeof session.conversationStore.getMessageSeqById === 'function') {
4961
- const last = replayEntries[replayEntries.length - 1];
4962
- if (last && last.id) latestSeq = session.conversationStore.getMessageSeqById(last.id);
4963
- }
4964
-
4965
- if (sessionId) {
4966
- emitHistoryChunk({
4967
- sessionId,
4968
- messages: replayEntries,
4969
- mode: 'recent',
4970
- oldestSeq: visiblePage.oldestSeq,
4971
- hasMore: visiblePage.hasMore,
4972
- latestSeq,
4973
- turns: limit,
4974
- });
4975
- } else {
4976
- emitLegacyHistoryOutputFrames(replayEntries);
4977
- }
4978
-
4979
- // Compute the pagination cursor for the bootstrap load so the frontend
4980
- // knows whether a "Load older messages" hint should be shown and where
4981
- // to start the next page. For group history, this is computed from the
4982
- // visible projected page, not raw persisted rows, so reflection/internal
4983
- // tail rows cannot consume the bootstrap window or create false hasMore.
4984
- let hasMore = false;
4985
- let oldestSeq = null;
4986
- if (sessionId) {
4987
- hasMore = visiblePage.hasMore;
4988
- oldestSeq = visiblePage.oldestSeq;
4989
- }
4990
-
4991
- // hasCompactSummary used to read a single session-global file, so it was
4992
- // always true once ANY group/VP in the session had compacted. For group
4993
- // replay, only scoped per-(group, vp) summaries count; legacy compact.md is
4994
- // reserved for non-group / pre-scoped 1:1 callers.
4995
- let hasCompactSummaryFlag = !!compactSummary;
4996
- if (sessionId && typeof session.conversationStore.hasAnyCompactSummaryForSession === 'function') {
4997
- hasCompactSummaryFlag = session.conversationStore.hasAnyCompactSummaryForSession(sessionId);
4998
- }
4999
-
5000
- sendSessionEvent({
5001
- type: 'history_loaded',
5002
- mode: 'recent',
5003
- count: replayEntries.length,
5004
- hasCompactSummary: hasCompactSummaryFlag,
5005
- totalHot: session.conversationStore.countHot(),
5006
- totalCold: session.conversationStore.countCold(),
5007
- sessionId,
5008
- hasMore,
5009
- oldestSeq,
5010
- latestSeq,
5011
- });
5026
+ replayHistoryFromStore();
5012
5027
  }
5013
5028
 
5014
5029
  /**