@xmanrui/dsh-im 4.32.0 → 4.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,149 @@
1
+ import { createCommandListSnapshots } from './command-list-snapshots.mjs';
2
+ import { t } from './i18n.mjs';
3
+ import { withSessionBindingLock } from './session-binding-lock.mjs';
4
+ import { splitWorkspaceCommandMessage } from './workspace-command.mjs';
5
+ import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
6
+
7
+ const COMMAND = /^\/(?:permission|permissionlist)(?=$|\s)/iu;
8
+ const SNAPSHOTS = createCommandListSnapshots();
9
+ const USAGE = '用法:/permission 查看当前权限;/permissionlist 列出档位;/permission <序号或完整ID> 切换;纯数字 ID 使用 /permission id:<ID>。';
10
+ const SCOPE = '仅影响当前会话;沙箱与审批策略会同时按 Host 的档位定义更新。';
11
+ const REFRESH = '请先执行 /permissionlist,再按列表序号选择权限档位。';
12
+ const validId = (id) => typeof id === 'string' && /^[^\s\p{Cc}\p{Cf}]+$/u.test(id);
13
+ const display = (value) => typeof value === 'string'
14
+ ? value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').trim() : '';
15
+
16
+ function result(message) {
17
+ return { handled: true, message, messages: splitWorkspaceCommandMessage(message) };
18
+ }
19
+
20
+ function permissions(value) {
21
+ if (!value || !validId(value.currentValue) || !Array.isArray(value.options)
22
+ || value.options.some((item) => !item || !validId(item.value))) {
23
+ throw new TypeError('Harness returned invalid permissions');
24
+ }
25
+ const seen = new Set();
26
+ return {
27
+ currentValue: value.currentValue,
28
+ options: value.options.filter((item) => {
29
+ if (item.value === 'custom' || seen.has(item.value)) return false;
30
+ seen.add(item.value);
31
+ return true;
32
+ }),
33
+ };
34
+ }
35
+
36
+ function description(item) {
37
+ // Translate known Host copy, not an assumed ID -> policy mapping: presets are configurable.
38
+ const text = display(item.description);
39
+ if (text === 'Full file access without approval prompts.') {
40
+ return t('完全文件访问,不再请求审批。');
41
+ }
42
+ if (text === 'Write inside the workspace and permitted temporary directories; wider retries require approval.') {
43
+ return t('可写工作区和允许的临时目录;扩大访问范围需要审批。');
44
+ }
45
+ return text;
46
+ }
47
+
48
+ function errorMessage(error, writing) {
49
+ const code = error?.code ?? error?.failure?.code;
50
+ if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found') {
51
+ return t('工作区或机器人状态已发生变化,请重试。');
52
+ }
53
+ if (['session-not-found', 'session/not-found', 'agent-not-found'].includes(code)) {
54
+ return t('当前聊天绑定的会话已不存在,请先绑定或建立会话。');
55
+ }
56
+ if (['commands-unavailable', 'permissions-unavailable', 'harness-api-not-found',
57
+ 'endpoint-not-found', 'gateway/endpoint-not-found'].includes(code)) {
58
+ return t('当前 Host 暂不支持从机器人查看或切换权限档位。');
59
+ }
60
+ return writing
61
+ ? t('未能确认权限切换结果,请执行 /permission 核对当前档位。')
62
+ : t('暂时无法获取权限档位,请稍后重试。');
63
+ }
64
+
65
+ export function isPermissionCommand(text) {
66
+ return typeof text === 'string' && COMMAND.test(text.trim());
67
+ }
68
+
69
+ export async function runPermissionCommand(text, harness, state, key, options = {}) {
70
+ if (!isPermissionCommand(text)) return null;
71
+ if (options.hasImages || options.hasFiles) {
72
+ return result(t('权限命令仅支持纯文字,请移除图片或文件后重试。'));
73
+ }
74
+ const match = /^\/(permission|permissionlist)(?:[ \t]+([^\s]+))?[ \t]*$/iu.exec(text.trim());
75
+ if (!match || (match[1].toLowerCase() === 'permissionlist' && match[2])) return result(t(USAGE));
76
+ const list = match[1].toLowerCase() === 'permissionlist';
77
+ const requested = match[2];
78
+ if (requested && (!validId(requested) || requested.startsWith('--'))) return result(t(USAGE));
79
+ const signal = options.signal
80
+ ? AbortSignal.any([options.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000);
81
+ let writing = false;
82
+ try {
83
+ return await withSessionBindingLock(state, key, async () => {
84
+ signal.throwIfAborted();
85
+ const sessionId = state.sessionFor?.(key);
86
+ if (!sessionId) return result(t('当前聊天尚未绑定会话,请先发送普通消息建立会话,或使用 /session 绑定已有会话。'));
87
+ const session = typeof harness.workspaceSession === 'function'
88
+ ? harness.workspaceSession(sessionId, key)
89
+ : {
90
+ permissions: harness.getSessionPermissions && ((opts) => harness.getSessionPermissions(sessionId, opts)),
91
+ executeCommand: harness.executeCommand && ((line, opts) => harness.executeCommand(sessionId, line, opts)),
92
+ };
93
+ if (!requested) {
94
+ if (typeof session?.permissions !== 'function') return result(t('当前 Host 暂不支持从机器人查看或切换权限档位。'));
95
+ const current = permissions(await session.permissions({ signal }));
96
+ if (state.sessionFor(key) !== sessionId) return result(t('当前会话已变化,请重新执行 /permissionlist。'));
97
+ const lines = [t('当前会话权限:{id}', { id: current.currentValue }), ''];
98
+ if (list) {
99
+ SNAPSHOTS.save(state, key, { sessionId, ids: current.options.map((item) => item.value) });
100
+ for (const [index, item] of current.options.entries()) {
101
+ const label = display(item.name);
102
+ const detail = description(item);
103
+ lines.push(`${index + 1}. ${item.value}${label && label !== item.value ? `(${label})` : ''}${item.value === current.currentValue ? t(' ✓ 当前') : ''}${detail ? ` — ${detail}` : ''}`);
104
+ }
105
+ if (!current.options.length) lines.push(t('当前没有可用权限档位。'));
106
+ lines.push('', t('切换:/permission <序号或完整ID>'));
107
+ } else {
108
+ lines.push(t('查看可用档位与序号:/permissionlist'));
109
+ }
110
+ lines.push(t(SCOPE));
111
+ return result(lines.join('\n'));
112
+ }
113
+
114
+ let selected = requested;
115
+ const numericId = /^id:(\d+)$/iu.exec(requested);
116
+ if (numericId) selected = numericId[1];
117
+ else if (/^\d+$/u.test(requested)) {
118
+ const snapshot = SNAPSHOTS.load(state, key);
119
+ if (!snapshot || snapshot.sessionId !== sessionId) return result(t(REFRESH));
120
+ const index = Number(requested);
121
+ if (!Number.isSafeInteger(index) || index < 1 || !snapshot.ids[index - 1]) {
122
+ return result(t('权限档位序号无效,请重新执行 /permissionlist。'));
123
+ }
124
+ selected = snapshot.ids[index - 1];
125
+ }
126
+ if (selected === 'custom' || /^id:/iu.test(selected)) return result(t(USAGE));
127
+ if (typeof session?.executeCommand !== 'function') return result(t('当前 Host 暂不支持从机器人查看或切换权限档位。'));
128
+ signal.throwIfAborted();
129
+ writing = true;
130
+ const execution = await session.executeCommand(`/permission ${selected}`, { signal });
131
+ if (execution === undefined) return result(t('当前 Host 未注册 /permission 命令,请确认权限预设组件已启用。'));
132
+ const outcome = execution?.result;
133
+ if (!outcome || !['success', 'error'].includes(outcome.kind)
134
+ || (outcome.text !== undefined && typeof outcome.text !== 'string')) throw new TypeError('Invalid permission command result');
135
+ if (outcome.kind === 'error') {
136
+ if (outcome.text?.startsWith('unknown preset ')) {
137
+ return result(t('该权限档位不存在或已不可用,请重新执行 /permissionlist。'));
138
+ }
139
+ return result([t('权限切换失败。'), display(outcome.text)].filter(Boolean).join('\n'));
140
+ }
141
+ return result([
142
+ t('当前会话权限已切换为:{id}', { id: selected }), '', t(SCOPE),
143
+ ...(selected === 'danger-full-access' ? [t('恢复工作区权限:/permission workspace-write(需 Host 支持该档位)')] : []),
144
+ ].join('\n'));
145
+ });
146
+ } catch (error) {
147
+ return result(errorMessage(error, writing));
148
+ }
149
+ }
@@ -1,3 +1,4 @@
1
+ import { createCommandListSnapshots, COMMAND_LIST_TTL_MS, COMMAND_LIST_MAX_ENTRIES } from './command-list-snapshots.mjs';
1
2
  import {
2
3
  normalizeAgentPresetCatalog,
3
4
  normalizeAgentPresetId,
@@ -19,9 +20,9 @@ const PRESET_USAGE = [
19
20
  '/preset --default 跟随 Host 默认',
20
21
  ].join('\n');
21
22
  const UNSAFE_DISPLAY_TEXT_GLOBAL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
22
- const LIST_SNAPSHOTS = new WeakMap();
23
- export const PRESET_LIST_SNAPSHOT_TTL_MS = 15 * 60_000;
24
- export const PRESET_LIST_SNAPSHOT_MAX_ENTRIES = 256;
23
+ const LIST_SNAPSHOTS = createCommandListSnapshots();
24
+ export const PRESET_LIST_SNAPSHOT_TTL_MS = COMMAND_LIST_TTL_MS;
25
+ export const PRESET_LIST_SNAPSHOT_MAX_ENTRIES = COMMAND_LIST_MAX_ENTRIES;
25
26
 
26
27
  function commandResult(message) {
27
28
  return {
@@ -140,59 +141,13 @@ function formatUpdated(settings) {
140
141
  ].join('\n');
141
142
  }
142
143
 
143
- function stateSnapshots(state, { create = false } = {}) {
144
- if ((typeof state !== 'object' || state === null) && typeof state !== 'function') return null;
145
- let snapshots = LIST_SNAPSHOTS.get(state);
146
- if (!snapshots && create) {
147
- snapshots = new Map();
148
- LIST_SNAPSHOTS.set(state, snapshots);
149
- }
150
- return snapshots ?? null;
151
- }
152
-
153
- function pruneExpiredSnapshots(snapshots, now) {
154
- for (const [snapshotKey, snapshot] of snapshots) {
155
- if (snapshot.expiresAt <= now) snapshots.delete(snapshotKey);
156
- }
157
- }
158
-
159
- function saveSnapshot(state, key, items) {
160
- const snapshots = stateSnapshots(state, { create: true });
161
- if (!snapshots) return;
162
- const now = Date.now();
163
- pruneExpiredSnapshots(snapshots, now);
164
- snapshots.delete(key);
165
- snapshots.set(key, {
166
- expiresAt: now + PRESET_LIST_SNAPSHOT_TTL_MS,
167
- ids: items.map((item) => item.id),
168
- });
169
- while (snapshots.size > PRESET_LIST_SNAPSHOT_MAX_ENTRIES) {
170
- const oldest = snapshots.keys().next();
171
- if (oldest.done) break;
172
- snapshots.delete(oldest.value);
173
- }
174
- }
175
-
176
- function loadSnapshot(state, key) {
177
- const snapshots = stateSnapshots(state);
178
- const snapshot = snapshots?.get(key);
179
- if (!snapshots || !snapshot) return null;
180
- if (snapshot.expiresAt <= Date.now()) {
181
- snapshots.delete(key);
182
- return null;
183
- }
184
- snapshots.delete(key);
185
- snapshots.set(key, snapshot);
186
- return snapshot.ids;
187
- }
188
-
189
144
  function presetFromSnapshot(state, key, requested) {
190
145
  if (!/^\d+$/u.test(requested)) return { numeric: false, id: null };
191
146
  const index = Number(requested);
192
147
  if (!Number.isSafeInteger(index) || index < 1) {
193
148
  return { numeric: true, error: t('Agent Preset 序号无效,请先执行 /presetlist。') };
194
149
  }
195
- const snapshot = loadSnapshot(state, key);
150
+ const snapshot = LIST_SNAPSHOTS.load(state, key);
196
151
  if (!snapshot) {
197
152
  return { numeric: true, error: t('请先执行 /presetlist,再按列表序号选择 Agent Preset。') };
198
153
  }
@@ -260,7 +215,7 @@ export async function runPresetCommand(text, harness, state, key, options = {})
260
215
  if (!/^\/(?:presetlist|presets)[ \t]*$/iu.test(command)) return commandResult(t(PRESET_LIST_USAGE));
261
216
  try {
262
217
  const current = await settings(harness, requestOptions);
263
- saveSnapshot(state, key, current.agentPresetCatalog.items);
218
+ LIST_SNAPSHOTS.save(state, key, current.agentPresetCatalog.items.map((item) => item.id));
264
219
  return commandResult(formatList(current));
265
220
  } catch (error) {
266
221
  return commandResult(presetErrorMessage(error, 'list'));
@@ -276,8 +276,8 @@ export class OutboundArtifactRegistry {
276
276
  }
277
277
 
278
278
  /**
279
- * Bind one channel request to the Turn it starts. This owns cleanup only:
280
- * it never changes tool visibility or decides whether a file may be sent.
279
+ * Bind one channel delivery consumer to the Turn its request starts.
280
+ * Only this consumer can retain files for delivery after the Turn ends.
281
281
  */
282
282
  openConsumer(sessionId, promptRpcId) {
283
283
  if (typeof sessionId !== 'string' || !sessionId
@@ -310,8 +310,24 @@ export class OutboundArtifactRegistry {
310
310
  };
311
311
  }
312
312
 
313
- /** Observe durable Session events solely to terminate unclaimed snapshots. */
314
- observeSessionEvent(session, event) {
313
+ /** The Host claims input before assembling the first step's prompt. */
314
+ observeClaimedMessage(session, message, turn) {
315
+ const sessionId = sessionIdOf(session);
316
+ if (!sessionId || !Number.isInteger(turn) || turn < 0
317
+ || this.#currentTurn(session) !== turn) return;
318
+ this.#bindConsumer(sessionId, message?.source?.rpcId, turn);
319
+ }
320
+
321
+ #bindConsumer(sessionId, rpcId, turn) {
322
+ if (typeof rpcId !== 'string' || !rpcId) return;
323
+ const consumer = this.#consumersByPrompt.get(promptKey(sessionId, rpcId));
324
+ if (!consumer || consumer.released) return;
325
+ consumer.turn = turn;
326
+ this.#consumersByTurn.set(turnKey(sessionId, turn), consumer);
327
+ }
328
+
329
+ /** Observe durable Session events to bind delivery ownership and clean up. */
330
+ observeSessionEvent(session, event, { logger } = {}) {
315
331
  const sessionId = sessionIdOf(session);
316
332
  if (!sessionId || !event || typeof event !== 'object') return;
317
333
  if (event.type === 'turn/start') {
@@ -321,21 +337,50 @@ export class OutboundArtifactRegistry {
321
337
  }
322
338
  if (event.type === 'user/message') {
323
339
  const rpcId = event.data?.source?.rpcId;
324
- const turn = this.#openTurns.get(sessionId) ?? currentTurn({ session });
340
+ const turn = this.#currentTurn(session);
325
341
  if (typeof rpcId !== 'string' || !rpcId || !Number.isInteger(turn)) return;
326
342
  this.#openTurns.set(sessionId, turn);
327
- const consumer = this.#consumersByPrompt.get(promptKey(sessionId, rpcId));
328
- if (!consumer || consumer.released) return;
329
- consumer.turn = turn;
330
- this.#consumersByTurn.set(turnKey(sessionId, turn), consumer);
343
+ this.#bindConsumer(sessionId, rpcId, turn);
331
344
  return;
332
345
  }
333
346
  if (event.type !== 'turn/end') return;
334
347
  const turn = event.data?.turn;
335
348
  if (!Number.isInteger(turn) || turn < 0) return;
336
- if (this.#openTurns.get(sessionId) === turn) this.#openTurns.delete(sessionId);
349
+ // Retain the observed closed state so a stale Session snapshot cannot
350
+ // reopen a Turn while its consumer is still waiting to claim the files.
351
+ if (this.#currentTurn(session) === turn) this.#openTurns.set(sessionId, null);
352
+ if (!this.#consumerForTurn(sessionId, turn)) {
353
+ const keyForTurn = turnKey(sessionId, turn);
354
+ const artifactCount = (this.#turns.get(keyForTurn)?.size ?? 0)
355
+ + (this.#stagedTurns.get(keyForTurn)?.size ?? 0);
356
+ this.discard(sessionId, turn);
357
+ if (artifactCount > 0) {
358
+ logger?.warn?.('[dsh-im] discarded files without an active IM delivery consumer:', {
359
+ sessionId, turn, artifactCount,
360
+ });
361
+ }
362
+ }
363
+ }
364
+
365
+ #currentTurn(session) {
366
+ const sessionId = sessionIdOf(session);
367
+ return this.#openTurns.has(sessionId)
368
+ ? this.#openTurns.get(sessionId)
369
+ : currentTurn({ session });
370
+ }
371
+
372
+ #consumerForTurn(sessionId, turn) {
337
373
  const consumer = this.#consumersByTurn.get(turnKey(sessionId, turn));
338
- if (!consumer || consumer.released) this.discard(sessionId, turn);
374
+ return consumer && !consumer.released && consumer.turn === turn
375
+ && this.#consumersByPrompt.get(promptKey(sessionId, consumer.promptRpcId)) === consumer
376
+ ? consumer : null;
377
+ }
378
+
379
+ /** Prompt guidance follows the active Turn's actual delivery owner. */
380
+ hasActiveConsumer(session) {
381
+ const turn = this.#currentTurn(session);
382
+ return Number.isInteger(turn)
383
+ && this.#consumerForTurn(sessionIdOf(session), turn) !== null;
339
384
  }
340
385
 
341
386
  /** A disposed Session cannot have another channel consumer claim its files. */
@@ -371,7 +416,7 @@ export class OutboundArtifactRegistry {
371
416
  const agent = exec?.agent;
372
417
  const sessionId = agent?.session?.header?.id;
373
418
  const workspace = agent?.session?.header?.cwd;
374
- const turn = this.#openTurns.get(sessionId) ?? currentTurn(agent);
419
+ const turn = this.#currentTurn(agent?.session);
375
420
  if (typeof sessionId !== 'string' || !sessionId
376
421
  || typeof workspace !== 'string' || !workspace || turn === null) {
377
422
  throw artifactError(
@@ -380,8 +425,25 @@ export class OutboundArtifactRegistry {
380
425
  );
381
426
  }
382
427
 
428
+ const consumer = this.#consumerForTurn(sessionId, turn);
429
+ const unavailable = () => artifactError(
430
+ 'artifact-consumer-required',
431
+ 'No active IM delivery consumer owns this turn. The file was not queued. Use the host present tool if available to return the file in this conversation.',
432
+ );
433
+ if (!consumer) throw unavailable();
434
+
383
435
  const snapshot = await snapshotFile(workspace, requestedPath, exec?.signal);
384
436
  const { storagePath, ...snapshotMetadata } = snapshot;
437
+ // Copying and hashing yield to cancellation, disposal and Turn completion.
438
+ // Never leave a late snapshot behind after its delivery owner has gone.
439
+ try {
440
+ exec?.signal?.throwIfAborted();
441
+ if (this.#currentTurn(agent?.session) !== turn
442
+ || this.#consumerForTurn(sessionId, turn) !== consumer) throw unavailable();
443
+ } catch (error) {
444
+ await unlink(storagePath).catch(() => undefined);
445
+ throw error;
446
+ }
385
447
  const artifact = Object.freeze({
386
448
  kind: ARTIFACT_KIND,
387
449
  schemaVersion: 1,
@@ -578,7 +640,7 @@ export function createOutboundArtifactTool({ registry = outboundArtifactRegistry
578
640
  };
579
641
  const definition = Object.freeze({
580
642
  name: OUTBOUND_ARTIFACT_TOOL,
581
- description: 'Register a readable file or generated image for delivery through the current conversation after this turn. Existing and newly created files are both valid. Success means queued, not sent; do not claim the user has received the file.',
643
+ description: 'Register a readable file or generated image for delivery through the current IM conversation after this turn. Requires an active IM delivery consumer; in Web, Desktop, or CLI conversations use the host present tool if available. Existing and newly created files are both valid. Success means queued, not sent; do not claim the user has received the file.',
582
644
  parameters: {
583
645
  type: 'object',
584
646
  additionalProperties: false,
@@ -643,20 +705,28 @@ export function createOutboundArtifactTool({ registry = outboundArtifactRegistry
643
705
  return Object.freeze({ definition, onResult });
644
706
  }
645
707
 
646
- /** Register the file-return tool without a per-request Gate. */
708
+ /** Register the file-return tool with guidance scoped to an owned IM Turn. */
647
709
  export function installOutboundArtifactTool(ctx, { registry = outboundArtifactRegistry } = {}) {
648
710
  if (typeof ctx?.tools?.register !== 'function'
649
- || typeof ctx?.systemPrompt?.section !== 'function'
711
+ || typeof ctx?.systemPrompt?.context !== 'function'
650
712
  || typeof ctx?.on !== 'function') return false;
713
+ const logger = typeof ctx.logger === 'function'
714
+ ? ctx.logger('dsh-im:outbound-artifact')
715
+ : (ctx.logger ?? console);
651
716
  const tool = createOutboundArtifactTool({ registry });
652
717
  ctx.tools.register(tool.definition);
653
718
  ctx.on('tools/result', tool.onResult);
654
- ctx.on('session/event', (session, event) => registry.observeSessionEvent(session, event));
719
+ ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => {
720
+ registry.observeClaimedMessage(agent?.session, message, turn);
721
+ }, { global: true });
722
+ ctx.on('session/event', (session, event) => registry.observeSessionEvent(session, event, { logger }));
655
723
  ctx.on('session/disposed', (session) => registry.disposeSession(session));
656
- ctx.systemPrompt.section({
724
+ ctx.systemPrompt.context({
657
725
  name: 'dsh-im:return-file',
658
726
  order: 115,
659
- text: `When the user asks to receive a file or generated image, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery. This tool only registers the file; the channel uploads and sends it after your turn finishes. In your reply say the file is prepared or queued, never that it has already been sent or received. The channel reports delivery failures separately.`,
727
+ text: (assembly) => registry.hasActiveConsumer(assembly?.agent?.session)
728
+ ? `When the user asks to receive a file or generated image, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery. This tool only registers the file; the channel uploads and sends it after your turn finishes. In your reply say the file is prepared or queued, never that it has already been sent or received. The channel reports delivery failures separately.`
729
+ : '',
660
730
  });
661
731
  return true;
662
732
  }
@@ -1,5 +1,4 @@
1
1
  import { extractConnectionEvidence, createConnectionDiagnostics, atConnectionStage } from '../shared/connection-error.mjs';
2
- import { randomInt } from 'node:crypto';
3
2
 
4
3
  import { createEditableMessageStream } from '../shared/editable-message-stream.mjs';
5
4
  import { createTextDeliveryBlock } from '../shared/semantic/delivery.mjs';
@@ -357,6 +356,11 @@ function telegramMessageId(value) {
357
356
  return Number.isSafeInteger(value?.message_id) ? String(value.message_id) : null;
358
357
  }
359
358
 
359
+ function telegramMessageUnchanged(error) {
360
+ return error?.providerCode === 400
361
+ && /^(?:Bad Request: )?message is not modified(?::|$)/iu.test(error?.message ?? '');
362
+ }
363
+
360
364
  function telegramFailure(error) {
361
365
  const providerCode = Number(error?.providerCode);
362
366
  const status = Number(error?.status);
@@ -687,6 +691,9 @@ export class TelegramBotClient {
687
691
  messageId: placeholderMessageId,
688
692
  text: chunks[0],
689
693
  signal: this.#signal,
694
+ }).catch((error) => {
695
+ // The final answer may already be visible from the last progress edit.
696
+ if (!telegramMessageUnchanged(error)) throw error;
690
697
  });
691
698
  firstUnsent = 1;
692
699
  } catch (error) {
@@ -814,6 +821,8 @@ export class TelegramBotClient {
814
821
  messageId,
815
822
  richMessage: { markdown: chunks[0].markdown },
816
823
  signal: this.#signal,
824
+ }).catch((error) => {
825
+ if (!telegramMessageUnchanged(error)) throw error;
817
826
  });
818
827
  } catch (error) {
819
828
  const failure = telegramFailure(error);
@@ -869,31 +878,8 @@ export class TelegramBotClient {
869
878
  }
870
879
 
871
880
  async openDeliveryStream(target) {
872
- if (target.chatType === 'private') {
873
- const draftId = randomInt(1, 2_147_483_647);
874
- const updateDraft = async (block) => {
875
- const richMessage = { markdown: toTelegramRichMarkdown(block.text) };
876
- await this.#api.sendRichMessageDraft({
877
- chatId: target.chatId,
878
- draftId,
879
- richMessage,
880
- messageThreadId: target.messageThreadId,
881
- signal: this.#signal,
882
- });
883
- return deliveryResult('telegram-rich-draft', []);
884
- };
885
- const stream = new TelegramDeliveryStream({
886
- update: updateDraft,
887
- finish: (block) => this.#sendRich(target, block),
888
- fail: (block) => this.#sendPlain(target, block.text),
889
- presentation: 'telegram-rich-draft',
890
- keepalive: true,
891
- logger: this.#logger,
892
- });
893
- await stream.update(createTextDeliveryBlock(t('正在处理…'), 'plain'));
894
- return stream;
895
- }
896
-
881
+ // Real messages keep the Android composer available for /stop and /steer;
882
+ // private-chat Rich Drafts can block sending for the whole turn (#276).
897
883
  const placeholder = await this.#api.sendMessage({
898
884
  chatId: target.chatId,
899
885
  text: t('正在处理…'),