@yeaft/webchat-agent 0.1.590 → 0.1.592

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.590",
3
+ "version": "0.1.592",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -45,6 +45,31 @@ function thinkingV1Enabled() {
45
45
  return process.env.UNIFY_THINKING_V1 === '1';
46
46
  }
47
47
 
48
+ /**
49
+ * task-DESIGN-v4: Chat Completions adapter is deprecated in favour of
50
+ * `openai-responses.js` (Responses API) for OpenAI-protocol providers and
51
+ * `anthropic.js` for Anthropic. This warning fires once per process the
52
+ * first time the adapter is instantiated, unless UNIFY_SUPPRESS_DEPRECATION=1.
53
+ * Removal is scheduled for Phase 7 of the multi-VP redesign — see
54
+ * `agent/unify/DESIGN.md` § "Migration Plan".
55
+ */
56
+ let _chatCompletionsDeprecationWarned = false;
57
+ function warnChatCompletionsDeprecated() {
58
+ if (_chatCompletionsDeprecationWarned) return;
59
+ if (process.env.UNIFY_SUPPRESS_DEPRECATION === '1') {
60
+ _chatCompletionsDeprecationWarned = true;
61
+ return;
62
+ }
63
+ _chatCompletionsDeprecationWarned = true;
64
+ // eslint-disable-next-line no-console
65
+ console.warn(
66
+ '[unify] ChatCompletionsAdapter is deprecated. Migrate OpenAI-protocol '
67
+ + 'providers to the Responses API (set provider.protocol="openai-responses"). '
68
+ + 'This adapter will be removed in a future release. Set '
69
+ + 'UNIFY_SUPPRESS_DEPRECATION=1 to silence this warning.'
70
+ );
71
+ }
72
+
48
73
  /**
49
74
  * Check if a model ID is an OpenAI model that supports max_completion_tokens.
50
75
  * OpenAI introduced max_completion_tokens with o1 and made it standard for
@@ -79,6 +104,7 @@ export class ChatCompletionsAdapter extends LLMAdapter {
79
104
  super({ apiKey, baseUrl });
80
105
  this.#apiKey = apiKey;
81
106
  this.#baseUrl = baseUrl.replace(/\/+$/, ''); // strip trailing slash
107
+ warnChatCompletionsDeprecated();
82
108
  }
83
109
 
84
110
  /** Expose baseUrl for testing. */
@@ -33,9 +33,34 @@ import {
33
33
  LLMServerError,
34
34
  LLMAbortError,
35
35
  } from './adapter.js';
36
+ import {
37
+ normalizeEffort,
38
+ getThinkingCapability,
39
+ } from '../models.js';
36
40
 
37
41
  const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
38
42
 
43
+ /**
44
+ * Feature-flag accessor mirroring anthropic.js. UNIFY_THINKING_V1 is OFF by
45
+ * default; set env to '1' to enable thinking-mode field translation. Read
46
+ * lazily so tests can flip the flag between calls.
47
+ */
48
+ function thinkingV1Enabled() {
49
+ return process.env.UNIFY_THINKING_V1 === '1';
50
+ }
51
+
52
+ /**
53
+ * Translate a normalised effort ('low'|'medium'|'high'|'max') into the value
54
+ * accepted by the OpenAI Responses `reasoning.effort` field. Responses today
55
+ * accepts 'low'|'medium'|'high' — 'max' degrades to 'high' to match the
56
+ * registry's normaliseEffort downgrade rule.
57
+ */
58
+ function effortForResponses(effort) {
59
+ if (!effort) return null;
60
+ if (effort === 'max') return 'high';
61
+ return effort;
62
+ }
63
+
39
64
  export class OpenAIResponsesAdapter extends LLMAdapter {
40
65
  #apiKey;
41
66
  #baseUrl;
@@ -200,9 +225,9 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
200
225
  // ─── Streaming ──────────────────────────────────────────
201
226
 
202
227
  /**
203
- * @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, extraBody?: object, signal?: AbortSignal }} params
228
+ * @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'low'|'medium'|'high'|'max', extraBody?: object, signal?: AbortSignal }} params
204
229
  */
205
- async *stream({ model, system, messages, tools, maxTokens = 16384, extraBody, signal }) {
230
+ async *stream({ model, system, messages, tools, maxTokens = 16384, effort, extraBody, signal }) {
206
231
  if (signal?.aborted) throw new LLMAbortError();
207
232
 
208
233
  const body = {
@@ -214,6 +239,20 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
214
239
  if (system) body.instructions = system;
215
240
  const translatedTools = this.#translateTools(tools);
216
241
  if (translatedTools) body.tools = translatedTools;
242
+
243
+ // Inject Responses-API thinking-mode field. Mirrors anthropic.js gating:
244
+ // feature flag must be on, effort must be a known value, and the model's
245
+ // registry entry must declare thinkingProtocol === 'openai-reasoning'.
246
+ // Unknown / unsupported models silently drop the field.
247
+ const normEffort = normalizeEffort(effort);
248
+ if (thinkingV1Enabled() && normEffort) {
249
+ const cap = getThinkingCapability(model);
250
+ if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning') {
251
+ const wireEffort = effortForResponses(normEffort);
252
+ if (wireEffort) body.reasoning = { effort: wireEffort };
253
+ }
254
+ }
255
+
217
256
  if (extraBody) Object.assign(body, extraBody);
218
257
 
219
258
  let response;
@@ -375,7 +414,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
375
414
 
376
415
  // ─── Non-streaming call() ───────────────────────────────
377
416
 
378
- async call({ model, system, messages, maxTokens = 4096, extraBody, signal }) {
417
+ async call({ model, system, messages, maxTokens = 4096, effort, extraBody, signal }) {
379
418
  if (signal?.aborted) throw new LLMAbortError();
380
419
 
381
420
  const body = {
@@ -384,6 +423,17 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
384
423
  max_output_tokens: maxTokens,
385
424
  };
386
425
  if (system) body.instructions = system;
426
+
427
+ // Mirror stream()'s thinking injection for non-streaming side queries.
428
+ const normEffort = normalizeEffort(effort);
429
+ if (thinkingV1Enabled() && normEffort) {
430
+ const cap = getThinkingCapability(model);
431
+ if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning') {
432
+ const wireEffort = effortForResponses(normEffort);
433
+ if (wireEffort) body.reasoning = { effort: wireEffort };
434
+ }
435
+ }
436
+
387
437
  if (extraBody) Object.assign(body, extraBody);
388
438
 
389
439
  let response;
package/unify/prompts.js CHANGED
@@ -158,6 +158,11 @@ const RAW_TEMPLATES = {
158
158
  modeUnified: readTemplate('mode-unified.md'),
159
159
  modeDream: readTemplate('mode-dream.md'),
160
160
  toolGuidance: readTemplate('tool-guidance.md'),
161
+ // Phase 1 — DESIGN.md "Migration Plan" harness fragments. Optional so
162
+ // older deployments without the templates still boot; buildWorkerPrompt /
163
+ // buildRouterPrompt callers will simply omit the section.
164
+ harnessWorkerShape: readTemplate('harness/worker-shape.md', { required: false }),
165
+ harnessRouterShape: readTemplate('harness/router-shape.md', { required: false }),
161
166
  };
162
167
 
163
168
  /**
@@ -637,3 +642,164 @@ function renderCoreMemory(coreMemory, lang, memoryTraceAvailable) {
637
642
  }
638
643
  return lines.join('\n');
639
644
  }
645
+
646
+ // ─── Phase 1: Worker / Router prompt splits ──────────────────────
647
+ //
648
+ // DESIGN.md (multi-VP redesign) describes two distinct prompt shapes:
649
+ //
650
+ // • Worker prompt — what a VP sees when it executes a turn. Layered as
651
+ // A (identity + summaries) / B (router-preselected memory) / C (task
652
+ // scope) / D (turn scope).
653
+ // • Router prompt — what the per-VP Router sees before it decides
654
+ // plans[]. Identity-summary layer + recent group state, no task /
655
+ // turn-scope detail.
656
+ //
657
+ // To stay backwards-compatible with existing callers we KEEP
658
+ // `buildSystemPrompt` and treat the two new entry points as thin wrappers
659
+ // that:
660
+ // 1) compose Layer-A summaries (user / group / vp) into the right
661
+ // headed sections, and
662
+ // 2) prepend the matching harness/*-shape.md fragment when present.
663
+ //
664
+ // Subsequent phases will migrate engine.js / router.js to these entry
665
+ // points and start filling Layers B / C with the new memory tree. For
666
+ // now they exist primarily so tests can pin the contract.
667
+
668
+ const LAYER_A_HEADERS = {
669
+ en: {
670
+ user: '## summary_user',
671
+ group: '## summary_group',
672
+ vp: '## summary_vp',
673
+ },
674
+ zh: {
675
+ user: '## 用户总结',
676
+ group: '## 群组总结',
677
+ vp: '## VP 总结',
678
+ },
679
+ };
680
+
681
+ /**
682
+ * Render Layer A's three rolling summaries (user / group / vp). Each is
683
+ * optional; missing or empty strings are skipped. Headers follow the
684
+ * `## summary_<scope>` convention so Layer-B/C/D headers don't collide.
685
+ *
686
+ * @param {{user?: string, group?: string, vp?: string}} summaries
687
+ * @param {'en'|'zh'} language
688
+ * @returns {string} concatenated block ('' when nothing to render)
689
+ */
690
+ export function renderLayerASummaries(summaries, language = 'en') {
691
+ if (!summaries || typeof summaries !== 'object') return '';
692
+ const headers = LAYER_A_HEADERS[language] || LAYER_A_HEADERS.en;
693
+ const out = [];
694
+ for (const key of ['user', 'group', 'vp']) {
695
+ const body = typeof summaries[key] === 'string' ? summaries[key].trim() : '';
696
+ if (!body) continue;
697
+ out.push(`${headers[key]}\n${body}`);
698
+ }
699
+ return out.join('\n\n');
700
+ }
701
+
702
+ /**
703
+ * Worker prompt entry point (DESIGN.md Phase 1).
704
+ *
705
+ * Layered output:
706
+ * harness/worker-shape — what each layer means (optional fragment)
707
+ * Layer A — buildSystemPrompt(...) output (identity + persona + Layer-A
708
+ * summaries via `summaries`)
709
+ * Layer B — `preselectedMemory` block (router-supplied)
710
+ * Layer C — `taskScope` block (active task summary + related-task window)
711
+ * Layer D — `turnScope` block (inbound envelope, in-flight turn notes)
712
+ *
713
+ * Layers B/C/D are passed in as already-rendered strings so this builder
714
+ * stays free of memory-store / task-store IO. Phase 2/3 will provide the
715
+ * real renderers; for now any caller can stub them.
716
+ *
717
+ * @param {{
718
+ * language?: 'en'|'zh',
719
+ * summaries?: {user?: string, group?: string, vp?: string},
720
+ * preselectedMemory?: string,
721
+ * taskScope?: string,
722
+ * turnScope?: string,
723
+ * includeShape?: boolean,
724
+ * ...rest: import('./prompts.js').buildSystemPrompt
725
+ * }} params
726
+ * @returns {string}
727
+ */
728
+ export function buildWorkerPrompt(params = {}) {
729
+ const {
730
+ language = 'en',
731
+ summaries,
732
+ preselectedMemory,
733
+ taskScope,
734
+ turnScope,
735
+ includeShape = true,
736
+ ...rest
737
+ } = params;
738
+
739
+ const parts = [];
740
+
741
+ // Optional harness — describes the layered shape.
742
+ if (includeShape) {
743
+ const shape = getTemplate('harnessWorkerShape', language);
744
+ if (shape) parts.push(shape);
745
+ }
746
+
747
+ // Layer A — base + persona + summaries.
748
+ const baseBlock = buildSystemPrompt({ ...rest, language });
749
+ if (baseBlock) parts.push(baseBlock);
750
+ const summaryBlock = renderLayerASummaries(summaries, language);
751
+ if (summaryBlock) parts.push(summaryBlock);
752
+
753
+ // Layer B — router-preselected memory entries (rendered upstream).
754
+ if (typeof preselectedMemory === 'string' && preselectedMemory.trim()) {
755
+ parts.push(preselectedMemory.trim());
756
+ }
757
+
758
+ // Layer C — task scope.
759
+ if (typeof taskScope === 'string' && taskScope.trim()) {
760
+ parts.push(taskScope.trim());
761
+ }
762
+
763
+ // Layer D — turn scope (inbound envelope, in-flight turn notes).
764
+ if (typeof turnScope === 'string' && turnScope.trim()) {
765
+ parts.push(turnScope.trim());
766
+ }
767
+
768
+ return parts.join('\n\n');
769
+ }
770
+
771
+ /**
772
+ * Router prompt entry point (DESIGN.md Phase 1).
773
+ *
774
+ * The Router sees identity context (no persona — it speaks as a routing
775
+ * brain, not as any specific VP), the three Layer-A summaries, and a
776
+ * `routerContext` block prepared upstream (group roster, recent turns,
777
+ * pending tasks). Output schema is enforced by the harness fragment.
778
+ *
779
+ * @param {{
780
+ * language?: 'en'|'zh',
781
+ * summaries?: {user?: string, group?: string, vp?: string},
782
+ * routerContext?: string,
783
+ * includeShape?: boolean,
784
+ * }} params
785
+ * @returns {string}
786
+ */
787
+ export function buildRouterPrompt(params = {}) {
788
+ const { language = 'en', summaries, routerContext, includeShape = true } = params;
789
+ const parts = [];
790
+
791
+ if (includeShape) {
792
+ const shape = getTemplate('harnessRouterShape', language);
793
+ if (shape) parts.push(shape);
794
+ }
795
+
796
+ const summaryBlock = renderLayerASummaries(summaries, language);
797
+ if (summaryBlock) parts.push(summaryBlock);
798
+
799
+ if (typeof routerContext === 'string' && routerContext.trim()) {
800
+ parts.push(routerContext.trim());
801
+ }
802
+
803
+ return parts.join('\n\n');
804
+ }
805
+
@@ -0,0 +1,49 @@
1
+ <!-- lang:en -->
2
+ # Prompt Shape (Router)
3
+
4
+ You are the per-VP Router. You see the group's roster, summaries, recent
5
+ turns, and the latest user message. You return a JSON `plans[]` array — one
6
+ plan per VP that should act this turn, in execution order.
7
+
8
+ Each plan contains:
9
+
10
+ - `vpId` — which VP runs.
11
+ - `forwardQuery` — `{ userOriginal, intent }`. `userOriginal` is the
12
+ verbatim user text; `intent` is a one-line gloss in third person. Do not
13
+ rewrite the user's words; the worker will read both.
14
+ - `preselect` — `{ memoryPaths[], taskIds[] }`. Memory paths are
15
+ scope-prefixed (`user/`, `groups/<id>/`, `vp/<id>/`, `tasks/<id>/`).
16
+ - `thinking` — `null | "high" | "max"`. Set when the turn warrants
17
+ deeper reasoning; leave `null` to use the VP / global default.
18
+ - `thinkingReason` — short justification when `thinking` is non-null.
19
+
20
+ Hard rules:
21
+ - Never include `vp/<other>/` paths in `preselect.memoryPaths`. Cross-VP
22
+ private memory is hard-blocked.
23
+ - Plans run sequentially in the order returned. Treat ordering as load
24
+ bearing; the second plan can read the first plan's output.
25
+ - If no VP needs to act, return `{"plans": []}`.
26
+ <!-- lang:zh -->
27
+ # Prompt 结构(Router)
28
+
29
+ 你是当前群组的 Router。你能看到群成员、总结、最近的回合,以及最新的用户
30
+ 消息。你返回一个 JSON `plans[]` 数组——每个需要发言的 VP 一个 plan,按
31
+ 执行顺序排列。
32
+
33
+ 每个 plan 包含:
34
+
35
+ - `vpId`:要执行的 VP。
36
+ - `forwardQuery`:`{ userOriginal, intent }`。`userOriginal` 是用户的
37
+ 原话;`intent` 是用第三人称写的一行意图说明。不要改写用户原话,Worker
38
+ 会同时看到两者。
39
+ - `preselect`:`{ memoryPaths[], taskIds[] }`。memoryPaths 必须带 scope
40
+ 前缀(`user/`、`groups/<id>/`、`vp/<id>/`、`tasks/<id>/`)。
41
+ - `thinking`:`null | "high" | "max"`。需要深度推理时设置,否则保持 null
42
+ 使用 VP / 全局默认。
43
+ - `thinkingReason`:当 `thinking` 非空时的简短理由。
44
+
45
+ 硬规则:
46
+ - `preselect.memoryPaths` 不允许包含 `vp/<其他 VP>/`。跨 VP 私有记忆硬
47
+ 屏蔽。
48
+ - plans 按返回顺序串行执行;后一个 plan 可以读到前一个 plan 的输出。
49
+ - 如果本轮无需任何 VP 发言,返回 `{"plans": []}`。
@@ -0,0 +1,35 @@
1
+ <!-- lang:en -->
2
+ # Prompt Shape (Worker)
3
+
4
+ You are a Worker VP turn. Your prompt is built from four layers, in order:
5
+
6
+ - **Layer A — Identity & Context**: who you are (VP persona) plus the three
7
+ rolling summaries (user / group / vp). Slow-changing; updated by the
8
+ hourly Dream pass.
9
+ - **Layer B — Pre-selected Memory**: a small set of memory entries the
10
+ Router decided are relevant for this turn. Treat these as authoritative
11
+ context; do not re-fetch unless something is missing.
12
+ - **Layer C — Task Scope**: the active task summary and a short window of
13
+ related task threads. Empty when the turn has no task binding.
14
+ - **Layer D — Turn Scope**: the in-flight messages, tool traces, and any
15
+ inbound envelope (a forwarded handoff from another VP).
16
+
17
+ When information is missing, prefer to ask via tools rather than fabricating
18
+ it. When in doubt about scope, the order of trust is: turn → task →
19
+ preselected memory → identity summary.
20
+ <!-- lang:zh -->
21
+ # Prompt 结构(Worker)
22
+
23
+ 你是一个 Worker VP 的回合。Prompt 由四层组成,自上而下:
24
+
25
+ - **A 层 · 身份与背景**:你的 VP 人设,以及三段缓慢更新的总结(用户 /
26
+ 群组 / VP)。由每小时一次的 Dream 维护。
27
+ - **B 层 · 路由预选记忆**:Router 判定与本轮相关的少量记忆条目,视为权威
28
+ 上下文,缺失时再去取。
29
+ - **C 层 · 任务范围**:当前任务的 summary,以及最近的相关任务窗口。无
30
+ 任务绑定时该层为空。
31
+ - **D 层 · 当前回合**:本轮的消息、工具调用 trace,以及(如有)从其他
32
+ VP 转交而来的 inbound envelope。
33
+
34
+ 信息缺失时优先用工具询问,不要编造。判定信息可信度的顺序:当前回合 >
35
+ 任务范围 > 预选记忆 > 身份总结。
@@ -138,22 +138,31 @@ function isPermissionErrorMsg(msg) {
138
138
  * Send a unify_output message carrying claude_output-format data.
139
139
  * The server forwards this as-is to the web client.
140
140
  * The frontend's handleUnifyOutput will dispatch via handleClaudeOutput.
141
+ *
142
+ * Optional `groupId` tags every emitted assistant/tool/user mirror with
143
+ * the originating group so the frontend can stamp arriving messages with
144
+ * the SEND-context group instead of the user's CURRENT filter (which can
145
+ * change while the reply is in flight). Without this, switching groups
146
+ * mid-reply lands the assistant turn in the wrong group.
141
147
  */
142
- function sendUnifyOutput(data) {
148
+ function sendUnifyOutput(data, groupId) {
143
149
  sendToServer({
144
150
  type: 'unify_output',
145
151
  conversationId: unifyConversationId,
152
+ ...(groupId ? { groupId } : {}),
146
153
  data,
147
154
  });
148
155
  }
149
156
 
150
157
  /**
151
158
  * Send a unify_output event (non-claude_output metadata).
159
+ * Optional `groupId` — see sendUnifyOutput for rationale.
152
160
  */
153
- function sendUnifyEvent(event) {
161
+ function sendUnifyEvent(event, groupId) {
154
162
  sendToServer({
155
163
  type: 'unify_output',
156
164
  conversationId: unifyConversationId,
165
+ ...(groupId ? { groupId } : {}),
157
166
  event,
158
167
  });
159
168
  }
@@ -775,6 +784,7 @@ export function parseThreadPrefix(text) {
775
784
  */
776
785
  function forwardPipelineEvent(ev, ctx) {
777
786
  if (!ev || typeof ev !== 'object') return false;
787
+ const gid = ctx && ctx.groupId;
778
788
  switch (ev.type) {
779
789
  case 'input_queue_updated':
780
790
  sendUnifyEvent({
@@ -784,7 +794,7 @@ function forwardPipelineEvent(ev, ctx) {
784
794
  routing: ev.routing,
785
795
  dispatched: ev.dispatched,
786
796
  head: ev.head,
787
- });
797
+ }, gid);
788
798
  return false;
789
799
  case 'routing_decision':
790
800
  sendUnifyEvent({
@@ -794,7 +804,7 @@ function forwardPipelineEvent(ev, ctx) {
794
804
  targetThreadId: ev.targetThreadId,
795
805
  source: ev.source,
796
806
  reason: ev.reason,
797
- });
807
+ }, gid);
798
808
  return false;
799
809
  case 'thread_list_updated':
800
810
  // Dispatcher built it already; just forward.
@@ -802,7 +812,7 @@ function forwardPipelineEvent(ev, ctx) {
802
812
  type: 'thread_list_updated',
803
813
  threads: ev.threads,
804
814
  currentThreadId: ev.currentThreadId,
805
- });
815
+ }, gid);
806
816
  return false;
807
817
  case 'engine_event':
808
818
  ctx.onEngineEvent(ev.event, ev.threadId);
@@ -827,6 +837,7 @@ function forwardPipelineEvent(ev, ctx) {
827
837
  */
828
838
  function handleEngineEvent(event, threadId, hctx) {
829
839
  hctx.resetQueryTimer();
840
+ const gid = hctx && hctx.groupId;
830
841
 
831
842
  // task-325b: translate Engine lifecycle events into a single
832
843
  // `thread_status` event for the frontend Working Status panel. These
@@ -851,11 +862,11 @@ function handleEngineEvent(event, threadId, hctx) {
851
862
  type: 'assistant',
852
863
  message: { content: [{ type: 'text', text: event.text }] },
853
864
  threadId,
854
- });
865
+ }, gid);
855
866
  break;
856
867
 
857
868
  case 'thinking_delta':
858
- sendUnifyEvent({ type: 'thinking_delta', text: event.text, threadId });
869
+ sendUnifyEvent({ type: 'thinking_delta', text: event.text, threadId }, gid);
859
870
  break;
860
871
 
861
872
  case 'tool_call':
@@ -874,7 +885,7 @@ function handleEngineEvent(event, threadId, hctx) {
874
885
  type: 'assistant',
875
886
  message: { content: [] },
876
887
  threadId,
877
- });
888
+ }, gid);
878
889
  sendUnifyOutput({
879
890
  type: 'assistant',
880
891
  message: {
@@ -886,7 +897,7 @@ function handleEngineEvent(event, threadId, hctx) {
886
897
  }],
887
898
  },
888
899
  threadId: event.threadId || threadId,
889
- });
900
+ }, gid);
890
901
  break;
891
902
 
892
903
  case 'tool_start':
@@ -895,7 +906,7 @@ function handleEngineEvent(event, threadId, hctx) {
895
906
  id: event.id,
896
907
  name: event.name,
897
908
  threadId: event.threadId || threadId,
898
- });
909
+ }, gid);
899
910
  break;
900
911
 
901
912
  case 'tool_end':
@@ -919,7 +930,7 @@ function handleEngineEvent(event, threadId, hctx) {
919
930
  is_error: event.isError || false,
920
931
  }],
921
932
  threadId: event.threadId || threadId,
922
- });
933
+ }, gid);
923
934
  if (THREAD_MUTATING_TOOLS.has(event.name)) {
924
935
  sendThreadListUpdate();
925
936
  }
@@ -937,7 +948,7 @@ function handleEngineEvent(event, threadId, hctx) {
937
948
  inputTokens: event.inputTokens,
938
949
  outputTokens: event.outputTokens,
939
950
  threadId,
940
- });
951
+ }, gid);
941
952
  break;
942
953
 
943
954
  case 'recall':
@@ -946,7 +957,7 @@ function handleEngineEvent(event, threadId, hctx) {
946
957
  entryCount: event.entryCount,
947
958
  cached: event.cached,
948
959
  threadId,
949
- });
960
+ }, gid);
950
961
  break;
951
962
 
952
963
  case 'consolidate':
@@ -962,7 +973,7 @@ function handleEngineEvent(event, threadId, hctx) {
962
973
  archivedCount: event.archivedCount,
963
974
  extractedCount: event.extractedCount,
964
975
  threadId,
965
- });
976
+ }, gid);
966
977
  break;
967
978
 
968
979
  case 'fallback':
@@ -972,7 +983,7 @@ function handleEngineEvent(event, threadId, hctx) {
972
983
  to: event.to,
973
984
  reason: event.reason,
974
985
  threadId,
975
- });
986
+ }, gid);
976
987
  break;
977
988
 
978
989
  case 'debug_turn':
@@ -992,7 +1003,7 @@ function handleEngineEvent(event, threadId, hctx) {
992
1003
  rawRequest: event.rawRequest,
993
1004
  rawResponse: event.rawResponse,
994
1005
  threadId,
995
- });
1006
+ }, gid);
996
1007
  break;
997
1008
 
998
1009
  case 'error': {
@@ -1012,7 +1023,7 @@ function handleEngineEvent(event, threadId, hctx) {
1012
1023
  }],
1013
1024
  },
1014
1025
  threadId,
1015
- });
1026
+ }, gid);
1016
1027
  }
1017
1028
  // Don't show subsequent permission errors.
1018
1029
  } else {
@@ -1022,7 +1033,7 @@ function handleEngineEvent(event, threadId, hctx) {
1022
1033
  content: [{ type: 'text', text: `⚠️ Error: ${errMsg}` }],
1023
1034
  },
1024
1035
  threadId,
1025
- });
1036
+ }, gid);
1026
1037
  }
1027
1038
  break;
1028
1039
  }
@@ -1089,6 +1100,53 @@ export async function handleUnifyGroupChat(msg) {
1089
1100
  return;
1090
1101
  }
1091
1102
 
1103
+ // Bug 2: When the user @-mentions a VP that exists in the library but is
1104
+ // not yet in the group's roster, auto-add it. This is the natural "invite"
1105
+ // gesture in group chat — failing here would punt to the legacy fallback
1106
+ // and surface the misleading "only Yeaft is in this conversation" error
1107
+ // even though the VP exists. We also ensure the group has a defaultVpId
1108
+ // when its roster is non-empty, so unaddressed messages route correctly.
1109
+ try {
1110
+ const meta = groupHandle.getMeta();
1111
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
1112
+ const wantsAdd = mentions.filter(
1113
+ (m) => m && m !== 'all' && !meta.roster.includes(m)
1114
+ );
1115
+ if (wantsAdd.length && yeaftDir) {
1116
+ let mutated = false;
1117
+ for (const vpId of wantsAdd) {
1118
+ try {
1119
+ const vp = readVp(vpId);
1120
+ if (!vp) continue;
1121
+ addMember(yeaftDir, groupId, vpId);
1122
+ mutated = true;
1123
+ } catch { /* skip strangers */ }
1124
+ }
1125
+ if (mutated) {
1126
+ // Re-open with fresh meta so the coordinator sees the new roster.
1127
+ try { groupHandle.close && groupHandle.close(); } catch { /* best-effort */ }
1128
+ const { openGroup } = await import('./groups/group-store.js');
1129
+ const { join } = await import('node:path');
1130
+ groupHandle = openGroup(join(yeaftDir, 'groups'), groupId);
1131
+ sendGroupRosterChanged(groupHandle.getMeta());
1132
+ }
1133
+ }
1134
+ // Heal missing defaultVpId — pick roster[0] when one exists.
1135
+ const meta2 = groupHandle.getMeta();
1136
+ if (!meta2.defaultVpId && meta2.roster.length && yeaftDir) {
1137
+ try {
1138
+ setGroupDefaultVp(yeaftDir, groupId, meta2.roster[0]);
1139
+ try { groupHandle.close && groupHandle.close(); } catch { /* best-effort */ }
1140
+ const { openGroup } = await import('./groups/group-store.js');
1141
+ const { join } = await import('node:path');
1142
+ groupHandle = openGroup(join(yeaftDir, 'groups'), groupId);
1143
+ sendGroupRosterChanged(groupHandle.getMeta());
1144
+ } catch { /* best-effort */ }
1145
+ }
1146
+ } catch (err) {
1147
+ console.warn('[Unify] unify_group_chat: auto-roster heal failed', err?.message || err);
1148
+ }
1149
+
1092
1150
  // Adapter layer (PM red-line: do NOT modify coordinator to fit this
1093
1151
  // consumer). We drive `createCoordinator()` with a capturing `deliver`
1094
1152
  // callback, collect its dispatched/fallback report, then translate each
@@ -1238,6 +1296,10 @@ export async function handleUnifyChat(msg) {
1238
1296
  // branch and reach the dispatcher.submit() queryOpts.
1239
1297
  const vpId = typeof msg.vpId === 'string' && msg.vpId.trim() ? msg.vpId.trim() : null;
1240
1298
  const groupCoordinator = msg._groupCoordinator || null;
1299
+ // Bug 1: every event we emit during this query must carry the originating
1300
+ // groupId so the frontend stamps arriving messages with the SEND-context
1301
+ // group, not the user's CURRENT filter (which can change mid-reply).
1302
+ const groupId = typeof msg.groupId === 'string' && msg.groupId.trim() ? msg.groupId.trim() : null;
1241
1303
 
1242
1304
  // Deprecation warning — task-297 removed chat/work mode distinction
1243
1305
  if (mode !== undefined && mode !== null) {
@@ -1358,14 +1420,16 @@ export async function handleUnifyChat(msg) {
1358
1420
  routing: 0,
1359
1421
  dispatched: 0,
1360
1422
  head: { id: entry.id, status: entry.status, text: entry.text.slice(0, 80) },
1361
- });
1423
+ }, groupId);
1362
1424
 
1363
1425
  const pipelineCtx = {
1426
+ groupId,
1364
1427
  onEngineEvent: (event, threadId) => handleEngineEvent(event, threadId, {
1365
1428
  assistantTextParts,
1366
1429
  toolCallsAccum,
1367
1430
  toolResultsAccum,
1368
1431
  resetQueryTimer,
1432
+ groupId,
1369
1433
  }),
1370
1434
  onError: (err) => { throw err; },
1371
1435
  };
@@ -1427,12 +1491,12 @@ export async function handleUnifyChat(msg) {
1427
1491
  sendUnifyOutput({
1428
1492
  type: 'assistant',
1429
1493
  message: { content: [] },
1430
- });
1494
+ }, groupId);
1431
1495
  // Send result to clear processing state
1432
1496
  sendUnifyOutput({
1433
1497
  type: 'result',
1434
1498
  result_text: '',
1435
- });
1499
+ }, groupId);
1436
1500
 
1437
1501
  } finally {
1438
1502
  // Always clear the timeout guard
@@ -1451,7 +1515,7 @@ export async function handleUnifyChat(msg) {
1451
1515
  sendUnifyOutput({
1452
1516
  type: 'result',
1453
1517
  result_text: '',
1454
- });
1518
+ }, groupId);
1455
1519
  return;
1456
1520
  }
1457
1521
 
@@ -1469,7 +1533,7 @@ export async function handleUnifyChat(msg) {
1469
1533
  text: '⚠️ Cannot write to ~/.yeaft/ directory — some features (memory, history) are unavailable. Please check directory permissions: `chmod -R u+rw ~/.yeaft/`',
1470
1534
  }],
1471
1535
  },
1472
- });
1536
+ }, groupId);
1473
1537
  }
1474
1538
  } else {
1475
1539
  sendUnifyOutput({
@@ -1480,13 +1544,13 @@ export async function handleUnifyChat(msg) {
1480
1544
  text: `⚠️ Session error: ${err.message}`,
1481
1545
  }],
1482
1546
  },
1483
- });
1547
+ }, groupId);
1484
1548
  }
1485
1549
  // Still send result to clear processing state
1486
1550
  sendUnifyOutput({
1487
1551
  type: 'result',
1488
1552
  result_text: '',
1489
- });
1553
+ }, groupId);
1490
1554
  } finally {
1491
1555
  // task-320: only clear the per-thread slot if THIS controller is still
1492
1556
  // the registered one. If a newer message already overwrote it, leaving
@@ -2123,8 +2187,11 @@ export async function handleUnifyLoadHistory(msg) {
2123
2187
  // task-334m: replay groups snapshot so Sidebar Groups rebuilds on refresh.
2124
2188
  sendGroupSnapshotBroadcast();
2125
2189
 
2126
- const limit = msg.limit || 50;
2127
- const messages = session.conversationStore.loadRecent(limit);
2190
+ // Honor explicit limit:0 frontend uses it on Unify re-entry to refresh
2191
+ // metadata (model/status/group snapshot via the unconditional replay
2192
+ // above) without re-streaming the message history.
2193
+ const limit = (typeof msg.limit === 'number') ? msg.limit : 50;
2194
+ const messages = limit > 0 ? session.conversationStore.loadRecent(limit) : [];
2128
2195
  const compactSummary = session.conversationStore.readCompactSummary();
2129
2196
 
2130
2197
  // Send each message through standard claude_output rendering pipeline