@xmanrui/dsh-im 4.23.0 → 4.24.1

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.
Files changed (41) hide show
  1. package/README.en.md +7 -2
  2. package/README.md +7 -2
  3. package/lib/client.js +498 -347
  4. package/lib/index.js +284 -283
  5. package/package.json +11 -2
  6. package/plugin-src/client/channel-card-meta.js +3 -9
  7. package/plugin-src/client/channels/dingtalk/index.js +9 -8
  8. package/plugin-src/client/channels/feishu/index.js +21 -17
  9. package/plugin-src/client/channels/qq/index.js +9 -8
  10. package/plugin-src/client/channels/shared/collapsible-account.js +49 -26
  11. package/plugin-src/client/channels/shared/token-api.js +3 -0
  12. package/plugin-src/client/channels/shared/token-channel.js +9 -8
  13. package/plugin-src/client/channels/telegram/index.js +3 -0
  14. package/plugin-src/client/channels/telegram/styles.js +12 -0
  15. package/plugin-src/client/channels/telegram/thinking-traces.js +25 -0
  16. package/plugin-src/client/channels/wecom/index.js +9 -8
  17. package/plugin-src/client/channels/wecom-app/index.js +9 -8
  18. package/plugin-src/client/channels/weixin/index.js +9 -8
  19. package/plugin-src/client/channels/whatsapp/index.js +9 -8
  20. package/plugin-src/client/i18n.js +13 -1
  21. package/plugin-src/client/index.js +10 -2
  22. package/plugin-src/client/styles.js +12 -8
  23. package/plugin-src/client/update-panel.js +18 -8
  24. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  25. package/plugin-src/host/channels/shared/thinking-traces-rpc.mjs +11 -0
  26. package/plugin-src/host/modern-harness-api.mjs +7 -2
  27. package/plugin-src/host/update-service.mjs +19 -14
  28. package/scripts/verify-package.mjs +11 -5
  29. package/src/channels/email/email-runtime.mjs +9 -2
  30. package/src/channels/email/transports/agent-mail.mjs +14 -3
  31. package/src/channels/feishu/bridge.mjs +122 -46
  32. package/src/channels/feishu/feishu-channel.mjs +35 -0
  33. package/src/channels/feishu/live-cot.mjs +260 -0
  34. package/src/channels/feishu/slash-command-registry.mjs +17 -0
  35. package/src/channels/feishu/step-push-mode.mjs +10 -4
  36. package/src/channels/shared/harness-client.mjs +224 -39
  37. package/src/channels/shared/text-harness-bridge.mjs +60 -14
  38. package/src/channels/shared/workspace-session.mjs +7 -1
  39. package/src/channels/telegram/config-store.mjs +4 -1
  40. package/src/channels/telegram/telegram-controller.mjs +13 -1
  41. package/src/channels/telegram/telegram-runtime.mjs +198 -1
@@ -1,6 +1,7 @@
1
1
  export const FEISHU_STEP_PUSH_MODES = Object.freeze({
2
2
  POST: 'post',
3
3
  STREAMING_CARD: 'streaming_card',
4
+ LIVE_COT: 'live_cot',
4
5
  });
5
6
 
6
7
  /** New connections explicitly opt into the process-card presentation. */
@@ -8,12 +9,17 @@ export const DEFAULT_FEISHU_STEP_PUSH_MODE = FEISHU_STEP_PUSH_MODES.STREAMING_CA
8
9
 
9
10
  export function normalizeFeishuStepPushMode(value) {
10
11
  // Bots created before modes existed used posts when step push was enabled.
11
- return value === FEISHU_STEP_PUSH_MODES.STREAMING_CARD
12
- ? FEISHU_STEP_PUSH_MODES.STREAMING_CARD
13
- : FEISHU_STEP_PUSH_MODES.POST;
12
+ if (value === FEISHU_STEP_PUSH_MODES.STREAMING_CARD) {
13
+ return FEISHU_STEP_PUSH_MODES.STREAMING_CARD;
14
+ }
15
+ if (value === FEISHU_STEP_PUSH_MODES.LIVE_COT) {
16
+ return FEISHU_STEP_PUSH_MODES.LIVE_COT;
17
+ }
18
+ return FEISHU_STEP_PUSH_MODES.POST;
14
19
  }
15
20
 
16
21
  export function isFeishuStepPushMode(value) {
17
22
  return value === FEISHU_STEP_PUSH_MODES.POST
18
- || value === FEISHU_STEP_PUSH_MODES.STREAMING_CARD;
23
+ || value === FEISHU_STEP_PUSH_MODES.STREAMING_CARD
24
+ || value === FEISHU_STEP_PUSH_MODES.LIVE_COT;
19
25
  }
@@ -356,10 +356,23 @@ export function textFromHarnessContent(content) {
356
356
  .trim();
357
357
  }
358
358
 
359
+ /** Join only reasoning blocks from one Harness message payload. */
360
+ export function reasoningFromHarnessContent(content) {
361
+ return (Array.isArray(content) ? content : [])
362
+ .filter((part) => part?.type === 'reasoning' && typeof part.text === 'string')
363
+ .map((part) => part.text)
364
+ .join('\n')
365
+ .trim();
366
+ }
367
+
359
368
  function assistantMessageText(event) {
360
369
  return textFromHarnessContent(event?.data?.message?.content);
361
370
  }
362
371
 
372
+ function assistantReasoningText(event) {
373
+ return reasoningFromHarnessContent(event?.data?.message?.content);
374
+ }
375
+
363
376
  /** Aggregate assistant text in stable step/index order for one Harness Turn. */
364
377
  export class AssistantTextAccumulator {
365
378
  #steps = new Map();
@@ -415,6 +428,29 @@ function toolResultErrorText(error) {
415
428
  return null;
416
429
  }
417
430
 
431
+ /** Join the visible text carried by a Harness tool result. */
432
+ function toolResultText(data) {
433
+ const blocks = Array.isArray(data?.message?.content) ? data.message.content : [];
434
+ const nested = blocks.flatMap((block) => (
435
+ Array.isArray(block?.content) ? block.content : [block]
436
+ ));
437
+ const text = nested
438
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
439
+ .map((block) => block.text)
440
+ .join('');
441
+ return text || nonEmptyText(data?.text) || toolResultErrorText(data?.error) || '';
442
+ }
443
+
444
+ function toolResultCallId(data) {
445
+ const blocks = Array.isArray(data?.message?.content) ? data.message.content : [];
446
+ return blocks
447
+ .map((block) => nonEmptyText(block?.toolCallId))
448
+ .find(Boolean)
449
+ ?? nonEmptyText(data?.message?.source?.callId)
450
+ ?? nonEmptyText(data?.callId)
451
+ ?? nonEmptyText(data?.subCallId);
452
+ }
453
+
418
454
  function consumeInteractionOwnership(ownership, entries) {
419
455
  const ordered = [...entries]
420
456
  .map((entry) => entry?.event ?? entry)
@@ -505,10 +541,18 @@ export class HarnessReplyTracker {
505
541
  #reason = null;
506
542
  #toolNames = new Map();
507
543
  #lastToolName = null;
544
+ #transientSeqs = new Set();
545
+ #pendingReasoning = new Map();
546
+ #pendingReasoningChars = 0;
547
+ #reasoning = false;
508
548
 
509
- constructor({ promptRpcId, afterSeq = -1 }) {
549
+ constructor({ promptRpcId, afterSeq = -1, reasoning = false }) {
510
550
  this.#promptRpcId = promptRpcId;
511
551
  this.#lastSeq = afterSeq;
552
+ // Reasoning updates are opt-in per consumer: only channels that surface
553
+ // thinking traces (Telegram thinking mode) subscribe; every other channel
554
+ // keeps its pre-thinking-traces update stream untouched.
555
+ this.#reasoning = reasoning === true;
512
556
  }
513
557
 
514
558
  get finished() {
@@ -542,7 +586,35 @@ export class HarnessReplyTracker {
542
586
  pushUpdate({ type: 'text', text });
543
587
  }
544
588
 
545
- consumeAll(entries) {
589
+ #bufferReasoning(event) {
590
+ const text = event.data?.chunk?.text;
591
+ if (typeof text !== 'string' || !text || text.length > 65536
592
+ || this.#pendingReasoning.has(event.seq)) return;
593
+ this.#pendingReasoning.set(event.seq, event);
594
+ this.#pendingReasoningChars += text.length;
595
+ while (this.#pendingReasoning.size > 256 || this.#pendingReasoningChars > 65536) {
596
+ const [seq, oldest] = this.#pendingReasoning.entries().next().value;
597
+ this.#pendingReasoning.delete(seq);
598
+ this.#pendingReasoningChars -= oldest.data.chunk.text.length;
599
+ }
600
+ }
601
+
602
+ #takePendingReasoning() {
603
+ const events = [...this.#pendingReasoning.values()];
604
+ this.#pendingReasoning.clear();
605
+ this.#pendingReasoningChars = 0;
606
+ return events;
607
+ }
608
+
609
+ #emitReasoning(event, pushUpdate) {
610
+ const text = event.data?.chunk?.text;
611
+ if (event.data?.turn !== this.#targetTurn || this.#transientSeqs.has(event.seq)
612
+ || typeof text !== 'string' || !text) return;
613
+ this.#transientSeqs.add(event.seq);
614
+ pushUpdate({ type: 'reasoning', turn: this.#targetTurn, text });
615
+ }
616
+
617
+ consumeAll(entries, { live = false, fromMux = false } = {}) {
546
618
  const updates = [];
547
619
  // 同一批轮询内的 text 帧只保留最新累积,其余事件逐帧透出,
548
620
  // 让消费方能按顺序看到每个工具调用与结果。
@@ -556,20 +628,53 @@ export class HarnessReplyTracker {
556
628
  }
557
629
  updates.push(update);
558
630
  };
559
- const ordered = [...entries]
631
+ const ordered = [...(fromMux ? [] : this.#takePendingReasoning()), ...entries]
560
632
  .map((entry) => entry?.event ?? entry)
561
633
  .filter(Boolean)
562
634
  .sort((left, right) => (left.seq ?? -1) - (right.seq ?? -1));
563
635
 
564
636
  for (const event of ordered) {
637
+ if (this.#finished) break;
565
638
  const seq = event.seq ?? -1;
639
+ const isReasoning = event.type === 'assistant/chunk'
640
+ && event.data?.chunk?.type === 'reasoning-delta';
641
+ // The mux is lossy across reconnects. Only history may advance the
642
+ // durable cursor or finish the reply; the mux supplements reasoning.
643
+ if (fromMux && !isReasoning) continue;
644
+ if (isReasoning) {
645
+ if (!live || !Number.isFinite(seq)) continue;
646
+ if (this.#targetTurn === null) this.#bufferReasoning(event);
647
+ else this.#emitReasoning(event, pushUpdate);
648
+ continue;
649
+ }
650
+ if (this.#targetTurn === null) {
651
+ if (seq <= this.#lastSeq) continue;
652
+ if (event.type === 'turn/start') {
653
+ this.#openTurn = event.data?.turn ?? null;
654
+ continue;
655
+ }
656
+ if (event.type === 'user/message' && event.data?.source?.rpcId === this.#promptRpcId) {
657
+ this.#lastSeq = seq;
658
+ this.#targetTurn = event.data?.turn ?? this.#openTurn;
659
+ if (live && this.#targetTurn !== null) {
660
+ pushUpdate({ type: 'turn-start', turn: this.#targetTurn });
661
+ // Only earlier fragments flush here. Later steps remain in the
662
+ // sorted batch, interleaved with their tool calls and results.
663
+ for (const pending of this.#takePendingReasoning()) {
664
+ this.#emitReasoning(pending, pushUpdate);
665
+ }
666
+ }
667
+ }
668
+ continue;
669
+ }
566
670
  if (seq <= this.#lastSeq) continue;
567
671
  this.#lastSeq = seq;
568
672
 
569
673
  if (event.type === 'turn/start') this.#openTurn = event.data?.turn ?? null;
570
674
 
571
675
  if (event.type === 'user/message' && event.data?.source?.rpcId === this.#promptRpcId) {
572
- this.#targetTurn = this.#openTurn;
676
+ this.#targetTurn = event.data?.turn ?? this.#openTurn;
677
+ if (live) pushUpdate({ type: 'turn-start', turn: this.#targetTurn });
573
678
  continue;
574
679
  }
575
680
  if (this.#targetTurn === null) continue;
@@ -579,6 +684,13 @@ export class HarnessReplyTracker {
579
684
  this.#finished = true;
580
685
  this.#reason = event.data?.reason ?? null;
581
686
  this.#openTurn = null;
687
+ if (live) {
688
+ pushUpdate({
689
+ type: 'turn-end',
690
+ turn: this.#targetTurn,
691
+ reason: this.#reason,
692
+ });
693
+ }
582
694
  continue;
583
695
  }
584
696
  if (event.data?.turn !== this.#targetTurn) continue;
@@ -597,7 +709,22 @@ export class HarnessReplyTracker {
597
709
  this.#assistantText.setCanonical(step, text);
598
710
  // canonical 定稿且非空时按 step 透出,供分步推送消费方使用;
599
711
  // 先于 commitText 透出,保持 text 更新作为批次末尾的既有语义。
600
- if (text) pushUpdate({ type: 'assistant-message', step, text });
712
+ if (text) {
713
+ pushUpdate({
714
+ type: 'assistant-message',
715
+ step,
716
+ text,
717
+ ...(live ? { turn: this.#targetTurn } : {}),
718
+ });
719
+ }
720
+ // Thinking-trace channels consume this as the 💭 line that precedes the
721
+ // tool call it explains; only consumers that explicitly subscribed
722
+ // (reasoning: true) see these updates, so default-mode channels keep
723
+ // their pre-thinking-traces progress stream.
724
+ if (this.#reasoning) {
725
+ const reasoning = assistantReasoningText(event);
726
+ if (reasoning) pushUpdate({ type: 'reasoning', step, text: reasoning });
727
+ }
601
728
  this.#commitText(this.#assistantText.text, pushUpdate);
602
729
  continue;
603
730
  }
@@ -620,20 +747,38 @@ export class HarnessReplyTracker {
620
747
  }
621
748
  }
622
749
  }
623
- pushUpdate({ type: 'tool', name, ...(argsText ? { arguments: argsText } : {}), ...(callId ? { callId } : {}) });
750
+ pushUpdate({
751
+ type: 'tool',
752
+ name,
753
+ ...(argsText ? { arguments: argsText } : {}),
754
+ ...(callId ? { callId } : {}),
755
+ ...(live ? { turn: this.#targetTurn } : {}),
756
+ });
624
757
  } else if (event.type === 'tool/result') {
625
- const callId = nonEmptyText(event.data?.message?.source?.callId)
626
- ?? nonEmptyText(event.data?.callId)
627
- ?? nonEmptyText(event.data?.subCallId);
758
+ const callId = toolResultCallId(event.data);
628
759
  const toolName = (callId ? this.#toolNames.get(callId) : null)
629
760
  ?? this.#lastToolName;
630
761
  const error = toolResultErrorText(event.data?.error);
631
- pushUpdate({
632
- type: 'status',
633
- text: t('正在整理结果…'),
634
- ...(toolName ? { toolName } : {}),
635
- ...(error ? { error } : {}),
636
- });
762
+ if (live) {
763
+ const providerErrorCode = event.data?.error?.code;
764
+ pushUpdate({
765
+ type: 'tool-result',
766
+ turn: this.#targetTurn,
767
+ ...(callId ? { callId } : {}),
768
+ ...(toolName ? { toolName } : {}),
769
+ text: toolResultText(event.data),
770
+ ...(providerErrorCode !== undefined && providerErrorCode !== null
771
+ ? { errorCode: String(providerErrorCode) }
772
+ : {}),
773
+ });
774
+ } else {
775
+ pushUpdate({
776
+ type: 'status',
777
+ text: t('正在整理结果…'),
778
+ ...(toolName ? { toolName } : {}),
779
+ ...(error ? { error } : {}),
780
+ });
781
+ }
637
782
  }
638
783
  }
639
784
  return updates;
@@ -644,6 +789,19 @@ export class HarnessReplyTracker {
644
789
  }
645
790
  }
646
791
 
792
+ /**
793
+ * Progress updates an ask() consumer actually receives. latest 模式只投递一条
794
+ * 最新进展;assistant-message 是分步推送专用更新,且 canonical 去重后可能成为
795
+ * 批次唯一变化,绝不能冒充进度投给全部渠道。reasoning 同为思考留痕渠道专用,
796
+ * 默认模式下不得冒充进度(钉钉、企微等会展示其 text)。
797
+ */
798
+ export function visibleProgressUpdates(updates, progressMode) {
799
+ if (progressMode === 'all') return updates;
800
+ return updates
801
+ .filter((update) => update.type !== 'assistant-message' && update.type !== 'reasoning')
802
+ .slice(-1);
803
+ }
804
+
647
805
  export class HarnessRpcError extends Error {
648
806
  constructor(method, error) {
649
807
  super(`${method}: ${error?.message ?? 'unknown Harness RPC error'}`);
@@ -1146,6 +1304,7 @@ export class HarnessClient {
1146
1304
  signal,
1147
1305
  onInteraction,
1148
1306
  onResolved,
1307
+ onSessionEvent,
1149
1308
  onOpen,
1150
1309
  ownership,
1151
1310
  } = {}) {
@@ -1159,6 +1318,9 @@ export class HarnessClient {
1159
1318
  if (onResolved !== undefined && typeof onResolved !== 'function') {
1160
1319
  throw new TypeError('onResolved must be a function');
1161
1320
  }
1321
+ if (onSessionEvent !== undefined && typeof onSessionEvent !== 'function') {
1322
+ throw new TypeError('onSessionEvent must be a function');
1323
+ }
1162
1324
  if (onOpen !== undefined && typeof onOpen !== 'function') {
1163
1325
  throw new TypeError('onOpen must be a function');
1164
1326
  }
@@ -1169,6 +1331,7 @@ export class HarnessClient {
1169
1331
  signal,
1170
1332
  onInteraction,
1171
1333
  onResolved,
1334
+ onSessionEvent,
1172
1335
  onOpen,
1173
1336
  ownership,
1174
1337
  });
@@ -1449,7 +1612,12 @@ export class HarnessClient {
1449
1612
  const timeoutMs = options.timeoutMs ?? 600_000;
1450
1613
  const signal = options.signal;
1451
1614
  const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null;
1452
- const progressMode = options.progressMode === 'all' ? 'all' : 'latest';
1615
+ const progressMode = options.progressMode === 'live'
1616
+ ? 'live'
1617
+ : options.progressMode === 'all' ? 'all' : 'latest';
1618
+ // Reasoning updates only exist for consumers that opt in (thinking-trace
1619
+ // mode); default ask() consumers keep the pre-thinking-traces stream.
1620
+ const reasoning = options.reasoning === true;
1453
1621
  const onArtifact = typeof options.onArtifact === 'function' ? options.onArtifact : null;
1454
1622
  const onInteraction = typeof options.onInteraction === 'function'
1455
1623
  ? options.onInteraction
@@ -1470,8 +1638,39 @@ export class HarnessClient {
1470
1638
  const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1));
1471
1639
  const promptRpcId = `${this.#rpcIdPrefix}-${randomUUID()}`;
1472
1640
  const releasePromptInputOrigin = registerImInputOrigin(this.#interactionRegistry, promptRpcId);
1473
- const tracker = new HarnessReplyTracker({ promptRpcId, afterSeq: baselineSeq });
1641
+ const tracker = new HarnessReplyTracker({ promptRpcId, afterSeq: baselineSeq, reasoning });
1642
+ let lastProgressAt = Date.now();
1643
+ let lastPollSeq = tracker.lastSeq;
1644
+ let progressTail = Promise.resolve();
1645
+ const consumeProgress = (entries, { fromMux = false } = {}) => {
1646
+ const updates = tracker.consumeAll(entries, {
1647
+ live: progressMode === 'live',
1648
+ fromMux,
1649
+ });
1650
+ const seqAdvanced = tracker.lastSeq > lastPollSeq;
1651
+ lastPollSeq = tracker.lastSeq;
1652
+ if (seqAdvanced) lastProgressAt = Date.now();
1653
+ if (!onUpdate) return progressTail;
1654
+ const visibleUpdates = progressMode === 'all' || progressMode === 'live'
1655
+ ? updates
1656
+ : updates
1657
+ .filter((update) => update.type !== 'assistant-message' && update.type !== 'reasoning')
1658
+ .slice(-1);
1659
+ for (const update of visibleUpdates) {
1660
+ progressTail = progressTail
1661
+ .then(() => onUpdate(update))
1662
+ .catch((error) => {
1663
+ console.warn(
1664
+ '[dsh-im] ignored a progress update failure:',
1665
+ this.#logPrefix,
1666
+ error.message,
1667
+ );
1668
+ });
1669
+ }
1670
+ return progressTail;
1671
+ };
1474
1672
  const interactionController = onInteraction || onInteractionResolved
1673
+ || (progressMode === 'live' && onUpdate)
1475
1674
  ? new AbortController()
1476
1675
  : null;
1477
1676
  const interactionSignal = interactionController
@@ -1568,6 +1767,9 @@ export class HarnessClient {
1568
1767
  signal: interactionSignal,
1569
1768
  onInteraction,
1570
1769
  onResolved: onInteractionResolved,
1770
+ onSessionEvent: progressMode === 'live'
1771
+ ? (event) => { consumeProgress([event], { fromMux: true }); }
1772
+ : undefined,
1571
1773
  onOpen: markOpen,
1572
1774
  ownership,
1573
1775
  });
@@ -1645,8 +1847,6 @@ export class HarnessClient {
1645
1847
  // confirm the Session is still running before renewing the wait.
1646
1848
  // Interaction ownership is intentionally not a liveness signal: it stays
1647
1849
  // active until turn/end and can therefore outlive a stalled turn.
1648
- let lastProgressAt = Date.now();
1649
- let lastPollSeq = tracker.lastSeq;
1650
1850
  while (true) {
1651
1851
  await sleep(300, signal);
1652
1852
  const history = await this.rpc(
@@ -1660,24 +1860,7 @@ export class HarnessClient {
1660
1860
  this.#consumeInteractionOwnerships(sessionId, history.events ?? []);
1661
1861
  if (!wasActive && ownership.active) ownership.reconnect?.();
1662
1862
  }
1663
- const updates = tracker.consumeAll(history.events ?? []);
1664
- const seqAdvanced = tracker.lastSeq > lastPollSeq;
1665
- lastPollSeq = tracker.lastSeq;
1666
- if (seqAdvanced) lastProgressAt = Date.now();
1667
- if (onUpdate) {
1668
- // latest 模式只投递一条最新进展;assistant-message 是分步推送专用更新,
1669
- // 且 canonical 去重后可能成为批次唯一变化,绝不能冒充进度投给全部渠道。
1670
- const visibleUpdates = progressMode === 'all'
1671
- ? updates
1672
- : updates.filter((update) => update.type !== 'assistant-message').slice(-1);
1673
- for (const update of visibleUpdates) {
1674
- try {
1675
- await onUpdate(update);
1676
- } catch (error) {
1677
- console.warn('[dsh-im] ignored a progress update failure:', this.#logPrefix, error.message);
1678
- }
1679
- }
1680
- }
1863
+ await consumeProgress(history.events ?? []);
1681
1864
  if (tracker.finished) {
1682
1865
  turnFinished = true;
1683
1866
  if (!ownership?.stopRequested && !harnessTurnSucceeded(tracker.reason)) {
@@ -1761,6 +1944,7 @@ export class HarnessClient {
1761
1944
  signal,
1762
1945
  onInteraction,
1763
1946
  onResolved,
1947
+ onSessionEvent,
1764
1948
  onOpen,
1765
1949
  ownership,
1766
1950
  }) {
@@ -1800,8 +1984,9 @@ export class HarnessClient {
1800
1984
  };
1801
1985
  const processEnvelope = (envelope) => {
1802
1986
  const payload = envelope.payload;
1803
- if (ownership && payload.type === 'session/event') {
1804
- this.#consumeInteractionOwnerships(sessionId, [payload.event]);
1987
+ if (payload.type === 'session/event') {
1988
+ if (ownership) this.#consumeInteractionOwnerships(sessionId, [payload.event]);
1989
+ dispatch(onSessionEvent, payload.event);
1805
1990
  return;
1806
1991
  }
1807
1992
  if (payload.type === 'question/requested' || payload.type === 'approval/requested') {
@@ -168,6 +168,7 @@ export class TextHarnessBridge {
168
168
  #deferred;
169
169
  #contextEnhancement;
170
170
  #accessPolicy;
171
+ #thinkingTraces;
171
172
  #status;
172
173
  #logger;
173
174
  #replyTimeoutMs;
@@ -195,6 +196,7 @@ export class TextHarnessBridge {
195
196
  state,
196
197
  contextEnhancement,
197
198
  accessPolicy,
199
+ thinkingTraces = false,
198
200
  status = createTextBridgeStatus(),
199
201
  logger = console,
200
202
  replyTimeoutMs = 600_000,
@@ -211,6 +213,7 @@ export class TextHarnessBridge {
211
213
  this.#state = state;
212
214
  this.#contextEnhancement = contextEnhancement;
213
215
  this.#accessPolicy = accessPolicy;
216
+ this.#thinkingTraces = thinkingTraces;
214
217
  this.#status = status;
215
218
  this.#logger = logger;
216
219
  this.#replyTimeoutMs = replyTimeoutMs;
@@ -654,6 +657,10 @@ export class TextHarnessBridge {
654
657
  const batchSubmission = message.batchSubmission;
655
658
  let stream = null;
656
659
  let semanticStream = false;
660
+ // Thinking streams accept the same delivery-block contract as semantic
661
+ // streams: finish() must receive { text, format } so a markdown answer
662
+ // renders rich instead of falling back to plain text.
663
+ let thinkingStream = false;
657
664
  // A keepalive heartbeat keeps short-lived carriers (e.g. Telegram's
658
665
  // private-chat Rich Draft) visible during long silent stretches such as a
659
666
  // running tool call. Declared outside the try so every exit path (including
@@ -724,29 +731,51 @@ export class TextHarnessBridge {
724
731
  this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] typing indicator failed:`, error);
725
732
  });
726
733
  let streamFinished = false;
727
- if (typeof this.#bot.openDeliveryStream === 'function') {
734
+ if (this.#thinkingTraces && typeof this.#bot.openThinkingStream === 'function') {
728
735
  try {
729
- stream = await this.#bot.openDeliveryStream(target);
730
- semanticStream = true;
736
+ stream = await this.#bot.openThinkingStream(target);
737
+ thinkingStream = true;
731
738
  } catch (error) {
739
+ stream = null;
732
740
  this.#logger.warn?.(
733
- `[dsh-im:${this.#descriptor.key}] unable to start a semantic reply stream; using final delivery:`,
741
+ `[dsh-im:${this.#descriptor.key}] unable to start a thinking stream; using final delivery:`,
734
742
  error,
735
743
  );
736
744
  }
737
- } else if (typeof this.#bot.openStream === 'function') {
738
- try {
739
- stream = await this.#bot.openStream(target);
740
- } catch (error) {
741
- this.#logger.warn?.(
742
- `[dsh-im:${this.#descriptor.key}] unable to start a streamed reply; using text:`,
743
- error,
744
- );
745
+ }
746
+ if (!stream) {
747
+ if (typeof this.#bot.openDeliveryStream === 'function') {
748
+ try {
749
+ stream = await this.#bot.openDeliveryStream(target);
750
+ semanticStream = true;
751
+ } catch (error) {
752
+ this.#logger.warn?.(
753
+ `[dsh-im:${this.#descriptor.key}] unable to start a semantic reply stream; using final delivery:`,
754
+ error,
755
+ );
756
+ }
757
+ } else if (typeof this.#bot.openStream === 'function') {
758
+ try {
759
+ stream = await this.#bot.openStream(target);
760
+ } catch (error) {
761
+ this.#logger.warn?.(
762
+ `[dsh-im:${this.#descriptor.key}] unable to start a streamed reply; using text:`,
763
+ error,
764
+ );
765
+ }
745
766
  }
746
767
  }
768
+ const thinkingMode = Boolean(stream
769
+ && this.#thinkingTraces
770
+ && typeof stream.sendToolTrace === 'function');
771
+ // What the model sees. A message may carry two texts: `controlText`, the
772
+ // plain body the parsers read, and `content`, the same body decorated for
773
+ // the model — the email channel prefixes the mail headers there, so a
774
+ // subject-only instruction is not silently dropped. Falling back to the
775
+ // parsed text kept commands working but lost that decoration entirely.
747
776
  let content = hasImages || hasReply
748
777
  ? await promptContentForInboundMessage(message, { signal: this.#signal, deferImages: true })
749
- : undefined;
778
+ : cleanText(message.content) || undefined;
750
779
  const snapshot = this.#acceptedMessageIds.get(messageId);
751
780
  let contextEnhanced = false;
752
781
  if (snapshot) {
@@ -801,7 +830,24 @@ export class TextHarnessBridge {
801
830
  timeoutMs: this.#replyTimeoutMs,
802
831
  signal: this.#signal,
803
832
  control: { owner: this, key: conversationKey },
833
+ // Thinking mode needs every update (reasoning + tool calls) in
834
+ // order; latest mode filters as before. Only thinking mode subscribes
835
+ // to reasoning updates, so default-mode consumers on other channels
836
+ // never see them (their progress handlers would render update.text).
837
+ progressMode: thinkingMode ? 'all' : undefined,
838
+ reasoning: thinkingMode,
804
839
  onUpdate: stream ? async (update) => {
840
+ if (thinkingMode) {
841
+ // Only the 💭 and 🔧 lines are surfaced; status/text updates
842
+ // and the assistant-message canonical text are not.
843
+ if (update.type === 'reasoning') {
844
+ await stream.sendThinking(update.text);
845
+ } else if (update.type === 'tool') {
846
+ await stream.sendToolTrace(update.name, update.arguments);
847
+ }
848
+ return;
849
+ }
850
+ if (update.type === 'reasoning') return;
805
851
  const progress = update.type === 'text' ? update.text
806
852
  : update.type === 'tool' ? t('正在使用{name}…', { name: update.name }) : update.text;
807
853
  if (progress) {
@@ -841,7 +887,7 @@ export class TextHarnessBridge {
841
887
  let textReceipt = null;
842
888
  if (stream) {
843
889
  try {
844
- const result = await stream.finish(semanticStream
890
+ const result = await stream.finish(semanticStream || thinkingStream
845
891
  ? createTextDeliveryBlock(visibleAnswer, answerFormat)
846
892
  : visibleAnswer);
847
893
  streamFinished = true;
@@ -94,6 +94,7 @@ export async function askInWorkspaceSession({
94
94
  key,
95
95
  text,
96
96
  content,
97
+ prepareContent,
97
98
  titleText,
98
99
  sourceGuidance,
99
100
  contextEnhanced = false,
@@ -165,8 +166,13 @@ export async function askInWorkspaceSession({
165
166
  await originalOnArtifact?.(artifact);
166
167
  };
167
168
  let answer;
169
+ // Prepare session-dependent context after binding, outside the binding
170
+ // lock. A stale-workspace retry must prepare it for the new session too.
171
+ const prompt = prepareContent
172
+ ? await prepareContent({ sessionId: binding.sessionId, content, text })
173
+ : content ?? text;
168
174
  try {
169
- answer = await binding.session.ask(content ?? text, artifactOptions);
175
+ answer = await binding.session.ask(prompt, artifactOptions);
170
176
  } catch (error) {
171
177
  if (error?.code === 'harness-reply-timeout' && deferredDelivery) {
172
178
  try {
@@ -50,12 +50,15 @@ export function normalizeTelegramAccessPolicy(value = {}) {
50
50
  function normalizeTelegramBotExtension(value) {
51
51
  const hasAccessMode = Object.hasOwn(value, 'accessMode');
52
52
  const hasAllowedUsers = Object.hasOwn(value, 'allowedUsers');
53
- if (!hasAccessMode && !hasAllowedUsers) return {};
53
+ const hasThinkingTraces = Object.hasOwn(value, 'thinkingTraces');
54
+ if (!hasAccessMode && !hasAllowedUsers && !hasThinkingTraces) return {};
54
55
  try {
55
56
  const policy = normalizeTelegramAccessPolicy(value);
56
57
  return {
57
58
  ...(hasAccessMode ? { accessMode: policy.accessMode } : {}),
58
59
  ...(hasAllowedUsers || hasAccessMode ? { allowedUsers: policy.allowedUsers } : {}),
60
+ // Explicit field is kept; an absent field stays absent.
61
+ ...(hasThinkingTraces ? { thinkingTraces: value.thinkingTraces === true } : {}),
59
62
  };
60
63
  } catch {
61
64
  return null;
@@ -28,7 +28,12 @@ export class TelegramController extends TokenBotController {
28
28
  bots: snapshot.bots.map((bot) => {
29
29
  const config = this.#configStore.get(bot.botId);
30
30
  const accessPolicy = normalizeTelegramAccessPolicy(config ?? {});
31
- return { ...bot, accessPolicy };
31
+ return {
32
+ ...bot,
33
+ accessPolicy,
34
+ // Default ON; only an explicit false in the config opts out.
35
+ thinkingTraces: config?.thinkingTraces !== false,
36
+ };
32
37
  }),
33
38
  };
34
39
  }
@@ -37,4 +42,11 @@ export class TelegramController extends TokenBotController {
37
42
  const accessPolicy = normalizeTelegramAccessPolicy(value);
38
43
  return this.updateBotConfig(botId, (config) => ({ ...config, ...accessPolicy }));
39
44
  }
45
+
46
+ async setThinkingTraces(botId, value) {
47
+ return this.updateBotConfig(botId, (config) => ({
48
+ ...config,
49
+ thinkingTraces: value === true,
50
+ }));
51
+ }
40
52
  }