@yeaft/webchat-agent 0.1.796 → 0.1.798

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, 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, 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) {
@@ -502,6 +502,14 @@ export async function handleMessage(msg) {
502
502
  await handleUnifyFetchToolStats(msg);
503
503
  break;
504
504
 
505
+ // fix-vp-multi-thread (bug 4): hydrate the Unify debug panel from
506
+ // the persistent SQLite trace. Without this, the panel only shows
507
+ // turns that happened after the panel was opened — every previous
508
+ // turn is invisible.
509
+ case 'unify_fetch_debug_history':
510
+ await handleUnifyFetchDebugHistory(msg);
511
+ break;
512
+
505
513
  // Expert roles definition (for ExpertPanel detail view)
506
514
  case 'get_expert_roles': {
507
515
  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.796",
3
+ "version": "0.1.798",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -28,7 +28,18 @@ const SCHEMA = `
28
28
  latency_ms INTEGER,
29
29
  response_text TEXT,
30
30
  started_at INTEGER NOT NULL,
31
- ended_at INTEGER
31
+ ended_at INTEGER,
32
+ group_id TEXT,
33
+ vp_id TEXT,
34
+ thread_id TEXT,
35
+ system_prompt TEXT,
36
+ messages_json TEXT,
37
+ tool_calls_json TEXT,
38
+ usage_json TEXT,
39
+ ttfb_ms INTEGER,
40
+ raw_request TEXT,
41
+ raw_response TEXT,
42
+ user_prompt TEXT
32
43
  );
33
44
 
34
45
  CREATE TABLE IF NOT EXISTS trace_tools (
@@ -61,9 +72,47 @@ const SCHEMA = `
61
72
  CREATE INDEX IF NOT EXISTS idx_events_type ON trace_events(event_type);
62
73
  `;
63
74
 
75
+ /**
76
+ * Indexes that reference columns added by the v0.1.x fix-vp-multi-thread
77
+ * migration. Must be executed AFTER `migrateAddColumn` for those columns
78
+ * — running them inside the main SCHEMA block would fail on an old DB
79
+ * whose `trace_turns` table predates `group_id` / `vp_id` / `thread_id`
80
+ * (`CREATE TABLE IF NOT EXISTS` is a no-op when the table already
81
+ * exists, so the columns are never added by SCHEMA alone).
82
+ */
83
+ const POST_MIGRATION_INDEXES = `
84
+ CREATE INDEX IF NOT EXISTS idx_turns_group_id ON trace_turns(group_id);
85
+ CREATE INDEX IF NOT EXISTS idx_turns_vp_id ON trace_turns(vp_id);
86
+ CREATE INDEX IF NOT EXISTS idx_turns_thread_id ON trace_turns(thread_id);
87
+ `;
88
+
89
+ /**
90
+ * Idempotent column-adds for pre-existing trace databases. `ALTER TABLE …
91
+ * ADD COLUMN` throws if the column already exists, so we wrap each call.
92
+ * Mirrors the columns introduced above so older DBs upgrade in place.
93
+ */
94
+ function migrateAddColumn(db, table, column, type) {
95
+ try {
96
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
97
+ } catch (err) {
98
+ if (!String(err?.message || err).match(/duplicate column name/i)) {
99
+ throw err;
100
+ }
101
+ }
102
+ }
103
+
64
104
  /** Max tool_output size stored (10KB). Longer outputs are truncated. */
65
105
  const MAX_TOOL_OUTPUT = 10240;
66
106
 
107
+ /**
108
+ * Max per-loop payload (system prompt, messages JSON, raw request /
109
+ * response, response text) stored per row. Larger than MAX_TOOL_OUTPUT
110
+ * because real-world LLM exchanges (system prompt + 30K-token message
111
+ * trail + raw response) routinely cross 10KB. 256KB lets us replay the
112
+ * panel verbatim for the most recent traces without bloating the DB.
113
+ */
114
+ const MAX_LOOP_PAYLOAD = 256 * 1024;
115
+
67
116
  /**
68
117
  * Truncate a string to a max length, appending "... [truncated]" if needed.
69
118
  * @param {string|null|undefined} str
@@ -98,29 +147,53 @@ export class DebugTrace {
98
147
  this.#db.exec('PRAGMA journal_mode = WAL');
99
148
  this.#db.exec('PRAGMA foreign_keys = ON');
100
149
  this.#db.exec(SCHEMA);
150
+ // Forward-compat: a DB created by an older version of the bridge
151
+ // will be missing the group/vp/thread + per-loop snapshot columns.
152
+ // Add them on open; no-op for fresh DBs (column already exists
153
+ // from SCHEMA above).
154
+ migrateAddColumn(this.#db, 'trace_turns', 'group_id', 'TEXT');
155
+ migrateAddColumn(this.#db, 'trace_turns', 'vp_id', 'TEXT');
156
+ migrateAddColumn(this.#db, 'trace_turns', 'thread_id', 'TEXT');
157
+ migrateAddColumn(this.#db, 'trace_turns', 'system_prompt', 'TEXT');
158
+ migrateAddColumn(this.#db, 'trace_turns', 'messages_json', 'TEXT');
159
+ migrateAddColumn(this.#db, 'trace_turns', 'tool_calls_json', 'TEXT');
160
+ migrateAddColumn(this.#db, 'trace_turns', 'usage_json', 'TEXT');
161
+ migrateAddColumn(this.#db, 'trace_turns', 'ttfb_ms', 'INTEGER');
162
+ migrateAddColumn(this.#db, 'trace_turns', 'raw_request', 'TEXT');
163
+ migrateAddColumn(this.#db, 'trace_turns', 'raw_response', 'TEXT');
164
+ // C2 fix: explicit `user_prompt` column. Deriving the prompt from
165
+ // `messages_json` is unsafe because every loop after turn 1 in a
166
+ // multi-loop tool-call cycle persists the *cumulative* conversation
167
+ // snapshot — `messages.find(role==='user')` would return turn 1's
168
+ // text for every subsequent turn, mislabeling every Turn header.
169
+ migrateAddColumn(this.#db, 'trace_turns', 'user_prompt', 'TEXT');
170
+ // Indexes on the just-added columns. Must run AFTER the ALTER TABLEs
171
+ // — running them inside SCHEMA's CREATE INDEX IF NOT EXISTS block
172
+ // would fail with "no such column: group_id" on a pre-bugfix DB.
173
+ this.#db.exec(POST_MIGRATION_INDEXES);
101
174
  }
102
175
 
103
176
  // ─── Write API ───────────────────────────────────────────────
104
177
 
105
178
  /**
106
179
  * Start a new turn.
107
- * @param {{ traceId: string, messageId?: string, mode?: string, turnNumber?: number }} opts
180
+ * @param {{ traceId: string, messageId?: string, mode?: string, turnNumber?: number, groupId?: string, vpId?: string, threadId?: string, userPrompt?: string }} opts
108
181
  * @returns {string} — turnId
109
182
  */
110
- startTurn({ traceId, messageId = null, mode = null, turnNumber = null }) {
183
+ startTurn({ traceId, messageId = null, mode = null, turnNumber = null, groupId = null, vpId = null, threadId = null, userPrompt = null }) {
111
184
  const id = randomUUID();
112
185
  const now = Date.now();
113
186
  this.#prepare('insertTurn', `
114
- INSERT INTO trace_turns (id, trace_id, message_id, mode, turn_number, started_at)
115
- VALUES (?, ?, ?, ?, ?, ?)
116
- `).run(id, traceId, messageId, mode, turnNumber, now);
187
+ INSERT INTO trace_turns (id, trace_id, message_id, mode, turn_number, started_at, group_id, vp_id, thread_id, user_prompt)
188
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
189
+ `).run(id, traceId, messageId, mode, turnNumber, now, groupId, vpId, threadId, truncate(userPrompt, MAX_LOOP_PAYLOAD));
117
190
  return id;
118
191
  }
119
192
 
120
193
  /**
121
194
  * End a turn with model response info.
122
195
  * @param {string} turnId
123
- * @param {{ model?: string, inputTokens?: number, outputTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, stopReason?: string, latencyMs?: number, responseText?: string }} info
196
+ * @param {{ model?: string, inputTokens?: number, outputTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, stopReason?: string, latencyMs?: number, responseText?: string, systemPrompt?: string, messages?: unknown, toolCalls?: unknown, usage?: unknown, ttfbMs?: number, rawRequest?: unknown, rawResponse?: unknown }} info
124
197
  */
125
198
  endTurn(turnId, {
126
199
  model = null,
@@ -131,19 +204,68 @@ export class DebugTrace {
131
204
  stopReason = null,
132
205
  latencyMs = null,
133
206
  responseText = null,
207
+ systemPrompt = null,
208
+ messages = null,
209
+ toolCalls = null,
210
+ usage = null,
211
+ ttfbMs = null,
212
+ rawRequest = null,
213
+ rawResponse = null,
134
214
  } = {}) {
135
215
  const now = Date.now();
216
+ // JSON-stringify the structured fields so they round-trip through
217
+ // SQLite as TEXT. JSON serialisation might fail (cyclic structure /
218
+ // BigInt) — guard with try/catch and persist null on failure so a
219
+ // single bad message can never tank the whole turn record.
220
+ //
221
+ // I2 fix: if the serialised JSON exceeds MAX_LOOP_PAYLOAD, the naïve
222
+ // `truncate(s, MAX)` would append `... [truncated]` mid-string and
223
+ // make the row's JSON unparseable. The reader's `parseJsonSafe` would
224
+ // then silently return null and the panel would render the loop with
225
+ // empty messages / toolCalls / usage. Persist a structured sentinel
226
+ // instead so the panel can render a "[truncated, N bytes]" notice.
227
+ const safeStringify = (v) => {
228
+ if (v == null) return null;
229
+ try {
230
+ const s = JSON.stringify(v);
231
+ if (s.length <= MAX_LOOP_PAYLOAD) return s;
232
+ return JSON.stringify({
233
+ __truncated: true,
234
+ originalBytes: s.length,
235
+ maxBytes: MAX_LOOP_PAYLOAD,
236
+ });
237
+ } catch { return null; }
238
+ };
239
+ // For raw request/response, accept either a pre-stringified blob
240
+ // (treat as opaque text — truncation here is fine because
241
+ // parseJsonSafe is not used on raw_*) or a structured object (route
242
+ // through safeStringify which preserves JSON validity).
243
+ const stringifyRaw = (v) => {
244
+ if (v == null) return null;
245
+ if (typeof v === 'string') return truncate(v, MAX_LOOP_PAYLOAD);
246
+ return safeStringify(v);
247
+ };
136
248
  this.#prepare('endTurn', `
137
249
  UPDATE trace_turns SET
138
250
  model = ?, input_tokens = ?, output_tokens = ?,
139
251
  cache_read_tokens = ?, cache_write_tokens = ?,
140
- stop_reason = ?, latency_ms = ?, response_text = ?, ended_at = ?
252
+ stop_reason = ?, latency_ms = ?, response_text = ?, ended_at = ?,
253
+ system_prompt = ?, messages_json = ?, tool_calls_json = ?,
254
+ usage_json = ?, ttfb_ms = ?, raw_request = ?, raw_response = ?
141
255
  WHERE id = ?
142
256
  `).run(
143
257
  model, inputTokens, outputTokens,
144
258
  cacheReadTokens, cacheWriteTokens,
145
- stopReason, latencyMs, truncate(responseText, MAX_TOOL_OUTPUT),
146
- now, turnId,
259
+ stopReason, latencyMs, truncate(responseText, MAX_LOOP_PAYLOAD),
260
+ now,
261
+ truncate(systemPrompt, MAX_LOOP_PAYLOAD),
262
+ safeStringify(messages),
263
+ safeStringify(toolCalls),
264
+ safeStringify(usage),
265
+ ttfbMs,
266
+ stringifyRaw(rawRequest),
267
+ stringifyRaw(rawResponse),
268
+ turnId,
147
269
  );
148
270
  }
149
271
 
@@ -236,6 +358,125 @@ export class DebugTrace {
236
358
  `).all(limit);
237
359
  }
238
360
 
361
+ /**
362
+ * Fetch the recent debug history for the UnifyDebugPanel. Returns one
363
+ * record per LLM loop (ordered oldest → newest) with the structured
364
+ * fields the panel expects. JSON columns are parsed; truncated /
365
+ * malformed payloads degrade to null instead of failing the call.
366
+ *
367
+ * @param {{ limit?: number, groupId?: string|null, threadId?: string|null }} [opts]
368
+ * @returns {{ loops: object[], turns: object[] }}
369
+ */
370
+ fetchRecentDebugHistory({ limit = 100, groupId = null, threadId = null } = {}) {
371
+ const lim = Math.max(1, Math.min(500, Number(limit) || 100));
372
+ const where = [];
373
+ const args = [];
374
+ if (groupId) { where.push('group_id = ?'); args.push(groupId); }
375
+ if (threadId) { where.push('thread_id = ?'); args.push(threadId); }
376
+ const sql = `
377
+ SELECT * FROM trace_turns
378
+ ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
379
+ ORDER BY started_at DESC
380
+ LIMIT ?
381
+ `;
382
+ args.push(lim);
383
+ const rows = this.#db.prepare(sql).all(...args);
384
+ const turnIds = rows.map(r => r.id);
385
+ const tools = turnIds.length > 0
386
+ ? this.#db.prepare(
387
+ `SELECT * FROM trace_tools WHERE turn_id IN (${turnIds.map(() => '?').join(',')}) ORDER BY created_at`
388
+ ).all(...turnIds)
389
+ : [];
390
+ const parseJsonSafe = (s) => {
391
+ if (s == null) return null;
392
+ try { return JSON.parse(s); }
393
+ catch { return null; }
394
+ };
395
+ // Group rows by (turnId, threadId, groupId, vpId) → frontend Turn
396
+ // record. Each row is also surfaced as a Loop.
397
+ const turnsById = new Map();
398
+ const loops = rows.map((r) => {
399
+ const parsedMessages = parseJsonSafe(r.messages_json) || [];
400
+ const parsedUsage = parseJsonSafe(r.usage_json);
401
+ const loop = {
402
+ turnId: r.trace_id, // panel groups loops by trace_id-as-turnId
403
+ loopNumber: r.turn_number || 0,
404
+ model: r.model || null,
405
+ systemPrompt: r.system_prompt || '',
406
+ messages: parsedMessages,
407
+ response: r.response_text || '',
408
+ toolCalls: parseJsonSafe(r.tool_calls_json) || [],
409
+ usage: parsedUsage || {
410
+ inputTokens: r.input_tokens || 0,
411
+ outputTokens: r.output_tokens || 0,
412
+ totalTokens: (r.input_tokens || 0) + (r.output_tokens || 0),
413
+ },
414
+ latencyMs: r.latency_ms || 0,
415
+ ttfbMs: r.ttfb_ms || null,
416
+ stopReason: r.stop_reason || null,
417
+ rawRequest: r.raw_request || null,
418
+ rawResponse: r.raw_response || null,
419
+ groupId: r.group_id || null,
420
+ vpId: r.vp_id || null,
421
+ threadId: r.thread_id || null,
422
+ };
423
+ if (!turnsById.has(r.trace_id)) {
424
+ turnsById.set(r.trace_id, {
425
+ turnId: r.trace_id,
426
+ // C2 fix: read the explicit `user_prompt` column persisted at
427
+ // startTurn time. Deriving from messages_json is unsafe — each
428
+ // tool-loop iteration overwrites messages_json with the
429
+ // cumulative conversation snapshot, so `messages[0].content`
430
+ // would be turn-1's prompt for every subsequent turn header.
431
+ userPrompt: r.user_prompt || '',
432
+ groupId: r.group_id || null,
433
+ vpId: r.vp_id || null,
434
+ threadId: r.thread_id || null,
435
+ openedAt: r.started_at || 0,
436
+ closedAt: r.ended_at || null,
437
+ totalMs: 0,
438
+ totalTokens: 0,
439
+ loopCount: 0,
440
+ memoryLoaded: null,
441
+ memoryAdjust: null,
442
+ tools: [],
443
+ });
444
+ }
445
+ const t = turnsById.get(r.trace_id);
446
+ t.loopCount += 1;
447
+ // Aggregate per-loop latency / tokens so the Turn header shows the
448
+ // same totals the live `turn_close` event would have stamped.
449
+ t.totalMs += r.latency_ms || 0;
450
+ const usageTokens = parsedUsage && Number.isFinite(parsedUsage.totalTokens)
451
+ ? parsedUsage.totalTokens
452
+ : (r.input_tokens || 0) + (r.output_tokens || 0);
453
+ t.totalTokens += usageTokens;
454
+ if (r.ended_at && (!t.closedAt || r.ended_at > t.closedAt)) t.closedAt = r.ended_at;
455
+ return loop;
456
+ });
457
+ // Attach tools to their parent Turn so the panel can render per-tool
458
+ // timing without scanning the loop bodies.
459
+ for (const tool of tools) {
460
+ // Find which loop row this tool belongs to → that row's trace_id
461
+ // identifies the Turn.
462
+ const owner = rows.find(r => r.id === tool.turn_id);
463
+ if (!owner) continue;
464
+ const t = turnsById.get(owner.trace_id);
465
+ if (!t) continue;
466
+ t.tools.push({
467
+ loopNumber: owner.turn_number || 0,
468
+ callId: tool.id,
469
+ name: tool.tool_name,
470
+ durationMs: tool.duration_ms || 0,
471
+ isError: !!tool.is_error,
472
+ });
473
+ }
474
+ // Reverse to oldest-first so the panel's existing append-driven UI
475
+ // renders in chronological order on hydration.
476
+ loops.reverse();
477
+ return { loops, turns: Array.from(turnsById.values()) };
478
+ }
479
+
239
480
  /**
240
481
  * Query tool calls with optional filters.
241
482
  * @param {{ name?: string, since?: number }} [filters={}]
@@ -383,6 +624,7 @@ export class NullTrace {
383
624
  cleanup() { return { deletedTurns: 0, deletedTools: 0, deletedEvents: 0 }; }
384
625
  purge() {}
385
626
  close() {}
627
+ fetchRecentDebugHistory() { return { loops: [], turns: [] }; }
386
628
  }
387
629
 
388
630
  /**
package/unify/engine.js CHANGED
@@ -1468,6 +1468,18 @@ export class Engine {
1468
1468
  const turnId = this.#trace.startTurn({
1469
1469
  traceId: this.#traceId,
1470
1470
  turnNumber,
1471
+ // fix-vp-multi-thread (bug 4): stamp routing context so the
1472
+ // debug-trace SQL row carries enough info to be filtered by
1473
+ // group / thread / VP later when the panel hydrates from disk.
1474
+ groupId: groupId || null,
1475
+ vpId: queryVpId || null,
1476
+ threadId: threadId || null,
1477
+ // Persist the user prompt EXPLICITLY rather than reconstruct it
1478
+ // post-hoc from `messages_json` — every tool-loop iteration
1479
+ // writes the *cumulative* messages array, so deriving the prompt
1480
+ // from `messages.find(role==='user')` would always return turn
1481
+ // 1's prompt and mislabel every subsequent Turn header.
1482
+ userPrompt: userQuestionPreview,
1471
1483
  });
1472
1484
 
1473
1485
  const startTime = Date.now();
@@ -1677,6 +1689,20 @@ export class Engine {
1677
1689
  stopReason: 'error',
1678
1690
  latencyMs,
1679
1691
  responseText,
1692
+ // fix-vp-multi-thread (bug 4): persist the snapshot on the
1693
+ // error path too — failure traces are the most valuable for
1694
+ // hydration.
1695
+ systemPrompt,
1696
+ messages: conversationMessages.map(mapDebugMessage),
1697
+ toolCalls: toolCalls.map(tc => ({ id: tc.id, name: tc.name, input: tc.input })),
1698
+ usage: {
1699
+ inputTokens: totalUsage.inputTokens || 0,
1700
+ outputTokens: totalUsage.outputTokens || 0,
1701
+ totalTokens: (totalUsage.inputTokens || 0) + (totalUsage.outputTokens || 0),
1702
+ },
1703
+ ttfbMs,
1704
+ rawRequest,
1705
+ rawResponse,
1680
1706
  });
1681
1707
 
1682
1708
  // Emit `loop` event for error path too (was `debug_turn`).
@@ -1759,6 +1785,21 @@ export class Engine {
1759
1785
  stopReason,
1760
1786
  latencyMs,
1761
1787
  responseText,
1788
+ // fix-vp-multi-thread (bug 4): persist the full per-loop
1789
+ // snapshot. The frontend debug panel only renders what it has
1790
+ // in-memory — without these columns the user can never see
1791
+ // history from before the panel was opened.
1792
+ systemPrompt,
1793
+ messages: conversationMessages.map(mapDebugMessage),
1794
+ toolCalls: toolCalls.map(tc => ({ id: tc.id, name: tc.name, input: tc.input })),
1795
+ usage: {
1796
+ inputTokens: totalUsage.inputTokens || 0,
1797
+ outputTokens: totalUsage.outputTokens || 0,
1798
+ totalTokens: (totalUsage.inputTokens || 0) + (totalUsage.outputTokens || 0),
1799
+ },
1800
+ ttfbMs,
1801
+ rawRequest,
1802
+ rawResponse,
1762
1803
  });
1763
1804
 
1764
1805
  // Emit `loop` event for the debug panel.
@@ -52,6 +52,7 @@ import {
52
52
  } from './history-compact.js';
53
53
  import { persistUnifyAttachments, attachmentsForPersistence } from './attachments.js';
54
54
  import { parseSeqFromId } from './conversation/persist.js';
55
+ import { sliceLastNTurns } from './turn-utils.js';
55
56
  import { createVpStatusBroker } from './vp-status-broker.js';
56
57
  import { classifyThread as defaultClassifyThread, fallbackTitle } from './vp/thread-classifier.js';
57
58
 
@@ -523,6 +524,54 @@ function projectPersistedToHistoryEntry(m) {
523
524
  return entry;
524
525
  }
525
526
 
527
+ function projectPersistedToVisibleHistoryEntry(m) {
528
+ const entry = projectPersistedToHistoryEntry(m);
529
+ return entry && (entry.role === 'user' || entry.role === 'assistant') ? entry : null;
530
+ }
531
+
532
+ function loadVisibleGroupHistoryPage(store, groupId, limit, beforeSeq = null) {
533
+ if (!store || !groupId || !(limit > 0)) return { messages: [], oldestSeq: null, hasMore: false };
534
+
535
+ let rows = [];
536
+ try {
537
+ if (typeof store.loadOlderByGroup === 'function') {
538
+ // Use an unbounded raw prefix, then project/slice visible rows below.
539
+ // This preserves loadOlderByGroup's hot+cold scan without letting raw
540
+ // reflection/internal rows consume the UI-visible page window.
541
+ rows = store.loadOlderByGroup(groupId, beforeSeq, Infinity).messages || [];
542
+ } else if (Number.isFinite(beforeSeq)) {
543
+ const all = typeof store.loadAllByGroup === 'function'
544
+ ? store.loadAllByGroup(groupId)
545
+ : store.loadRecentByGroup(groupId, Infinity);
546
+ rows = all.filter(m => parseSeqFromId(m?.id) < beforeSeq);
547
+ } else if (typeof store.loadAllByGroup === 'function') {
548
+ rows = store.loadAllByGroup(groupId);
549
+ } else {
550
+ rows = store.loadRecentByGroup(groupId, Infinity);
551
+ }
552
+ } catch (err) {
553
+ console.error('[Unify] visible history page load failed:', err?.message || err);
554
+ return { messages: [], oldestSeq: null, hasMore: false };
555
+ }
556
+
557
+ const visible = rows
558
+ .map(projectPersistedToVisibleHistoryEntry)
559
+ .filter(Boolean);
560
+ const messages = sliceLastNTurns(visible, limit);
561
+ const oldestSeq = messages.length ? parseSeqFromId(messages[0].id) : null;
562
+ const firstVisibleSeq = visible.length ? parseSeqFromId(visible[0].id) : null;
563
+ const hasMore = messages.length > 0
564
+ && Number.isFinite(oldestSeq)
565
+ && Number.isFinite(firstVisibleSeq)
566
+ && oldestSeq > firstVisibleSeq;
567
+
568
+ return {
569
+ messages,
570
+ oldestSeq: Number.isFinite(oldestSeq) ? oldestSeq : null,
571
+ hasMore,
572
+ };
573
+ }
574
+
526
575
  /**
527
576
  * Hydrate a freshly-created GroupContext's history from the on-disk
528
577
  * conversation store. Returns an empty array if the session isn't
@@ -837,7 +886,7 @@ async function routeEnvelopeToVpThread(groupId, vpId, envelope) {
837
886
 
838
887
  if (related) {
839
888
  const content = promptParts || prompt;
840
- thread.pendingQueries.push({ content, preview: prompt });
889
+ thread.pendingQueries.push({ content, preview: prompt, originalText: text, originalParts: Array.isArray(envelope?._promptParts) ? envelope._promptParts : null });
841
890
  persistInboundMessageOnceByMsgId({
842
891
  msgId: envelope?.msg?.id,
843
892
  text,
@@ -979,6 +1028,72 @@ function ensureDriverRunning(groupId, vpId, threadId = 'main') {
979
1028
  }, { groupId, vpId, threadId: thread.threadId, turnId });
980
1029
  }
981
1030
  } catch { /* never crash WS pipeline */ }
1031
+
1032
+ // fix-vp-multi-thread (bug 1 + 3): rescue any orphaned related-
1033
+ // appends. If a user (or a VP via route_forward) added queries
1034
+ // to this thread's `pendingQueries` AFTER the engine had already
1035
+ // decided to end_turn (so the inner drain at engine.js:1850 no
1036
+ // longer fires), those queries would be silently lost. Convert
1037
+ // each leftover into a synthetic inbox envelope so the driver
1038
+ // re-enters and runs a fresh turn on the same thread.
1039
+ if (thread && Array.isArray(thread.pendingQueries) && thread.pendingQueries.length > 0) {
1040
+ const leftovers = thread.pendingQueries.splice(0);
1041
+ for (const leftover of leftovers) {
1042
+ // `originalText` / `originalParts` capture the inbound payload
1043
+ // BEFORE `buildVpPromptPayload` prepended `@vp-<id> ` and added
1044
+ // any suffix. Replaying through `buildVpPromptPayload` (via the
1045
+ // driver) re-applies the prefix, so we must NOT pass the
1046
+ // already-decorated `preview` here or the prompt would carry
1047
+ // a double `@vp-<id> @vp-<id> ...` mention.
1048
+ const replayText = typeof leftover?.originalText === 'string'
1049
+ ? leftover.originalText
1050
+ : '';
1051
+ const replayParts = Array.isArray(leftover?.originalParts) && leftover.originalParts.length > 0
1052
+ ? leftover.originalParts
1053
+ : null;
1054
+ if (!replayText && !replayParts) continue;
1055
+ const followUpId = `followup_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
1056
+ const followUpEnvelope = {
1057
+ groupId,
1058
+ taskId: envelope?.taskId || null,
1059
+ trigger: 'pending_rescue',
1060
+ msg: {
1061
+ id: followUpId,
1062
+ from: 'user',
1063
+ text: replayText,
1064
+ meta: { rescuedFrom: 'pendingQueries', threadId: thread.threadId },
1065
+ },
1066
+ ...(replayParts ? { _promptParts: replayParts } : {}),
1067
+ };
1068
+ const followUpTurnId = `${randomUUID().slice(0, 8)}:${vpId}`;
1069
+ inbox.push({ envelope: followUpEnvelope, turnId: followUpTurnId, thread });
1070
+ try {
1071
+ thread.status = 'typing';
1072
+ thread.updatedAt = Date.now();
1073
+ getVpStatusBroker().transition({
1074
+ groupId,
1075
+ vpId,
1076
+ threadId: thread.threadId,
1077
+ title: thread.title || '',
1078
+ state: 'typing',
1079
+ turnId: followUpTurnId,
1080
+ messageCount: thread.messageIds.length,
1081
+ });
1082
+ } catch (err) {
1083
+ console.warn('[Unify] vp-status typing transition (rescue) failed:', err?.message || err);
1084
+ }
1085
+ try {
1086
+ sendUnifyEvent({
1087
+ type: 'vp_typing_start',
1088
+ groupId,
1089
+ vpId,
1090
+ threadId: thread.threadId,
1091
+ turnId: followUpTurnId,
1092
+ ts: Date.now(),
1093
+ }, { groupId, vpId, threadId: thread.threadId, turnId: followUpTurnId });
1094
+ } catch { /* never crash WS pipeline */ }
1095
+ }
1096
+ }
982
1097
  }
983
1098
  vpDrivers.delete(key);
984
1099
  const tail = vpInboxes.get(key);
@@ -2518,6 +2633,20 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, threadId =
2518
2633
  } catch (err) {
2519
2634
  console.warn('[Unify] vp-status settleIdle failed:', err?.message || err);
2520
2635
  }
2636
+ // fix-vp-multi-thread (bug 2): the bridge tracks per-thread status
2637
+ // on `thread.status` separately from the broker. Multiple sites
2638
+ // (`maybeTransitionVpStatus`, `routeEnvelopeToVpThread`'s typing
2639
+ // transition) write to it but no site cleared it on turn end, so
2640
+ // every finished thread was stuck reporting `thinking|streaming|tool`
2641
+ // forever. `getRunningThreads` filters on this field, so the
2642
+ // classifier next time the user spoke would treat the zombie as
2643
+ // a live thread and route the new query as "related" — orphaning
2644
+ // the message in `pendingQueries` because no engine was running
2645
+ // to drain it. Always settle to 'idle' here.
2646
+ if (thread) {
2647
+ thread.status = 'idle';
2648
+ thread.updatedAt = Date.now();
2649
+ }
2521
2650
  }
2522
2651
  }
2523
2652
 
@@ -3059,6 +3188,55 @@ export async function handleUnifyFetchToolStats(_msg = {}) {
3059
3188
  });
3060
3189
  }
3061
3190
 
3191
+ /**
3192
+ * Hydrate the UnifyDebugPanel from the persistent SQLite trace. The
3193
+ * panel state (`unifyDebugLoops` / `unifyDebugTurnsById`) is otherwise
3194
+ * built ONLY from in-flight `loop` / `turn_open` events on the wire,
3195
+ * so a panel opened after a turn has finished sees nothing for that
3196
+ * turn. This handler ships back a frontend-shaped snapshot the store
3197
+ * splices into place.
3198
+ *
3199
+ * Inputs (all optional):
3200
+ * - `limit` — max number of loops to return (1..500, default 100)
3201
+ * - `groupId` — narrow by group
3202
+ * - `threadId` — narrow by thread
3203
+ *
3204
+ * Sends:
3205
+ * { type: 'unify_debug_history', loops: [...], turns: [...] }
3206
+ *
3207
+ * Best-effort: if the session / trace isn't ready, sends an empty
3208
+ * snapshot so the panel renders a placeholder instead of spinning.
3209
+ */
3210
+ export async function handleUnifyFetchDebugHistory(msg = {}) {
3211
+ const limit = Number.isFinite(msg?.limit) ? Number(msg.limit) : 100;
3212
+ const groupId = typeof msg?.groupId === 'string' && msg.groupId ? msg.groupId : null;
3213
+ const threadId = typeof msg?.threadId === 'string' && msg.threadId ? msg.threadId : null;
3214
+ let loops = [];
3215
+ let turns = [];
3216
+ try {
3217
+ if (session?.trace && typeof session.trace.fetchRecentDebugHistory === 'function') {
3218
+ const out = session.trace.fetchRecentDebugHistory({ limit, groupId, threadId });
3219
+ loops = Array.isArray(out?.loops) ? out.loops : [];
3220
+ turns = Array.isArray(out?.turns) ? out.turns : [];
3221
+ }
3222
+ } catch (err) {
3223
+ sendToServer({
3224
+ type: 'unify_debug_history',
3225
+ loops: [],
3226
+ turns: [],
3227
+ error: err && err.message ? err.message : String(err),
3228
+ });
3229
+ return;
3230
+ }
3231
+ sendToServer({
3232
+ type: 'unify_debug_history',
3233
+ loops,
3234
+ turns,
3235
+ groupId,
3236
+ threadId,
3237
+ });
3238
+ }
3239
+
3062
3240
  /** Deprecated mode switch — Unify is single-mode. */
3063
3241
  export function handleUnifyModeSwitch(_msg) {
3064
3242
  console.warn('[Unify] unify_mode_switch is deprecated and ignored — Unify now runs in a single unified mode.');
@@ -3154,12 +3332,17 @@ export async function handleUnifyLoadHistory(msg) {
3154
3332
  // ~20–25 turns; in the turn-count world 50 turns of UI scrollback is
3155
3333
  // still cheap and matches what the frontend already passes through.
3156
3334
  const limit = (typeof msg.limit === 'number') ? msg.limit : 50;
3157
- const messages = limit > 0 ? pickRecent(session.conversationStore, limit) : [];
3335
+ const visiblePage = groupId
3336
+ ? loadVisibleGroupHistoryPage(session.conversationStore, groupId, limit)
3337
+ : { messages: limit > 0 ? pickRecent(session.conversationStore, limit) : [], oldestSeq: null, hasMore: false };
3158
3338
  const compactSummary = session.conversationStore.readCompactSummary();
3339
+ const replayEntries = groupId
3340
+ ? visiblePage.messages
3341
+ : visiblePage.messages
3342
+ .map(projectPersistedToVisibleHistoryEntry)
3343
+ .filter(Boolean);
3159
3344
 
3160
- for (const m of messages) {
3161
- const entry = projectPersistedToHistoryEntry(m);
3162
- if (!entry) continue;
3345
+ for (const entry of replayEntries) {
3163
3346
  if (entry.role === 'user') {
3164
3347
  sendUnifyOutput({ type: 'user', message: { content: entry.content, id: entry.id || null } }, { groupId: entry.groupId || null });
3165
3348
  } else if (entry.role === 'assistant') {
@@ -3181,33 +3364,19 @@ export async function handleUnifyLoadHistory(msg) {
3181
3364
 
3182
3365
  // Compute the pagination cursor for the bootstrap load so the frontend
3183
3366
  // knows whether a "Load older messages" hint should be shown and where
3184
- // to start the next page. The cursor is the seq of the oldest replayed
3185
- // message; `hasMore` is true iff there's an earlier message in the
3186
- // group that we did NOT replay.
3367
+ // to start the next page. For group history, this is computed from the
3368
+ // visible projected page, not raw persisted rows, so reflection/internal
3369
+ // tail rows cannot consume the bootstrap window or create false hasMore.
3187
3370
  let hasMore = false;
3188
3371
  let oldestSeq = null;
3189
- if (groupId && messages.length > 0) {
3190
- const firstId = messages[0].id;
3191
- const seq = parseSeqFromId(firstId);
3192
- // Defend against malformed ids: a NaN cursor would round-trip back as
3193
- // a poison `beforeSeq` and degrade subsequent paginations to "give me
3194
- // the newest page again". Surface as null instead.
3195
- oldestSeq = Number.isFinite(seq) ? seq : null;
3196
- if (oldestSeq != null) {
3197
- // Consult the store for whether anything older exists in the same
3198
- // group. Cheap: a single extra `loadOlderByGroup` with turns=1.
3199
- try {
3200
- const probe = session.conversationStore.loadOlderByGroup(groupId, oldestSeq, 1);
3201
- hasMore = probe.messages.length > 0;
3202
- } catch (err) {
3203
- console.error('[Unify] history-load probe failed:', err.message);
3204
- }
3205
- }
3372
+ if (groupId) {
3373
+ hasMore = visiblePage.hasMore;
3374
+ oldestSeq = visiblePage.oldestSeq;
3206
3375
  }
3207
3376
 
3208
3377
  sendUnifyEvent({
3209
3378
  type: 'history_loaded',
3210
- count: messages.length,
3379
+ count: replayEntries.length,
3211
3380
  hasCompactSummary: !!compactSummary,
3212
3381
  totalHot: session.conversationStore.countHot(),
3213
3382
  totalCold: session.conversationStore.countCold(),
@@ -3249,7 +3418,7 @@ export async function handleUnifyLoadMoreHistory(msg) {
3249
3418
 
3250
3419
  let result;
3251
3420
  try {
3252
- result = session.conversationStore.loadOlderByGroup(groupId, beforeSeq, turns);
3421
+ result = loadVisibleGroupHistoryPage(session.conversationStore, groupId, turns, beforeSeq);
3253
3422
  } catch (err) {
3254
3423
  console.error('[Unify] loadOlderByGroup failed:', err.message);
3255
3424
  result = { messages: [], oldestSeq: null, hasMore: false };
@@ -3260,8 +3429,6 @@ export async function handleUnifyLoadMoreHistory(msg) {
3260
3429
  // server-side, and stable ids + speaker attribution ride with each row
3261
3430
  // so older-history prepend renders exactly like refresh replay.
3262
3431
  const projected = (result.messages || [])
3263
- .map(projectPersistedToHistoryEntry)
3264
- .filter(m => m && (m.role === 'user' || m.role === 'assistant'))
3265
3432
  .map(m => ({
3266
3433
  ...(m.id ? { id: m.id } : {}),
3267
3434
  role: m.role,