@yeaft/webchat-agent 0.1.504 → 0.1.506

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.
@@ -25,7 +25,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
25
25
  import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
26
26
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
27
27
  import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings } from '../unify/config-api.js';
28
- import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread } from '../unify/web-bridge.js';
28
+ import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread, handleUnifyAbortThread, handleUnifyAbortAll } from '../unify/web-bridge.js';
29
29
 
30
30
  export async function handleMessage(msg) {
31
31
  switch (msg.type) {
@@ -373,6 +373,19 @@ export async function handleMessage(msg) {
373
373
  handleUnifyForkThread(msg);
374
374
  break;
375
375
 
376
+ case 'unify_abort_thread':
377
+ // task-325c: user-initiated abort of a single thread's in-flight
378
+ // query. Payload `{ threadId }`. Silent no-op when the thread has
379
+ // no in-flight controller.
380
+ handleUnifyAbortThread(msg);
381
+ break;
382
+
383
+ case 'unify_abort_all':
384
+ // task-325c: user-initiated abort of ALL in-flight queries across
385
+ // every thread. Always emits `unify_aborted` ack.
386
+ handleUnifyAbortAll();
387
+ break;
388
+
376
389
  // Expert roles definition (for ExpertPanel detail view)
377
390
  case 'get_expert_roles': {
378
391
  const { getExpertRolesDefinition } = await import('../expert-roles.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.504",
3
+ "version": "0.1.506",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -14,6 +14,19 @@ import {
14
14
  LLMServerError,
15
15
  LLMAbortError,
16
16
  } from './adapter.js';
17
+ import {
18
+ normalizeEffort,
19
+ thinkingBudgetForEffort,
20
+ getThinkingCapability,
21
+ } from '../models.js';
22
+
23
+ /**
24
+ * task-327a: feature-flag accessor. thinkingV1 is OFF by default; set
25
+ * env UNIFY_THINKING_V1=1 to enable. Read lazily so tests can flip.
26
+ */
27
+ function thinkingV1Enabled() {
28
+ return process.env.UNIFY_THINKING_V1 === '1';
29
+ }
17
30
 
18
31
  const DEFAULT_BASE_URL = 'https://api.anthropic.com';
19
32
  const API_VERSION = '2023-06-01';
@@ -123,10 +136,10 @@ export class AnthropicAdapter extends LLMAdapter {
123
136
  }
124
137
 
125
138
  /**
126
- * @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, signal?: AbortSignal }} params
139
+ * @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'low'|'medium'|'high'|'max', signal?: AbortSignal }} params
127
140
  * @returns {AsyncGenerator<import('./adapter.js').StreamEvent>}
128
141
  */
129
- async *stream({ model, system, messages, tools, maxTokens = 16384, signal }) {
142
+ async *stream({ model, system, messages, tools, maxTokens = 16384, effort, signal }) {
130
143
  if (signal?.aborted) throw new LLMAbortError();
131
144
 
132
145
  const body = {
@@ -137,6 +150,26 @@ export class AnthropicAdapter extends LLMAdapter {
137
150
  stream: true,
138
151
  };
139
152
 
153
+ // task-327a: inject extended-thinking only when feature flag on, effort is
154
+ // a valid value, and the model's registry entry says it supports the
155
+ // 'anthropic' thinking protocol. Unknown models or non-thinking models
156
+ // silently drop the parameter — red line: never error on unsupported.
157
+ const normEffort = normalizeEffort(effort);
158
+ if (thinkingV1Enabled() && normEffort) {
159
+ const cap = getThinkingCapability(model);
160
+ if (cap.supportsThinking && cap.thinkingProtocol === 'anthropic') {
161
+ const budget = thinkingBudgetForEffort(model, normEffort);
162
+ if (budget && budget > 0) {
163
+ // Anthropic requires max_tokens > budget_tokens. Widen max_tokens
164
+ // if the caller's value is too small to fit the thinking budget
165
+ // plus a sane reply margin (1024 tokens).
166
+ const minMax = budget + 1024;
167
+ if (body.max_tokens < minMax) body.max_tokens = minMax;
168
+ body.thinking = { type: 'enabled', budget_tokens: budget };
169
+ }
170
+ }
171
+ }
172
+
140
173
  const translatedTools = this.#translateTools(tools);
141
174
  if (translatedTools) body.tools = translatedTools;
142
175
 
@@ -28,6 +28,18 @@ import {
28
28
  LLMServerError,
29
29
  LLMAbortError,
30
30
  } from './adapter.js';
31
+ import {
32
+ normalizeEffort,
33
+ mapEffortToOpenAIReasoning,
34
+ getThinkingCapability,
35
+ } from '../models.js';
36
+
37
+ /**
38
+ * task-327a: feature-flag accessor. Read lazily so tests can flip.
39
+ */
40
+ function thinkingV1Enabled() {
41
+ return process.env.UNIFY_THINKING_V1 === '1';
42
+ }
31
43
 
32
44
  /**
33
45
  * Check if a model ID is an OpenAI model that supports max_completion_tokens.
@@ -184,10 +196,10 @@ export class ChatCompletionsAdapter extends LLMAdapter {
184
196
  }
185
197
 
186
198
  /**
187
- * @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, extraBody?: object, signal?: AbortSignal }} params
199
+ * @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
188
200
  * @returns {AsyncGenerator<import('./adapter.js').StreamEvent>}
189
201
  */
190
- async *stream({ model, system, messages, tools, maxTokens = 16384, extraBody, signal }) {
202
+ async *stream({ model, system, messages, tools, maxTokens = 16384, effort, extraBody, signal }) {
191
203
  if (signal?.aborted) throw new LLMAbortError();
192
204
 
193
205
  const body = {
@@ -198,6 +210,21 @@ export class ChatCompletionsAdapter extends LLMAdapter {
198
210
  stream_options: { include_usage: true },
199
211
  };
200
212
 
213
+ // task-327a: inject OpenAI reasoning.effort when feature flag on, effort is
214
+ // valid, and model's registry entry flags openai-reasoning protocol.
215
+ // 'max' downgrades to 'high' (OpenAI has no 'max' enum). Unknown / unsupported
216
+ // models silently drop the parameter.
217
+ const normEffort = normalizeEffort(effort);
218
+ if (thinkingV1Enabled() && normEffort) {
219
+ const cap = getThinkingCapability(model);
220
+ if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning') {
221
+ const reasoningEffort = mapEffortToOpenAIReasoning(normEffort);
222
+ if (reasoningEffort) {
223
+ body.reasoning = { effort: reasoningEffort };
224
+ }
225
+ }
226
+ }
227
+
201
228
  const translatedTools = this.#translateTools(tools);
202
229
  if (translatedTools) body.tools = translatedTools;
203
230
 
@@ -13,6 +13,47 @@
13
13
  */
14
14
 
15
15
  import { LLMAdapter } from './adapter.js';
16
+ import { getThinkingCapability, normalizeEffort } from '../models.js';
17
+
18
+ /**
19
+ * task-327a: feature-flag accessor. Read lazily so tests can flip.
20
+ */
21
+ function thinkingV1Enabled() {
22
+ return process.env.UNIFY_THINKING_V1 === '1';
23
+ }
24
+
25
+ /**
26
+ * task-327a: router-level effort filter.
27
+ *
28
+ * Strips `effort` from the outgoing params when:
29
+ * - feature flag is off (thinkingV1 == off)
30
+ * - effort value is unknown
31
+ * - model capability is `thinkingProtocol: 'none'` (silently drop)
32
+ *
33
+ * Adapter-level guards also enforce these rules; this is defense in depth
34
+ * so a no-op path stays consistently a no-op regardless of adapter.
35
+ *
36
+ * @param {object} params
37
+ * @returns {object} new params object with effort possibly removed
38
+ */
39
+ export function filterEffortForModel(params) {
40
+ if (!params || !('effort' in params)) return params;
41
+ if (!thinkingV1Enabled()) {
42
+ const { effort: _drop, ...rest } = params;
43
+ return rest;
44
+ }
45
+ const norm = normalizeEffort(params.effort);
46
+ if (!norm) {
47
+ const { effort: _drop, ...rest } = params;
48
+ return rest;
49
+ }
50
+ const cap = getThinkingCapability(params.model);
51
+ if (!cap.supportsThinking || cap.thinkingProtocol === 'none') {
52
+ const { effort: _drop, ...rest } = params;
53
+ return rest;
54
+ }
55
+ return { ...params, effort: norm };
56
+ }
16
57
 
17
58
  /**
18
59
  * AdapterRouter — Implements LLMAdapter, routes by model → provider.
@@ -106,8 +147,9 @@ export class AdapterRouter extends LLMAdapter {
106
147
  * @returns {AsyncGenerator<import('./adapter.js').StreamEvent>}
107
148
  */
108
149
  async *stream(params) {
109
- const adapter = await this.#resolveAdapter(params.model);
110
- yield* adapter.stream(params);
150
+ const filtered = filterEffortForModel(params);
151
+ const adapter = await this.#resolveAdapter(filtered.model);
152
+ yield* adapter.stream(filtered);
111
153
  }
112
154
 
113
155
  /**
@@ -117,8 +159,9 @@ export class AdapterRouter extends LLMAdapter {
117
159
  * @returns {Promise<{ text: string, usage: { inputTokens: number, outputTokens: number } }>}
118
160
  */
119
161
  async call(params) {
120
- const adapter = await this.#resolveAdapter(params.model);
121
- return adapter.call(params);
162
+ const filtered = filterEffortForModel(params);
163
+ const adapter = await this.#resolveAdapter(filtered.model);
164
+ return adapter.call(filtered);
122
165
  }
123
166
 
124
167
  /**
package/unify/models.js CHANGED
@@ -20,6 +20,16 @@
20
20
  * @property {number} contextWindow — Max context tokens
21
21
  * @property {number} maxOutputTokens — Max output tokens
22
22
  * @property {string} displayName — Human-readable model name
23
+ * @property {boolean} [supportsThinking] — task-327a: model supports thinking/reasoning effort.
24
+ * @property {'anthropic' | 'openai-reasoning' | 'none'} [thinkingProtocol] — task-327a:
25
+ * 'anthropic' → thinking:{type:'enabled', budget_tokens:N}
26
+ * 'openai-reasoning' → reasoning:{effort:'low'|'medium'|'high'}
27
+ * 'none' (default) → parameter silently dropped by router
28
+ * @property {'low' | 'medium' | 'high' | 'max' | null} [defaultEffort] — task-327a: adapter-level default
29
+ * when caller doesn't specify effort (null = no default / decision-tree decides).
30
+ * @property {number} [maxBudgetTokens] — task-327a: for anthropic protocol, the cap used when
31
+ * effort='max' (e.g. Opus 4 = 64K, Sonnet 4 = 32K). For openai-reasoning this field is unused
32
+ * because the provider only exposes 3 enum levels.
23
33
  */
24
34
 
25
35
  /** @type {Map<string, ModelInfo>} */
@@ -32,6 +42,11 @@ export const MODEL_REGISTRY = new Map([
32
42
  contextWindow: 200000,
33
43
  maxOutputTokens: 16384,
34
44
  displayName: 'Claude Sonnet 4',
45
+ // task-327a: extended thinking supported; budget caps at 32K on Sonnet.
46
+ supportsThinking: true,
47
+ thinkingProtocol: 'anthropic',
48
+ defaultEffort: null,
49
+ maxBudgetTokens: 32000,
35
50
  }],
36
51
  ['claude-opus-4-20250514', {
37
52
  provider: 'anthropic',
@@ -40,6 +55,11 @@ export const MODEL_REGISTRY = new Map([
40
55
  contextWindow: 200000,
41
56
  maxOutputTokens: 16384,
42
57
  displayName: 'Claude Opus 4',
58
+ // task-327a: PM decision — Opus max budget = 64K.
59
+ supportsThinking: true,
60
+ thinkingProtocol: 'anthropic',
61
+ defaultEffort: null,
62
+ maxBudgetTokens: 64000,
43
63
  }],
44
64
  ['claude-haiku-3-20250414', {
45
65
  provider: 'anthropic',
@@ -48,6 +68,9 @@ export const MODEL_REGISTRY = new Map([
48
68
  contextWindow: 200000,
49
69
  maxOutputTokens: 8192,
50
70
  displayName: 'Claude Haiku 3',
71
+ // task-327a: Haiku 3 does not support extended thinking — effort is dropped.
72
+ supportsThinking: false,
73
+ thinkingProtocol: 'none',
51
74
  }],
52
75
 
53
76
  // ── OpenAI ─────────────────────────────────────────────────────
@@ -58,6 +81,10 @@ export const MODEL_REGISTRY = new Map([
58
81
  contextWindow: 256000,
59
82
  maxOutputTokens: 16384,
60
83
  displayName: 'GPT-5',
84
+ // task-327a: GPT-5 supports reasoning.effort (low/medium/high). No 'max'.
85
+ supportsThinking: true,
86
+ thinkingProtocol: 'openai-reasoning',
87
+ defaultEffort: null,
61
88
  }],
62
89
  // gpt-5-mini/-nano/-pro: keep id + family/protocol metadata so they appear
63
90
  // as known models, but do NOT hardcode context/maxOutput — the real limits
@@ -119,6 +146,10 @@ export const MODEL_REGISTRY = new Map([
119
146
  contextWindow: 200000,
120
147
  maxOutputTokens: 100000,
121
148
  displayName: 'o3',
149
+ // task-327a: o-series reasoning models use reasoning.effort.
150
+ supportsThinking: true,
151
+ thinkingProtocol: 'openai-reasoning',
152
+ defaultEffort: null,
122
153
  }],
123
154
  ['o4-mini', {
124
155
  provider: 'openai',
@@ -127,6 +158,9 @@ export const MODEL_REGISTRY = new Map([
127
158
  contextWindow: 200000,
128
159
  maxOutputTokens: 100000,
129
160
  displayName: 'o4-mini',
161
+ supportsThinking: true,
162
+ thinkingProtocol: 'openai-reasoning',
163
+ defaultEffort: null,
130
164
  }],
131
165
 
132
166
  // ── DeepSeek ───────────────────────────────────────────────────
@@ -231,7 +265,110 @@ export function parseModelRef(ref) {
231
265
  };
232
266
  }
233
267
 
234
- // ─── task-284: config-driven context / maxOutput ────────────────
268
+ // ─── task-327a: thinking / reasoning capability ─────────────────
269
+
270
+ /**
271
+ * Valid effort levels accepted by Unify adapters.
272
+ * @typedef {'low' | 'medium' | 'high' | 'max'} Effort
273
+ */
274
+
275
+ /**
276
+ * Budget-token map for the Anthropic extended-thinking protocol.
277
+ * 'max' is model-specific (override via ModelInfo.maxBudgetTokens).
278
+ *
279
+ * These numbers are adapter defaults — `thinkingBudgetForEffort()` below
280
+ * consults the registry entry first before falling back to this table.
281
+ */
282
+ export const ANTHROPIC_THINKING_BUDGETS = {
283
+ low: 4096,
284
+ medium: 8192,
285
+ high: 16384,
286
+ // 'max' resolves per-model; default fallback if model has no maxBudgetTokens.
287
+ max: 32000,
288
+ };
289
+
290
+ /**
291
+ * Map a Unify effort level to the OpenAI reasoning.effort enum. OpenAI does
292
+ * not expose a 'max' level — callers that pass 'max' get 'high' (the highest
293
+ * available on that protocol). The router/engine should log this downgrade
294
+ * but the adapter MUST NOT error.
295
+ *
296
+ * @param {Effort} effort
297
+ * @returns {'low' | 'medium' | 'high' | null}
298
+ */
299
+ export function mapEffortToOpenAIReasoning(effort) {
300
+ if (!effort) return null;
301
+ switch (effort) {
302
+ case 'low': return 'low';
303
+ case 'medium': return 'medium';
304
+ case 'high': return 'high';
305
+ // OpenAI doesn't support 'max'; degrade to 'high'. Engine may emit a
306
+ // debug line noting the downgrade — adapter level stays silent.
307
+ case 'max': return 'high';
308
+ default: return null;
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Resolve the Anthropic thinking budget_tokens value for a given (model, effort).
314
+ *
315
+ * Priority:
316
+ * 1. Registry ModelInfo.maxBudgetTokens when effort === 'max'
317
+ * 2. ANTHROPIC_THINKING_BUDGETS[effort]
318
+ *
319
+ * @param {string} model
320
+ * @param {Effort} effort
321
+ * @returns {number | null} Null when effort is unknown/falsy.
322
+ */
323
+ export function thinkingBudgetForEffort(model, effort) {
324
+ if (!effort) return null;
325
+ if (effort === 'max') {
326
+ const info = MODEL_REGISTRY.get(model);
327
+ if (info?.maxBudgetTokens) return info.maxBudgetTokens;
328
+ return ANTHROPIC_THINKING_BUDGETS.max;
329
+ }
330
+ return ANTHROPIC_THINKING_BUDGETS[effort] ?? null;
331
+ }
332
+
333
+ /**
334
+ * Get the thinking capability for a model. Models not in the registry or
335
+ * explicitly marked supportsThinking:false return a noop capability — the
336
+ * router uses this to silently drop the `effort` parameter for unsupported
337
+ * models (red line: never error on unsupported).
338
+ *
339
+ * @param {string} model
340
+ * @returns {{ supportsThinking: boolean, thinkingProtocol: 'anthropic' | 'openai-reasoning' | 'none', defaultEffort: Effort | null, maxBudgetTokens: number | null }}
341
+ */
342
+ export function getThinkingCapability(model) {
343
+ const info = MODEL_REGISTRY.get(model);
344
+ if (!info || !info.supportsThinking) {
345
+ return {
346
+ supportsThinking: false,
347
+ thinkingProtocol: 'none',
348
+ defaultEffort: null,
349
+ maxBudgetTokens: null,
350
+ };
351
+ }
352
+ return {
353
+ supportsThinking: true,
354
+ thinkingProtocol: info.thinkingProtocol || 'none',
355
+ defaultEffort: info.defaultEffort ?? null,
356
+ maxBudgetTokens: info.maxBudgetTokens ?? null,
357
+ };
358
+ }
359
+
360
+ /**
361
+ * Valid-effort guard. Unknown values → null (caller should treat as "no effort").
362
+ *
363
+ * @param {unknown} effort
364
+ * @returns {Effort | null}
365
+ */
366
+ export function normalizeEffort(effort) {
367
+ if (effort === 'low' || effort === 'medium' || effort === 'high' || effort === 'max') {
368
+ return effort;
369
+ }
370
+ return null;
371
+ }
235
372
 
236
373
  /**
237
374
  * Coerce a possibly-stringy numeric value to a positive integer, or
package/unify/session.js CHANGED
@@ -269,5 +269,13 @@ export async function loadSession(options = {}) {
269
269
  threadStore: getThreadStore(),
270
270
  status,
271
271
  shutdown,
272
+ // task-325c: user-initiated abort API. Delegates to web-bridge which
273
+ // owns the per-thread AbortController registry (`abortByThread`).
274
+ // Lazy-imported to avoid a hard cycle with web-bridge.js (which already
275
+ // imports this module to call loadSession).
276
+ async abort(opts = {}) {
277
+ const { abortUnifySession } = await import('./web-bridge.js');
278
+ return abortUnifySession(opts);
279
+ },
272
280
  };
273
281
  }
@@ -848,6 +848,90 @@ export async function handleUnifyChat(msg) {
848
848
  }
849
849
  }
850
850
 
851
+ /**
852
+ * task-325c: user-initiated abort of an in-flight Unify query on ONE thread.
853
+ *
854
+ * Cancels the AbortController registered for `msg.threadId` (if any). Silent
855
+ * no-op when the thread has no in-flight round — users clicking Stop on an
856
+ * already-idle thread should not trigger an error bubble. Emits an
857
+ * `unify_aborted` event for UI acknowledgement and a fresh
858
+ * `thread_list_updated` so inflight pills clear immediately.
859
+ *
860
+ * Red line (PM): the `thread_list_updated` event name is preserved; no
861
+ * new per-thread abort signal leaks into `Engine.abort()`'s signature.
862
+ *
863
+ * @param {{ threadId?: string }} msg
864
+ * @returns {{ aborted: string[], all: boolean }}
865
+ */
866
+ export function handleUnifyAbortThread(msg = {}) {
867
+ const aborted = [];
868
+ const threadId = msg && msg.threadId;
869
+ if (threadId) {
870
+ const ctrl = abortByThread.get(threadId);
871
+ if (ctrl) {
872
+ try { ctrl.abort(); } catch { /* best-effort */ }
873
+ abortByThread.delete(threadId);
874
+ aborted.push(threadId);
875
+ }
876
+ }
877
+ sendUnifyEvent({ type: 'unify_aborted', aborted, all: false });
878
+ sendThreadListUpdate();
879
+ return { aborted, all: false };
880
+ }
881
+
882
+ /**
883
+ * task-325c: user-initiated abort of ALL in-flight Unify queries.
884
+ *
885
+ * Iterates every registered controller, aborts it, then clears the map.
886
+ * Always emits `unify_aborted` with `all:true` (even when nothing was
887
+ * running) so the UI can confirm the click landed.
888
+ *
889
+ * @returns {{ aborted: string[], all: boolean }}
890
+ */
891
+ export function handleUnifyAbortAll() {
892
+ const aborted = [];
893
+ for (const [threadId, ctrl] of abortByThread.entries()) {
894
+ try { ctrl.abort(); } catch { /* best-effort */ }
895
+ aborted.push(threadId);
896
+ }
897
+ abortByThread.clear();
898
+ sendUnifyEvent({ type: 'unify_aborted', aborted, all: true });
899
+ sendThreadListUpdate();
900
+ return { aborted, all: true };
901
+ }
902
+
903
+ /**
904
+ * Unified dispatcher bound onto `session.abort({ threadId?, all? })`.
905
+ * Routes to {@link handleUnifyAbortThread} or {@link handleUnifyAbortAll}
906
+ * per input. Kept exported so message-router and tests can call it too.
907
+ *
908
+ * @param {{ threadId?: string, all?: boolean }} [opts]
909
+ */
910
+ export function abortUnifySession(opts = {}) {
911
+ if (opts && opts.all) return handleUnifyAbortAll();
912
+ if (opts && opts.threadId) return handleUnifyAbortThread({ threadId: opts.threadId });
913
+ // No payload — conservative default: abort nothing, just emit ack so
914
+ // callers see the no-op round-trip. Matches PM "don't accidentally
915
+ // nuke everything on a bare click".
916
+ sendUnifyEvent({ type: 'unify_aborted', aborted: [], all: false });
917
+ return { aborted: [], all: false };
918
+ }
919
+
920
+ /**
921
+ * Test-only: seed / inspect the abort registry without spinning up a
922
+ * full session. Never use from production code — the prod registry is
923
+ * managed by handleUnifyChat's per-query controller lifecycle.
924
+ * @private
925
+ */
926
+ export function __testSeedAbortController(threadId, ctrl) {
927
+ abortByThread.set(threadId, ctrl);
928
+ }
929
+
930
+ /** Test-only: returns the set of thread ids currently registered. */
931
+ export function __testGetRegisteredThreadIds() {
932
+ return [...abortByThread.keys()];
933
+ }
934
+
851
935
  /**
852
936
  * Handle mode switch from the web UI.
853
937
  * DEPRECATED (task-297): Unify no longer has chat/work mode distinction.