@yeaft/webchat-agent 0.1.803 → 0.1.805

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.
@@ -36,7 +36,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
36
36
  import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
37
37
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
38
38
  import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings, getSearchSettings, updateSearchSettings, fetchTavilyUsage } from '../unify/config-api.js';
39
- import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyLoadMoreHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyUpdateGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger, handleUnifyFetchToolStats, handleUnifyFetchDebugHistory, broadcastLanguageChange } from '../unify/web-bridge.js';
39
+ import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyLoadMoreHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyUpdateGroup, handleUnifyUpdateGroupConfig, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger, handleUnifyFetchToolStats, handleUnifyFetchDebugHistory, broadcastLanguageChange } from '../unify/web-bridge.js';
40
40
 
41
41
  export async function handleMessage(msg) {
42
42
  switch (msg.type) {
@@ -476,6 +476,9 @@ export async function handleMessage(msg) {
476
476
  case 'unify_update_group':
477
477
  handleUnifyUpdateGroup(msg);
478
478
  break;
479
+ case 'unify_update_group_config':
480
+ handleUnifyUpdateGroupConfig(msg);
481
+ break;
479
482
  case 'unify_archive_group':
480
483
  handleUnifyArchiveGroup(msg);
481
484
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.803",
3
+ "version": "0.1.805",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -142,6 +142,33 @@ function serializeMessage(msg) {
142
142
  }
143
143
  }
144
144
 
145
+ // task-327d: persist Anthropic extended-thinking blocks so the next turn
146
+ // can echo them back with their server-signed signature. Both fields are
147
+ // base64'd: thinking is multi-line text, and the signature is opaque
148
+ // bytes that don't need to be human-readable. Without this round-trip
149
+ // the next Anthropic request 400s with "content[].thinking in the
150
+ // thinking mode must be passed back to the API".
151
+ if (msg.thinkingBlocks && msg.thinkingBlocks.length > 0) {
152
+ fm.push(`thinkingBlocks:`);
153
+ for (const tb of msg.thinkingBlocks) {
154
+ if (!tb || typeof tb.signature !== 'string' || !tb.signature) continue;
155
+ if (tb.redacted) {
156
+ if (typeof tb.data !== 'string') continue;
157
+ const dataB64 = Buffer.from(tb.data, 'utf8').toString('base64');
158
+ const signatureB64 = Buffer.from(tb.signature, 'utf8').toString('base64');
159
+ fm.push(` - redacted: true`);
160
+ fm.push(` dataB64: ${dataB64}`);
161
+ fm.push(` signatureB64: ${signatureB64}`);
162
+ } else {
163
+ if (typeof tb.thinking !== 'string') continue;
164
+ const thinkingB64 = Buffer.from(tb.thinking, 'utf8').toString('base64');
165
+ const signatureB64 = Buffer.from(tb.signature, 'utf8').toString('base64');
166
+ fm.push(` - thinkingB64: ${thinkingB64}`);
167
+ fm.push(` signatureB64: ${signatureB64}`);
168
+ }
169
+ }
170
+ }
171
+
145
172
  fm.push('---');
146
173
  fm.push('');
147
174
  fm.push(content);
@@ -229,6 +256,44 @@ export function parseMessage(raw) {
229
256
  if (toolCalls.length > 0) msg.toolCalls = toolCalls;
230
257
  }
231
258
 
259
+ // task-327d: parse thinkingBlocks (mirror of toolCalls parser above)
260
+ if (frontmatter.includes('thinkingBlocks:')) {
261
+ const thinkingBlocks = [];
262
+ const tbMatch = frontmatter.match(/thinkingBlocks:\n((?:\s+-\s+[\s\S]*?)(?=\n\w|$))/);
263
+ if (tbMatch) {
264
+ const tbBlock = tbMatch[1];
265
+ const entries = tbBlock.split(/\n\s+-\s+/).filter(Boolean);
266
+ for (const entry of entries) {
267
+ const tb = {};
268
+ for (const line of entry.split('\n')) {
269
+ const trimmed = line.trim().replace(/^-\s+/, '');
270
+ const ci = trimmed.indexOf(':');
271
+ if (ci === -1) continue;
272
+ const k = trimmed.slice(0, ci).trim();
273
+ const v = trimmed.slice(ci + 1).trim();
274
+ if (k === 'thinkingB64') {
275
+ tb.thinking = Buffer.from(v, 'base64').toString('utf8');
276
+ } else if (k === 'dataB64') {
277
+ tb.data = Buffer.from(v, 'base64').toString('utf8');
278
+ } else if (k === 'signatureB64') {
279
+ tb.signature = Buffer.from(v, 'base64').toString('utf8');
280
+ } else if (k === 'redacted') {
281
+ tb.redacted = v === 'true';
282
+ }
283
+ }
284
+ // Both fields required — an unsigned block would 400 on replay.
285
+ if (tb.redacted) {
286
+ if (typeof tb.data === 'string' && typeof tb.signature === 'string' && tb.signature) {
287
+ thinkingBlocks.push(tb);
288
+ }
289
+ } else if (typeof tb.thinking === 'string' && typeof tb.signature === 'string' && tb.signature) {
290
+ thinkingBlocks.push(tb);
291
+ }
292
+ }
293
+ }
294
+ if (thinkingBlocks.length > 0) msg.thinkingBlocks = thinkingBlocks;
295
+ }
296
+
232
297
  return msg;
233
298
  }
234
299
 
package/unify/effort.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * 4. null (no effort = adapter/router drops the param)
14
14
  *
15
15
  * Red lines:
16
- * • Never error on unknown scenario — default to 'high'.
16
+ * • Never error on unknown scenario — default to 'max'.
17
17
  * • Feature flag UNIFY_THINKING_V1 is enforced at the adapter/router
18
18
  * layer; this module just computes the intended value. If the flag
19
19
  * is off, adapters drop it anyway.
@@ -36,7 +36,8 @@ export const LONG_LOOP_TURN_THRESHOLD = 8;
36
36
  * a scenario string before invoking `pickEffort()`.
37
37
  *
38
38
  * Tiers (6 scenarios per architect spec):
39
- * chat → high (default interactive pair-programming turn)
39
+ * chat → max (default interactive pair-programming turn
40
+ * quality over latency; per user 2026-05-22)
40
41
  * consolidate → max (memory compaction — quality matters, runs once)
41
42
  * dream → max (memory maintenance — same rationale)
42
43
  * sub_agent → max (coordinator spawns + merges)
@@ -47,7 +48,7 @@ export const LONG_LOOP_TURN_THRESHOLD = 8;
47
48
  * Unknown scenarios fall through to 'high'.
48
49
  */
49
50
  export const SCENARIO_EFFORT = Object.freeze({
50
- chat: 'high',
51
+ chat: 'max',
51
52
  consolidate: 'max',
52
53
  dream: 'max',
53
54
  sub_agent: 'max',
@@ -65,7 +66,7 @@ export const SCENARIO_EFFORT = Object.freeze({
65
66
  * `/max` prefix, Settings slider, or API caller.
66
67
  * 2. If toolLoopTurns >= LONG_LOOP_TURN_THRESHOLD, upgrade the
67
68
  * base scenario to 'long_loop' (→ 'max').
68
- * 3. Look up SCENARIO_EFFORT[scenario]; unknown → 'high'.
69
+ * 3. Look up SCENARIO_EFFORT[scenario]; unknown → 'max'.
69
70
  *
70
71
  * @param {object} ctx
71
72
  * @param {string} [ctx.scenario='chat'] — Scenario tag; see SCENARIO_EFFORT.
@@ -92,7 +93,7 @@ export function pickEffort({ scenario = 'chat', toolLoopTurns = 0, userEffort =
92
93
  }
93
94
 
94
95
  // 3. Scenario table lookup.
95
- return SCENARIO_EFFORT[scenario] || 'high';
96
+ return SCENARIO_EFFORT[scenario] || 'max';
96
97
  }
97
98
 
98
99
  /**
package/unify/engine.js CHANGED
@@ -1486,6 +1486,7 @@ export class Engine {
1486
1486
  let ttfbMs = null; // Time to first token
1487
1487
  let responseText = '';
1488
1488
  const toolCalls = [];
1489
+ const thinkingBlocks = []; // task-327d: collected from adapter for round-trip
1489
1490
  let stopReason = 'end_turn';
1490
1491
  const totalUsage = { inputTokens: 0, outputTokens: 0 };
1491
1492
  // task-344: capture redacted raw request / raw response for debug panel.
@@ -1660,6 +1661,22 @@ export class Engine {
1660
1661
  case 'thinking_delta':
1661
1662
  yield event;
1662
1663
  break;
1664
+ case 'thinking_block_end':
1665
+ // task-327d: collect server-signed thinking block for
1666
+ // round-trip replay. Anthropic 400s the next turn if a
1667
+ // thinking block (regular or redacted) was emitted but not
1668
+ // echoed back with its original signature. Drop blocks
1669
+ // missing a signature — replay-without-sig 400s identically.
1670
+ if (event.signature) {
1671
+ if (event.redacted) {
1672
+ thinkingBlocks.push({ redacted: true, data: event.data, signature: event.signature });
1673
+ } else {
1674
+ thinkingBlocks.push({ thinking: event.thinking, signature: event.signature });
1675
+ }
1676
+ } else {
1677
+ console.warn('[Engine] thinking block missing signature — dropping; next turn would 400 on replay');
1678
+ }
1679
+ break;
1663
1680
  case 'tool_call':
1664
1681
  toolCalls.push(event);
1665
1682
  yield event;
@@ -1843,6 +1860,17 @@ export class Engine {
1843
1860
  input: tc.input,
1844
1861
  }));
1845
1862
  }
1863
+ // task-327d: persist thinking blocks for the next turn's replay.
1864
+ // Anthropic requires assistant.thinking blocks to be echoed back
1865
+ // verbatim (text + signature) when the previous turn used extended
1866
+ // thinking — see translateMessages in anthropic.js.
1867
+ if (thinkingBlocks.length > 0) {
1868
+ assistantMsg.thinkingBlocks = thinkingBlocks.map(tb => (
1869
+ tb.redacted
1870
+ ? { redacted: true, data: tb.data, signature: tb.signature }
1871
+ : { thinking: tb.thinking, signature: tb.signature }
1872
+ ));
1873
+ }
1846
1874
  // Phase 8 (DESIGN.md §9.15): carry the router plan back on the
1847
1875
  // assistant message that produced it. Stripped at the wire by
1848
1876
  // stripMetaForWire — pure bookkeeping for priorPlan continuity.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * group-config.js — Per-group configuration overrides.
3
+ *
4
+ * Each group may carry its own `config.json` at
5
+ * ~/.yeaft/groups/<groupId>/config.json
6
+ *
7
+ * v1 schema (intentionally tiny — extend via additive keys only):
8
+ * {
9
+ * "model": "my-proxy/claude-sonnet-4-20250514" // optional
10
+ * }
11
+ *
12
+ * Missing file → empty object. Missing field → fall back to user-level
13
+ * config (`~/.yeaft/config.json` via loadConfig()). Resolution is a
14
+ * shallow overlay (group fields override user fields when truthy).
15
+ *
16
+ * Storage layer only — no engine wiring, no validation of model strings
17
+ * against the provider registry (that's done lazily at resolve time by
18
+ * the engine when it tries to dispatch to AdapterRouter).
19
+ */
20
+
21
+ import { existsSync, readFileSync } from 'fs';
22
+ import { join } from 'path';
23
+ import { writeAtomic } from '../storage/index.js';
24
+ import { groupsRoot, resolveGroupYeaftDir } from './group-crud.js';
25
+
26
+ const CONFIG_FILE = 'config.json';
27
+
28
+ /** Whitelist of fields a group may override. Reject everything else. */
29
+ const ALLOWED_KEYS = new Set(['model']);
30
+
31
+ export class GroupConfigError extends Error {
32
+ constructor(code, message) {
33
+ super(message || code);
34
+ this.name = 'GroupConfigError';
35
+ this.code = code;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Resolve the on-disk path for a group's config.json. Honours the
41
+ * per-group workDir registry so groups bound to a project directory
42
+ * keep their config alongside the group meta.
43
+ */
44
+ export function groupConfigPath(yeaftDir, groupId) {
45
+ if (!yeaftDir) return null;
46
+ const groupYeaftDir = resolveGroupYeaftDir(yeaftDir, groupId);
47
+ return join(groupsRoot(groupYeaftDir), groupId, CONFIG_FILE);
48
+ }
49
+
50
+ /**
51
+ * Read a group's config.json. Returns `{}` when the file is missing or
52
+ * corrupt — callers fall back to user-level defaults via
53
+ * `resolveGroupConfig`. We never auto-write on read.
54
+ *
55
+ * @param {string} yeaftDir
56
+ * @param {string} groupId
57
+ * @returns {object}
58
+ */
59
+ export function loadGroupConfig(yeaftDir, groupId) {
60
+ if (!groupId || !yeaftDir) return {};
61
+ const path = groupConfigPath(yeaftDir, groupId);
62
+ if (!path || !existsSync(path)) return {};
63
+ try {
64
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
65
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
66
+ // Strip unknown keys at read time too — defensive against
67
+ // hand-edited files that predate a key removal.
68
+ const out = {};
69
+ for (const k of Object.keys(parsed)) {
70
+ if (ALLOWED_KEYS.has(k)) out[k] = parsed[k];
71
+ }
72
+ return out;
73
+ } catch {
74
+ return {};
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Validate a partial group-config object. Throws GroupConfigError on
80
+ * any unknown key or malformed value. Empty / null values for known
81
+ * keys are allowed — they signal "fall back to user default".
82
+ *
83
+ * @param {object} cfg
84
+ */
85
+ export function validateGroupConfig(cfg) {
86
+ if (cfg === null || cfg === undefined) return;
87
+ if (typeof cfg !== 'object' || Array.isArray(cfg)) {
88
+ throw new GroupConfigError('invalid_shape', 'config must be an object');
89
+ }
90
+ for (const k of Object.keys(cfg)) {
91
+ if (!ALLOWED_KEYS.has(k)) {
92
+ throw new GroupConfigError('unknown_key', `unknown config key: ${k}`);
93
+ }
94
+ }
95
+ if ('model' in cfg && cfg.model !== null && cfg.model !== undefined && cfg.model !== '') {
96
+ if (typeof cfg.model !== 'string' || !cfg.model.trim()) {
97
+ throw new GroupConfigError('invalid_model', 'model must be a non-empty string');
98
+ }
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Shallow-merge `partial` into the group's existing config.json and
104
+ * persist atomically. Returns the resulting object. Passing `null` or
105
+ * empty string for a known key removes that key (so the group falls
106
+ * back to the user default).
107
+ *
108
+ * @param {string} yeaftDir
109
+ * @param {string} groupId
110
+ * @param {object} partial
111
+ * @returns {object}
112
+ */
113
+ export function saveGroupConfig(yeaftDir, groupId, partial) {
114
+ if (!groupId) throw new GroupConfigError('missing_group_id', 'groupId required');
115
+ validateGroupConfig(partial);
116
+ const current = loadGroupConfig(yeaftDir, groupId);
117
+ const next = { ...current };
118
+ for (const [k, v] of Object.entries(partial || {})) {
119
+ if (!ALLOWED_KEYS.has(k)) continue;
120
+ if (v === null || v === undefined || v === '') {
121
+ delete next[k];
122
+ } else {
123
+ next[k] = typeof v === 'string' ? v.trim() : v;
124
+ }
125
+ }
126
+ const path = groupConfigPath(yeaftDir, groupId);
127
+ writeAtomic(path, `${JSON.stringify(next, null, 2)}\n`);
128
+ return next;
129
+ }
130
+
131
+ /**
132
+ * Initialise an empty config.json next to a brand-new group. Idempotent —
133
+ * leaves an existing file untouched. Called by `createGroupFromSpec`.
134
+ */
135
+ export function ensureGroupConfigFile(yeaftDir, groupId) {
136
+ const path = groupConfigPath(yeaftDir, groupId);
137
+ if (!path || existsSync(path)) return;
138
+ try {
139
+ writeAtomic(path, `${JSON.stringify({}, null, 2)}\n`);
140
+ } catch {
141
+ // Best-effort — a permission failure here should never break group
142
+ // create. Read path returns {} on missing file anyway.
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Resolve the effective config for a group by overlaying group-level
148
+ * overrides on top of the user-level config.
149
+ *
150
+ * Only fields that the per-group schema knows about are overlaid;
151
+ * everything else (providers, language, token budgets, ...) is taken
152
+ * verbatim from the user config.
153
+ *
154
+ * @param {object} userConfig — loadConfig() result
155
+ * @param {object} groupConfig — loadGroupConfig() result
156
+ * @returns {object} — A new config object safe to hand to the engine.
157
+ */
158
+ export function resolveGroupConfig(userConfig, groupConfig) {
159
+ const base = userConfig ? { ...userConfig } : {};
160
+ const overrides = groupConfig && typeof groupConfig === 'object' ? groupConfig : {};
161
+ if (overrides.model && typeof overrides.model === 'string' && overrides.model.trim()) {
162
+ const model = overrides.model.trim();
163
+ base.model = model;
164
+ base.primaryModel = model;
165
+ }
166
+ return base;
167
+ }
@@ -53,6 +53,7 @@ import { seedDefaultGroup, DEFAULT_GROUP_ID } from './seed-default.js';
53
53
  import { nextGroupId, validateVpId, isReservedVpId } from './ids.js';
54
54
  import { scanVpLibrary, DEFAULT_VP_LIB_DIR } from '../vp/vp-store.js';
55
55
  import { seedSummaryIfMissingSync, removeScopeDirSync } from '../memory/store-v2.js';
56
+ import { ensureGroupConfigFile, saveGroupConfig, loadGroupConfig } from './group-config.js';
56
57
 
57
58
  /**
58
59
  * Default memory root used when callers don't pass `options.memoryRoot`.
@@ -263,6 +264,20 @@ export function createGroupFromSpec(yeaftDir, spec, options = {}) {
263
264
  handle.close();
264
265
  if (normalizedWorkDir) registerGroupWorkDir(yeaftDir, id, normalizedWorkDir);
265
266
 
267
+ // Per-group config (v1: model only). We always create an empty
268
+ // config.json so the file's presence signals "owned by this group" and
269
+ // hand-editing tools can find a stub. Initial overrides from the
270
+ // wizard spec (currently just `config.model`) are persisted here so
271
+ // the engine cache picks them up on the very first turn.
272
+ try {
273
+ ensureGroupConfigFile(yeaftDir, id);
274
+ if (spec && spec.config && typeof spec.config === 'object') {
275
+ saveGroupConfig(yeaftDir, id, spec.config);
276
+ }
277
+ } catch (err) {
278
+ console.warn(`[group-crud] failed to seed config.json for ${id}:`, err?.message || err);
279
+ }
280
+
266
281
  // Seed Layer-A resident summary so the first session has memory content
267
282
  // even before Dream-v2 has run. No-op if a summary.md already exists.
268
283
  // Best-effort: a memory-root permission failure must NOT break group create.
@@ -313,6 +328,22 @@ export function updateGroupAnnouncement(yeaftDir, groupId, text) {
313
328
  return next;
314
329
  }
315
330
 
331
+ /**
332
+ * (A.2.c) Update per-group config overrides (v1: just `model`).
333
+ * Returns the persisted config object so the caller can broadcast it.
334
+ *
335
+ * Throws GroupConfigError on validation failure (unknown key, bad type).
336
+ * Group must exist (we call requireGroup to assert).
337
+ */
338
+ export function updateGroupConfig(yeaftDir, groupId, partial) {
339
+ const handle = requireGroup(yeaftDir, groupId);
340
+ try {
341
+ return saveGroupConfig(yeaftDir, groupId, partial || {});
342
+ } finally {
343
+ handle.close();
344
+ }
345
+ }
346
+
316
347
  /**
317
348
  * (A.3) Archive — renames the dir to `.archived-<ts>-<id>`. Directory
318
349
  * prefix `.` keeps `listGroups` from picking it up (readdirSync filter in
@@ -488,6 +519,11 @@ export function snapshotGroups(yeaftDir) {
488
519
  const meta = existsSync(dir) ? loadGroupMeta(dir) : null;
489
520
  if (meta) byId.set(meta.id, meta);
490
521
  }
522
+ // Attach per-group config overrides (v1: just `model`). Frontend can
523
+ // render the effective model without re-querying.
524
+ for (const meta of byId.values()) {
525
+ meta.config = loadGroupConfig(yeaftDir, meta.id);
526
+ }
491
527
  return Array.from(byId.values()).sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')));
492
528
  }
493
529
 
@@ -50,7 +50,16 @@ export {
50
50
  removeMember,
51
51
  setGroupDefaultVp,
52
52
  snapshotGroups,
53
+ updateGroupConfig,
54
+ updateGroupAnnouncement,
53
55
  } from './group-crud.js';
56
+ export {
57
+ loadGroupConfig,
58
+ saveGroupConfig,
59
+ resolveGroupConfig,
60
+ validateGroupConfig,
61
+ GroupConfigError,
62
+ } from './group-config.js';
54
63
  export {
55
64
  nextMsgId,
56
65
  nextGroupId,
@@ -40,20 +40,23 @@
40
40
  /**
41
41
  * @typedef {{ type: 'text_delta', text: string }} TextDeltaEvent
42
42
  * @typedef {{ type: 'thinking_delta', text: string }} ThinkingDeltaEvent
43
+ * @typedef {{ type: 'thinking_block_end', thinking: string, signature: string }} ThinkingBlockEndEvent
43
44
  * @typedef {{ type: 'tool_call', id: string, name: string, input: object }} ToolCallEvent
44
45
  * @typedef {{ type: 'usage', inputTokens: number, outputTokens: number, cacheReadTokens?: number, cacheWriteTokens?: number }} UsageEvent
45
46
  * @typedef {{ type: 'stop', stopReason: 'end_turn' | 'tool_use' | 'max_tokens' }} StopEvent
46
47
  * @typedef {{ type: 'error', error: Error, retryable: boolean }} ErrorEvent
47
48
  *
48
- * @typedef {TextDeltaEvent | ThinkingDeltaEvent | ToolCallEvent | UsageEvent | StopEvent | ErrorEvent} StreamEvent
49
+ * @typedef {TextDeltaEvent | ThinkingDeltaEvent | ThinkingBlockEndEvent | ToolCallEvent | UsageEvent | StopEvent | ErrorEvent} StreamEvent
49
50
  */
50
51
 
51
52
  // ─── Unified Message Types ─────────────────────────────────────
52
53
 
53
54
  /**
55
+ * @typedef {{ thinking: string, signature: string }} ThinkingBlock
56
+ *
54
57
  * @typedef {{ role: 'system', content: string }} SystemMessage
55
58
  * @typedef {{ role: 'user', content: string }} UserMessage
56
- * @typedef {{ role: 'assistant', content: string, toolCalls?: UnifiedToolCall[] }} AssistantMessage
59
+ * @typedef {{ role: 'assistant', content: string, toolCalls?: UnifiedToolCall[], thinkingBlocks?: ThinkingBlock[] }} AssistantMessage
57
60
  * @typedef {{ role: 'tool', toolCallId: string, content: string, isError?: boolean }} ToolMessage
58
61
  *
59
62
  * @typedef {SystemMessage | UserMessage | AssistantMessage | ToolMessage} UnifiedMessage
@@ -73,6 +73,24 @@ export class AnthropicAdapter extends LLMAdapter {
73
73
  result.push({ role: 'user', content: msg.content });
74
74
  } else if (msg.role === 'assistant') {
75
75
  const content = [];
76
+ // task-327d: Anthropic requires thinking blocks to appear BEFORE
77
+ // any text / tool_use in the content array on echo-back. When the
78
+ // previous turn produced thinking blocks (with server-signed
79
+ // signature), we MUST replay them verbatim or the next request
80
+ // 400s with "content[].thinking in the thinking mode must be
81
+ // passed back to the API". Order is mandatory.
82
+ if (Array.isArray(msg.thinkingBlocks)) {
83
+ for (const tb of msg.thinkingBlocks) {
84
+ if (!tb || typeof tb.signature !== 'string' || !tb.signature) continue;
85
+ if (tb.redacted) {
86
+ if (typeof tb.data !== 'string') continue;
87
+ content.push({ type: 'redacted_thinking', data: tb.data, signature: tb.signature });
88
+ } else {
89
+ if (typeof tb.thinking !== 'string') continue;
90
+ content.push({ type: 'thinking', thinking: tb.thinking, signature: tb.signature });
91
+ }
92
+ }
93
+ }
76
94
  if (msg.content) {
77
95
  content.push({ type: 'text', text: msg.content });
78
96
  }
@@ -216,9 +234,16 @@ export class AnthropicAdapter extends LLMAdapter {
216
234
  const reader = response.body.getReader();
217
235
  const decoder = new TextDecoder();
218
236
  let buffer = '';
219
- let currentToolCallId = null;
220
- let currentToolName = null;
221
- let currentToolInput = '';
237
+ // task-327d: index-keyed per-block state. Anthropic streams content
238
+ // blocks sequentially today, but the protocol exposes `event.index`
239
+ // precisely because that's not guaranteed. Dispatch in
240
+ // content_block_stop must look up by index, never "whichever scalar
241
+ // happens to still be set." States by kind: 'tool_use', 'thinking',
242
+ // 'redacted_thinking'. Redacted blocks carry opaque `data` instead
243
+ // of `thinking` text but share the same echo-back rule (drop without
244
+ // signature → next turn 400s identically).
245
+ /** @type {Map<number, { kind: string, [k: string]: any }>} */
246
+ const blockByIndex = new Map();
222
247
  // Accumulate raw SSE body verbatim for the debug panel. No truncation:
223
248
  // see `redactRawRequest` in adapter.js for the verbatim-design rationale.
224
249
  // Push-then-join keeps allocation bounded for multi-MiB payloads (avoids
@@ -254,38 +279,90 @@ export class AnthropicAdapter extends LLMAdapter {
254
279
 
255
280
  if (type === 'content_block_start') {
256
281
  const block = event.content_block;
282
+ const idx = event.index;
257
283
  if (block?.type === 'tool_use') {
258
- currentToolCallId = block.id;
259
- currentToolName = block.name;
260
- currentToolInput = '';
284
+ blockByIndex.set(idx, {
285
+ kind: 'tool_use',
286
+ id: block.id,
287
+ name: block.name,
288
+ input: '',
289
+ });
290
+ } else if (block?.type === 'thinking') {
291
+ blockByIndex.set(idx, {
292
+ kind: 'thinking',
293
+ thinking: typeof block.thinking === 'string' ? block.thinking : '',
294
+ signature: typeof block.signature === 'string' ? block.signature : '',
295
+ });
296
+ } else if (block?.type === 'redacted_thinking') {
297
+ // task-327d: API-redacted thinking. Body is opaque `data`
298
+ // (server-encrypted, not user-readable); we still need to
299
+ // echo it back with signature on the next turn or the API
300
+ // 400s with the same "must be passed back" error.
301
+ blockByIndex.set(idx, {
302
+ kind: 'redacted_thinking',
303
+ data: typeof block.data === 'string' ? block.data : '',
304
+ signature: typeof block.signature === 'string' ? block.signature : '',
305
+ });
261
306
  }
262
307
  } else if (type === 'content_block_delta') {
263
308
  const delta = event.delta;
309
+ const idx = event.index;
310
+ const st = blockByIndex.get(idx);
264
311
  if (delta?.type === 'text_delta') {
265
312
  yield { type: 'text_delta', text: delta.text };
266
313
  } else if (delta?.type === 'thinking_delta') {
314
+ // Forward delta for live UI; ALSO accumulate for round-trip.
315
+ if (st && st.kind === 'thinking') st.thinking += delta.thinking || '';
267
316
  yield { type: 'thinking_delta', text: delta.thinking };
317
+ } else if (delta?.type === 'signature_delta') {
318
+ // Anthropic typically sends signature in one delta near the
319
+ // end of the (redacted_)thinking block. Accumulate defensively.
320
+ if (st && (st.kind === 'thinking' || st.kind === 'redacted_thinking')) {
321
+ st.signature += delta.signature || '';
322
+ }
268
323
  } else if (delta?.type === 'input_json_delta') {
269
- currentToolInput += delta.partial_json;
324
+ if (st && st.kind === 'tool_use') st.input += delta.partial_json;
270
325
  }
271
326
  } else if (type === 'content_block_stop') {
272
- if (currentToolCallId) {
327
+ const idx = event.index;
328
+ const st = blockByIndex.get(idx);
329
+ if (!st) {
330
+ // Unknown / unhandled block kind (e.g. text — we don't track
331
+ // text state because text_delta is forwarded immediately).
332
+ } else if (st.kind === 'tool_use') {
273
333
  let parsedInput = {};
274
334
  try {
275
- parsedInput = currentToolInput ? JSON.parse(currentToolInput) : {};
335
+ parsedInput = st.input ? JSON.parse(st.input) : {};
276
336
  } catch {
277
337
  parsedInput = {};
278
338
  }
279
339
  yield {
280
340
  type: 'tool_call',
281
- id: currentToolCallId,
282
- name: currentToolName,
341
+ id: st.id,
342
+ name: st.name,
283
343
  input: parsedInput,
284
344
  };
285
- currentToolCallId = null;
286
- currentToolName = null;
287
- currentToolInput = '';
345
+ } else if (st.kind === 'thinking' || st.kind === 'redacted_thinking') {
346
+ // task-327d: emit ONE end-of-block event with the assembled
347
+ // payload + signature. Engine collects these for replay.
348
+ // We emit even when signature is empty so engine can
349
+ // warn-and-drop; replaying without signature would 400.
350
+ if (st.kind === 'thinking') {
351
+ yield {
352
+ type: 'thinking_block_end',
353
+ thinking: st.thinking,
354
+ signature: st.signature,
355
+ };
356
+ } else {
357
+ yield {
358
+ type: 'thinking_block_end',
359
+ redacted: true,
360
+ data: st.data,
361
+ signature: st.signature,
362
+ };
363
+ }
288
364
  }
365
+ blockByIndex.delete(idx);
289
366
  } else if (type === 'message_delta') {
290
367
  const stopReason = event.delta?.stop_reason;
291
368
  if (stopReason) {
@@ -45,6 +45,8 @@ import {
45
45
  groupsRoot,
46
46
  } from './groups/group-crud.js';
47
47
  import { openGroup, loadGroupMeta } from './groups/group-store.js';
48
+ import { loadGroupConfig, resolveGroupConfig, GroupConfigError } from './groups/group-config.js';
49
+ import { updateGroupConfig } from './groups/group-crud.js';
48
50
  import { createCoordinator } from './groups/coordinator.js';
49
51
  import { seedDefaultGroup } from './groups/seed-default.js';
50
52
  import {
@@ -760,10 +762,16 @@ function getOrCreateVpEngine(groupId, vpId, threadId = 'main') {
760
762
  let eng = vpEngines.get(key);
761
763
  if (eng) return eng;
762
764
  if (!session) throw new Error('getOrCreateVpEngine: session not loaded');
765
+ // Per-group config overlay (v1: model only). Falls back to the
766
+ // session's user-level config when no override is set. The resolver
767
+ // never mutates session.config — it returns a new object.
768
+ const yeaftDir = ctx.CONFIG?.yeaftDir || session.yeaftDir;
769
+ const groupCfg = loadGroupConfig(yeaftDir, groupId);
770
+ const effectiveConfig = resolveGroupConfig(session.config, groupCfg);
763
771
  eng = new Engine({
764
772
  adapter: session.adapter,
765
773
  trace: session.trace,
766
- config: session.config,
774
+ config: effectiveConfig,
767
775
  conversationStore: session.conversationStore,
768
776
  memoryIndex: session.memoryIndex || null,
769
777
  amsRegistry: session.amsRegistry,
@@ -1318,8 +1326,11 @@ function sendGroupRosterChanged(group) {
1318
1326
  }
1319
1327
 
1320
1328
  function groupErrorPayload(err) {
1329
+ let code = 'unknown';
1330
+ if (err instanceof GroupCrudError) code = err.code;
1331
+ else if (err instanceof GroupConfigError) code = err.code;
1321
1332
  return {
1322
- code: err instanceof GroupCrudError ? err.code : 'unknown',
1333
+ code,
1323
1334
  groupId: err && err.groupId,
1324
1335
  message: err && err.message,
1325
1336
  };
@@ -1404,6 +1415,36 @@ export function handleUnifyUpdateGroup(msg) {
1404
1415
  }
1405
1416
  }
1406
1417
 
1418
+ /**
1419
+ * Update per-group config overrides (v1: `model`). Cache invalidation:
1420
+ * drop every cached Engine whose key starts with `${groupId}::` so the
1421
+ * next turn picks up the new model. The group meta itself is untouched.
1422
+ *
1423
+ * Payload: { groupId, requestId, config: { model?: string|null } }
1424
+ * - `model: ''` or `null` clears the override (group falls back to user default).
1425
+ */
1426
+ export function handleUnifyUpdateGroupConfig(msg) {
1427
+ const requestId = msg && msg.requestId;
1428
+ const groupId = msg && msg.groupId;
1429
+ const partial = (msg && msg.config && typeof msg.config === 'object') ? msg.config : null;
1430
+ try {
1431
+ if (!groupId) throw new GroupConfigError('missing_group_id', 'groupId required');
1432
+ if (!partial) throw new GroupConfigError('invalid_patch', 'config object required');
1433
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
1434
+ const savedConfig = updateGroupConfig(yeaftDir, groupId, partial);
1435
+ // Drop cached engines so the next VP turn rebuilds with the new model.
1436
+ const prefix = `${groupId}::`;
1437
+ for (const k of Array.from(vpEngines.keys())) {
1438
+ if (k.startsWith(prefix)) vpEngines.delete(k);
1439
+ }
1440
+ invalidateGroupContext(groupId);
1441
+ sendGroupCrudResult({ op: 'update_config', requestId, ok: true, groupId, config: savedConfig });
1442
+ sendGroupSnapshotBroadcast();
1443
+ } catch (err) {
1444
+ sendGroupCrudResult({ op: 'update_config', requestId, ok: false, error: groupErrorPayload(err) });
1445
+ }
1446
+ }
1447
+
1407
1448
  export function handleUnifyArchiveGroup(msg) {
1408
1449
  const requestId = msg && msg.requestId;
1409
1450
  const groupId = msg && msg.groupId;
@@ -1610,7 +1651,7 @@ function maybeTransitionVpStatus(hctx, state) {
1610
1651
  * todos, debug cards, and persistence all share the same boundary.
1611
1652
  *
1612
1653
  * @param {object} event — engine event (text_delta / tool_call / …)
1613
- * @param {{assistantTextParts:string[], toolCallsAccum:Array, toolResultsAccum:Array, resetQueryTimer:Function, groupId?:string, vpId?:string, turnId?:string}} hctx
1654
+ * @param {{assistantTextParts:string[], toolCallsAccum:Array, toolResultsAccum:Array, thinkingBlocksAccum?:Array, resetQueryTimer:Function, groupId?:string, vpId?:string, turnId?:string}} hctx
1614
1655
  */
1615
1656
  function handleEngineEvent(event, hctx) {
1616
1657
  hctx.resetQueryTimer();
@@ -1638,6 +1679,30 @@ function handleEngineEvent(event, hctx) {
1638
1679
  sendUnifyEvent({ type: 'thinking_delta', text: event.text }, envelope);
1639
1680
  break;
1640
1681
 
1682
+ case 'thinking_block_end':
1683
+ // task-327d: capture the assembled thinking block (with server-
1684
+ // signed signature) so the group history we hand to subsequent
1685
+ // turns / VPs includes it. Without this echo Anthropic 400s the
1686
+ // next request with "content[].thinking in the thinking mode must
1687
+ // be passed back to the API". The signature stays server-side
1688
+ // only — wire serializers (stripMetaForWire / sendUnifyOutput)
1689
+ // never reference thinkingBlocks, so it cannot leak to the UI.
1690
+ if (hctx.thinkingBlocksAccum && event.signature) {
1691
+ if (event.redacted) {
1692
+ hctx.thinkingBlocksAccum.push({
1693
+ redacted: true,
1694
+ data: event.data,
1695
+ signature: event.signature,
1696
+ });
1697
+ } else {
1698
+ hctx.thinkingBlocksAccum.push({
1699
+ thinking: event.thinking,
1700
+ signature: event.signature,
1701
+ });
1702
+ }
1703
+ }
1704
+ break;
1705
+
1641
1706
  case 'tool_call':
1642
1707
  // Capture tool_call for the assistant message's toolCalls array so
1643
1708
  // the next turn's history pairs `tool_calls` with `role:'tool'`
@@ -2493,6 +2558,7 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, threadId =
2493
2558
  const assistantTextParts = [];
2494
2559
  const toolCallsAccum = [];
2495
2560
  const toolResultsAccum = [];
2561
+ const thinkingBlocksAccum = []; // task-327d: round-trip to next turn
2496
2562
  const appendedUserPrompts = [];
2497
2563
  let vpEngine = null;
2498
2564
 
@@ -2518,6 +2584,7 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, threadId =
2518
2584
  assistantTextParts,
2519
2585
  toolCallsAccum,
2520
2586
  toolResultsAccum,
2587
+ thinkingBlocksAccum,
2521
2588
  resetQueryTimer,
2522
2589
  groupId,
2523
2590
  vpId,
@@ -2558,7 +2625,7 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, threadId =
2558
2625
  }
2559
2626
 
2560
2627
  // Turn completed — atomically append this VP's output to shared history.
2561
- appendTurnToGroupHistory(groupId, threadId, [prompt, ...appendedUserPrompts], assistantTextParts, toolCallsAccum, toolResultsAccum);
2628
+ appendTurnToGroupHistory(groupId, threadId, [prompt, ...appendedUserPrompts], assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum);
2562
2629
 
2563
2630
  sendUnifyOutput({
2564
2631
  type: 'assistant',
@@ -2664,7 +2731,7 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, threadId =
2664
2731
  * a session, this in-memory tape carries the un-collapsed form — which
2665
2732
  * is fine because each VP turn's `engine.query` re-collapses on the fly.
2666
2733
  */
2667
- function appendTurnToGroupHistory(groupId, threadId, prompts, assistantTextParts, toolCallsAccum, toolResultsAccum) {
2734
+ function appendTurnToGroupHistory(groupId, threadId, prompts, assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum) {
2668
2735
  if (!groupId) return;
2669
2736
  const history = getOrCreateGroupHistory(groupId);
2670
2737
  const promptList = Array.isArray(prompts) ? prompts : [prompts];
@@ -2684,6 +2751,18 @@ function appendTurnToGroupHistory(groupId, threadId, prompts, assistantTextParts
2684
2751
  input: tc.input,
2685
2752
  }));
2686
2753
  }
2754
+ // task-327d: carry thinking blocks across turns. Anthropic protocol
2755
+ // requires us to echo them back on the next request or the API
2756
+ // returns "content[].thinking in the thinking mode must be passed
2757
+ // back to the API". The signature is server-private — it stays in
2758
+ // this in-memory history and in agent-side persistence only.
2759
+ if (Array.isArray(thinkingBlocksAccum) && thinkingBlocksAccum.length > 0) {
2760
+ assistantMsg.thinkingBlocks = thinkingBlocksAccum.map(tb => (
2761
+ tb.redacted
2762
+ ? { redacted: true, data: tb.data, signature: tb.signature }
2763
+ : { thinking: tb.thinking, signature: tb.signature }
2764
+ ));
2765
+ }
2687
2766
  history.push(assistantMsg);
2688
2767
 
2689
2768
  for (const tr of toolResultsAccum) {