@yeaft/webchat-agent 0.1.503 → 0.1.505
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 +1 -1
- package/unify/llm/anthropic.js +35 -2
- package/unify/llm/chat-completions.js +29 -2
- package/unify/llm/router.js +47 -4
- package/unify/models.js +138 -1
- package/unify/web-bridge.js +133 -0
package/package.json
CHANGED
package/unify/llm/anthropic.js
CHANGED
|
@@ -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
|
|
package/unify/llm/router.js
CHANGED
|
@@ -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
|
|
110
|
-
|
|
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
|
|
121
|
-
|
|
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-
|
|
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/web-bridge.js
CHANGED
|
@@ -243,6 +243,109 @@ const THREAD_MUTATING_TOOLS = new Set([
|
|
|
243
243
|
'AttachThreadToTask',
|
|
244
244
|
]);
|
|
245
245
|
|
|
246
|
+
/**
|
|
247
|
+
* task-325b — Working Status event stream.
|
|
248
|
+
*
|
|
249
|
+
* Surfaces Engine lifecycle events (emitted by 325a) as a single
|
|
250
|
+
* `thread_status` event for the frontend Working Status panel, plus
|
|
251
|
+
* `thread_list_snapshot` for cold-start / reconnect.
|
|
252
|
+
*
|
|
253
|
+
* Contract (aligned with designer spec):
|
|
254
|
+
* thread_status → { type: 'thread_status', threadId, state,
|
|
255
|
+
* startedAt?, completedAt?, toolName?, reason? }
|
|
256
|
+
* state ∈ 'running' | 'idle' | 'aborted' | 'error'
|
|
257
|
+
* thread_list_snapshot → { type: 'thread_list_snapshot', threads[],
|
|
258
|
+
* currentThreadId, serverTime }
|
|
259
|
+
*
|
|
260
|
+
* Red lines (per PM): do NOT mutate engine state; this layer is a pure
|
|
261
|
+
* observer + translator. Event names match designer doc verbatim.
|
|
262
|
+
*/
|
|
263
|
+
|
|
264
|
+
/** Map Engine event name → Working Status state string. */
|
|
265
|
+
function engineEventToState(engineEventType) {
|
|
266
|
+
switch (engineEventType) {
|
|
267
|
+
case 'thread_started': return 'running';
|
|
268
|
+
case 'thread_completed': return 'idle';
|
|
269
|
+
case 'thread_aborted': return 'aborted';
|
|
270
|
+
case 'thread_error': return 'error';
|
|
271
|
+
default: return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Build and broadcast a `thread_status` payload translated from a raw
|
|
277
|
+
* engine lifecycle event. The engine event shape (325a) is:
|
|
278
|
+
* { type, threadId, startedAt?, completedAt?, toolName?, reason? }
|
|
279
|
+
* Unknown fields pass through untouched so future engine additions
|
|
280
|
+
* (e.g. `attempt`) flow to the UI without another bridge change.
|
|
281
|
+
*
|
|
282
|
+
* @param {object} ev — engine event
|
|
283
|
+
* @returns {boolean} true if a thread_status was emitted
|
|
284
|
+
*/
|
|
285
|
+
function emitThreadStatusFromEngineEvent(ev) {
|
|
286
|
+
if (!ev || typeof ev !== 'object') return false;
|
|
287
|
+
const state = engineEventToState(ev.type);
|
|
288
|
+
if (!state) return false;
|
|
289
|
+
const payload = { type: 'thread_status', threadId: ev.threadId, state };
|
|
290
|
+
if (ev.startedAt != null) payload.startedAt = ev.startedAt;
|
|
291
|
+
if (ev.completedAt != null) payload.completedAt = ev.completedAt;
|
|
292
|
+
if (ev.toolName) payload.toolName = ev.toolName;
|
|
293
|
+
if (ev.reason) payload.reason = ev.reason;
|
|
294
|
+
if (ev.error?.message) payload.error = ev.error.message;
|
|
295
|
+
sendUnifyEvent(payload);
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* task-325b: full-snapshot push distinct from `thread_list_updated`.
|
|
301
|
+
* Emits `thread_list_snapshot` — a complete state dump the client uses
|
|
302
|
+
* on page load / WebSocket reconnect to rebuild the Working Status panel
|
|
303
|
+
* without missing any in-flight thread.
|
|
304
|
+
*
|
|
305
|
+
* Snapshot includes per-thread `state` (idle / running / aborted) resolved
|
|
306
|
+
* from the engine registry's live inflight set. Threads the registry has
|
|
307
|
+
* no entry for default to 'idle'.
|
|
308
|
+
*/
|
|
309
|
+
function sendThreadListSnapshot() {
|
|
310
|
+
try {
|
|
311
|
+
const store = getThreadStore();
|
|
312
|
+
const registry = session?.engineRegistry || null;
|
|
313
|
+
const inflight = new Set(
|
|
314
|
+
typeof registry?.inflightThreadIds === 'function'
|
|
315
|
+
? registry.inflightThreadIds()
|
|
316
|
+
: [],
|
|
317
|
+
);
|
|
318
|
+
const threads = store.list().map(t => ({
|
|
319
|
+
id: t.id,
|
|
320
|
+
name: t.name,
|
|
321
|
+
goal: t.goal || '',
|
|
322
|
+
parentThreadId: t.parentThreadId || null,
|
|
323
|
+
status: t.status,
|
|
324
|
+
archived: !!t.archived,
|
|
325
|
+
messageCount: t.messageCount || 0,
|
|
326
|
+
lastMessageAt: t.lastMessageAt || null,
|
|
327
|
+
lastActivityAt: t.lastActivityAt || t.lastMessageAt || t.updatedAt || null,
|
|
328
|
+
unread: t.unread || 0,
|
|
329
|
+
preview: t.preview || '',
|
|
330
|
+
createdAt: t.createdAt,
|
|
331
|
+
updatedAt: t.updatedAt,
|
|
332
|
+
taskId: (typeof store.attachedTask === 'function')
|
|
333
|
+
? (store.attachedTask(t.id) || null)
|
|
334
|
+
: null,
|
|
335
|
+
running: t.id === store.currentId,
|
|
336
|
+
state: inflight.has(t.id) ? 'running' : 'idle',
|
|
337
|
+
}));
|
|
338
|
+
sendUnifyEvent({
|
|
339
|
+
type: 'thread_list_snapshot',
|
|
340
|
+
threads,
|
|
341
|
+
currentThreadId: store.currentId,
|
|
342
|
+
serverTime: Date.now(),
|
|
343
|
+
});
|
|
344
|
+
} catch (err) {
|
|
345
|
+
console.warn('[Unify] sendThreadListSnapshot failed:', err?.message || err);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
246
349
|
/**
|
|
247
350
|
* task-310: parse a leading `@thread-<id>` or `@thread-<name>` marker on
|
|
248
351
|
* the user's input and return it as a dispatcher override. The marker
|
|
@@ -331,6 +434,23 @@ function forwardPipelineEvent(ev, ctx) {
|
|
|
331
434
|
*/
|
|
332
435
|
function handleEngineEvent(event, threadId, hctx) {
|
|
333
436
|
hctx.resetQueryTimer();
|
|
437
|
+
|
|
438
|
+
// task-325b: translate Engine lifecycle events into a single
|
|
439
|
+
// `thread_status` event for the frontend Working Status panel. These
|
|
440
|
+
// events are observer-only — they never mutate bridge state. The raw
|
|
441
|
+
// engine event is NOT forwarded further; the switch below handles
|
|
442
|
+
// anything the UI still needs.
|
|
443
|
+
if (event && (
|
|
444
|
+
event.type === 'thread_started' ||
|
|
445
|
+
event.type === 'thread_completed' ||
|
|
446
|
+
event.type === 'thread_aborted' ||
|
|
447
|
+
event.type === 'thread_error'
|
|
448
|
+
)) {
|
|
449
|
+
// Engine events carry their own threadId; fall back to envelope id.
|
|
450
|
+
emitThreadStatusFromEngineEvent({ ...event, threadId: event.threadId || threadId });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
334
454
|
switch (event.type) {
|
|
335
455
|
case 'text_delta':
|
|
336
456
|
hctx.assistantTextParts.push(event.text);
|
|
@@ -562,6 +682,10 @@ export async function handleUnifyChat(msg) {
|
|
|
562
682
|
// task-301 Part 2: initial thread snapshot so sidebar V2 renders
|
|
563
683
|
// the real 'main' thread (and any restored threads) right away.
|
|
564
684
|
sendThreadListUpdate();
|
|
685
|
+
// task-325b: full Working Status snapshot (superset with state +
|
|
686
|
+
// serverTime) so a freshly-connected client can restore inflight
|
|
687
|
+
// status without waiting for the next engine event.
|
|
688
|
+
sendThreadListSnapshot();
|
|
565
689
|
}
|
|
566
690
|
|
|
567
691
|
// ─── Per-call AbortController (task-320) ──
|
|
@@ -945,6 +1069,12 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
945
1069
|
tools: session.status.tools,
|
|
946
1070
|
});
|
|
947
1071
|
sendThreadListUpdate();
|
|
1072
|
+
// task-325b: after a page refresh / reconnect the frontend needs the
|
|
1073
|
+
// full Working Status snapshot to rebuild the panel (which thread is
|
|
1074
|
+
// running, idle, aborted). `thread_list_updated` is intentionally a
|
|
1075
|
+
// mutation-delta stream; `thread_list_snapshot` is the single
|
|
1076
|
+
// authoritative "everything right now" payload.
|
|
1077
|
+
sendThreadListSnapshot();
|
|
948
1078
|
|
|
949
1079
|
const limit = msg.limit || 50;
|
|
950
1080
|
const messages = session.conversationStore.loadRecent(limit);
|
|
@@ -1030,6 +1160,9 @@ export async function resetUnifySession() {
|
|
|
1030
1160
|
});
|
|
1031
1161
|
// task-301 Part 2: re-push thread snapshot after session reset.
|
|
1032
1162
|
sendThreadListUpdate();
|
|
1163
|
+
// task-325b: also push the full Working Status snapshot so the UI
|
|
1164
|
+
// doesn't retain stale "running" badges from the prior session.
|
|
1165
|
+
sendThreadListSnapshot();
|
|
1033
1166
|
} catch (err) {
|
|
1034
1167
|
console.error('[Unify] Failed to re-initialize session after reset:', err.message);
|
|
1035
1168
|
}
|