@yeaft/webchat-agent 1.0.407 → 1.0.409

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.
Binary file
@@ -16,6 +16,6 @@
16
16
  </head>
17
17
  <body>
18
18
  <div id="app"></div>
19
- <script type="module" src="app.bundle.js?v=d7ace2f5"></script>
19
+ <script type="module" src="app.bundle.js?v=e4516fa0"></script>
20
20
  </body>
21
21
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.407",
3
+ "version": "1.0.409",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -6,7 +6,7 @@ import ctx from '../context.js';
6
6
  import { resolveAndValidatePath, BINARY_EXTENSIONS } from './utils.js';
7
7
 
8
8
  export async function handleReadFile(msg) {
9
- const { conversationId, filePath, _requestUserId } = msg;
9
+ const { conversationId, filePath, requestId, _requestUserId, _requestClientId } = msg;
10
10
  console.log('[Agent] handleReadFile received:', { filePath, conversationId, workDir: msg.workDir });
11
11
  const conv = ctx.conversations.get(conversationId);
12
12
  const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
@@ -23,7 +23,9 @@ export async function handleReadFile(msg) {
23
23
  ctx.sendToServer({
24
24
  type: 'file_content',
25
25
  conversationId,
26
+ requestId,
26
27
  _requestUserId,
28
+ _requestClientId,
27
29
  filePath: resolved,
28
30
  requestedFilePath: filePath,
29
31
  content: buffer.toString('base64'),
@@ -57,7 +59,9 @@ export async function handleReadFile(msg) {
57
59
  ctx.sendToServer({
58
60
  type: 'file_content',
59
61
  conversationId,
62
+ requestId,
60
63
  _requestUserId,
64
+ _requestClientId,
61
65
  filePath: resolved,
62
66
  requestedFilePath: filePath,
63
67
  content,
@@ -68,7 +72,9 @@ export async function handleReadFile(msg) {
68
72
  ctx.sendToServer({
69
73
  type: 'file_content',
70
74
  conversationId,
75
+ requestId,
71
76
  _requestUserId,
77
+ _requestClientId,
72
78
  filePath,
73
79
  requestedFilePath: filePath,
74
80
  content: '',
@@ -78,7 +84,7 @@ export async function handleReadFile(msg) {
78
84
  }
79
85
 
80
86
  export async function handleWriteFile(msg) {
81
- const { conversationId, filePath, content, _requestUserId } = msg;
87
+ const { conversationId, filePath, content, requestId, _requestUserId, _requestClientId } = msg;
82
88
  const conv = ctx.conversations.get(conversationId);
83
89
  const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
84
90
 
@@ -89,16 +95,22 @@ export async function handleWriteFile(msg) {
89
95
  ctx.sendToServer({
90
96
  type: 'file_saved',
91
97
  conversationId,
98
+ requestId,
92
99
  _requestUserId,
100
+ _requestClientId,
93
101
  filePath: resolved,
102
+ requestedFilePath: filePath,
94
103
  success: true
95
104
  });
96
105
  } catch (e) {
97
106
  ctx.sendToServer({
98
107
  type: 'file_saved',
99
108
  conversationId,
109
+ requestId,
100
110
  _requestUserId,
111
+ _requestClientId,
101
112
  filePath,
113
+ requestedFilePath: filePath,
102
114
  success: false,
103
115
  error: e.message
104
116
  });
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { join } from 'node:path';
3
3
  import { Engine } from './engine.js';
4
4
  import { createRouter } from './routing/router.js';
5
+ import { createLoopGuard } from './routing/loop-guard.js';
5
6
  import { createCoordinator } from './sessions/coordinator.js';
6
7
  import { resolveMemberId } from './sessions/roster.js';
7
8
  import { sessionsRoot } from './sessions/session-crud.js';
@@ -10,6 +11,86 @@ import { loadSessionConfig, resolveSessionConfig } from './sessions/session-conf
10
11
  import { readVp } from './vp/vp-crud.js';
11
12
  import { COLLAB_TOOL_POLICY } from './tools/registry.js';
12
13
 
14
+ const MAX_ROUTE_FORWARD_RESULT_CHARS = 12_000;
15
+ const MAX_ROUTE_FORWARD_PROMPT_CHARS = 60_000;
16
+
17
+ function cleanString(value) {
18
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
19
+ }
20
+
21
+ function routeForwardCompletionFor(row) {
22
+ const envelope = row?.envelope;
23
+ const meta = envelope?.msg?.meta;
24
+ if (!meta || row?.stopReason === 'tool_handoff' || row?.stopReason === 'aborted') return null;
25
+ const parent = meta.injectedBy === 'route_forward_result'
26
+ && meta.routeForwardParent
27
+ && typeof meta.routeForwardParent === 'object'
28
+ ? meta.routeForwardParent
29
+ : null;
30
+ if (meta.injectedBy !== 'route_forward' && !parent) return null;
31
+ const sourceVpId = cleanString(parent?.sourceVpId ?? meta.senderVpId);
32
+ const forwardId = cleanString(parent?.forwardId ?? envelope?.msg?.id);
33
+ if (!sourceVpId || !forwardId || sourceVpId === row.vpId) return null;
34
+ const rawExpectedVpIds = parent?.expectedVpIds ?? meta.routeForwardExpectedTargets;
35
+ const expectedVpIds = Array.isArray(rawExpectedVpIds)
36
+ ? [...new Set(rawExpectedVpIds.map(cleanString).filter(Boolean))]
37
+ : [row.vpId];
38
+ if (!expectedVpIds.includes(row.vpId)) return null;
39
+ return {
40
+ forwardId,
41
+ sourceVpId,
42
+ sourceThreadId: cleanString(parent?.sourceThreadId ?? meta.sourceThreadId) || 'main',
43
+ causedBy: Array.isArray(parent?.causedBy ?? meta.causedBy)
44
+ ? (parent?.causedBy ?? meta.causedBy).slice()
45
+ : [],
46
+ dispatchErrors: [
47
+ ...(Array.isArray(parent?.dispatchErrors) ? parent.dispatchErrors : []),
48
+ ...(Array.isArray(meta.routeForwardDispatchErrors) ? meta.routeForwardDispatchErrors : []),
49
+ ],
50
+ truncatedAtFanOutCap: Boolean(parent?.truncatedAtFanOutCap)
51
+ || Boolean(meta.routeForwardTruncatedAtFanOutCap),
52
+ parentRouteForward: parent?.parentRouteForward
53
+ || (meta.injectedBy === 'route_forward' ? (meta.routeForwardParent || null) : null),
54
+ expectedVpIds,
55
+ vpId: row.vpId,
56
+ result: row.result || '',
57
+ error: row.error || null,
58
+ stopReason: row.stopReason || 'end_turn',
59
+ envelope,
60
+ };
61
+ }
62
+
63
+ function formatRouteForwardResult(aggregate, results) {
64
+ const sections = results.map((entry) => {
65
+ const rawBody = entry.error
66
+ ? `Error: ${entry.error.message || String(entry.error)}`
67
+ : (entry.result || '(no text returned)');
68
+ const body = rawBody.length > MAX_ROUTE_FORWARD_RESULT_CHARS
69
+ ? `${rawBody.slice(0, MAX_ROUTE_FORWARD_RESULT_CHARS)}\n[Result truncated]`
70
+ : rawBody;
71
+ return `[${entry.vpId} — ${entry.stopReason}]\n${body}`;
72
+ });
73
+ const notices = [];
74
+ if (aggregate.truncatedAtFanOutCap) {
75
+ notices.push('Warning: the requested fan-out was truncated at the Session limit.');
76
+ }
77
+ if (aggregate.dispatchErrors.length > 0) {
78
+ notices.push(`Dispatch errors: ${JSON.stringify(aggregate.dispatchErrors)}`);
79
+ }
80
+ const prompt = [
81
+ '[RouteForward result]',
82
+ notices.length > 0
83
+ ? 'The accepted delegated VP work has finished, but dispatch was partial. Continue the same user request using the results and warnings below.'
84
+ : 'The delegated VP work has finished. Continue the same user request using the result below.',
85
+ ...notices,
86
+ '',
87
+ ...sections,
88
+ ].join('\n');
89
+ return prompt.length > MAX_ROUTE_FORWARD_PROMPT_CHARS
90
+ ? `${prompt.slice(0, MAX_ROUTE_FORWARD_PROMPT_CHARS)}\n[Combined RouteForward results truncated]`
91
+ : prompt;
92
+ }
93
+
13
94
  function buildVpPersona(vpId, loaded) {
14
95
  const vp = readVp(vpId, { libDir: join(loaded.yeaftDir, 'virtual-persons') });
15
96
  if (!vp) return null;
@@ -79,6 +160,8 @@ export function createCliSessionRunner({
79
160
  // new row carries one durable causalRootId; the legacy ids remain fallbacks
80
161
  // for rows produced before that field existed.
81
162
  const rootOrderByIdentity = new Map();
163
+ const activeTurnContexts = new Map();
164
+ const routeForwardGuard = createLoopGuard();
82
165
  let nextRootOrder = 0;
83
166
  let closed = false;
84
167
 
@@ -107,6 +190,8 @@ export function createCliSessionRunner({
107
190
  const rootOrder = Number.isInteger(envelope?._cliRootOrder)
108
191
  ? envelope._cliRootOrder
109
192
  : null;
193
+ const inboundMessageId = cleanString(envelope?.msg?.id);
194
+ const includeCurrentRoot = envelope?.msg?.meta?.injectedBy === 'route_forward_result';
110
195
  // Engine.query() appends `prompt` itself. Exclude this root's durable user
111
196
  // row and every later root turn, regardless of where their assistant/tool
112
197
  // rows landed in the globally sequenced transcript. This preserves rows
@@ -115,6 +200,10 @@ export function createCliSessionRunner({
115
200
  const messages = loaded.conversationStore
116
201
  .loadSessionHistoryForVp(sessionId, vpId)
117
202
  .filter((message) => {
203
+ if (inboundMessageId
204
+ && (message?.id === inboundMessageId || message?.messageId === inboundMessageId)) {
205
+ return false;
206
+ }
118
207
  if (persistedUserClientMessageId
119
208
  && message?.role === 'user'
120
209
  && message.clientMessageId === persistedUserClientMessageId) return false;
@@ -129,11 +218,14 @@ export function createCliSessionRunner({
129
218
  } else if (typeof message?.turnId === 'string') {
130
219
  messageRootOrder = rootOrderByIdentity.get(message.turnId);
131
220
  }
132
- return !Number.isInteger(messageRootOrder) || messageRootOrder < rootOrder;
221
+ return !Number.isInteger(messageRootOrder)
222
+ || messageRootOrder < rootOrder
223
+ || (includeCurrentRoot && messageRootOrder === rootOrder);
133
224
  });
134
225
  const todos = [];
135
226
  let resultText = '';
136
227
  let failed = null;
228
+ let stopReason = null;
137
229
  const scopedCoordinator = {
138
230
  group: coordinator.group,
139
231
  ingest(input, opts) {
@@ -158,7 +250,7 @@ export function createCliSessionRunner({
158
250
  sessionMembers: meta.roster.slice(),
159
251
  sessionAnnouncement: meta.announcement || '',
160
252
  vpPersona: personaFactory(vpId, loaded),
161
- router: createRouter({ coordinator: scopedCoordinator }),
253
+ router: createRouter({ coordinator: scopedCoordinator, guard: routeForwardGuard }),
162
254
  inboundEnvelope: envelope,
163
255
  userAlreadyPersisted: true,
164
256
  causalRootId,
@@ -177,6 +269,8 @@ export function createCliSessionRunner({
177
269
  userEffort: options.modelEffort || null,
178
270
  };
179
271
 
272
+ const turnContext = envelope?._cliTurnContext;
273
+ turnContext?.activeEngines.add(engine);
180
274
  try {
181
275
  for await (const event of engine.query(queryOptions)) {
182
276
  if (event.type === 'text_delta') resultText += event.text || '';
@@ -185,6 +279,11 @@ export function createCliSessionRunner({
185
279
  ? event.error
186
280
  : new Error(String(event.error?.message || event.error || 'Unknown Engine error'));
187
281
  }
282
+ if (event.type === 'turn_end' && event.terminal && event.stopReason) {
283
+ stopReason = event.stopReason;
284
+ } else if (event.type === 'stop' && event.stopReason) {
285
+ stopReason = event.stopReason;
286
+ }
188
287
  await options.onEvent?.({ vpId, event, sessionId, turnId: queryOptions.vpTurnId });
189
288
  }
190
289
  } catch (error) {
@@ -195,20 +294,53 @@ export function createCliSessionRunner({
195
294
  turnId: queryOptions.vpTurnId,
196
295
  event: { type: 'error', error, retryable: false },
197
296
  });
297
+ } finally {
298
+ turnContext?.activeEngines.delete(engine);
198
299
  }
199
- return { vpId, result: resultText, error: failed };
300
+ return {
301
+ vpId,
302
+ result: resultText,
303
+ error: failed,
304
+ stopReason: failed ? 'error' : (stopReason || 'end_turn'),
305
+ envelope,
306
+ };
200
307
  };
201
308
 
202
309
  const enqueue = (vpId, envelope) => {
203
310
  if (closed) throw new Error('CLI Session runner is closed');
204
311
  const turnContext = envelope?._cliTurnContext;
205
312
  if (!turnContext) throw new Error('CLI Session envelope is missing its turn context');
206
- if (turnContext.claimedVpIds.has(vpId)) {
313
+ if (turnContext.cancellation.cancelled) {
314
+ return Promise.resolve({
315
+ vpId,
316
+ result: '',
317
+ error: null,
318
+ stopReason: 'aborted',
319
+ envelope,
320
+ });
321
+ }
322
+ const routeForwardResultId = cleanString(envelope?.msg?.meta?.routeForwardId);
323
+ const isRouteForwardResult = envelope?.msg?.meta?.injectedBy === 'route_forward_result'
324
+ && cleanString(envelope?.msg?.meta?.routeTargetVpId) === vpId
325
+ && routeForwardResultId
326
+ && turnContext.routeForwardReturnIds.has(routeForwardResultId);
327
+ if (turnContext.claimedVpIds.has(vpId) && !isRouteForwardResult) {
207
328
  return { ok: false, error: 'target_already_claimed' };
208
329
  }
209
- turnContext.claimedVpIds.add(vpId);
330
+ if (!isRouteForwardResult) turnContext.claimedVpIds.add(vpId);
210
331
  const previous = tails.get(vpId) || Promise.resolve();
211
- const task = previous.catch(() => {}).then(() => runEnvelope(vpId, envelope, turnContext.options));
332
+ const task = previous.catch(() => {}).then(() => {
333
+ if (turnContext.cancellation.cancelled) {
334
+ return {
335
+ vpId,
336
+ result: '',
337
+ error: null,
338
+ stopReason: 'aborted',
339
+ envelope,
340
+ };
341
+ }
342
+ return runEnvelope(vpId, envelope, turnContext.options);
343
+ });
212
344
  tails.set(vpId, task);
213
345
  pending.add(task);
214
346
  turnContext.tasks.push(task);
@@ -221,6 +353,56 @@ export function createCliSessionRunner({
221
353
 
222
354
  coordinator = createCoordinator(handle, { deliver: enqueue });
223
355
 
356
+ const enqueueRouteForwardReturns = (completed, turnContext) => {
357
+ if (turnContext.cancellation.cancelled) return;
358
+ for (const row of completed) {
359
+ const completion = routeForwardCompletionFor(row);
360
+ if (!completion) continue;
361
+ let aggregate = turnContext.routeForwardReturns.get(completion.forwardId);
362
+ if (!aggregate) {
363
+ aggregate = {
364
+ ...completion,
365
+ results: new Map(),
366
+ };
367
+ turnContext.routeForwardReturns.set(completion.forwardId, aggregate);
368
+ }
369
+ aggregate.results.set(completion.vpId, completion);
370
+ if (turnContext.routeForwardReturnIds.has(completion.forwardId)
371
+ || !aggregate.expectedVpIds.every(vpId => aggregate.results.has(vpId))) continue;
372
+
373
+ // A RouteForward can return to its source exactly once. The source
374
+ // remains claimed for the root turn, so this explicit, internally
375
+ // marked continuation is the only allowed re-entry path.
376
+ turnContext.routeForwardReturnIds.add(completion.forwardId);
377
+ const results = aggregate.expectedVpIds.map(vpId => aggregate.results.get(vpId));
378
+ const representative = results[0];
379
+ coordinator.ingest({
380
+ id: randomUUID(),
381
+ from: representative.vpId,
382
+ role: 'assistant',
383
+ text: formatRouteForwardResult(aggregate, results),
384
+ internal: true,
385
+ meta: {
386
+ synthetic: true,
387
+ injectedBy: 'route_forward_result',
388
+ routeTargetVpId: aggregate.sourceVpId,
389
+ senderVpId: representative.vpId,
390
+ sourceThreadId: aggregate.sourceThreadId,
391
+ routeForwardId: aggregate.forwardId,
392
+ ...(aggregate.parentRouteForward
393
+ ? { routeForwardParent: aggregate.parentRouteForward }
394
+ : {}),
395
+ causedBy: aggregate.causedBy,
396
+ routeForwardDispatchErrors: aggregate.dispatchErrors,
397
+ routeForwardTruncatedAtFanOutCap: aggregate.truncatedAtFanOutCap,
398
+ },
399
+ _cliRootOrder: representative.envelope?._cliRootOrder,
400
+ _cliCausalRootId: representative.envelope?._cliCausalRootId,
401
+ _cliTurnContext: turnContext,
402
+ });
403
+ }
404
+ };
405
+
224
406
  async function drain(tasks = pending) {
225
407
  const results = [];
226
408
  while (tasks.size > 0) {
@@ -263,12 +445,24 @@ export function createCliSessionRunner({
263
445
  explicit: routingIntent.explicit === true,
264
446
  });
265
447
  }
448
+ const messageId = randomUUID();
449
+ const cancellationId = cleanString(options.cancellationId) || messageId;
450
+ if (activeTurnContexts.has(cancellationId)) {
451
+ throw new Error(`CLI Session cancellation id ${cancellationId} is already active`);
452
+ }
266
453
  const turnContext = Object.freeze({
454
+ rootId: messageId,
455
+ cancellationId,
267
456
  options: Object.freeze({ ...options }),
268
457
  tasks: [],
269
458
  claimedVpIds: new Set(),
459
+ routeForwardReturns: new Map(),
460
+ routeForwardReturnIds: new Set(),
461
+ cancellation: { cancelled: false, reason: null },
462
+ activeEngines: new Set(),
270
463
  });
271
- const messageId = randomUUID();
464
+ activeTurnContexts.set(cancellationId, turnContext);
465
+ try {
272
466
  const rootOrder = nextRootOrder++;
273
467
  rootOrderByIdentity.set(messageId, rootOrder);
274
468
  // The shared user row is the durability boundary. Validate structured
@@ -314,14 +508,29 @@ export function createCliSessionRunner({
314
508
  while (cursor < turnContext.tasks.length) {
315
509
  const batch = turnContext.tasks.slice(cursor);
316
510
  cursor += batch.length;
317
- results.push(...await Promise.all(batch));
511
+ const completed = await Promise.all(batch);
512
+ results.push(...completed);
513
+ enqueueRouteForwardReturns(completed, turnContext);
318
514
  await Promise.resolve();
319
515
  }
320
516
  return { report, results };
517
+ } finally {
518
+ activeTurnContexts.delete(cancellationId);
519
+ }
321
520
  },
322
- abort(reason = 'user') {
521
+ abort(reason = 'user', options = {}) {
522
+ const cancellationId = cleanString(options?.cancellationId);
523
+ const contexts = cancellationId
524
+ ? [activeTurnContexts.get(cancellationId)].filter(Boolean)
525
+ : Array.from(activeTurnContexts.values());
526
+ const enginesToAbort = new Set();
527
+ for (const turnContext of contexts) {
528
+ turnContext.cancellation.cancelled = true;
529
+ turnContext.cancellation.reason = reason;
530
+ for (const engine of turnContext.activeEngines) enginesToAbort.add(engine);
531
+ }
323
532
  let count = 0;
324
- for (const engine of engines.values()) {
533
+ for (const engine of enginesToAbort) {
325
534
  if (engine.abort?.(reason)) count += 1;
326
535
  }
327
536
  return count;
package/yeaft/engine.js CHANGED
@@ -2219,7 +2219,7 @@ export class Engine {
2219
2219
  const runtimeThreadId = (typeof threadId === 'string' && threadId.trim())
2220
2220
  ? threadId.trim()
2221
2221
  : MAIN_THREAD_ID;
2222
- const executionOrigin = inboundEnvelope?.msg?.meta?.injectedBy === 'route_forward'
2222
+ const executionOrigin = ['route_forward', 'route_forward_result'].includes(inboundEnvelope?.msg?.meta?.injectedBy)
2223
2223
  ? 'route_forward'
2224
2224
  : null;
2225
2225
  // The bridge-provided VP turn id is also persisted on assistant messages and
@@ -28,6 +28,39 @@
28
28
  import { resolveMemberId } from '../sessions/roster.js';
29
29
  import { createLoopGuard, extendCausedBy } from './loop-guard.js';
30
30
 
31
+ function routeForwardParentFromEnvelope(envelope) {
32
+ const msg = envelope?.msg;
33
+ const meta = msg?.meta;
34
+ if (!meta || typeof meta !== 'object') return null;
35
+ if (meta.injectedBy === 'route_forward_result') {
36
+ return meta.routeForwardParent && typeof meta.routeForwardParent === 'object'
37
+ ? { ...meta.routeForwardParent }
38
+ : null;
39
+ }
40
+ if (meta.injectedBy !== 'route_forward') return null;
41
+ const forwardId = typeof msg.id === 'string' ? msg.id.trim() : '';
42
+ const sourceVpId = typeof meta.senderVpId === 'string' ? meta.senderVpId.trim() : '';
43
+ if (!forwardId || !sourceVpId) return null;
44
+ return {
45
+ forwardId,
46
+ sourceVpId,
47
+ sourceThreadId: typeof meta.sourceThreadId === 'string' && meta.sourceThreadId.trim()
48
+ ? meta.sourceThreadId.trim()
49
+ : 'main',
50
+ expectedVpIds: Array.isArray(meta.routeForwardExpectedTargets)
51
+ ? meta.routeForwardExpectedTargets.slice()
52
+ : [],
53
+ causedBy: Array.isArray(meta.causedBy) ? meta.causedBy.slice() : [],
54
+ dispatchErrors: Array.isArray(meta.routeForwardDispatchErrors)
55
+ ? meta.routeForwardDispatchErrors.slice()
56
+ : [],
57
+ truncatedAtFanOutCap: Boolean(meta.routeForwardTruncatedAtFanOutCap),
58
+ parentRouteForward: meta.routeForwardParent && typeof meta.routeForwardParent === 'object'
59
+ ? { ...meta.routeForwardParent }
60
+ : null,
61
+ };
62
+ }
63
+
31
64
  /**
32
65
  * Build a router bound to a single GroupCoordinator + loop guard.
33
66
  *
@@ -112,6 +145,7 @@ export function createRouter(deps = {}) {
112
145
  // The guard runs against the *pre-dispatch* chain; that matches the
113
146
  // spec's intent ("depth of forwards already taken").
114
147
  const chain = extendCausedBy(args.inboundEnvelope || null, null);
148
+ const routeForwardParent = routeForwardParentFromEnvelope(args.inboundEnvelope);
115
149
 
116
150
  // Loop guard: for broadcast, use 'all' as the target key so one VP
117
151
  // spamming @all still gets throttled even if each cycle hits different
@@ -155,6 +189,7 @@ export function createRouter(deps = {}) {
155
189
  senderVpId: from,
156
190
  reason: args.reason || null,
157
191
  causedBy: chain,
192
+ ...(routeForwardParent ? { routeForwardParent } : {}),
158
193
  sourceThreadId: typeof args.sourceThreadId === 'string' && args.sourceThreadId.trim()
159
194
  ? args.sourceThreadId.trim()
160
195
  : null,
@@ -163,11 +198,35 @@ export function createRouter(deps = {}) {
163
198
  opts,
164
199
  );
165
200
 
201
+ // `deliver()` queues its work, so the target envelopes still share this
202
+ // stored message object when forward() returns. Record the accepted target
203
+ // set for the active runtime only: it lets a stream Session return one
204
+ // combined result to the caller after an @all fan-out finishes. The
205
+ // transient value is deliberately not required for durable replay.
206
+ if (report?.message?.meta && Array.isArray(report.dispatched)) {
207
+ report.message.meta.routeForwardExpectedTargets = report.dispatched.slice();
208
+ report.message.meta.routeForwardDispatchErrors = Array.isArray(report.errors)
209
+ ? report.errors.slice()
210
+ : [];
211
+ report.message.meta.routeForwardTruncatedAtFanOutCap = Boolean(report.truncatedAtFanOutCap);
212
+ }
213
+
166
214
  // Record AFTER Coordinator accepts. If Coordinator produced zero
167
215
  // dispatches (e.g. task.members gate) we still count it as a hit —
168
216
  // the forwarder still tried, and the guard's job is to throttle the
169
217
  // sender's ability to keep trying.
170
218
  guard.record({ sessionId: meta.id, targetVpId: guardKey });
219
+ if (!Array.isArray(report.dispatched) || report.dispatched.length === 0) {
220
+ return {
221
+ ok: false,
222
+ error: 'no_targets_dispatched',
223
+ detail: {
224
+ errors: Array.isArray(report.errors) ? report.errors : [],
225
+ truncatedAtFanOutCap: Boolean(report.truncatedAtFanOutCap),
226
+ },
227
+ report,
228
+ };
229
+ }
171
230
 
172
231
  return {
173
232
  ok: true,
@@ -79,12 +79,14 @@ export function createCoordinator(group, options = {}) {
79
79
  ? input._routingIntent
80
80
  : null;
81
81
  const isRouteForwardInjection = inputMeta.injectedBy === 'route_forward';
82
+ const isRouteForwardResultInjection = inputMeta.injectedBy === 'route_forward_result';
82
83
  const isTaskResultInjection = inputMeta.injectedBy === 'task_result';
83
84
  const fromUser = input.from === 'user'
84
85
  || input.role === 'user'
85
86
  || isRouteForwardInjection
87
+ || isRouteForwardResultInjection
86
88
  || isTaskResultInjection;
87
- const forcedRouteTarget = (isRouteForwardInjection || isTaskResultInjection)
89
+ const forcedRouteTarget = (isRouteForwardInjection || isRouteForwardResultInjection || isTaskResultInjection)
88
90
  && typeof inputMeta.routeTargetVpId === 'string'
89
91
  ? inputMeta.routeTargetVpId.trim()
90
92
  : (isRouteForwardInjection && typeof inputMeta.routeForwardTarget === 'string'
@@ -243,4 +245,3 @@ function makeEnvelope(msg, meta, trigger, ephemeral = {}) {
243
245
  * @property {boolean=} truncatedAtFanOutCap
244
246
  * @property {string=} skipped
245
247
  */
246
-