@xmanrui/dsh-im 4.34.2 → 4.35.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.
package/locale/en.json CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "meta": {
3
+ "title": "IM bots",
3
4
  "description": "Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH."
4
5
  }
5
6
  }
package/locale/zh.json CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "meta": {
3
+ "title": "IM 机器人",
3
4
  "description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。"
4
5
  }
5
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.34.2",
3
+ "version": "4.35.1",
4
4
  "icon": "./assets/logo-plugin-message-link-gradient.webp",
5
5
  "description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。 Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH.",
6
6
  "keywords": [
@@ -286,8 +286,8 @@ export function createDeliveryAdapter({ channel, workspaces, coreController, sta
286
286
  if (typeof coreController.sendProactiveText !== 'function') {
287
287
  throw new TypeError('delivery controller cannot send proactive text');
288
288
  }
289
- await coreController.sendProactiveText(botId, normalized, text, options);
290
- return { sent: true };
289
+ const result = await coreController.sendProactiveText(botId, normalized, text, options);
290
+ return options.receipt === true ? result : { sent: true };
291
291
  },
292
292
  async setSessionSync(botId, targetId, enabled) {
293
293
  if (typeof workspaces.setDeliveryTargetSessionSync !== 'function') {
@@ -30,6 +30,7 @@ const DELIVERY_ERROR_CODES = new Set([
30
30
  'bot-not-connected',
31
31
  'target-rejected',
32
32
  'delivery-failed',
33
+ 'send-result-unknown',
33
34
  'session-sync-unavailable',
34
35
  'cancelled',
35
36
  ]);
@@ -314,10 +315,10 @@ export class DeliveryService {
314
315
  } catch (error) { throw publicOperationError(error); }
315
316
  }
316
317
 
317
- async sendChecked(botId, targetId, text, { expectedFingerprint, expectedTargetDigest, signal, format = 'plain' } = {}) {
318
+ async sendChecked(botId, targetId, text, { expectedFingerprint, expectedTargetDigest, signal, format = 'plain', receipt = false } = {}) {
318
319
  const id = botIdOf(botId);
319
320
  const key = targetIdOf(targetId);
320
- if (typeof text !== 'string' || !text.trim() || !['plain', 'markdown'].includes(format)
321
+ if (typeof text !== 'string' || !text.trim() || !['plain', 'markdown'].includes(format) || typeof receipt !== 'boolean'
321
322
  || !/^[a-f0-9]{64}$/.test(expectedFingerprint ?? '') || !/^[a-f0-9]{64}$/.test(expectedTargetDigest ?? '')) {
322
323
  throw deliveryError('bad-request');
323
324
  }
@@ -341,14 +342,22 @@ export class DeliveryService {
341
342
  throw deliveryError('capability-unavailable');
342
343
  }
343
344
  if (account.account?.fingerprint !== expectedFingerprint) throw deliveryError('account-changed');
345
+ if (receipt && (!account.capabilities?.includes('proactive-receipt-checked') || target.kind !== 'group'))
346
+ throw deliveryError('capability-unavailable');
344
347
  const beforeSend = () => {
345
348
  cancellation(signal);
346
349
  this.#assertRegistered(registration);
347
350
  };
348
351
  beforeSend();
349
- await adapter.sendText(id, target, text, { signal, expectedFingerprint, beforeSend,
352
+ const result = await adapter.sendText(id, target, text, { signal, expectedFingerprint, beforeSend,
353
+ ...(receipt ? { receipt: true } : {}),
350
354
  ...(format === 'markdown' ? { format } : {}) });
351
- return { sent: true };
355
+ if (!receipt) return { sent: true };
356
+ if (result?.sent !== true || result.receipt?.version !== 1
357
+ || typeof result.receipt.messageId !== 'string' || !result.receipt.messageId || result.receipt.messageId.length > 512
358
+ || result.receipt.conversationId !== target.route.chatId)
359
+ throw deliveryError('send-result-unknown');
360
+ return { sent: true, receipt: { version: 1, messageId: result.receipt.messageId, conversationId: result.receipt.conversationId } };
352
361
  } catch (error) { throw publicOperationError(error); }
353
362
  }
354
363
 
@@ -92,6 +92,7 @@ export function createImHostPlugin(internals = {}) {
92
92
  if (typeof ctx?.provide === 'function') {
93
93
  ctx.provide('dshIm', Object.freeze({
94
94
  contractVersion: 1,
95
+ receiptVersion: 1,
95
96
  describeBot: (botId) => deliveryService.describeBot(botId),
96
97
  sendChecked: (botId, targetId, text, options) => deliveryService.sendChecked(botId, targetId, text, options),
97
98
  send: (botId, targetId, text, options) => (
@@ -3041,14 +3041,14 @@ export class FeishuHarnessBridge {
3041
3041
 
3042
3042
  async #showWorkspaces({ chatId, key, replyTo = null }, { updateMessageId = null } = {}) {
3043
3043
  try {
3044
- const { current, paths } = await workspacePathSnapshot(
3044
+ const { current, paths, titles } = await workspacePathSnapshot(
3045
3045
  this.#harness,
3046
- { signal: this.#cardDataSignal() },
3046
+ { signal: this.#cardDataSignal(), includeTitles: true },
3047
3047
  );
3048
3048
  this.#rememberMenu(key, { kind: 'workspaces', paths });
3049
3049
  await this.#sendCard(
3050
3050
  chatId,
3051
- workspaceListCard(paths, current),
3051
+ workspaceListCard(paths, current, titles),
3052
3052
  { key, updateMessageId, replyTo },
3053
3053
  );
3054
3054
  } catch (error) {
@@ -3239,7 +3239,7 @@ export class FeishuHarnessBridge {
3239
3239
  const dataSignal = this.#cardDataSignal();
3240
3240
  // Independent sections start together. Each one degrades on its own so a
3241
3241
  // slow preset/model RPC cannot force redundant session-list scans.
3242
- const workspaceTask = workspacePathSnapshot(this.#harness, { signal: dataSignal })
3242
+ const workspaceTask = workspacePathSnapshot(this.#harness, { signal: dataSignal, includeTitles: true })
3243
3243
  .catch(() => {
3244
3244
  const current = typeof this.#harness.currentWorkspace === 'function'
3245
3245
  ? this.#harness.currentWorkspace()
@@ -3316,7 +3316,7 @@ export class FeishuHarnessBridge {
3316
3316
  const archiveVisible = this.#state?.includesArchivedSessions?.() ?? false;
3317
3317
  this.#rememberMenu(key, { kind: 'menu', chatId });
3318
3318
  const menuContext = {
3319
- workspaces, currentWorkspace,
3319
+ workspaces, currentWorkspace, workspaceTitles: snapshot.titles,
3320
3320
  currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
3321
3321
  sessions, archiveVisible, presetCatalog, modelCatalog,
3322
3322
  newSessionAction: { id: randomUUID(), status: 'idle' },
@@ -14,6 +14,7 @@
14
14
  * without button callbacks.
15
15
  */
16
16
 
17
+ import { posix, win32 } from 'node:path';
17
18
  import { t } from '../shared/i18n.mjs';
18
19
 
19
20
  export const MENU_PAGE_SIZE = 10;
@@ -87,9 +88,56 @@ function initialIndex(options, currentValue) {
87
88
  return idx >= 0 ? idx + 1 : 0;
88
89
  }
89
90
 
91
+ function cleanTitle(value) {
92
+ return String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim();
93
+ }
94
+
90
95
  function safeTitle(value) {
91
- const title = String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim();
92
- return title || t('暂无标题');
96
+ return cleanTitle(value) || t('暂无标题');
97
+ }
98
+
99
+ const WORKSPACE_LABEL_LENGTH = 22;
100
+
101
+ /** Display labels only: paths and their snapshot order remain the callback identity. */
102
+ function workspaceLabels(paths, titles = {}) {
103
+ const entries = paths.map((path) => {
104
+ const api = /^[a-z]:[\\/]|^\\\\/iu.test(path) ? win32 : posix;
105
+ const normalized = api.normalize(path);
106
+ const name = cleanTitle(api.basename(normalized) || api.parse(normalized).root);
107
+ const base = (typeof titles[path] === 'string' && cleanTitle(titles[path])) || name;
108
+ return {
109
+ api, base, label: base, parent: api.dirname(normalized),
110
+ context: base === name ? '' : api.basename(normalized),
111
+ };
112
+ });
113
+ const counts = () => {
114
+ const result = new Map();
115
+ for (const { label } of entries) result.set(label, (result.get(label) ?? 0) + 1);
116
+ return result;
117
+ };
118
+ // Grow only ambiguous path suffixes; stop at the root even if display text collides.
119
+ for (;;) {
120
+ const repeated = counts();
121
+ let changed = false;
122
+ for (const entry of entries) {
123
+ if (repeated.get(entry.label) < 2 || entry.parent === null) continue;
124
+ const { api, parent } = entry;
125
+ entry.context = api.join(api.basename(parent) || parent, entry.context);
126
+ entry.label = `${entry.base} · ${cleanTitle(entry.context)}`;
127
+ const next = api.dirname(parent);
128
+ entry.parent = next === parent ? null : next;
129
+ changed = true;
130
+ }
131
+ if (!changed) break;
132
+ }
133
+ for (const entry of entries) {
134
+ const chars = Array.from(entry.label);
135
+ if (chars.length > WORKSPACE_LABEL_LENGTH) {
136
+ entry.label = `${chars.slice(0, 11).join('')}…${chars.slice(-10).join('')}`;
137
+ }
138
+ }
139
+ const repeated = counts();
140
+ return entries.map(({ label }) => ({ label, numbered: repeated.get(label) > 1 }));
93
141
  }
94
142
 
95
143
  function cardWith(headerText, elements) {
@@ -117,6 +165,7 @@ function backButton() {
117
165
  * 4. 系统 → 状态 · 帮助
118
166
  * `ctx` bundle:
119
167
  * - workspaces: string[] (工作区下拉选项)
168
+ * - workspaceTitles: object (可选,按绝对路径索引的工作区名称)
120
169
  * - currentWorkspace: string|null (当前工作区,下拉高亮)
121
170
  * - currentSession: {id,title}|null (当前绑定会话,续写目标)
122
171
  * - sessions: {id,title}[] (最近会话,供会话下拉切换)
@@ -130,6 +179,7 @@ function backButton() {
130
179
  export function menuCard(ctx) {
131
180
  const {
132
181
  workspaces = [],
182
+ workspaceTitles = {},
133
183
  currentWorkspace = null,
134
184
  currentSession = null,
135
185
  sessions = [],
@@ -188,8 +238,9 @@ export function menuCard(ctx) {
188
238
 
189
239
  let workspaceDropdown = null;
190
240
  if (hasWorkspaces) {
191
- const wsOptions = workspaces.slice(0, 20).map((path) => ({
192
- text: { tag: 'plain_text', content: `${path === currentWorkspace ? '✓ ' : ''}${path}` },
241
+ const labels = workspaceLabels(workspaces, workspaceTitles);
242
+ const wsOptions = workspaces.slice(0, 20).map((path, index) => ({
243
+ text: { tag: 'plain_text', content: `${path === currentWorkspace ? '✓ ' : ''}${labels[index].numbered ? `${index + 1}. ` : ''}${labels[index].label}` },
193
244
  value: path,
194
245
  }));
195
246
  workspaceDropdown = {
@@ -726,7 +777,8 @@ export function sessionListCard(workspace, sessions, page, total, watchedSession
726
777
  // ── Workspace list card (preserved, with back button) ─────────────────────
727
778
 
728
779
  /** The workspace list card (switch-workspace buttons + reply fallback). */
729
- export function workspaceListCard(paths, current) {
780
+ export function workspaceListCard(paths, current, titles = {}) {
781
+ const labels = workspaceLabels(paths, titles);
730
782
  const elements = paths.length === 0
731
783
  ? [
732
784
  { tag: 'div', text: markdown(t('当前 Host 上没有已登记的工作区。')) },
@@ -735,7 +787,7 @@ export function workspaceListCard(paths, current) {
735
787
  : [
736
788
  { tag: 'div', text: markdown(t('回复数字切换工作区,或点击按钮:')) },
737
789
  ...paths.map((path, index) => button(
738
- `${index + 1}. ${path}${path === current ? t('(当前)') : ''}`,
790
+ `${index + 1}. ${labels[index].label}${path === current ? t('(当前)') : ''}`,
739
791
  `workspace:${path}`,
740
792
  )),
741
793
  { tag: 'hr' },
@@ -675,7 +675,7 @@ export class FeishuRuntime {
675
675
  });
676
676
  }
677
677
 
678
- async sendProactiveText(target, text, { signal, format = 'plain' } = {}) {
678
+ async sendProactiveText(target, text, { signal, format = 'plain', receipt = false } = {}) {
679
679
  if (!this.#status.ready || !this.#client) {
680
680
  const error = new Error('飞书机器人尚未连接');
681
681
  error.code = 'bot-not-connected';
@@ -698,6 +698,8 @@ export class FeishuRuntime {
698
698
  error.code = 'bad-request';
699
699
  throw error;
700
700
  }
701
+ if (typeof receipt !== 'boolean' || (receipt && target.kind !== 'group'))
702
+ throw Object.assign(new Error('capability-unavailable'), { code: 'capability-unavailable' });
701
703
  signal?.throwIfAborted();
702
704
  // Use the same native Markdown element as chat, without opening a stream
703
705
  // or retrying as plain text after a possibly accepted delivery.
@@ -724,7 +726,12 @@ export class FeishuRuntime {
724
726
  error.code = 'target-rejected';
725
727
  throw error;
726
728
  }
727
- return { sent: true };
729
+ if (!receipt) return { sent: true };
730
+ const messageId = response?.data?.message_id;
731
+ const conversationId = response?.data?.chat_id;
732
+ if (typeof messageId !== 'string' || !messageId || messageId.length > 512 || conversationId !== receiveId)
733
+ throw Object.assign(new Error('send-result-unknown'), { code: 'send-result-unknown' });
734
+ return { sent: true, receipt: { version: 1, messageId, conversationId } };
728
735
  }
729
736
 
730
737
  /**
@@ -594,7 +594,7 @@ export class MultiBotDshFeishuController {
594
594
  this.#assertOpen('capability-unavailable');
595
595
  return { version: 1, botId, channel: 'feishu', account,
596
596
  connected: isConnected(connectionStatus(this.#runtimes.get(botId))),
597
- capabilities: ['proactive-text-checked'] };
597
+ capabilities: ['proactive-text-checked', 'proactive-receipt-checked'] };
598
598
  });
599
599
  }
600
600
 
@@ -75,6 +75,7 @@ import {
75
75
  } from './qq-menu.mjs';
76
76
  import { withSessionBindingLock } from '../shared/session-binding-lock.mjs';
77
77
  import { t } from '../shared/i18n.mjs';
78
+ import { isQqMessageAddressed, normalizeQqMentions } from './qq-mention.mjs';
78
79
 
79
80
  function interactionResolvedText() {
80
81
  return t('这个问题已在其他客户端处理,无需再次回答。');
@@ -408,7 +409,7 @@ async function sendQqFile(
408
409
  function canClaimInteractionReply(message, pending) {
409
410
  return pending.questions[pending.index]
410
411
  && nonEmptyString(message?.senderId) === pending.actor
411
- && (message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE')
412
+ && isQqMessageAddressed(message)
412
413
  && !hasQqImageAttachments(message)
413
414
  && !hasQqFileAttachments(message)
414
415
  && nonEmptyString(safeText(message));
@@ -487,6 +488,7 @@ export class QqHarnessBridge {
487
488
  this.#replyTimeoutMs = replyTimeoutMs;
488
489
  this.#signal = signal;
489
490
  this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
491
+ finalAnswerOnly: true,
490
492
  deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
491
493
  });
492
494
  this.#fetchImpl = fetchImpl;
@@ -506,9 +508,9 @@ export class QqHarnessBridge {
506
508
  || !['c2c', 'group'].includes(message?.kind)
507
509
  || this.#state.hasSeen(messageId)
508
510
  || this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
511
+ message = normalizeQqMentions(message, new Set([this.#bot.appId]));
509
512
  const key = conversationKey(message);
510
- const addressed = message.kind !== 'group'
511
- || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
513
+ const addressed = isQqMessageAddressed(message);
512
514
  const commandText = safeText(message);
513
515
  const menuTextOnly = !hasQqImageAttachments(message) && !hasQqFileAttachments(message)
514
516
  && !qqReplyReference(message);
@@ -634,7 +636,7 @@ export class QqHarnessBridge {
634
636
  actor: sender,
635
637
  messageId,
636
638
  text: hasQqImageAttachments(message) || hasQqFileAttachments(message) ? '' : safeText(message),
637
- addressed: message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE',
639
+ addressed,
638
640
  hasPendingQuestion: Boolean(pending),
639
641
  questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
640
642
  ? pending.queue
@@ -689,8 +691,7 @@ export class QqHarnessBridge {
689
691
  alreadyRecorded = false,
690
692
  batchSubmission = null,
691
693
  } = {}) {
692
- const addressed = message.kind !== 'group'
693
- || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
694
+ const addressed = isQqMessageAddressed(message);
694
695
  const preparedMessage = addressed
695
696
  ? prefetchInboundFiles(
696
697
  qqInboundMessage(message, { fetchImpl: this.#fetchImpl }),
@@ -984,7 +985,7 @@ export class QqHarnessBridge {
984
985
  await this.#state.markSeen(messageId);
985
986
  messageRecorded = true;
986
987
  };
987
- if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
988
+ if (!isQqMessageAddressed(message)) return;
988
989
 
989
990
  const target = message.replyTarget;
990
991
  const promptMessage = preparedMessage
@@ -1085,6 +1086,7 @@ export class QqHarnessBridge {
1085
1086
  signal: this.#signal,
1086
1087
  control: { owner: this, key },
1087
1088
  progressMode: 'all',
1089
+ finalAnswerOnly: true,
1088
1090
  onUpdate: (update) => {
1089
1091
  if (update.error) {
1090
1092
  const name = (nonEmptyString(update.toolName) ?? t('工具'))
@@ -1245,7 +1247,7 @@ export class QqHarnessBridge {
1245
1247
  this.#status.messagesReceived += 1;
1246
1248
  this.#status.lastMessageAt = new Date().toISOString();
1247
1249
 
1248
- if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
1250
+ if (!isQqMessageAddressed(message)) return;
1249
1251
  const text = nonEmptyString(safeText(message));
1250
1252
  if (!text || hasQqImageAttachments(message) || hasQqFileAttachments(message)) {
1251
1253
  await this.#bot.sendText(message.replyTarget, t('请用文字回答当前问题。'));
@@ -0,0 +1,37 @@
1
+ // Keep the decision after the SDK sanitizes the mention out of the content.
2
+ const addressedToBot = Symbol('qq.addressedToBot');
3
+
4
+ function selfMentions(message) {
5
+ return Array.isArray(message?.mentions)
6
+ ? message.mentions.filter((mention) => mention?.is_you === true)
7
+ : [];
8
+ }
9
+
10
+ export function isQqMessageAddressed(message) {
11
+ return Boolean(message) && (message.kind !== 'group'
12
+ || message[addressedToBot] === true
13
+ || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE'
14
+ || selfMentions(message).length > 0);
15
+ }
16
+
17
+ /** botIds belongs to one runtime; only platform-marked self mentions add IDs. */
18
+ export function normalizeQqMentions(message, botIds = new Set()) {
19
+ if (message?.kind !== 'group') return message;
20
+ for (const mention of selfMentions(message)) {
21
+ for (const id of [mention.id, mention.user_openid, mention.member_openid]) {
22
+ if (typeof id === 'string' && id.trim()) botIds.add(id.trim());
23
+ }
24
+ }
25
+ const marker = /<@!?([^<>\s]+)>/gu;
26
+ const content = typeof message.content === 'string' ? message.content : '';
27
+ // raw.content is still the current message, never the quoted message snapshot.
28
+ const rawContent = typeof message.raw?.content === 'string' ? message.raw.content : '';
29
+ const mentioned = isQqMessageAddressed(message)
30
+ || [...`${content}\n${rawContent}`.matchAll(marker)].some((match) => botIds.has(match[1]));
31
+ // Match the SDK sanitizer's in-place contract: later sender enrichment must
32
+ // remain visible to the existing queued message and source-context capture.
33
+ message[addressedToBot] = mentioned;
34
+ message.content = content.replace(/<@!?([^<>\s]+)>[ \t]*/gu,
35
+ (match, id) => botIds.has(id) ? '' : match).trim();
36
+ return message;
37
+ }
@@ -8,6 +8,7 @@ import {
8
8
  import { t } from '../shared/i18n.mjs';
9
9
  import { evaluateInboundAccess } from '../shared/inbound-access.mjs';
10
10
  import { createQqBridgeStatus, QqHarnessBridge } from './qq-bridge.mjs';
11
+ import { isQqMessageAddressed, normalizeQqMentions } from './qq-mention.mjs';
11
12
 
12
13
  function timeoutError() {
13
14
  const error = new Error('QQ WebSocket did not become ready in time');
@@ -178,6 +179,11 @@ export class QqRuntime {
178
179
  replyTimeoutMs: this.#replyTimeoutMs,
179
180
  signal: controller.signal,
180
181
  });
182
+ const botMentionIds = new Set([this.#config.appId]);
183
+ bot.use(async (ctx, next) => {
184
+ ctx.message = normalizeQqMentions(ctx.message, botMentionIds);
185
+ await next();
186
+ });
181
187
  // QQ delivers emoji/face messages as opaque `<faceType=..,faceId="..",ext="..">`
182
188
  // tags. Parse them into readable text so the Harness sees what the sender
183
189
  // actually meant instead of an unusable markup fragment.
@@ -186,8 +192,7 @@ export class QqRuntime {
186
192
  keepAlive: true,
187
193
  predicate: (ctx) => {
188
194
  const message = ctx?.message;
189
- if (!message || (message.kind === 'group'
190
- && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE')) return false;
195
+ if (!isQqMessageAddressed(message)) return false;
191
196
  if (!this.#accessPolicy) {
192
197
  return message.kind === 'group'
193
198
  || this.#config.ownerUserOpenid === '*'
@@ -207,7 +212,10 @@ export class QqRuntime {
207
212
  readyResolve = resolve;
208
213
  readyReject = reject;
209
214
  });
210
- const onReady = () => {
215
+ const onReady = (data) => {
216
+ if (typeof data?.user?.id === 'string' && data.user.id.trim()) {
217
+ botMentionIds.add(data.user.id.trim());
218
+ }
211
219
  const now = Date.now();
212
220
  this.#status.ready = true;
213
221
  this.#status.qqConnectionState = 'connected';
@@ -1709,6 +1709,7 @@ export function createBotWorkspaceScope(
1709
1709
  };
1710
1710
  }
1711
1711
  if ((property === 'listWorkspaces'
1712
+ || property === 'listWorkspaceEntries'
1712
1713
  || property === 'listWorkspaceSessions'
1713
1714
  || property === 'listModels')
1714
1715
  && typeof target[property] === 'function') {
@@ -24,6 +24,7 @@ function orderedEvents(history) {
24
24
  export function createDeferredDeliveryCoordinator({
25
25
  harness, state, deliver, signal, logger = console,
26
26
  retryDelayMs = 1_000, pollDelayMs = 30_000, watch = true,
27
+ finalAnswerOnly = false,
27
28
  } = {}) {
28
29
  const enabled = ['deferredEntries', 'putDeferred', 'patchDeferred', 'removeDeferred']
29
30
  .every((name) => typeof state?.[name] === 'function');
@@ -100,7 +101,7 @@ export function createDeferredDeliveryCoordinator({
100
101
  }
101
102
  const outcome = targetTurn === null
102
103
  ? { found: false, endSeq: -1, turn: null }
103
- : extractCompletedTurnAnswer(ordered, { turn: targetTurn });
104
+ : extractCompletedTurnAnswer(ordered, { turn: targetTurn, finalAnswerOnly });
104
105
  const hasStart = ordered.some((event) => event.type === 'turn/start' && event.data?.turn === targetTurn);
105
106
  if (!history.hasMore || hasStart) return { ...outcome, turn: targetTurn };
106
107
  const oldest = batch[0]?.seq;
@@ -1,4 +1,4 @@
1
- import { AssistantTextAccumulator } from './harness-client.mjs';
1
+ import { AssistantTextAccumulator, accumulateFinalAssistantText } from './harness-client.mjs';
2
2
 
3
3
  export const MAX_DEFERRED_PER_KEY = 4;
4
4
 
@@ -35,12 +35,13 @@ function textFromContent(content) {
35
35
  *
36
36
  * Reuses HarnessReplyTracker text accumulation: chunk deltas accumulate per
37
37
  * (step, part index); a canonical assistant/message replaces its step; steps
38
- * join with a blank line. `turn` omitted → prefer the latest completed answer.
38
+ * join with a blank line. finalAnswerOnly discards text before tool calls.
39
+ * `turn` omitted → prefer the latest completed answer.
39
40
  * Returns `{ found, turn, text, reason, endSeq }`; `found: false` when the
40
41
  * turn ended without a completed final answer (stopped/failed/empty/
41
42
  * interrupted) — `reason`/`endSeq` still identify the confirmed terminal.
42
43
  */
43
- export function extractCompletedTurnAnswer(entries, { turn } = {}) {
44
+ export function extractCompletedTurnAnswer(entries, { turn, finalAnswerOnly = false } = {}) {
44
45
  const events = orderedEvents(entries);
45
46
  const wantTurn = Number.isSafeInteger(turn) ? turn : null;
46
47
  function turnAnswer(turnEnd) {
@@ -48,12 +49,14 @@ export function extractCompletedTurnAnswer(entries, { turn } = {}) {
48
49
  let interrupted = false;
49
50
  for (const event of events) {
50
51
  if (event.seq > turnEnd.seq || event.data?.turn !== turnEnd.data.turn) continue;
51
- if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') {
52
+ if (finalAnswerOnly === true) {
53
+ accumulateFinalAssistantText(accumulator, event);
54
+ } else if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') {
52
55
  accumulator.appendDelta(event.data.step, event.data.chunk.index, event.data.chunk.text);
53
56
  } else if (event.type === 'assistant/message') {
54
57
  accumulator.setCanonical(event.data.step, textFromContent(event.data.message?.content));
55
- interrupted = event.data.interrupted === true;
56
58
  }
59
+ if (event.type === 'assistant/message') interrupted = event.data.interrupted === true;
57
60
  }
58
61
  return { text: accumulator.text, interrupted };
59
62
  }