@yeaft/webchat-agent 1.0.38 → 1.0.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/yeaft/conversation/persist.js +4 -0
- package/yeaft/engine.js +52 -8
- package/yeaft/stop-hooks.js +4 -0
- package/yeaft/web-bridge.js +47 -4
package/package.json
CHANGED
|
@@ -206,6 +206,7 @@ function serializeMessage(msg) {
|
|
|
206
206
|
// routing can filter/replay by thread without rescanning JSON blobs.
|
|
207
207
|
// Defaults to 'main' for legacy messages (see migrate-messages-threadid.js).
|
|
208
208
|
fm.push(`threadId: ${msg.threadId || 'main'}`);
|
|
209
|
+
if (msg.turnId) fm.push(`turnId: ${msg.turnId}`);
|
|
209
210
|
// task-313: when a thread is merged into another, the messages keep
|
|
210
211
|
// their original thread id in `sourceThreadId` so the UI can still
|
|
211
212
|
// render a small "#source" pill next to each bubble.
|
|
@@ -216,6 +217,7 @@ function serializeMessage(msg) {
|
|
|
216
217
|
// shows an empty pane.
|
|
217
218
|
if (msg.sessionId) fm.push(`sessionId: ${msg.sessionId}`);
|
|
218
219
|
if (msg.chatId) fm.push(`chatId: ${msg.chatId}`);
|
|
220
|
+
if (msg.clientMessageId) fm.push(`clientMessageId: ${msg.clientMessageId}`);
|
|
219
221
|
// Session attribution: when a VP authors an assistant turn (either
|
|
220
222
|
// its own reply or a route_forward injection from another VP), stamp
|
|
221
223
|
// the speaker so the UI can render the message on the correct VP track.
|
|
@@ -333,9 +335,11 @@ export function parseMessage(raw) {
|
|
|
333
335
|
case 'isError': msg.isError = value === 'true'; break;
|
|
334
336
|
case 'tokens_est': msg.tokens_est = parseInt(value, 10); break;
|
|
335
337
|
case 'threadId': msg.threadId = value; break;
|
|
338
|
+
case 'turnId': msg.turnId = value; break;
|
|
336
339
|
case 'sourceThreadId': msg.sourceThreadId = value; break;
|
|
337
340
|
case 'sessionId': msg.sessionId = value; break;
|
|
338
341
|
case 'chatId': msg.chatId = value; break;
|
|
342
|
+
case 'clientMessageId': msg.clientMessageId = value; break;
|
|
339
343
|
case 'speakerVpId': msg.speakerVpId = value; break;
|
|
340
344
|
case 'attachmentsB64':
|
|
341
345
|
try {
|
package/yeaft/engine.js
CHANGED
|
@@ -95,6 +95,36 @@ const RETRY_DEFAULTS = Object.freeze({
|
|
|
95
95
|
jitterRatio: 0.25,
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
const EXPLICIT_SKILL_COMMAND_RE = /^\/skill:([^\s]+)(\s+|$)/;
|
|
99
|
+
|
|
100
|
+
function parseExplicitSkillCommand(prompt) {
|
|
101
|
+
if (typeof prompt !== 'string') {
|
|
102
|
+
return { skillName: null, cleanedPrompt: prompt };
|
|
103
|
+
}
|
|
104
|
+
const match = prompt.match(EXPLICIT_SKILL_COMMAND_RE);
|
|
105
|
+
if (!match) {
|
|
106
|
+
return { skillName: null, cleanedPrompt: prompt };
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
skillName: match[1],
|
|
110
|
+
cleanedPrompt: prompt.slice(match[0].length),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function stripLeadingSkillCommandFromPromptParts(promptParts) {
|
|
115
|
+
if (!Array.isArray(promptParts) || promptParts.length === 0) return promptParts;
|
|
116
|
+
let stripped = false;
|
|
117
|
+
return promptParts.map(part => {
|
|
118
|
+
if (stripped || !part || part.type !== 'text' || typeof part.text !== 'string') {
|
|
119
|
+
return part;
|
|
120
|
+
}
|
|
121
|
+
const parsed = parseExplicitSkillCommand(part.text);
|
|
122
|
+
if (!parsed.skillName) return part;
|
|
123
|
+
stripped = true;
|
|
124
|
+
return { ...part, text: parsed.cleanedPrompt };
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
98
128
|
function resolveRetryPolicy(config) {
|
|
99
129
|
const raw = config?.llmRetry || {};
|
|
100
130
|
const num = (v, d) => (Number.isFinite(v) && v >= 0 ? v : d);
|
|
@@ -932,13 +962,21 @@ export class Engine {
|
|
|
932
962
|
* @param {string} [args.sessionAnnouncement]
|
|
933
963
|
* @param {string} [args.projectDoc] — resolved CLAUDE.md / AGENTS.md text (already truncated)
|
|
934
964
|
* @param {object} [args.taskCtx] — legacy task-context sub-block (optional)
|
|
965
|
+
* @param {string} [args.explicitSkillName] — leading /skill:<name> command, if present
|
|
935
966
|
* @returns {string}
|
|
936
967
|
*/
|
|
937
|
-
#buildSystemPrompt({ prompt, memoryInjection, vpPersona, activeScope, sessionAnnouncement, projectDoc, taskCtx, activeTasks } = {}) {
|
|
938
|
-
// Get relevant skill content if SkillManager is wired
|
|
968
|
+
#buildSystemPrompt({ prompt, memoryInjection, vpPersona, activeScope, sessionAnnouncement, projectDoc, taskCtx, activeTasks, explicitSkillName } = {}) {
|
|
969
|
+
// Get relevant skill content if SkillManager is wired. A leading
|
|
970
|
+
// /skill:<name> is explicit, not relevance matching: load that skill by
|
|
971
|
+
// name or inject a visible prompt warning when the command is unknown.
|
|
939
972
|
let skillContent = '';
|
|
940
|
-
if (this.#skillManager
|
|
941
|
-
|
|
973
|
+
if (this.#skillManager) {
|
|
974
|
+
if (explicitSkillName) {
|
|
975
|
+
skillContent = this.#skillManager.getPromptContent(explicitSkillName)
|
|
976
|
+
|| `## Skill command error\n\nRequested skill "${explicitSkillName}" was not found. Continue without that skill and tell the user it is unavailable.`;
|
|
977
|
+
} else if (prompt) {
|
|
978
|
+
skillContent = this.#skillManager.getRelevantPromptContent(prompt);
|
|
979
|
+
}
|
|
942
980
|
}
|
|
943
981
|
|
|
944
982
|
// Get tool names from the appropriate source
|
|
@@ -1559,7 +1597,7 @@ export class Engine {
|
|
|
1559
1597
|
* string-prompt shape (no regression for existing callers).
|
|
1560
1598
|
* @yields {EngineEvent}
|
|
1561
1599
|
*/
|
|
1562
|
-
async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, drainPendingUserMessages = null, collabToolPolicy = null } = {}) {
|
|
1600
|
+
async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, collabToolPolicy = null } = {}) {
|
|
1563
1601
|
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
|
|
1564
1602
|
yield {
|
|
1565
1603
|
type: 'error',
|
|
@@ -1583,7 +1621,11 @@ export class Engine {
|
|
|
1583
1621
|
// the merge, so an invalid caller value (e.g. 'ULTRA') does not shadow a
|
|
1584
1622
|
// valid prompt prefix.
|
|
1585
1623
|
const parsed = parseEffortPrefix(prompt);
|
|
1586
|
-
const
|
|
1624
|
+
const parsedSkill = parseExplicitSkillCommand(parsed.cleanedPrompt);
|
|
1625
|
+
const effectivePrompt = parsedSkill.cleanedPrompt;
|
|
1626
|
+
const effectivePromptParts = parsedSkill.skillName
|
|
1627
|
+
? stripLeadingSkillCommandFromPromptParts(promptParts)
|
|
1628
|
+
: promptParts;
|
|
1587
1629
|
const configuredEffort = normalizeEffort(this.#config?.modelEffort);
|
|
1588
1630
|
const effectiveUserEffort = normalizeEffort(userEffort) || parsed.effort || configuredEffort || null;
|
|
1589
1631
|
const effectiveCollabToolPolicy = collabToolPolicy === COLLAB_TOOL_POLICY.SINGLE_VP || collabToolPolicy === COLLAB_TOOL_POLICY.MULTI_VP
|
|
@@ -1623,7 +1665,7 @@ export class Engine {
|
|
|
1623
1665
|
|
|
1624
1666
|
try {
|
|
1625
1667
|
this.#currentThreadId = threadId || MAIN_THREAD_ID;
|
|
1626
|
-
yield* this.#runQuery({ prompt: effectivePrompt, promptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, threadId: this.#currentThreadId, drainPendingUserMessages, collabToolPolicy: effectiveCollabToolPolicy });
|
|
1668
|
+
yield* this.#runQuery({ prompt: effectivePrompt, promptParts: effectivePromptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, threadId: this.#currentThreadId, drainPendingUserMessages, collabToolPolicy: effectiveCollabToolPolicy, explicitSkillName: parsedSkill.skillName });
|
|
1627
1669
|
} finally {
|
|
1628
1670
|
if (signal) {
|
|
1629
1671
|
try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
|
|
@@ -1667,7 +1709,7 @@ export class Engine {
|
|
|
1667
1709
|
* in a try/finally without indenting the whole loop.
|
|
1668
1710
|
* @private
|
|
1669
1711
|
*/
|
|
1670
|
-
async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, drainPendingUserMessages = null, collabToolPolicy = null }) {
|
|
1712
|
+
async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, drainPendingUserMessages = null, collabToolPolicy = null, explicitSkillName = null }) {
|
|
1671
1713
|
|
|
1672
1714
|
const effectiveCollabToolPolicy = collabToolPolicy === COLLAB_TOOL_POLICY.SINGLE_VP || collabToolPolicy === COLLAB_TOOL_POLICY.MULTI_VP
|
|
1673
1715
|
? collabToolPolicy
|
|
@@ -1783,6 +1825,7 @@ export class Engine {
|
|
|
1783
1825
|
sessionAnnouncement,
|
|
1784
1826
|
projectDoc,
|
|
1785
1827
|
activeTasks,
|
|
1828
|
+
explicitSkillName,
|
|
1786
1829
|
});
|
|
1787
1830
|
|
|
1788
1831
|
// ─── HARD INVARIANT: Compact ≠ Dream (read DESIGN-COMPACT-VS-DREAM.md) ─
|
|
@@ -2790,6 +2833,7 @@ export class Engine {
|
|
|
2790
2833
|
// history replay can re-stamp them on reload.
|
|
2791
2834
|
sessionId,
|
|
2792
2835
|
threadId,
|
|
2836
|
+
turnId: vpTurnId || queryTurnId,
|
|
2793
2837
|
vpId: this.#vpId,
|
|
2794
2838
|
// Multi-VP fan-out (history-dedup): skip the user-row append
|
|
2795
2839
|
// in stop-hooks when the orchestrator already wrote it once
|
package/yeaft/stop-hooks.js
CHANGED
|
@@ -65,6 +65,7 @@ export async function runStopHooks(context) {
|
|
|
65
65
|
// history replay can route messages back into the originating group.
|
|
66
66
|
sessionId,
|
|
67
67
|
threadId,
|
|
68
|
+
turnId,
|
|
68
69
|
// Group VP attribution: persist the engine-bound VP id on assistant/tool
|
|
69
70
|
// rows so history replay can route replies back to the same visible VP.
|
|
70
71
|
vpId,
|
|
@@ -167,6 +168,9 @@ export async function runStopHooks(context) {
|
|
|
167
168
|
// Bug 6: stamp sessionId / threadId so replay can re-route by group.
|
|
168
169
|
if (sessionId) record.sessionId = sessionId;
|
|
169
170
|
if (threadId) record.threadId = threadId;
|
|
171
|
+
if (turnId && (msg.role === 'assistant' || msg.role === 'tool')) {
|
|
172
|
+
record.turnId = turnId;
|
|
173
|
+
}
|
|
170
174
|
if (vpId && (msg.role === 'assistant' || msg.role === 'tool')) {
|
|
171
175
|
record.speakerVpId = vpId;
|
|
172
176
|
}
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -73,6 +73,8 @@ import { buildMcpFlattenedTools } from './tools/mcp-tools.js';
|
|
|
73
73
|
import { getAgentRegistry, agentBelongsToScope } from './tools/agent.js';
|
|
74
74
|
import { isPromptableAgentStatus } from './sub-agent/status.js';
|
|
75
75
|
|
|
76
|
+
const SKILL_COMMAND_PREFIX = 'skill:';
|
|
77
|
+
|
|
76
78
|
/** @type {import('./session.js').Session | null} */
|
|
77
79
|
let session = null;
|
|
78
80
|
|
|
@@ -710,8 +712,9 @@ function projectPersistedToHistoryEntry(m) {
|
|
|
710
712
|
const entry = { role: m.role, content: m.role === 'tool' ? m.content : __testNormalizePersistedVisibleContent(m.content) };
|
|
711
713
|
if (m.id) entry.id = m.id;
|
|
712
714
|
entry.threadId = m.threadId || m.turnId || 'main';
|
|
713
|
-
entry.turnId = m.turnId
|
|
715
|
+
if (m.turnId) entry.turnId = m.turnId;
|
|
714
716
|
if (m.sessionId) entry.sessionId = m.sessionId;
|
|
717
|
+
if (m.clientMessageId) entry.clientMessageId = m.clientMessageId;
|
|
715
718
|
if (m.speakerVpId) entry.speakerVpId = m.speakerVpId;
|
|
716
719
|
if (m.toolCallId) entry.toolCallId = m.toolCallId;
|
|
717
720
|
if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
|
|
@@ -811,8 +814,9 @@ function projectVisibleHistoryChunkMessages(messages = []) {
|
|
|
811
814
|
content: m.content,
|
|
812
815
|
ts: m.ts || null,
|
|
813
816
|
sessionId: m.sessionId || null,
|
|
817
|
+
...(m.clientMessageId ? { clientMessageId: m.clientMessageId } : {}),
|
|
814
818
|
threadId: m.threadId || m.turnId || 'main',
|
|
815
|
-
turnId: m.turnId
|
|
819
|
+
...(m.turnId ? { turnId: m.turnId } : {}),
|
|
816
820
|
...(Array.isArray(m.attachments) && m.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(m.attachments) } : {}),
|
|
817
821
|
...(m.speakerVpId ? { speakerVpId: m.speakerVpId } : {}),
|
|
818
822
|
...(Number.isFinite(m.toolSummaryCount) && m.toolSummaryCount > 0
|
|
@@ -1390,6 +1394,7 @@ async function routeEnvelopeToVpThread(sessionId, vpId, envelope) {
|
|
|
1390
1394
|
attachments: Array.isArray(envelope?.msg?.meta?.attachments) ? envelope.msg.meta.attachments : [],
|
|
1391
1395
|
internal: isInternalAppend,
|
|
1392
1396
|
ts: envelope?.msg?.ts || null,
|
|
1397
|
+
clientMessageId: envelope?.msg?.meta?.clientMessageId || null,
|
|
1393
1398
|
});
|
|
1394
1399
|
thread.updatedAt = Date.now();
|
|
1395
1400
|
try {
|
|
@@ -1487,6 +1492,7 @@ function ensureDriverRunning(sessionId, vpId, threadId = 'main') {
|
|
|
1487
1492
|
attachments: Array.isArray(meta.attachments) ? meta.attachments : [],
|
|
1488
1493
|
internal: isInternal,
|
|
1489
1494
|
ts: envelope?.msg?.ts || null,
|
|
1495
|
+
clientMessageId: meta.clientMessageId || null,
|
|
1490
1496
|
});
|
|
1491
1497
|
}
|
|
1492
1498
|
} catch { /* never crash WS pipeline */ }
|
|
@@ -1690,6 +1696,37 @@ function sendSessionOutputFrame(data, { sessionId, chatId, vpId, turnId, threadI
|
|
|
1690
1696
|
});
|
|
1691
1697
|
}
|
|
1692
1698
|
|
|
1699
|
+
export function buildSkillSlashCommands(skillManager) {
|
|
1700
|
+
if (!skillManager || typeof skillManager.list !== 'function') return { commands: [], descriptions: {} };
|
|
1701
|
+
const commands = [];
|
|
1702
|
+
const descriptions = {};
|
|
1703
|
+
for (const skill of skillManager.list()) {
|
|
1704
|
+
if (!skill?.name || typeof skill.name !== 'string') continue;
|
|
1705
|
+
const commandName = `${SKILL_COMMAND_PREFIX}${skill.name}`;
|
|
1706
|
+
commands.push(commandName);
|
|
1707
|
+
descriptions[commandName] = skill.description || skill.trigger || 'Load Yeaft skill';
|
|
1708
|
+
}
|
|
1709
|
+
commands.sort((a, b) => a.localeCompare(b));
|
|
1710
|
+
return { commands, descriptions };
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
function broadcastSkillSlashCommands(sessionLike) {
|
|
1714
|
+
const { commands, descriptions } = buildSkillSlashCommands(sessionLike?.skillManager);
|
|
1715
|
+
const slashCommands = [...new Set([...(ctx.slashCommands || []), ...commands])];
|
|
1716
|
+
const slashCommandDescriptions = {
|
|
1717
|
+
...(ctx.slashCommandDescriptions || {}),
|
|
1718
|
+
...descriptions,
|
|
1719
|
+
};
|
|
1720
|
+
ctx.slashCommands = slashCommands;
|
|
1721
|
+
ctx.slashCommandDescriptions = slashCommandDescriptions;
|
|
1722
|
+
sendToServer({
|
|
1723
|
+
type: 'slash_commands_update',
|
|
1724
|
+
conversationId: '__preload__',
|
|
1725
|
+
slashCommands,
|
|
1726
|
+
slashCommandDescriptions,
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1693
1730
|
/** Send a Yeaft Session metadata event over the legacy-compatible envelope. */
|
|
1694
1731
|
function sendSessionEvent(event, { sessionId, chatId, vpId, turnId, threadId } = {}) {
|
|
1695
1732
|
sendToServer({
|
|
@@ -3119,6 +3156,7 @@ export async function handleYeaftSessionSend(msg) {
|
|
|
3119
3156
|
mentions,
|
|
3120
3157
|
// Persisted form (no base64) — safe for jsonl-log.
|
|
3121
3158
|
attachments: persistedAttachments,
|
|
3159
|
+
clientMessageId: typeof msg.id === 'string' && msg.id ? msg.id : null,
|
|
3122
3160
|
},
|
|
3123
3161
|
// Live form — adapters need the base64 image blocks; runVpTurn
|
|
3124
3162
|
// reads `_promptParts` off the envelope rather than going
|
|
@@ -3375,6 +3413,7 @@ async function ensureSessionLoaded() {
|
|
|
3375
3413
|
|
|
3376
3414
|
yeaftConversationId = `yeaft-${Date.now()}`;
|
|
3377
3415
|
hydrateYeaftStatusFromSession(session, { reason: 'session_ready', emitEvent: true });
|
|
3416
|
+
broadcastSkillSlashCommands(session);
|
|
3378
3417
|
|
|
3379
3418
|
// Per-group history is hydrated lazily on first `getOrCreateSessionHistory`
|
|
3380
3419
|
// — there's no global "all conversations" tape any more.
|
|
@@ -3657,6 +3696,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
|
|
|
3657
3696
|
// would render the user's prompt N times.
|
|
3658
3697
|
userAlreadyPersisted: true,
|
|
3659
3698
|
threadId,
|
|
3699
|
+
vpTurnId: turnId,
|
|
3660
3700
|
drainPendingUserMessages: () => {
|
|
3661
3701
|
if (!thread || !Array.isArray(thread.pendingQueries) || thread.pendingQueries.length === 0) return [];
|
|
3662
3702
|
return thread.pendingQueries.splice(0);
|
|
@@ -3911,11 +3951,11 @@ function appendTurnToSessionHistory(sessionId, threadId, vpId, prompts, assistan
|
|
|
3911
3951
|
* refresh replay can render chips without leaking image source data into
|
|
3912
3952
|
* the message body.
|
|
3913
3953
|
*
|
|
3914
|
-
* @param {{ msgId:string, text:string, sessionId:string, role?:string, speakerVpId?:string|null, attachments?:Array<object>, internal?:boolean, ts?:string|null }} args
|
|
3954
|
+
* @param {{ msgId:string, text:string, sessionId:string, role?:string, speakerVpId?:string|null, attachments?:Array<object>, internal?:boolean, ts?:string|null, clientMessageId?:string|null }} args
|
|
3915
3955
|
* @returns {boolean} true if this call wrote the row, false if a prior
|
|
3916
3956
|
* call already wrote it (dedup hit).
|
|
3917
3957
|
*/
|
|
3918
|
-
function persistInboundMessageOnceByMsgId({ msgId, text, sessionId, threadId = 'main', role, speakerVpId, attachments, internal = false, ts = null }) {
|
|
3958
|
+
function persistInboundMessageOnceByMsgId({ msgId, text, sessionId, threadId = 'main', role, speakerVpId, attachments, internal = false, ts = null, clientMessageId = null }) {
|
|
3919
3959
|
if (!session?.conversationStore) return false;
|
|
3920
3960
|
// No msgId means no dedup key — caller is responsible for guarding.
|
|
3921
3961
|
// Both call sites already do (`if (envMsgId && text)` and
|
|
@@ -3959,6 +3999,9 @@ function persistInboundMessageOnceByMsgId({ msgId, text, sessionId, threadId = '
|
|
|
3959
3999
|
threadId: threadId || 'main',
|
|
3960
4000
|
};
|
|
3961
4001
|
if (sessionId) record.sessionId = sessionId;
|
|
4002
|
+
if (persistRole === 'user' && clientMessageId && typeof clientMessageId === 'string') {
|
|
4003
|
+
record.clientMessageId = clientMessageId;
|
|
4004
|
+
}
|
|
3962
4005
|
// Stamp speakerVpId so the UI's loadHistory replay can route the row
|
|
3963
4006
|
// to the correct VP block. Only meaningful when role='assistant'; for
|
|
3964
4007
|
// a real user message we leave it unset (the UI's user track is
|