@yeaft/webchat-agent 1.0.641 → 1.0.643

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.
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Session 历史复制/合并的纯变换。持久 ID 只在所属 stream 内有意义;
3
+ * origin 跨 fork/merge 保留,绝不把相同提问文本当作共同历史。
4
+ */
5
+ import { isVisibleConversationRow } from './internal-control.js';
6
+
7
+ /** Create a history-boundary error with a stable machine-readable code. */
8
+ export function historyError(code, message) {
9
+ return Object.assign(new Error(message), { code });
10
+ }
11
+
12
+ /** Whether a durable row is a human request rather than protocol/control input. */
13
+ export function isHumanRequest(row) {
14
+ return row?.role === 'user' && isVisibleConversationRow(row);
15
+ }
16
+
17
+ /** Preserve fork lineage, or establish an origin in the source stream's lifetime. */
18
+ export function messageOrigin(row, sessionId, streamId) {
19
+ const origin = row?.origin;
20
+ if (origin && typeof origin.streamId === 'string' && origin.streamId
21
+ && typeof origin.messageId === 'string' && origin.messageId) return { ...origin };
22
+ return { sessionId, streamId, messageId: row.id, ...(row.turnId ? { turnId: row.turnId } : {}) };
23
+ }
24
+
25
+ /**
26
+ * Cross-Session rows are historical projections, never native continuations.
27
+ * Signatures/encrypted reasoning are bound to the original owner and exact
28
+ * tool IDs. Keep the source transcript (linked by origin) as provenance, not
29
+ * an opaque payload in the new Session. Reconstruct only canonical text/tools.
30
+ * This deliberately does not relax replayProviderState's owner/digest fences.
31
+ */
32
+ export function historicalReplayRow(row) {
33
+ const { providerState, thinkingBlocks, ...history } = row;
34
+ return history;
35
+ }
36
+
37
+ function originKey(origin) {
38
+ return JSON.stringify([origin.streamId, origin.messageId]);
39
+ }
40
+
41
+ function stableJson(value) {
42
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
43
+ if (value && typeof value === 'object') {
44
+ return `{${Object.keys(value).sort().filter(key => value[key] !== undefined)
45
+ .map(key => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
46
+ }
47
+ return JSON.stringify(value);
48
+ }
49
+
50
+ function sentAt(row) {
51
+ return row.time ?? row.timestamp ?? row.ts ?? '';
52
+ }
53
+
54
+ function toolScope(row, callId) {
55
+ return JSON.stringify([row.turnId || '', row.speakerVpId || row.vpId || '', row.threadId || 'main', callId]);
56
+ }
57
+
58
+ function ambiguous(message) {
59
+ throw historyError('ambiguous_merge_history', message);
60
+ }
61
+
62
+ // 原始 transcript 不被 sanitize(sanitize 会丢数据)。返回每个结果对应的
63
+ // assistant 调用位置,允许不同 VP/turn 重用同一个 provider tool-call ID。
64
+ function toolLinks(rows) {
65
+ const calls = new Map();
66
+ const results = new Map();
67
+ const paired = new Set();
68
+ for (const [index, row] of rows.entries()) {
69
+ for (const [callIndex, call] of (row.toolCalls || []).entries()) {
70
+ const key = toolScope(row, call.id);
71
+ if (!call.id || calls.has(key)) ambiguous('Duplicate tool call identity within a request');
72
+ calls.set(key, { index, callIndex });
73
+ }
74
+ if (row.role !== 'tool') continue;
75
+ const key = toolScope(row, row.toolCallId);
76
+ let call = calls.get(key);
77
+ // Some legacy tool rows lack speaker/turn metadata. Only accept an
78
+ // unambiguous preceding call, never a globally colliding bare tool id.
79
+ if (!call) {
80
+ const candidates = [...calls.values()].filter(candidate => {
81
+ const owner = rows[candidate.index];
82
+ return owner.toolCalls[candidate.callIndex].id === row.toolCallId
83
+ && (!row.turnId || owner.turnId === row.turnId)
84
+ && (!(row.speakerVpId || row.vpId) || (owner.speakerVpId || owner.vpId) === (row.speakerVpId || row.vpId))
85
+ && (!row.threadId || (owner.threadId || 'main') === row.threadId);
86
+ });
87
+ if (candidates.length === 1) [call] = candidates;
88
+ }
89
+ if (!call || paired.has(call)) ambiguous('Unpaired or ambiguous tool result within a request');
90
+ results.set(index, call);
91
+ paired.add(call);
92
+ }
93
+ if ([...calls.values()].some(call => !paired.has(call))) ambiguous('Incomplete tool history within a request');
94
+ return { calls, results };
95
+ }
96
+
97
+ function requestTurns(input) {
98
+ const { rows, sessionId, streamId } = input;
99
+ if (new Set(rows.map(row => row.id)).size !== rows.length) ambiguous('Duplicate persisted identity within one Session');
100
+ const turns = [];
101
+ const byRow = new Map();
102
+ const byClient = new Map();
103
+ for (const [index, row] of rows.entries()) {
104
+ if (!isHumanRequest(row)) continue;
105
+ const candidates = row.clientMessageId ? (byClient.get(row.clientMessageId) || []) : [];
106
+ // Historical multi-VP prompt variants share a client identity and source
107
+ // stream. Distinct origins in a merged Session must remain distinct even
108
+ // if two clients happened to reuse the same clientMessageId.
109
+ const sameStream = candidate => !candidate.user.origin || !row.origin
110
+ || candidate.user.origin.streamId === row.origin.streamId;
111
+ if (candidates.some(candidate => sameStream(candidate) && sentAt(candidate.user) !== sentAt(row))) {
112
+ ambiguous('Reused clientMessageId within one Session');
113
+ }
114
+ let turn = candidates.find(candidate => sameStream(candidate) && sentAt(candidate.user) === sentAt(row));
115
+ if (!turn) {
116
+ turn = { user: row, rows: [], origins: new Set(), explicitOrigins: false, index, input };
117
+ turns.push(turn);
118
+ if (row.clientMessageId) byClient.set(row.clientMessageId, [...candidates, turn]);
119
+ }
120
+ byRow.set(row.id, turn);
121
+ turn.origins.add(originKey(messageOrigin(row, sessionId, streamId)));
122
+ turn.explicitOrigins ||= !!row.origin;
123
+ }
124
+ if (!turns.length) {
125
+ if (rows.length) ambiguous('History without a durable human request cannot be merged');
126
+ return [];
127
+ }
128
+ const records = new Map(rows.map(row => [row.id, row]));
129
+ function causalTurn(row, seen = new Set()) {
130
+ if (!row?.causalRootId || seen.has(row.id)) return null;
131
+ seen.add(row.id);
132
+ const direct = byRow.get(row.causalRootId);
133
+ if (direct) return direct;
134
+ const candidates = byClient.get(row.causalRootId) || [];
135
+ if (candidates.length > 1) ambiguous('Ambiguous client causal reference');
136
+ return candidates[0] || causalTurn(records.get(row.causalRootId), seen);
137
+ }
138
+ const byVpTurn = new Map();
139
+ const vpTurnKey = row => row.turnId ? JSON.stringify([row.speakerVpId || row.vpId || '', row.turnId]) : null;
140
+ // Learn explicit causal ownership before assigning interleaved legacy tails.
141
+ for (const row of rows) {
142
+ const turn = causalTurn(row);
143
+ const key = vpTurnKey(row);
144
+ if (!turn || !key) continue;
145
+ if (byVpTurn.has(key) && byVpTurn.get(key) !== turn) ambiguous('A VP turn belongs to multiple human requests');
146
+ byVpTurn.set(key, turn);
147
+ }
148
+ let current = null;
149
+ for (const row of rows) {
150
+ if (isHumanRequest(row)) current = byRow.get(row.id);
151
+ const key = vpTurnKey(row);
152
+ const turn = byRow.get(row.id) || causalTurn(row) || byVpTurn.get(key) || current;
153
+ if (!turn) {
154
+ if (isVisibleConversationRow(row) && row.role !== 'system') ambiguous('History begins inside a human request');
155
+ turns[0].rows.push(row);
156
+ byRow.set(row.id, turns[0]);
157
+ continue;
158
+ }
159
+ if (key) {
160
+ if (byVpTurn.has(key) && byVpTurn.get(key) !== turn) ambiguous('Ambiguous interleaved VP history');
161
+ byVpTurn.set(key, turn);
162
+ }
163
+ turn.rows.push(row);
164
+ byRow.set(row.id, turn);
165
+ }
166
+ for (const turn of turns) {
167
+ turn.links = toolLinks(turn.rows);
168
+ turn.vpTurns = new Set(turn.rows.filter(row => row.role === 'assistant' && row.turnId)
169
+ .map(row => JSON.stringify([row.speakerVpId || row.vpId || '', row.origin?.turnId || row.turnId])));
170
+ // Legacy forks preserved these identities, even though persisted row IDs
171
+ // were remapped. Require sent-time evidence too; no text-only matching.
172
+ turn.legacyKey = turn.user.clientMessageId && sentAt(turn.user)
173
+ ? stableJson(['client', turn.user.clientMessageId, sentAt(turn.user)])
174
+ : (turn.vpTurns.size && sentAt(turn.user)
175
+ ? stableJson(['turns', [...turn.vpTurns].sort(), sentAt(turn.user)]) : null);
176
+ }
177
+ return turns;
178
+ }
179
+
180
+ function commonRequest(left, right) {
181
+ if ([...left.origins].some(key => right.origins.has(key))) return true;
182
+ if (left.user.clientMessageId && right.user.clientMessageId
183
+ && left.user.clientMessageId !== right.user.clientMessageId) return false;
184
+ // Explicit, different origins prove these are distinct user requests.
185
+ if (left.explicitOrigins && right.explicitOrigins) return false;
186
+ if (left.legacyKey && left.legacyKey === right.legacyKey) return true;
187
+ // A fork made at an assistant boundary may contain only one VP's answer.
188
+ // Recognize overlap, then reject unequal request bodies below instead of
189
+ // treating that partial shared request as two independent user requests.
190
+ return !!sentAt(left.user) && sentAt(left.user) === sentAt(right.user)
191
+ && [...left.vpTurns].some(key => right.vpTurns.has(key));
192
+ }
193
+
194
+ function equivalentRows(left, right, aliases) {
195
+ if (left.rows.length !== right.rows.length) return false;
196
+ function normalized(turn) {
197
+ const rowRefs = new Map();
198
+ for (const candidate of turn.input.turns) {
199
+ const canonical = aliases.get(candidate) || candidate;
200
+ const requestKey = originKey(messageOrigin(canonical.user, canonical.input.sessionId, canonical.input.streamId));
201
+ candidate.rows.forEach((row, index) => rowRefs.set(row.id, `${requestKey}:${index}`));
202
+ }
203
+ const clientRefs = new Map();
204
+ for (const candidate of turn.input.turns) {
205
+ if (!candidate.user.clientMessageId) continue;
206
+ const clientId = candidate.user.clientMessageId;
207
+ const canonical = rowRefs.get(candidate.user.id);
208
+ clientRefs.set(clientId, clientRefs.has(clientId) && clientRefs.get(clientId) !== canonical ? null : canonical);
209
+ }
210
+ const remap = value => rowRefs.get(value) || value;
211
+ const callRefs = new Map([...turn.links.calls].map(([key, call]) => [key, `call:${call.index}:${call.callIndex}`]));
212
+ return turn.rows.map((row, index) => {
213
+ const copy = historicalReplayRow(row);
214
+ for (const key of ['id', 'seq', 'sessionId', 'origin', 'cold']) delete copy[key];
215
+ if (row.turnId) copy.turnId = row.origin?.turnId || row.turnId;
216
+ if (row.causalRootId) copy.causalRootId = rowRefs.get(row.causalRootId) || clientRefs.get(row.causalRootId) || row.causalRootId;
217
+ for (const key of ['foldedMessageIds', 'sourceMessageIds']) {
218
+ if (Array.isArray(row[key])) copy[key] = row[key].map(remap);
219
+ }
220
+ if (row.quote?.messageId) copy.quote = { ...row.quote, messageId: remap(row.quote.messageId) };
221
+ if (Array.isArray(row.attachments)) copy.attachments = row.attachments.map(att => {
222
+ const { path, ...metadata } = att;
223
+ return metadata;
224
+ });
225
+ if (row.toolCalls) copy.toolCalls = row.toolCalls.map(call => ({ ...call, id: callRefs.get(toolScope(row, call.id)) }));
226
+ const call = turn.links.results.get(index);
227
+ if (call) copy.toolCallId = `call:${call.index}:${call.callIndex}`;
228
+ if (copy.presentationCallId && callRefs.has(toolScope(row, copy.presentationCallId))) {
229
+ copy.presentationCallId = callRefs.get(toolScope(row, copy.presentationCallId));
230
+ }
231
+ return copy;
232
+ });
233
+ }
234
+ return stableJson(normalized(left)) === stableJson(normalized(right));
235
+ }
236
+
237
+ /**
238
+ * 规划两份 idle Session transcript 的完整请求合并;不写磁盘。
239
+ * @param {Array<{sessionId:string, streamId:string, rows:object[]}>} inputs
240
+ * @returns {{turns:object[], aliases:Map<object,object>, sharedTurnCount:number}}
241
+ */
242
+ export function planSessionMerge(inputs) {
243
+ for (const input of inputs) input.turns = requestTurns(input);
244
+ const [left, right] = inputs.map(input => input.turns);
245
+ const aliases = new Map();
246
+ for (const other of right) {
247
+ const matches = left.filter(turn => commonRequest(turn, other));
248
+ if (matches.length > 1) ambiguous('Shared request identity is not unique');
249
+ if (matches.length) {
250
+ if ([...aliases.values()].includes(matches[0])) ambiguous('Shared request occurs more than once');
251
+ aliases.set(other, matches[0]);
252
+ }
253
+ }
254
+ for (const [other, source] of aliases) {
255
+ if (!equivalentRows(source, other, aliases)) ambiguous('Shared human request has divergent or partial branch history');
256
+ }
257
+ // Only an aligned shared prefix precedes the chronological merge. A shared
258
+ // request can also occur in the middle after earlier merges (AB + BC); moving
259
+ // every shared request to the front would incorrectly produce B, A, C.
260
+ let prefixLength = 0;
261
+ while (prefixLength < left.length && aliases.get(right[prefixLength]) === left[prefixLength]) prefixLength++;
262
+ const common = left.slice(0, prefixLength);
263
+ const tails = [...left.slice(prefixLength), ...right.slice(prefixLength).filter(turn => !aliases.has(turn))];
264
+ const timestamp = turn => {
265
+ const value = sentAt(turn.user);
266
+ const parsed = typeof value === 'number' ? value : Date.parse(value);
267
+ return Number.isFinite(parsed) ? parsed : 0;
268
+ };
269
+ // Stable tie break: source argument order, then original request order.
270
+ tails.sort((a, b) => timestamp(a) - timestamp(b)
271
+ || inputs.indexOf(a.input) - inputs.indexOf(b.input) || a.index - b.index);
272
+ return { turns: [...common, ...tails], aliases, sharedTurnCount: aliases.size };
273
+ }
274
+
275
+ /**
276
+ * 为计划分配新 persisted IDs 并重写内部引用;每个源有独立命名空间。
277
+ * 附件由调用者按实际源所有权复制;provider 私有状态转为 canonical 历史。
278
+ * @param {object} plan planSessionMerge 的返回值
279
+ * @param {string} targetSessionId 新 Session
280
+ * @param {number} firstSeq 已保留的起始序号
281
+ * @returns {{rows:object[], idMaps:Map<string,Map<string,string>>}}
282
+ */
283
+ export function materializeSessionMerge(plan, targetSessionId, firstSeq) {
284
+ const idMaps = new Map();
285
+ const mapping = input => {
286
+ if (!idMaps.has(input.sessionId)) idMaps.set(input.sessionId, new Map());
287
+ return idMaps.get(input.sessionId);
288
+ };
289
+ let seq = firstSeq;
290
+ for (const turn of plan.turns) {
291
+ for (const row of turn.rows) mapping(turn.input).set(row.id, `m${String(seq++).padStart(4, '0')}`);
292
+ }
293
+ for (const [other, source] of plan.aliases) {
294
+ other.rows.forEach((row, index) => mapping(other.input).set(row.id, mapping(source.input).get(source.rows[index].id)));
295
+ }
296
+ const clientMaps = new Map();
297
+ for (const input of new Set([...plan.turns, ...plan.aliases.keys()].map(turn => turn.input))) {
298
+ const clients = new Map();
299
+ for (const turn of input.turns) {
300
+ if (!turn.user.clientMessageId) continue;
301
+ const clientId = turn.user.clientMessageId;
302
+ const targetId = mapping(input).get(turn.user.id);
303
+ clients.set(clientId, clients.has(clientId) && clients.get(clientId) !== targetId ? null : targetId);
304
+ }
305
+ clientMaps.set(input, clients);
306
+ }
307
+ const rows = [];
308
+ for (const turn of plan.turns) {
309
+ const ids = mapping(turn.input);
310
+ const vpTurns = new Map();
311
+ const remap = id => ids.get(id) || id;
312
+ const callId = call => `merge_${ids.get(turn.rows[call.index].id)}_${call.callIndex}`;
313
+ const calls = new Map([...turn.links.calls].map(([key, call]) => [key, callId(call)]));
314
+ for (const [index, row] of turn.rows.entries()) {
315
+ const copy = { ...historicalReplayRow(row), id: ids.get(row.id), sessionId: targetSessionId,
316
+ origin: messageOrigin(row, turn.input.sessionId, turn.input.streamId) };
317
+ delete copy.seq;
318
+ if (row.turnId) {
319
+ const key = JSON.stringify([row.speakerVpId || row.vpId || '', row.turnId]);
320
+ if (!vpTurns.has(key)) vpTurns.set(key, `merge_${copy.id}_turn`);
321
+ copy.turnId = vpTurns.get(key);
322
+ }
323
+ if (row.causalRootId) copy.causalRootId = ids.get(row.causalRootId)
324
+ || clientMaps.get(turn.input)?.get(row.causalRootId) || row.causalRootId;
325
+ for (const key of ['foldedMessageIds', 'sourceMessageIds']) {
326
+ if (Array.isArray(row[key])) copy[key] = row[key].map(remap);
327
+ }
328
+ if (row.quote?.messageId) copy.quote = { ...row.quote, messageId: remap(row.quote.messageId) };
329
+ if (row.toolCalls) copy.toolCalls = row.toolCalls.map(call => ({ ...call, id: calls.get(toolScope(row, call.id)) }));
330
+ if (turn.links.results.has(index)) copy.toolCallId = callId(turn.links.results.get(index));
331
+ if (calls.has(toolScope(row, row.presentationCallId))) copy.presentationCallId = calls.get(toolScope(row, row.presentationCallId));
332
+ rows.push(copy);
333
+ }
334
+ }
335
+ return { rows, idMaps };
336
+ }
package/yeaft/engine.js CHANGED
@@ -1375,6 +1375,7 @@ export class Engine {
1375
1375
  effortDecision: snapshotEffortDecision(vpCtx?.effortDecision),
1376
1376
  requestIdentity: vpCtx?.requestIdentity,
1377
1377
  queryTurnId: vpCtx?.queryTurnId,
1378
+ causalRootId: vpCtx?.causalRootId || null,
1378
1379
  webResearch: vpCtx?.webResearch,
1379
1380
  historyBeforeSeq: vpCtx?.historyBeforeSeq,
1380
1381
  yeaftDir: this.#yeaftDir,
@@ -1651,6 +1652,7 @@ export class Engine {
1651
1652
  const persistedContinuation = this.#persistConversationMessage(continuation, {
1652
1653
  sessionId: update.sessionId || this.#sessionId,
1653
1654
  turnId: update.turnId || null,
1655
+ causalRootId: update.causalRootId || null,
1654
1656
  });
1655
1657
  if (persistedContinuation?.id) continuation._persistedMessageId = persistedContinuation.id;
1656
1658
  conversationMessages.push(continuation);
@@ -4090,6 +4092,7 @@ export class Engine {
4090
4092
  effortDecision: snapshotEffortDecision(requestEffortDecision),
4091
4093
  requestIdentity,
4092
4094
  queryTurnId,
4095
+ causalRootId,
4093
4096
  webResearch,
4094
4097
  historyBeforeSeq: Number.isFinite(persistedQueryUser?.seq) ? persistedQueryUser.seq : parseSeqFromId(persistedQueryUser?.id),
4095
4098
  router,
@@ -5195,7 +5198,7 @@ export class Engine {
5195
5198
  *
5196
5199
  * @param {string} taskId
5197
5200
  * @param {string|Array} content — pre-formatted task result body
5198
- * @param {{ preview?: string, sessionId?: string, vpId?: string, threadId?: string, taskKind?: string, taskStatus?: string, turnId?: string }} [opts]
5201
+ * @param {{ preview?: string, sessionId?: string, vpId?: string, threadId?: string, taskKind?: string, taskStatus?: string, turnId?: string, causalRootId?: string }} [opts]
5199
5202
  * @returns {boolean}
5200
5203
  */
5201
5204
  notifyAsyncTaskCompleted(taskId, content, opts = {}) {
@@ -5235,6 +5238,11 @@ export class Engine {
5235
5238
  taskKind: opts.taskKind,
5236
5239
  taskStatus: opts.taskStatus,
5237
5240
  turnId: opts.turnId || meta.turnId || null,
5241
+ // Registration owns provenance even when newer human input arrives before
5242
+ // completion. Retain it until provider acceptance or undelivered rescue.
5243
+ causalRootId: meta.causalRootId
5244
+ || (typeof opts.causalRootId === 'string' ? opts.causalRootId.trim() : null)
5245
+ || this.#currentCausalRootId || null,
5238
5246
  };
5239
5247
  this.#acceptedAsyncTaskResults.set(taskId, delivery);
5240
5248
  this.#asyncTaskToolMeta.delete(taskId);
@@ -5248,6 +5256,7 @@ export class Engine {
5248
5256
  sessionId: delivery.sessionId,
5249
5257
  vpId: delivery.vpId,
5250
5258
  turnId: delivery.turnId,
5259
+ causalRootId: delivery.causalRootId,
5251
5260
  // Capture durable row ownership at completion time, independently of
5252
5261
  // any provider-only compacted request copy.
5253
5262
  folded: !hasLiveToolRow,
@@ -5268,7 +5277,7 @@ export class Engine {
5268
5277
  * Register a result-producing async task as belonging to the current query.
5269
5278
  * Called by tools such as SpawnAgent via `toolCtx.registerAsyncTask`.
5270
5279
  * @param {string} taskId
5271
- * @param {{ id?: string, name?: string, threadId?: string, toolCallId?: string, toolName?: string, sessionId?: string, vpId?: string, turnId?: string }} [meta]
5280
+ * @param {{ id?: string, name?: string, threadId?: string, toolCallId?: string, toolName?: string, sessionId?: string, vpId?: string, turnId?: string, causalRootId?: string }} [meta]
5272
5281
  * @returns {void}
5273
5282
  */
5274
5283
  #registerAsyncTask(taskId, meta = {}) {
@@ -5277,22 +5286,23 @@ export class Engine {
5277
5286
  const toolCallId = typeof meta.toolCallId === 'string' && meta.toolCallId
5278
5287
  ? meta.toolCallId
5279
5288
  : (typeof meta.id === 'string' && meta.id ? meta.id : null);
5280
- if (toolCallId) {
5281
- this.#asyncTaskToolMeta.set(taskId, {
5282
- toolCallId,
5283
- toolName: typeof meta.toolName === 'string' && meta.toolName ? meta.toolName : (typeof meta.name === 'string' ? meta.name : undefined),
5284
- threadId: typeof meta.threadId === 'string' && meta.threadId ? meta.threadId : undefined,
5285
- sessionId: typeof meta.sessionId === 'string' && meta.sessionId
5286
- ? meta.sessionId
5287
- : (this.#sessionId || null),
5288
- vpId: typeof meta.vpId === 'string' && meta.vpId ? meta.vpId : (this.#vpId || null),
5289
- // query() exposes the wire turn id while the task is registered.
5290
- // Keep the originating turn even for legacy missing/folded tool rows.
5291
- turnId: typeof meta.turnId === 'string' && meta.turnId
5292
- ? meta.turnId
5293
- : (this.#currentQueryTurnId || null),
5294
- });
5295
- }
5289
+ this.#asyncTaskToolMeta.set(taskId, {
5290
+ toolCallId,
5291
+ toolName: typeof meta.toolName === 'string' && meta.toolName ? meta.toolName : (typeof meta.name === 'string' ? meta.name : undefined),
5292
+ threadId: typeof meta.threadId === 'string' && meta.threadId ? meta.threadId : undefined,
5293
+ sessionId: typeof meta.sessionId === 'string' && meta.sessionId
5294
+ ? meta.sessionId
5295
+ : (this.#sessionId || null),
5296
+ vpId: typeof meta.vpId === 'string' && meta.vpId ? meta.vpId : (this.#vpId || null),
5297
+ // query() exposes the wire turn id while the task is registered.
5298
+ // Keep provenance even for legacy missing/folded tool rows.
5299
+ turnId: typeof meta.turnId === 'string' && meta.turnId
5300
+ ? meta.turnId
5301
+ : (this.#currentQueryTurnId || null),
5302
+ causalRootId: typeof meta.causalRootId === 'string' && meta.causalRootId.trim()
5303
+ ? meta.causalRootId.trim()
5304
+ : (this.#currentCausalRootId || null),
5305
+ });
5296
5306
  try { this.#asyncTaskCoordinator?.onRegister?.(taskId, this); } catch { /* coord must not throw into tools */ }
5297
5307
  }
5298
5308
 
@@ -180,6 +180,10 @@ export function createRouter(deps = {}) {
180
180
  role: 'assistant', // VP-authored — persists as assistant turn
181
181
  text,
182
182
  taskId: args.taskId ?? null,
183
+ // Runtime provenance follows the human request through handoffs; the
184
+ // coordinator forwards underscore fields without persisting them.
185
+ _causalRootId: args.inboundEnvelope?._causalRootId
186
+ || args.inboundEnvelope?._persistedUserMessage?.id || null,
183
187
  // route_forward is already visible as the source VP's tool action.
184
188
  // Persist the synthetic handoff for audit/dispatch, but keep it out
185
189
  // of UI replay and future visible history so it doesn't render as a
@@ -43,6 +43,7 @@ export {
43
43
  ensureDefaultSessionIfEmpty,
44
44
  createSessionFromSpec,
45
45
  copySession,
46
+ mergeSessions,
46
47
  renameSession,
47
48
  archiveSession,
48
49
  deleteSession,
@@ -580,6 +580,14 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
580
580
  * Create an independent Session from an existing Session's durable state.
581
581
  * The Server inherits Project membership when it receives the copy result.
582
582
  * Server-owned assets retain their original ownership; message references are preserved.
583
+ * @param {string} yeaftDir Agent-owned data root
584
+ * @param {string} sourceSessionId Existing Session
585
+ * @param {{name?:string, throughBoundary?:object, throughTurnId?:string, throughMessageId?:string|null, beforeMessageId?:string}} [options]
586
+ * throughBoundary forwards the opaque raw-prefix token returned synchronously by
587
+ * ConversationStore.captureSessionCopyBoundary. A requested throughTurnId must
588
+ * end within that prefix or fail closed (never truncate an active turn).
589
+ * Legacy throughMessageId/null and beforeMessageId fences remain supported.
590
+ * @returns {object} New Session metadata with copiedMessageCount
583
591
  */
584
592
  export function copySession(yeaftDir, sourceSessionId, options = {}) {
585
593
  const sourceYeaftDir = resolveSessionYeaftDir(yeaftDir, sourceSessionId);
@@ -618,9 +626,10 @@ export function copySession(yeaftDir, sourceSessionId, options = {}) {
618
626
  }
619
627
 
620
628
  const transcript = new ConversationStore(sourceYeaftDir);
621
- const transcriptOptions = Object.prototype.hasOwnProperty.call(options, 'throughTurnId')
622
- ? { throughTurnId: options.throughTurnId }
623
- : {};
629
+ const transcriptOptions = {};
630
+ for (const key of ['throughBoundary', 'throughTurnId', 'throughMessageId', 'beforeMessageId']) {
631
+ if (Object.prototype.hasOwnProperty.call(options, key)) transcriptOptions[key] = options[key];
632
+ }
624
633
  const { copiedCount } = transcript.copySession(sourceSessionId, copied.id, transcriptOptions);
625
634
  return { ...requireSessionMeta(sourceYeaftDir, copied.id), copiedMessageCount: copiedCount };
626
635
  } catch (error) {
@@ -633,6 +642,62 @@ export function copySession(yeaftDir, sourceSessionId, options = {}) {
633
642
  }
634
643
  }
635
644
 
645
+ /**
646
+ * Create a NEW Session from two idle Sessions in this Agent data root.
647
+ * Source config, announcement, default VP and workDir win; roster is the
648
+ * source-first union. Server inherits source Project membership separately.
649
+ * Neither source transcript nor metadata is modified. Any failure after target
650
+ * creation removes the new Session (including attachments and discovery entry).
651
+ * Ambiguous/partial shared requests fail closed with ambiguous_merge_history.
652
+ *
653
+ * @param {string} yeaftDir Agent-owned data root
654
+ * @param {string} sourceId Source of config/project/workdir semantics
655
+ * @param {string} otherId Other idle Session; must differ from sourceId
656
+ * @param {{name?:string, libDir?:string}} [options]
657
+ * @returns {object} New Session metadata, copiedMessageCount and sharedTurnCount
658
+ * @throws {SessionCrudError} invalid_merge_source, not_found, or ambiguous_merge_history
659
+ */
660
+ export function mergeSessions(yeaftDir, sourceId, otherId, options = {}) {
661
+ if (typeof sourceId !== 'string' || typeof otherId !== 'string'
662
+ || !/^[A-Za-z0-9_-]+$/.test(sourceId) || !/^[A-Za-z0-9_-]+$/.test(otherId)
663
+ || sourceId === otherId) {
664
+ throw new SessionCrudError('invalid_merge_source', sourceId, 'Merge requires two distinct Sessions');
665
+ }
666
+ const sourceRoot = resolveSessionYeaftDir(yeaftDir, sourceId);
667
+ const otherRoot = resolveSessionYeaftDir(yeaftDir, otherId);
668
+ if (resolve(sourceRoot) !== resolve(otherRoot)) {
669
+ throw new SessionCrudError('invalid_merge_source', sourceId, 'Merge sources must belong to the same Agent data root');
670
+ }
671
+ const sourceMeta = requireSessionMeta(sourceRoot, sourceId);
672
+ const otherMeta = requireSessionMeta(otherRoot, otherId);
673
+ const sourceConfig = loadSessionConfig(sourceRoot, sourceId);
674
+ const roster = [...new Set([...(sourceMeta.roster || []), ...(otherMeta.roster || [])])];
675
+ const merged = createSessionFromSpec(sourceRoot, {
676
+ name: String(options.name || '').trim() || `${sourceMeta.name} merged`,
677
+ roster,
678
+ defaultVpId: sourceMeta.defaultVpId || roster[0] || null,
679
+ workDir: sourceMeta.workDir || '',
680
+ }, { ...options, preserveEmptyRoster: true });
681
+ try {
682
+ saveSessionConfig(sourceRoot, merged.id, sourceConfig);
683
+ const target = requireSession(sourceRoot, merged.id);
684
+ try {
685
+ const meta = { ...target.getMeta(), announcement: sourceMeta.announcement || '', metadataUpdatedAt: new Date().toISOString() };
686
+ target.saveMeta(meta);
687
+ addOrUpdateManifestSession(sourceRoot, meta, target.dir);
688
+ } finally { target.close(); }
689
+ const { copiedCount, sharedTurnCount } = new ConversationStore(sourceRoot)
690
+ .mergeSessions(sourceId, otherId, merged.id, options);
691
+ return { ...requireSessionMeta(sourceRoot, merged.id), copiedMessageCount: copiedCount, sharedTurnCount };
692
+ } catch (error) {
693
+ deleteSession(sourceRoot, merged.id, options);
694
+ if (error?.code === 'ambiguous_merge_history' || error?.code === 'invalid_merge_source') {
695
+ throw new SessionCrudError(error.code, sourceId, error.message);
696
+ }
697
+ throw error;
698
+ }
699
+ }
700
+
636
701
  function requireSessionMeta(yeaftDir, sessionId) {
637
702
  const handle = requireSession(yeaftDir, sessionId);
638
703
  try { return handle.getMeta(); } finally { handle.close(); }
@@ -521,7 +521,10 @@ stops or finalizes work no longer needed.`,
521
521
  title: spec.mission || spec.task || name,
522
522
  resultDelivery: TASK_RESULT_DELIVERY.MODEL_REENTRY,
523
523
  runtime: { subAgentId: agentId, name, cwd: agent.cwd },
524
- source: { threadId: callerScope.parentThreadId || ctx?.threadId || 'main' },
524
+ source: {
525
+ threadId: callerScope.parentThreadId || ctx?.threadId || 'main',
526
+ ...(ctx.causalRootId ? { causalRootId: ctx.causalRootId } : {}),
527
+ },
525
528
  });
526
529
  if (task?.id) {
527
530
  agent.taskId = task.id;