@yeaft/webchat-agent 0.1.754 → 0.1.756

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.754",
3
+ "version": "0.1.756",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -102,6 +102,11 @@ function serializeMessage(msg) {
102
102
  // in the default group and switching back to the originating group
103
103
  // shows an empty pane.
104
104
  if (msg.groupId) fm.push(`groupId: ${msg.groupId}`);
105
+ // Group-chat attribution: when a VP authors an assistant turn (either
106
+ // its own reply or a route_forward injection from another VP), stamp
107
+ // the speaker so the UI can render the message on the correct VP track.
108
+ // For real user messages this is unset.
109
+ if (msg.speakerVpId) fm.push(`speakerVpId: ${msg.speakerVpId}`);
105
110
 
106
111
  // Token estimate
107
112
  const content = msg.content || '';
@@ -173,6 +178,7 @@ export function parseMessage(raw) {
173
178
  case 'threadId': msg.threadId = value; break;
174
179
  case 'sourceThreadId': msg.sourceThreadId = value; break;
175
180
  case 'groupId': msg.groupId = value; break;
181
+ case 'speakerVpId': msg.speakerVpId = value; break;
176
182
  // toolCalls are multi-line YAML — handled separately below
177
183
  }
178
184
  }
@@ -64,7 +64,20 @@ export function createCoordinator(group, options = {}) {
64
64
  const meta = group.getMeta();
65
65
  if (!meta) throw new Error('group not initialised (call createGroup first)');
66
66
 
67
- const fromUser = input.from === 'user' || input.role === 'user';
67
+ // `fromUser` drives `selectRespondingVps` when true, the @-mention
68
+ // matrix runs (mention/broadcast/fallback). When false, VPs cannot
69
+ // text-@-route (VP-authored free text is surface noise per arch §6).
70
+ //
71
+ // route_forward injection is a special case: the message is VP-authored
72
+ // (role='assistant') but it MUST trigger dispatch (target VP needs to
73
+ // run). We detect it via `meta.injectedBy === 'route_forward'` and
74
+ // treat it as "user-like" for dispatch purposes only. Persistence still
75
+ // honours the caller's `role` field so the on-disk record correctly
76
+ // attributes the turn to the sending VP, not to the user.
77
+ const isRouteForwardInjection = input?.meta?.injectedBy === 'route_forward';
78
+ const fromUser = input.from === 'user'
79
+ || input.role === 'user'
80
+ || isRouteForwardInjection;
68
81
  const mentions = parseMentions(input.text);
69
82
 
70
83
  // Persist first — audit log / replay works even if dispatch has bugs.
@@ -125,17 +125,12 @@ export function createRouter(deps = {}) {
125
125
  }
126
126
 
127
127
  // Synthesize an injection message — coordinator's `ingest` expects the
128
- // {from, role, text} shape. We set role='user' ONLY because that's the
129
- // code path that triggers @-routing; semantically it is a VP-initiated
130
- // injection. The `from` field preserves the real sender vpId, and we
131
- // stamp meta.synthetic + meta.injectedBy so downstream auditors can
132
- // tell it apart from real user input.
133
- //
134
- // Why not add a third role to Coordinator? Scope discipline: Coordinator
135
- // (334b) owns user vs VP branching. A third branch would force edits
136
- // across both 334b and 334d for one hop. Setting role='user' with
137
- // synthetic meta keeps the Coordinator API frozen — and `from` still
138
- // reflects the real author, which is what the guard keys on anyway.
128
+ // {from, role, text} shape. The forwarded message is semantically the
129
+ // SENDER VP speaking (just delivered to a different VP's inbox), so it
130
+ // persists as role='assistant' attributed to `from`. The `meta.injectedBy`
131
+ // stamp + `synthetic` marker let Coordinator's `selectRespondingVps`
132
+ // still treat this like a routed turn (target VPs need to respond) even
133
+ // though role is now 'assistant'.
139
134
  const injectText = to === 'all'
140
135
  ? `@all ${text}`
141
136
  : `@${to} ${text}`;
@@ -143,7 +138,7 @@ export function createRouter(deps = {}) {
143
138
  const report = coordinator.ingest(
144
139
  {
145
140
  from, // real VP id — preserved for provenance
146
- role: 'user', // triggers coordinator dispatch path
141
+ role: 'assistant', // VP-authored persists as assistant turn
147
142
  text: injectText,
148
143
  taskId: args.taskId ?? null,
149
144
  meta: {
@@ -617,10 +617,22 @@ function ensureDriverRunning(groupId, vpId) {
617
617
  try {
618
618
  const envMsgId = envelope?.msg?.id;
619
619
  if (envMsgId && text) {
620
- persistUserMessageOnceByMsgId({
620
+ // route_forward injection: the envelope text was authored by the
621
+ // sending VP, not the user. Persist as an assistant row attributed
622
+ // to the sender so history replay puts it on the VP track, not on
623
+ // the human "user" track. Non-forward envelopes (real user input)
624
+ // persist as the user row exactly like before.
625
+ const meta = envelope?.msg?.meta || {};
626
+ const isForward = meta.injectedBy === 'route_forward';
627
+ const senderVpId = isForward
628
+ ? (meta.senderVpId || envelope?.msg?.from || null)
629
+ : null;
630
+ persistInboundMessageOnceByMsgId({
621
631
  msgId: envMsgId,
622
632
  text,
623
633
  groupId,
634
+ role: isForward ? 'assistant' : 'user',
635
+ speakerVpId: senderVpId,
624
636
  });
625
637
  }
626
638
  } catch { /* never crash WS pipeline */ }
@@ -1685,7 +1697,7 @@ export async function handleUnifyGroupChat(msg) {
1685
1697
  try {
1686
1698
  const persistedMsgId = report?.message?.id;
1687
1699
  if (persistedMsgId) {
1688
- persistUserMessageOnceByMsgId({
1700
+ persistInboundMessageOnceByMsgId({
1689
1701
  msgId: persistedMsgId,
1690
1702
  text,
1691
1703
  groupId,
@@ -1693,7 +1705,7 @@ export async function handleUnifyGroupChat(msg) {
1693
1705
  }
1694
1706
  } catch (err) {
1695
1707
  console.warn(
1696
- '[Unify] unify_group_chat: persistUserMessageOnceByMsgId failed',
1708
+ '[Unify] unify_group_chat: persistInboundMessageOnceByMsgId failed',
1697
1709
  err?.message || err,
1698
1710
  );
1699
1711
  }
@@ -2210,11 +2222,13 @@ function appendTurnToGroupHistory(groupId, prompt, assistantTextParts, toolCalls
2210
2222
  }
2211
2223
 
2212
2224
  /**
2213
- * Persist the user row to disk EXACTLY ONCE per coordinator-ingest call,
2214
- * keyed by the coordinator-assigned `msgId`. Both `handleUnifyGroupChat`
2215
- * (real user input) and `enqueueForVp`'s driver loop (route_forward
2216
- * synthetic injections) call this the Set guard makes either path
2217
- * the writer, whichever runs first, while the other becomes a no-op.
2225
+ * Persist an inbound message row to disk EXACTLY ONCE per
2226
+ * coordinator-ingest call, keyed by the coordinator-assigned `msgId`.
2227
+ * Both `handleUnifyGroupChat` (real user input, persists as
2228
+ * role='user') and `enqueueForVp`'s driver loop (route_forward
2229
+ * synthetic injections, persists as role='assistant' attributed via
2230
+ * `speakerVpId`) call this — the Set guard makes either path the
2231
+ * writer, whichever runs first, while the other becomes a no-op.
2218
2232
  *
2219
2233
  * Without this dedup, a 2-VP group prompt produces TWO `m{NNNN}.md`
2220
2234
  * user rows (one per engine) — `handleUnifyLoadHistory` then replays
@@ -2232,11 +2246,11 @@ function appendTurnToGroupHistory(groupId, prompt, assistantTextParts, toolCalls
2232
2246
  * schema today (the engine never wrote them either) — they live on the
2233
2247
  * coordinator's jsonl-log under group meta.
2234
2248
  *
2235
- * @param {{ msgId:string, text:string, groupId:string }} args
2249
+ * @param {{ msgId:string, text:string, groupId:string, role?:string, speakerVpId?:string|null }} args
2236
2250
  * @returns {boolean} true if this call wrote the row, false if a prior
2237
2251
  * call already wrote it (dedup hit).
2238
2252
  */
2239
- function persistUserMessageOnceByMsgId({ msgId, text, groupId }) {
2253
+ function persistInboundMessageOnceByMsgId({ msgId, text, groupId, role, speakerVpId }) {
2240
2254
  if (!session?.conversationStore) return false;
2241
2255
  // No msgId means no dedup key — caller is responsible for guarding.
2242
2256
  // Both call sites already do (`if (envMsgId && text)` and
@@ -2267,17 +2281,30 @@ function persistUserMessageOnceByMsgId({ msgId, text, groupId }) {
2267
2281
  }
2268
2282
  }
2269
2283
  try {
2284
+ // role defaults to 'user' for back-compat: handleUnifyGroupChat's
2285
+ // real-user call site passes no role and gets a user row. The driver
2286
+ // loop passes role='assistant' + speakerVpId for route_forward
2287
+ // injections so the on-disk record correctly attributes the text to
2288
+ // the sending VP.
2289
+ const persistRole = role === 'assistant' ? 'assistant' : 'user';
2270
2290
  const record = {
2271
- role: 'user',
2291
+ role: persistRole,
2272
2292
  content: text,
2273
2293
  threadId: 'main',
2274
2294
  };
2275
2295
  if (groupId) record.groupId = groupId;
2296
+ // Stamp speakerVpId so the UI's loadHistory replay can route the row
2297
+ // to the correct VP block. Only meaningful when role='assistant'; for
2298
+ // a real user message we leave it unset (the UI's user track is
2299
+ // unattributed).
2300
+ if (persistRole === 'assistant' && speakerVpId && typeof speakerVpId === 'string') {
2301
+ record.speakerVpId = speakerVpId;
2302
+ }
2276
2303
  session.conversationStore.append(record);
2277
2304
  return true;
2278
2305
  } catch (err) {
2279
2306
  console.warn(
2280
- '[Unify] persistUserMessageOnceByMsgId failed (non-fatal):',
2307
+ '[Unify] persistInboundMessageOnceByMsgId failed (non-fatal):',
2281
2308
  err?.message || err,
2282
2309
  );
2283
2310
  return false;
@@ -2731,11 +2758,19 @@ export async function handleUnifyLoadHistory(msg) {
2731
2758
  if (m.role === 'user') {
2732
2759
  sendUnifyOutput({ type: 'user', message: { content: m.content } }, { groupId: m.groupId || null });
2733
2760
  } else if (m.role === 'assistant') {
2761
+ // speakerVpId rides on the envelope so the frontend can route this
2762
+ // replayed assistant text to the correct VP track. Without it, the
2763
+ // history replay would merge replies from different VPs onto one
2764
+ // anonymous assistant turn.
2765
+ const envelopeOpts = {
2766
+ groupId: m.groupId || null,
2767
+ };
2768
+ if (m.speakerVpId) envelopeOpts.vpId = m.speakerVpId;
2734
2769
  sendUnifyOutput({
2735
2770
  type: 'assistant',
2736
2771
  message: { content: [{ type: 'text', text: m.content }] },
2737
- }, { groupId: m.groupId || null });
2738
- sendUnifyOutput({ type: 'result', result_text: '' }, { groupId: m.groupId || null });
2772
+ }, envelopeOpts);
2773
+ sendUnifyOutput({ type: 'result', result_text: '' }, envelopeOpts);
2739
2774
  }
2740
2775
  }
2741
2776