@xmanrui/dsh-im 4.35.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.35.0",
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": [
@@ -488,6 +488,7 @@ export class QqHarnessBridge {
488
488
  this.#replyTimeoutMs = replyTimeoutMs;
489
489
  this.#signal = signal;
490
490
  this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
491
+ finalAnswerOnly: true,
491
492
  deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
492
493
  });
493
494
  this.#fetchImpl = fetchImpl;
@@ -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('工具'))
@@ -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
  }
@@ -380,6 +380,11 @@ export class AssistantTextAccumulator {
380
380
  #steps = new Map();
381
381
  #legacyText = '';
382
382
 
383
+ clear() {
384
+ this.#steps.clear();
385
+ this.#legacyText = '';
386
+ }
387
+
383
388
  appendDelta(step, index, text) {
384
389
  if (typeof text !== 'string' || !text) return;
385
390
  const stepNumber = Number.isSafeInteger(step) ? step : 0;
@@ -415,6 +420,22 @@ export class AssistantTextAccumulator {
415
420
  }
416
421
  }
417
422
 
423
+ /** Keep answer text after the last tool boundary, shared by live and recovered replies. */
424
+ export function accumulateFinalAssistantText(accumulator, event) {
425
+ if (event.type === 'tool/call') {
426
+ accumulator.clear();
427
+ } else if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') {
428
+ accumulator.appendDelta(event.data.step, event.data.chunk.index, event.data.chunk.text);
429
+ } else if (event.type === 'assistant/message') {
430
+ const content = event.data?.message?.content;
431
+ const text = textFromHarnessContent(content);
432
+ // Canonical tool messages can arrive before or after the separate call.
433
+ // An explicitly empty final message must not revive an earlier preamble.
434
+ if (!text || content.some((part) => part?.type === 'tool-call')) accumulator.clear();
435
+ else accumulator.setCanonical(event.data.step, text);
436
+ }
437
+ }
438
+
418
439
  function nonEmptyText(value) {
419
440
  return typeof value === 'string' && value.trim() ? value.trim() : null;
420
441
  }
@@ -538,6 +559,7 @@ export class HarnessReplyTracker {
538
559
  #openTurn = null;
539
560
  #targetTurn = null;
540
561
  #assistantText = new AssistantTextAccumulator();
562
+ #finalAssistantText = null;
541
563
  #latestText = '';
542
564
  #finished = false;
543
565
  #reason = null;
@@ -548,13 +570,14 @@ export class HarnessReplyTracker {
548
570
  #pendingReasoningChars = 0;
549
571
  #reasoning = false;
550
572
 
551
- constructor({ promptRpcId, afterSeq = -1, reasoning = false }) {
573
+ constructor({ promptRpcId, afterSeq = -1, reasoning = false, finalAnswerOnly = false }) {
552
574
  this.#promptRpcId = promptRpcId;
553
575
  this.#lastSeq = afterSeq;
554
576
  // Reasoning updates are opt-in per consumer: only channels that surface
555
577
  // thinking traces (Telegram thinking mode) subscribe; every other channel
556
578
  // keeps its pre-thinking-traces update stream untouched.
557
579
  this.#reasoning = reasoning === true;
580
+ if (finalAnswerOnly === true) this.#finalAssistantText = new AssistantTextAccumulator();
558
581
  }
559
582
 
560
583
  get finished() {
@@ -570,6 +593,10 @@ export class HarnessReplyTracker {
570
593
  return this.#latestText.trim();
571
594
  }
572
595
 
596
+ get finalAnswer() {
597
+ return this.#finalAssistantText ? this.#finalAssistantText.text : this.answer;
598
+ }
599
+
573
600
  get reason() {
574
601
  return this.#reason;
575
602
  }
@@ -697,6 +724,8 @@ export class HarnessReplyTracker {
697
724
  }
698
725
  if (event.data?.turn !== this.#targetTurn) continue;
699
726
 
727
+ if (this.#finalAssistantText) accumulateFinalAssistantText(this.#finalAssistantText, event);
728
+
700
729
  if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') {
701
730
  const step = event.data?.step ?? 0;
702
731
  const index = event.data.chunk.index ?? 0;
@@ -876,6 +905,14 @@ function harnessTurnSucceeded(reason) {
876
905
  return (nonEmptyText(reason?.kind) ?? nonEmptyText(reason)) === 'completed';
877
906
  }
878
907
 
908
+ function sessionArchivedError(sessionId, cause) {
909
+ const error = new Error('The Session is archived and cannot process this message.', { cause });
910
+ error.code = 'session-archived';
911
+ error.promptAccepted = false;
912
+ error.details = { sessionId };
913
+ return error;
914
+ }
915
+
879
916
  export class HarnessClient {
880
917
  #baseUrl;
881
918
  #apiProxy;
@@ -1265,11 +1302,47 @@ export class HarnessClient {
1265
1302
  }, timeoutMs, options);
1266
1303
  }
1267
1304
 
1305
+ async #isSessionArchived(sessionId, options = {}) {
1306
+ const value = await this.rpc('workspace.list', {}, 30_000, options);
1307
+ if (!Array.isArray(value?.archivedSessionIds)
1308
+ || value.archivedSessionIds.some((id) => typeof id !== 'string' || !id)) {
1309
+ throw new HarnessTransportError('harness-response-invalid', 'workspace.list');
1310
+ }
1311
+ return value.archivedSessionIds.includes(sessionId);
1312
+ }
1313
+
1314
+ // A diagnostic recheck must not hide the original failure or cancellation.
1315
+ async #confirmedArchiveError(sessionId, options, cause) {
1316
+ try {
1317
+ if (await this.#isSessionArchived(sessionId, options)) return sessionArchivedError(sessionId, cause);
1318
+ } catch (error) {
1319
+ if (options?.signal?.aborted) throw options.signal.reason ?? error;
1320
+ }
1321
+ return null;
1322
+ }
1323
+
1324
+ async #sessionRpc(method, payload, timeoutMs, options) {
1325
+ try {
1326
+ return await this.rpc(method, payload, timeoutMs, options);
1327
+ } catch (error) {
1328
+ if (options?.signal?.aborted) throw options.signal.reason ?? error;
1329
+ if (error instanceof HarnessRpcError
1330
+ && ['internal', 'gateway/internal', 'session-not-found', 'session/not-found'].includes(error.code)) {
1331
+ throw await this.#confirmedArchiveError(payload.sessionId, { signal: options?.signal }, error) ?? error;
1332
+ }
1333
+ throw error;
1334
+ }
1335
+ }
1336
+
1268
1337
  async sessionExists(sessionId, options = {}) {
1338
+ // Archived Sessions still exist. Returning false here would silently bind
1339
+ // the conversation to a new Session; reading their active log can also fail.
1340
+ if (await this.#isSessionArchived(sessionId, options)) return true;
1269
1341
  try {
1270
- await this.rpc('session.history', { sessionId, maxMessages: 1 }, 30_000, options);
1342
+ await this.#sessionRpc('session.history', { sessionId, maxMessages: 1 }, 30_000, options);
1271
1343
  return true;
1272
1344
  } catch (error) {
1345
+ if (error.code === 'session-archived') return true;
1273
1346
  if (error instanceof HarnessRpcError && error.code === 'session-not-found') return false;
1274
1347
  throw error;
1275
1348
  }
@@ -1641,7 +1714,8 @@ export class HarnessClient {
1641
1714
  const inboundFiles = Array.isArray(options.files) ? options.files.filter(Boolean) : [];
1642
1715
  const inboundImages = Array.isArray(options.images) ? options.images.filter(Boolean) : [];
1643
1716
  await this.ensureRunning({ signal });
1644
- const before = await this.rpc(
1717
+ if (await this.#isSessionArchived(sessionId, { signal })) throw sessionArchivedError(sessionId);
1718
+ const before = await this.#sessionRpc(
1645
1719
  'session.history',
1646
1720
  { sessionId, maxMessages: 1 },
1647
1721
  30_000,
@@ -1650,7 +1724,10 @@ export class HarnessClient {
1650
1724
  const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1));
1651
1725
  const promptRpcId = `${this.#rpcIdPrefix}-${randomUUID()}`;
1652
1726
  const releasePromptInputOrigin = registerImInputOrigin(this.#interactionRegistry, promptRpcId);
1653
- const tracker = new HarnessReplyTracker({ promptRpcId, afterSeq: baselineSeq, reasoning });
1727
+ const tracker = new HarnessReplyTracker({
1728
+ promptRpcId, afterSeq: baselineSeq, reasoning, finalAnswerOnly: options.finalAnswerOnly,
1729
+ });
1730
+ let checkedBlockedSeq = baselineSeq;
1654
1731
  let lastProgressAt = Date.now();
1655
1732
  let lastPollSeq = tracker.lastSeq;
1656
1733
  let progressTail = Promise.resolve();
@@ -1808,7 +1885,7 @@ export class HarnessClient {
1808
1885
  // message that merely looks like a guidance block is not configuration.
1809
1886
  imSourceGuidance.publish(sessionId, options.sourceGuidance);
1810
1887
  const clientTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
1811
- const sendPrompt = (promptContent) => this.rpc('session.prompt', {
1888
+ const sendPrompt = (promptContent) => this.#sessionRpc('session.prompt', {
1812
1889
  sessionId,
1813
1890
  mode: 'queue',
1814
1891
  content: promptContent,
@@ -1862,7 +1939,7 @@ export class HarnessClient {
1862
1939
  // active until turn/end and can therefore outlive a stalled turn.
1863
1940
  while (true) {
1864
1941
  await sleep(300, signal);
1865
- const history = await this.rpc(
1942
+ const history = await this.#sessionRpc(
1866
1943
  'session.history',
1867
1944
  { sessionId, maxMessages: 50 },
1868
1945
  30_000,
@@ -1877,16 +1954,19 @@ export class HarnessClient {
1877
1954
  if (tracker.finished) {
1878
1955
  turnFinished = true;
1879
1956
  if (!ownership?.stopRequested && !harnessTurnSucceeded(tracker.reason)) {
1880
- throw harnessTurnError(tracker.reason);
1957
+ const failure = harnessTurnError(tracker.reason);
1958
+ if (failure.code === 'turn-blocked') {
1959
+ throw await this.#confirmedArchiveError(sessionId, { signal }, failure) ?? failure;
1960
+ }
1961
+ throw failure;
1881
1962
  }
1882
1963
  // An accepted /stop revokes attachment delivery even when Harness
1883
1964
  // preserved a useful partial text answer for the existing UX.
1884
1965
  const artifactCount = ownership?.stopRequested
1885
1966
  ? 0
1886
1967
  : await deliverArtifacts();
1887
- if (tracker.answer) {
1888
- return tracker.answer;
1889
- }
1968
+ const answer = ownership?.stopRequested ? tracker.answer : tracker.finalAnswer;
1969
+ if (answer) return answer;
1890
1970
  if (artifactCount > 0) return '';
1891
1971
  if (ownership?.stopRequested) throw turnStoppedError();
1892
1972
  if (artifactHandoffError) throw artifactHandoffError;
@@ -1898,8 +1978,30 @@ export class HarnessClient {
1898
1978
  throw harnessTurnError(tracker.reason);
1899
1979
  }
1900
1980
 
1981
+ if (tracker.turn === null && !ownership?.stopRequested) {
1982
+ // An admission gate can block before user/message supplies rpcId.
1983
+ // This event only prompts a Session-level archive check; it never
1984
+ // grants ownership of another caller's turn, artifacts or approvals.
1985
+ const blockedSeq = Math.max(checkedBlockedSeq, ...(history.events ?? [])
1986
+ .map((entry) => entry?.event ?? entry)
1987
+ .filter((event) => event?.type === 'turn/end'
1988
+ && (event.data?.reason?.kind ?? event.data?.reason) === 'blocked'
1989
+ && Number.isSafeInteger(event.seq))
1990
+ .map((event) => event.seq));
1991
+ if (blockedSeq > checkedBlockedSeq) {
1992
+ checkedBlockedSeq = blockedSeq;
1993
+ const archived = await this.#confirmedArchiveError(sessionId, { signal });
1994
+ if (archived) throw archived;
1995
+ }
1996
+ }
1997
+
1901
1998
  if (Date.now() - lastProgressAt < timeoutMs) continue;
1902
1999
 
2000
+ if (!ownership?.stopRequested) {
2001
+ const archived = await this.#confirmedArchiveError(sessionId, { signal });
2002
+ if (archived) throw archived;
2003
+ }
2004
+
1903
2005
  let running = false;
1904
2006
  try {
1905
2007
  running = await this.isSessionRunning(sessionId, { signal });
@@ -1917,6 +2019,10 @@ export class HarnessClient {
1917
2019
  throw timeoutError;
1918
2020
  }
1919
2021
  } catch (error) {
2022
+ if (error.code === 'session-archived') {
2023
+ error.promptAccepted = promptAccepted;
2024
+ error.details = { ...error.details, promptRpcId, baselineSeq, turn: tracker.turn, lastSeq: tracker.lastSeq };
2025
+ }
1920
2026
  if (error instanceof HarnessTurnError) {
1921
2027
  error.details ??= {
1922
2028
  sessionId, promptRpcId, baselineSeq, turn: tracker.turn, lastSeq: tracker.lastSeq,
@@ -78,8 +78,12 @@ export default {
78
78
  'The model rejected the current content. Revise the request and try again.',
79
79
  '模型达到输出长度上限,但没有生成可显示的结果。请缩小任务范围后重试。':
80
80
  'The model reached its output limit without producing a displayable result. Reduce the task scope and try again.',
81
- '任务正在等待无法在当前渠道完成的操作。请在 DeepSeek Harness 中处理后再试。':
82
- 'The task is waiting for an action that cannot be completed in this channel. Handle it in DeepSeek Harness, then try again.',
81
+ '本轮处理被阻止,未能完成。请在 DSH 中查看会话状态和相关提示。':
82
+ 'This turn was blocked and did not finish. Check the Session status and related notices in DSH.',
83
+ '当前会话已归档,无法继续处理。请在 DSH 中取消归档,或发送 /new 创建新会话。':
84
+ 'The current Session is archived and cannot continue. Unarchive it in DSH, or send /new to create a new Session.',
85
+ '本条消息可能仍在会话队列中,请先到 DSH 查看状态,避免重复提交。':
86
+ 'This message may still be queued in the Session. Check its status in DSH before submitting it again.',
83
87
  '任务被意外中断,本次未完成。请重试。':
84
88
  'The task was unexpectedly interrupted and did not finish. Please try again.',
85
89
  '当前会话已不存在。请发送 /new 创建新会话后重试。':
@@ -67,11 +67,13 @@ const FAILURE_MESSAGES = Object.freeze({
67
67
  MODEL_OUTPUT_LIMIT:
68
68
  '模型达到输出长度上限,但没有生成可显示的结果。请缩小任务范围后重试。',
69
69
  TURN_BLOCKED:
70
- '任务正在等待无法在当前渠道完成的操作。请在 DeepSeek Harness 中处理后再试。',
70
+ '本轮处理被阻止,未能完成。请在 DSH 中查看会话状态和相关提示。',
71
71
  TURN_INTERRUPTED:
72
72
  '任务被意外中断,本次未完成。请重试。',
73
73
  SESSION_NOT_FOUND:
74
74
  '当前会话已不存在。请发送 /new 创建新会话后重试。',
75
+ SESSION_ARCHIVED:
76
+ '当前会话已归档,无法继续处理。请在 DSH 中取消归档,或发送 /new 创建新会话。',
75
77
  SESSION_BUSY:
76
78
  '当前会话仍在处理上一项任务。请等待完成,或发送 /stop 后重试。',
77
79
  SESSION_STALE:
@@ -137,6 +139,7 @@ function failureCode(error) {
137
139
  if (code === 'turn-blocked') return 'TURN_BLOCKED';
138
140
  if (['turn-interrupted', 'turn-aborted'].includes(code)) return 'TURN_INTERRUPTED';
139
141
  if (code === 'session-not-found') return 'SESSION_NOT_FOUND';
142
+ if (code === 'session-archived') return 'SESSION_ARCHIVED';
140
143
  if (code === 'agent-busy') return 'SESSION_BUSY';
141
144
  if (code === 'workspace-session-stale') return 'SESSION_STALE';
142
145
  if (code.startsWith('workspace-')) return 'WORKSPACE_UNAVAILABLE';
@@ -220,7 +223,8 @@ export function classifyMessageFailure(error, {
220
223
  reason: safeReason ?? safeFailureReason(error?.code) ?? code,
221
224
  message: typeof userMessage === 'string' && userMessage.trim()
222
225
  ? userMessage.trim()
223
- : t(FAILURE_MESSAGES[code]),
226
+ : t(FAILURE_MESSAGES[code]) + (code === 'SESSION_ARCHIVED' && error?.promptAccepted === true
227
+ ? `\n${t('本条消息可能仍在会话队列中,请先到 DSH 查看状态,避免重复提交。')}` : ''),
224
228
  referenceId: safeReferenceId(referenceId),
225
229
  at: Number.isFinite(at) ? at : Date.now(),
226
230
  });