@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.
- package/connection/message-router.js +9 -1
- package/package.json +1 -1
- package/unify/debug-trace.js +252 -10
- package/unify/engine.js +41 -0
- package/unify/web-bridge.js +196 -29
|
@@ -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
package/unify/debug-trace.js
CHANGED
|
@@ -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,
|
|
146
|
-
now,
|
|
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.
|
package/unify/web-bridge.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
3185
|
-
//
|
|
3186
|
-
//
|
|
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
|
|
3190
|
-
|
|
3191
|
-
|
|
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:
|
|
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
|
|
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,
|