@qwen-code/channel-base 0.21.1 → 0.21.2

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.
@@ -1,4 +1,5 @@
1
1
  import { basename, join } from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { BlockStreamer } from './BlockStreamer.js';
3
4
  import { ChannelProactiveDeliveryError, isChannelProactiveDeliveryError, } from './ChannelProactiveDeliveryError.js';
4
5
  import { GroupGate } from './GroupGate.js';
@@ -62,6 +63,9 @@ const SENSITIVE_PAYLOAD_KEY_PATTERN = new RegExp([
62
63
  'senderNick',
63
64
  'senderName',
64
65
  ].join('|'), 'i');
66
+ function isRecord(value) {
67
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
68
+ }
65
69
  /**
66
70
  * Character class (sans the enclosing `[]`) for a slash-command token: alphanumerics
67
71
  * plus `_`, `:` and `-`, so hyphenated and namespaced agent commands (e.g.
@@ -212,6 +216,7 @@ export class ChannelBase {
212
216
  sessionId: event.sessionId,
213
217
  target,
214
218
  request: event.request,
219
+ settlementListeners: new Set(),
215
220
  };
216
221
  this.pendingPermissions.set(event.requestId, pending);
217
222
  const chatKey = this.permissionChatKey(target);
@@ -219,6 +224,10 @@ export class ChannelBase {
219
224
  requestIds.push(event.requestId);
220
225
  this.pendingPermissionsByChat.set(chatKey, requestIds);
221
226
  try {
227
+ const presentation = this.tryPresentUserInput(pending);
228
+ if (presentation && (await presentation)) {
229
+ return;
230
+ }
222
231
  const text = this.formatPermissionRequest(pending);
223
232
  if (target.threadId !== undefined &&
224
233
  this.supportsProactiveSend() &&
@@ -230,7 +239,7 @@ export class ChannelBase {
230
239
  }
231
240
  }
232
241
  catch (err) {
233
- this.removePendingPermission(event.requestId);
242
+ this.removePendingPermission(event.requestId, 'cancelled');
234
243
  try {
235
244
  await this.bridge.respondToPermission?.(event.requestId, {
236
245
  outcome: { outcome: 'cancelled' },
@@ -242,6 +251,154 @@ export class ChannelBase {
242
251
  throw err;
243
252
  }
244
253
  }
254
+ tryPresentUserInput(pending) {
255
+ const active = this.activePrompts.get(pending.sessionId);
256
+ const questions = this.normalizeUserQuestions(pending);
257
+ const submitOptionId = this.approvalOptionId(pending);
258
+ if (!active ||
259
+ active.loopPrompt ||
260
+ !active.owner ||
261
+ !questions ||
262
+ !submitOptionId) {
263
+ return undefined;
264
+ }
265
+ const precedingSegment = this.closeOutputSegment(pending.sessionId, active, pending.target);
266
+ let respondInvoked = false;
267
+ const context = {
268
+ requestId: pending.requestId,
269
+ sessionId: pending.sessionId,
270
+ runId: active.runId,
271
+ owner: active.owner,
272
+ target: pending.target,
273
+ ...(precedingSegment
274
+ ? { precedingSegmentId: precedingSegment.segmentId }
275
+ : {}),
276
+ questions,
277
+ submitOptionId,
278
+ onSettled: (listener) => {
279
+ if (pending.settled) {
280
+ listener(pending.settled);
281
+ return () => { };
282
+ }
283
+ pending.settlementListeners.add(listener);
284
+ return () => {
285
+ pending.settlementListeners.delete(listener);
286
+ };
287
+ },
288
+ respond: (response) => {
289
+ respondInvoked = true;
290
+ return this.respondToUserInput(pending, response);
291
+ },
292
+ };
293
+ pending.userInputPresented = true;
294
+ return (async () => {
295
+ try {
296
+ if (precedingSegment) {
297
+ await this.notifyOutputSegmentEnd(pending.target.chatId, pending.sessionId, precedingSegment, 'input_requested');
298
+ }
299
+ const result = await this.presentUserInputRequest(context);
300
+ if (this.pendingPermissions.get(pending.requestId) !== pending) {
301
+ return true;
302
+ }
303
+ if (result.kind === 'presented' ||
304
+ (result.kind === 'handled' && respondInvoked)) {
305
+ return true;
306
+ }
307
+ pending.userInputPresented = false;
308
+ return false;
309
+ }
310
+ catch (err) {
311
+ process.stderr.write(`[${this.name}] user input presentation failed for request ${sanitizeLogText(pending.requestId, 128)}: ${this.lifecycleError(err)}\n`);
312
+ if (this.pendingPermissions.get(pending.requestId) !== pending) {
313
+ return true;
314
+ }
315
+ pending.userInputPresented = false;
316
+ return false;
317
+ }
318
+ })();
319
+ }
320
+ normalizeUserQuestions(pending) {
321
+ const toolCall = pending.request.toolCall;
322
+ const meta = isRecord(toolCall['_meta']) ? toolCall['_meta'] : undefined;
323
+ const canonical = meta?.['qwenInteractionKind'] === 'user_question';
324
+ const identifiedLegacy = meta?.['toolName'] === 'ask_user_question' ||
325
+ toolCall['kind'] === 'ask_user_question';
326
+ const rawInput = isRecord(toolCall['rawInput'])
327
+ ? toolCall['rawInput']
328
+ : undefined;
329
+ const rawQuestions = canonical
330
+ ? meta?.['qwenQuestions']
331
+ : identifiedLegacy
332
+ ? rawInput?.['questions']
333
+ : undefined;
334
+ if (!Array.isArray(rawQuestions) ||
335
+ rawQuestions.length < 1 ||
336
+ rawQuestions.length > 4) {
337
+ return undefined;
338
+ }
339
+ const questions = [];
340
+ for (const [index, rawQuestion] of rawQuestions.entries()) {
341
+ if (!isRecord(rawQuestion)) {
342
+ return undefined;
343
+ }
344
+ const header = rawQuestion['header'];
345
+ const question = rawQuestion['question'];
346
+ const rawOptions = rawQuestion['options'];
347
+ const multiSelect = rawQuestion['multiSelect'];
348
+ if (typeof header !== 'string' ||
349
+ header.trim().length === 0 ||
350
+ typeof question !== 'string' ||
351
+ question.trim().length === 0 ||
352
+ !Array.isArray(rawOptions) ||
353
+ rawOptions.length < 2 ||
354
+ rawOptions.length > 4 ||
355
+ (multiSelect !== undefined && typeof multiSelect !== 'boolean')) {
356
+ return undefined;
357
+ }
358
+ const options = [];
359
+ for (const rawOption of rawOptions) {
360
+ if (!isRecord(rawOption) ||
361
+ typeof rawOption['label'] !== 'string' ||
362
+ rawOption['label'].trim().length === 0 ||
363
+ typeof rawOption['description'] !== 'string') {
364
+ return undefined;
365
+ }
366
+ options.push({
367
+ label: rawOption['label'],
368
+ description: rawOption['description'],
369
+ });
370
+ }
371
+ questions.push({
372
+ answerKey: String(index),
373
+ header,
374
+ question,
375
+ options,
376
+ multiSelect: multiSelect ?? false,
377
+ });
378
+ }
379
+ return questions;
380
+ }
381
+ async respondToUserInput(pending, response) {
382
+ if (pending.responsePromise) {
383
+ return pending.responsePromise;
384
+ }
385
+ if (this.pendingPermissions.get(pending.requestId) !== pending ||
386
+ !this.bridge.respondToPermission) {
387
+ return false;
388
+ }
389
+ pending.responsePromise = Promise.resolve()
390
+ .then(() => this.bridge.respondToPermission(pending.requestId, response))
391
+ .then((accepted) => {
392
+ this.removePendingPermission(pending.requestId, accepted
393
+ ? this.userInputSettlementReason(pending, response.outcome)
394
+ : 'cancelled');
395
+ return accepted;
396
+ }, (error) => {
397
+ this.removePendingPermission(pending.requestId, 'cancelled');
398
+ throw error;
399
+ });
400
+ return pending.responsePromise;
401
+ }
245
402
  permissionTargetForEvent(event) {
246
403
  const routeTarget = this.router.getTarget(event.sessionId);
247
404
  if (!routeTarget || routeTarget.channelName !== this.name) {
@@ -268,7 +425,11 @@ export class ChannelBase {
268
425
  return target;
269
426
  }
270
427
  dispatchPermissionResolved(event) {
271
- this.removePendingPermission(event.requestId);
428
+ const pending = this.pendingPermissions.get(event.requestId);
429
+ if (!pending) {
430
+ return;
431
+ }
432
+ this.removePendingPermission(event.requestId, this.userInputSettlementReason(pending, event.outcome));
272
433
  }
273
434
  constructor(name, config, bridge, options) {
274
435
  this.name = name;
@@ -322,6 +483,9 @@ export class ChannelBase {
322
483
  * logged, nothing more.
323
484
  */
324
485
  onTaskLifecycle(_event) { }
486
+ async presentUserInputRequest(_context) {
487
+ return { kind: 'unsupported' };
488
+ }
325
489
  emitTaskLifecycle(event) {
326
490
  try {
327
491
  const result = this.onTaskLifecycle(event);
@@ -351,6 +515,8 @@ export class ChannelBase {
351
515
  return;
352
516
  }
353
517
  active.cancellationEmitted = true;
518
+ const segment = this.closeOutputSegment(sessionId, active);
519
+ void this.notifyOutputSegmentEnd(active.chatId, sessionId, segment, 'cancelled');
354
520
  this.emitTaskLifecycle({
355
521
  ...this.lifecycleBase(active.chatId, sessionId, active.messageId),
356
522
  type: 'cancelled',
@@ -427,15 +593,72 @@ export class ChannelBase {
427
593
  return Boolean(this.config.identity || this.config.memoryScope);
428
594
  }
429
595
  lifecycleBase(chatId, sessionId, messageId) {
596
+ const active = this.activePrompts.get(sessionId);
430
597
  return {
431
598
  channelName: this.name,
432
599
  chatId,
433
600
  sessionId,
434
601
  ...(messageId ? { messageId } : {}),
602
+ ...(active?.runId ? { runId: active.runId } : {}),
603
+ ...(active?.owner ? { owner: active.owner } : {}),
435
604
  identity: this.identity,
436
605
  memoryScope: this.memoryScope,
437
606
  };
438
607
  }
608
+ outputSegmentContext(sessionId, active, segmentId, target) {
609
+ const resolvedTarget = target ??
610
+ (active.senderId
611
+ ? {
612
+ channelName: this.name,
613
+ chatId: active.chatId,
614
+ senderId: active.senderId,
615
+ ...(active.threadId ? { threadId: active.threadId } : {}),
616
+ ...(active.isGroup !== undefined
617
+ ? { isGroup: active.isGroup }
618
+ : {}),
619
+ }
620
+ : undefined);
621
+ if (!active.owner ||
622
+ !resolvedTarget ||
623
+ resolvedTarget.channelName !== this.name) {
624
+ return undefined;
625
+ }
626
+ return {
627
+ channelName: this.name,
628
+ sessionId,
629
+ runId: active.runId,
630
+ segmentId,
631
+ owner: active.owner,
632
+ target: resolvedTarget,
633
+ ...(active.messageId ? { messageId: active.messageId } : {}),
634
+ };
635
+ }
636
+ ensureOutputSegment(sessionId, active) {
637
+ if (!active.owner)
638
+ return undefined;
639
+ const segmentId = active.activeSegmentId ?? randomUUID();
640
+ const context = this.outputSegmentContext(sessionId, active, segmentId);
641
+ if (context)
642
+ active.activeSegmentId = segmentId;
643
+ return context;
644
+ }
645
+ closeOutputSegment(sessionId, active, target) {
646
+ const segmentId = active.activeSegmentId;
647
+ if (!segmentId)
648
+ return undefined;
649
+ active.activeSegmentId = undefined;
650
+ return this.outputSegmentContext(sessionId, active, segmentId, target);
651
+ }
652
+ async notifyOutputSegmentEnd(chatId, sessionId, segment, reason) {
653
+ if (!segment)
654
+ return;
655
+ try {
656
+ await this.onOutputSegmentEnd(chatId, sessionId, segment, reason);
657
+ }
658
+ catch (err) {
659
+ process.stderr.write(`[${this.name}] output segment boundary failed for session ${sanitizeLogText(sessionId, 64)}: ${this.lifecycleError(err)}\n`);
660
+ }
661
+ }
439
662
  supportsProactiveSend() {
440
663
  return false;
441
664
  }
@@ -751,6 +974,7 @@ export class ChannelBase {
751
974
  doneResolve = resolve;
752
975
  });
753
976
  const promptState = {
977
+ runId: randomUUID(),
754
978
  cancelled: false,
755
979
  done,
756
980
  resolve: doneResolve,
@@ -989,6 +1213,7 @@ export class ChannelBase {
989
1213
  doneResolve = resolve;
990
1214
  });
991
1215
  const promptState = {
1216
+ runId: randomUUID(),
992
1217
  cancelled: false,
993
1218
  done,
994
1219
  resolve: doneResolve,
@@ -1212,10 +1437,18 @@ export class ChannelBase {
1212
1437
  active.cancelled = true;
1213
1438
  this.stopActiveStreaming(active, sessionId, reason);
1214
1439
  this.dropCollectBuffer(sessionId);
1440
+ this.removePendingPermissionsForSession(sessionId, 'run_cancelled');
1215
1441
  this.emitTaskCancellation(active, sessionId, reason);
1216
1442
  return true;
1217
1443
  });
1218
1444
  }
1445
+ requestPromptRunCancellation(sessionId, runId, reason = 'cancel_command') {
1446
+ const active = this.activePrompts.get(sessionId);
1447
+ if (!active || active.runId !== runId) {
1448
+ return Promise.resolve(false);
1449
+ }
1450
+ return this.requestActivePromptCancellation(sessionId, reason);
1451
+ }
1219
1452
  dropCollectBuffer(sessionId) {
1220
1453
  const buffer = this.collectBuffers.get(sessionId);
1221
1454
  if (!buffer)
@@ -1310,7 +1543,12 @@ export class ChannelBase {
1310
1543
  * Override to implement progressive display (e.g., updating an AI card in-place).
1311
1544
  * Default: no-op (chunks are collected internally and delivered via onResponseComplete).
1312
1545
  */
1313
- onResponseChunk(_chatId, _chunk, _sessionId) { }
1546
+ onResponseChunk(_chatId, _chunk, _sessionId, _segment) { }
1547
+ onOutputSegmentEnd(chatId, sessionId, _segment, reason) {
1548
+ if (reason === 'response_boundary') {
1549
+ return this.onResponseBoundary(chatId, sessionId);
1550
+ }
1551
+ }
1314
1552
  /**
1315
1553
  * Called when the agent starts a new response segment for the same prompt.
1316
1554
  * Override to clear adapter-owned streaming buffers.
@@ -1322,12 +1560,33 @@ export class ChannelBase {
1322
1560
  const threadId = active?.threadId ?? target?.threadId;
1323
1561
  await this.sendThreadMessage(chatId, threadId, text);
1324
1562
  }
1563
+ /**
1564
+ * Adapter hook for delivery-only metadata. The response delivery path can read
1565
+ * the active prompt while it exists; adapters must not retain its raw content.
1566
+ */
1567
+ getResponseMessageId(sessionId) {
1568
+ return this.activePrompts.get(sessionId)?.messageId;
1569
+ }
1570
+ getResponseSenderId(sessionId) {
1571
+ return this.activePrompts.get(sessionId)?.senderId;
1572
+ }
1573
+ getResponseMetadata(sessionId) {
1574
+ return this.activePrompts.get(sessionId)?.metadata;
1575
+ }
1576
+ /**
1577
+ * Returns the active prompt's response thread while it remains available for
1578
+ * adapter delivery. Falls back to the session target after prompt cleanup.
1579
+ */
1580
+ getResponseThreadId(sessionId) {
1581
+ return (this.activePrompts.get(sessionId)?.threadId ??
1582
+ this.router.getTarget(sessionId)?.threadId);
1583
+ }
1325
1584
  /**
1326
1585
  * Called when the agent's full response is ready.
1327
1586
  * Override to customize delivery (e.g., finalize an AI card).
1328
1587
  * Default: sends the full response text.
1329
1588
  */
1330
- async onResponseComplete(chatId, fullText, sessionId) {
1589
+ async onResponseComplete(chatId, fullText, sessionId, _segment) {
1331
1590
  await this.sendResponseMessage(chatId, fullText, sessionId);
1332
1591
  }
1333
1592
  /**
@@ -1384,12 +1643,13 @@ export class ChannelBase {
1384
1643
  }
1385
1644
  return live;
1386
1645
  }
1387
- removePendingPermission(requestId) {
1646
+ removePendingPermission(requestId, reason = 'resolved_outside_presenter') {
1388
1647
  const pending = this.pendingPermissions.get(requestId);
1389
1648
  if (!pending) {
1390
1649
  return;
1391
1650
  }
1392
1651
  this.pendingPermissions.delete(requestId);
1652
+ this.settleUserInput(pending, reason);
1393
1653
  const chatKey = this.permissionChatKey(pending.target);
1394
1654
  const requestIds = this.pendingPermissionsByChat.get(chatKey);
1395
1655
  if (!requestIds) {
@@ -1403,17 +1663,48 @@ export class ChannelBase {
1403
1663
  this.pendingPermissionsByChat.set(chatKey, remaining);
1404
1664
  }
1405
1665
  }
1406
- removePendingPermissionsForSession(sessionId) {
1666
+ removePendingPermissionsForSession(sessionId, reason = 'cancelled') {
1407
1667
  const requestIds = Array.from(this.pendingPermissions)
1408
1668
  .filter(([, pending]) => pending.sessionId === sessionId)
1409
1669
  .map(([requestId]) => requestId);
1410
1670
  for (const requestId of requestIds) {
1411
- this.removePendingPermission(requestId);
1671
+ this.removePendingPermission(requestId, reason);
1412
1672
  }
1413
1673
  }
1414
1674
  clearPendingPermissions() {
1415
- this.pendingPermissions.clear();
1416
- this.pendingPermissionsByChat.clear();
1675
+ for (const requestId of Array.from(this.pendingPermissions.keys())) {
1676
+ this.removePendingPermission(requestId, 'cancelled');
1677
+ }
1678
+ }
1679
+ settleUserInput(pending, reason) {
1680
+ if (pending.settled) {
1681
+ return;
1682
+ }
1683
+ pending.settled = reason;
1684
+ const listeners = Array.from(pending.settlementListeners);
1685
+ pending.settlementListeners.clear();
1686
+ for (const listener of listeners) {
1687
+ try {
1688
+ listener(reason);
1689
+ }
1690
+ catch (err) {
1691
+ process.stderr.write(`[${this.name}] user input settlement listener failed for request ${sanitizeLogText(pending.requestId, 128)}: ${this.lifecycleError(err)}\n`);
1692
+ }
1693
+ }
1694
+ }
1695
+ userInputSettlementReason(pending, outcome) {
1696
+ if (outcome?.outcome === 'cancelled') {
1697
+ return 'cancelled';
1698
+ }
1699
+ if (outcome?.outcome === 'selected') {
1700
+ const selected = pending.request.options.find((option) => option.optionId === outcome.optionId);
1701
+ if (selected?.kind === 'reject_once' ||
1702
+ (selected?.optionId === 'cancel' &&
1703
+ selected.kind === undefined)) {
1704
+ return 'cancelled';
1705
+ }
1706
+ }
1707
+ return 'resolved_outside_presenter';
1417
1708
  }
1418
1709
  pendingPermissionForEnvelope(envelope, args) {
1419
1710
  const trimmed = args.trim();
@@ -1447,6 +1738,8 @@ export class ChannelBase {
1447
1738
  canEnvelopeAnswerPendingPermission(envelope, pending) {
1448
1739
  return (pending.target.chatId === envelope.chatId &&
1449
1740
  pending.target.threadId === envelope.threadId &&
1741
+ (!pending.userInputPresented ||
1742
+ pending.target.senderId === envelope.senderId) &&
1450
1743
  (this.isSharedSessionTarget(pending.target) ||
1451
1744
  pending.target.senderId === envelope.senderId));
1452
1745
  }
@@ -1541,6 +1834,10 @@ export class ChannelBase {
1541
1834
  return true;
1542
1835
  }
1543
1836
  const { pending } = lookup;
1837
+ if (pending.userInputPresented && decision !== 'deny') {
1838
+ await this.sendMessage(envelope.chatId, 'Submit this question through its interactive card, or use /deny [request-id] to cancel it.');
1839
+ return true;
1840
+ }
1544
1841
  const response = (() => {
1545
1842
  if (decision === 'deny') {
1546
1843
  return this.denialResponse(pending);
@@ -1560,7 +1857,9 @@ export class ChannelBase {
1560
1857
  }
1561
1858
  let accepted;
1562
1859
  try {
1563
- accepted = await this.bridge.respondToPermission(pending.requestId, response);
1860
+ accepted = pending.userInputPresented
1861
+ ? await this.respondToUserInput(pending, response)
1862
+ : await this.bridge.respondToPermission(pending.requestId, response);
1564
1863
  }
1565
1864
  catch (err) {
1566
1865
  this.removePendingPermission(pending.requestId);
@@ -1597,7 +1896,7 @@ export class ChannelBase {
1597
1896
  // already queued onto this session sees a stale generation and bails
1598
1897
  // instead of running bridge.prompt() against the cleared session.
1599
1898
  this.sessionGenerations.set(id, (this.sessionGenerations.get(id) ?? 0) + 1);
1600
- this.removePendingPermissionsForSession(id);
1899
+ this.removePendingPermissionsForSession(id, 'run_cancelled');
1601
1900
  // Cancel an in-flight turn (and drop its buffered follow-ups) before
1602
1901
  // purging, so a running prompt can't deliver a stale response into —
1603
1902
  // or resurrect via collect-drain — the just-cleared session.
@@ -3328,6 +3627,7 @@ export class ChannelBase {
3328
3627
  // turn at the channel level (cancelled is already set above), so
3329
3628
  // the event reflects that intent, not the bridge RPC outcome.
3330
3629
  this.emitTaskCancellation(active, sessionId, 'steer');
3630
+ this.removePendingPermissionsForSession(sessionId, 'run_cancelled');
3331
3631
  }
3332
3632
  // Diagnostic watchdog: if the predecessor turn is STILL the active prompt
3333
3633
  // after the wind-down bound, this steered turn is wedged behind a hung
@@ -3456,6 +3756,11 @@ export class ChannelBase {
3456
3756
  doneResolve = r;
3457
3757
  });
3458
3758
  const promptState = {
3759
+ runId: randomUUID(),
3760
+ owner: {
3761
+ kind: 'channel_user',
3762
+ id: envelope.senderId,
3763
+ },
3459
3764
  cancelled: false,
3460
3765
  done,
3461
3766
  resolve: doneResolve,
@@ -3465,6 +3770,7 @@ export class ChannelBase {
3465
3770
  messageId: envelope.messageId,
3466
3771
  senderId: envelope.senderId,
3467
3772
  senderName: envelope.senderName,
3773
+ metadata: envelope.metadata,
3468
3774
  };
3469
3775
  // This turn is now the single owner of the session's active-prompt slot.
3470
3776
  // (Steer no longer hands a still-active session to a replacement; only
@@ -3499,12 +3805,13 @@ export class ChannelBase {
3499
3805
  const releaseHeldChunks = () => {
3500
3806
  for (const held of heldChunks.splice(0)) {
3501
3807
  hasStreamedText = true;
3808
+ const segment = this.ensureOutputSegment(sessionId, promptState);
3502
3809
  this.emitTaskLifecycle({
3503
3810
  ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
3504
3811
  type: 'text_chunk',
3505
3812
  chunk: held,
3506
3813
  });
3507
- this.onResponseChunk(envelope.chatId, held, sessionId);
3814
+ this.onResponseChunk(envelope.chatId, held, sessionId, segment);
3508
3815
  streamer?.push(held);
3509
3816
  }
3510
3817
  };
@@ -3525,7 +3832,8 @@ export class ChannelBase {
3525
3832
  }
3526
3833
  heldChunks.length = 0;
3527
3834
  hasStreamedText = false;
3528
- this.onResponseBoundary(envelope.chatId, sessionId);
3835
+ const segment = this.closeOutputSegment(sessionId, promptState);
3836
+ void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'response_boundary');
3529
3837
  streamer?.stop();
3530
3838
  };
3531
3839
  const promptBridge = this.bridge;
@@ -3550,7 +3858,11 @@ export class ChannelBase {
3550
3858
  await streamer.flush();
3551
3859
  }
3552
3860
  else {
3553
- await this.onResponseComplete(envelope.chatId, response, sessionId);
3861
+ const segment = this.ensureOutputSegment(sessionId, promptState);
3862
+ await this.onResponseComplete(envelope.chatId, response, sessionId, segment);
3863
+ if (segment && promptState.activeSegmentId === segment.segmentId) {
3864
+ promptState.activeSegmentId = undefined;
3865
+ }
3554
3866
  }
3555
3867
  }
3556
3868
  // Once delivery started the turn's outcome is fixed — don't let a
@@ -3559,6 +3871,8 @@ export class ChannelBase {
3559
3871
  await this.settleCancelRequested(promptState);
3560
3872
  }
3561
3873
  if (!promptState.cancelled && !promptState.cancellationEmitted) {
3874
+ const segment = this.closeOutputSegment(sessionId, promptState);
3875
+ void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'completed');
3562
3876
  this.emitTaskLifecycle({
3563
3877
  ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
3564
3878
  type: 'completed',
@@ -3574,6 +3888,8 @@ export class ChannelBase {
3574
3888
  }
3575
3889
  if (!promptState.cancelled) {
3576
3890
  releaseHeldChunks();
3891
+ const segment = this.closeOutputSegment(sessionId, promptState);
3892
+ void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'failed');
3577
3893
  this.emitTaskLifecycle({
3578
3894
  ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
3579
3895
  type: 'failed',
@@ -3587,6 +3903,9 @@ export class ChannelBase {
3587
3903
  const safeMessageId = sanitizeLogText(envelope.messageId ?? '', 64);
3588
3904
  process.stderr.write(`[${channel}] turn ${safeMessageId} threw after cancellation for session ${safeSessionId}: ${this.lifecycleError(err)}\n`);
3589
3905
  }
3906
+ if (promptState.cancelled) {
3907
+ return;
3908
+ }
3590
3909
  throw err;
3591
3910
  }
3592
3911
  finally {