@xmanrui/dsh-im 4.38.1 → 4.38.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.38.1",
3
+ "version": "4.38.2",
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": [
@@ -4,6 +4,8 @@ import { normalizeDiagnosticDetails } from '../../src/channels/shared/diagnostic
4
4
  import { CONFIG_ISSUE_LABELS } from '../../src/channels/shared/diagnostic-details.mjs';
5
5
 
6
6
  const STAGE_LABELS = {
7
+ 'session.prepare': '准备会话', 'prompt.prepare': '准备消息', 'interaction.open': '建立交互通道',
8
+ 'prompt.submit': '提交消息', 'reply.wait': '等待处理结果',
7
9
  'credential.verify': '验证凭据', 'permission.check': '检查权限', 'sdk.load': '加载渠道组件',
8
10
  operation: '执行渠道操作', 'message.receive': '接收消息', 'message.send': '发送消息', 'connection.test': '发送测试消息',
9
11
  'startup.load': '加载渠道配置', 'qr.begin': '申请二维码', 'qr.encode': '生成二维码图片', 'qr.poll': '查询扫码状态',
@@ -15,6 +17,11 @@ const STAGE_LABELS = {
15
17
  'connection.poll': '监测连接与消息', 'connection.stop': '停止连接', 'status.read': '读取连接状态',
16
18
  rollback: '恢复原账号状态', 'management.request': '访问 DSH 管理接口',
17
19
  };
20
+ const SUBMISSION_LABELS = { 'not-submitted': '未提交', accepted: '已确认接收', unknown: '提交结果未知' };
21
+ const STREAM_REASON_LABELS = {
22
+ 'stream-closed-before-open': '打开前关闭', 'stream-failed-before-open': '打开前失败',
23
+ 'stream-failed': '通道连接失败', unknown: '暂未识别',
24
+ };
18
25
 
19
26
  export function provisioningErrorTitle(error) {
20
27
  const stage = error?.details?.stage;
@@ -30,7 +37,7 @@ export function formatConnectionDiagnostic(value) {
30
37
  return [
31
38
  localizeText(error.message), details.hint ? localizeText(details.hint) : null,
32
39
  localizeText('错误码') + ': ' + error.code,
33
- ...['channel', 'operation', 'stage', 'method', 'transport', 'reason', 'reasons', 'host', 'durationMs', 'timeoutMs', 'nodeVersion', 'dshVersion', 'platform', 'dependencies', 'proxyConfigured', 'truncated', 'httpStatus', 'providerCode', 'resource', 'file', 'field', 'issue', 'referenceId', 'occurredAt', 'rollback', 'pluginVersion']
40
+ ...['channel', 'operation', 'stage', 'method', 'transport', 'submissionState', 'attempts', 'lastFailureReason', 'reason', 'reasons', 'host', 'durationMs', 'timeoutMs', 'nodeVersion', 'dshVersion', 'platform', 'dependencies', 'proxyConfigured', 'truncated', 'httpStatus', 'providerCode', 'resource', 'file', 'field', 'issue', 'referenceId', 'occurredAt', 'rollback', 'pluginVersion']
34
41
  .filter(field => details[field] !== undefined)
35
42
  .map(field => `${field}: ${typeof details[field] === 'object' ? JSON.stringify(details[field]) : details[field]}`),
36
43
  ].filter(Boolean).join('\n');
@@ -51,7 +58,10 @@ export function ConnectionError({ error: value, warning = false, showMessage = t
51
58
  };
52
59
  const fields = [
53
60
  ['错误码', error.code], ['失败阶段', localizeText(STAGE_LABELS[details.stage] ?? details.stage ?? '')],
54
- ['RPC 方法', details.method], ['调用方式', details.transport === 'host-api' ? localizeText('宿主 API(进程内)') : details.transport === 'http' ? 'HTTP' : undefined],
61
+ ['RPC 方法', details.method], ['调用方式', details.transport === 'host-api' ? localizeText('宿主 API(进程内)') : details.transport === 'http' ? 'HTTP' : details.transport === 'websocket' ? 'WebSocket' : undefined],
62
+ ['消息提交状态', localizeText(SUBMISSION_LABELS[details.submissionState] ?? '')],
63
+ ['连接尝试次数', details.attempts],
64
+ ['最近一次连接失败', localizeText(STREAM_REASON_LABELS[details.lastFailureReason] ?? details.lastFailureReason ?? '')],
55
65
  ['底层原因', details.reason === 'unknown' ? localizeText('暂未识别') : details.reason], ['多个原因', details.reasons?.join(', ')], ['服务域名', details.host], ['请求耗时(毫秒)', details.durationMs], ['超时阈值(毫秒)', details.timeoutMs], ['Node 版本', details.nodeVersion], ['DSH 版本', details.dshVersion], ['依赖版本', details.dependencies ? Object.entries(details.dependencies).map(([name, version]) => `${name}: ${version}`).join(', ') : undefined], ['代理已配置', details.proxyConfigured], ['原因链已截断', details.truncated], ['运行平台', details.platform], ['HTTP 状态', details.httpStatus], ['服务返回码', details.providerCode],
56
66
  ['配置文件', details.file], ['配置字段', details.field],
57
67
  ['校验原因', details.issue ? localizeText(CONFIG_ISSUE_LABELS[details.issue]) : undefined],
@@ -116,7 +116,7 @@ export async function createProductionController(ctx, config = {}, internals = {
116
116
  ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
117
117
  imageInputPolicy: () => getImageInputSettingsStore(config).get(),
118
118
  });
119
- const modelCatalog = () => listModelCatalog(harness);
119
+ const modelCatalog = (options) => listModelCatalog(harness, options);
120
120
  const coreController = new Controller({
121
121
  deviceAuth,
122
122
  credentials: ctx.credentials,
@@ -219,7 +219,7 @@ export async function createProductionController(ctx, config = {}, internals = {
219
219
  const proxyEnv = internals.proxyEnv ?? process.env;
220
220
  const wsAgent = createFeishuWebSocketAgent(proxyEnv, internals.createProxyAgent);
221
221
 
222
- const modelCatalog = () => listModelCatalog(harness);
222
+ const modelCatalog = (options) => listModelCatalog(harness, options);
223
223
  const coreController = new Controller({
224
224
  logger,
225
225
  registerApp: (options) => lark.registerApp(options),
@@ -127,7 +127,7 @@ export async function createProductionController(ctx, config = {}, internals = {
127
127
  ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
128
128
  ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
129
129
  });
130
- const modelCatalog = () => listModelCatalog(harness);
130
+ const modelCatalog = (options) => listModelCatalog(harness, options);
131
131
  const knownCommandNames = new Set(
132
132
  commandsForChannel('matrix').flatMap((entry) => [entry.name, ...entry.aliases.map((alias) => alias.name)]),
133
133
  );
@@ -108,7 +108,7 @@ export async function createProductionController(ctx, config = {}, internals = {
108
108
  ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
109
109
  imageInputPolicy: () => getImageInputSettingsStore(config).get(),
110
110
  });
111
- const modelCatalog = () => listModelCatalog(harness);
111
+ const modelCatalog = (options) => listModelCatalog(harness, options);
112
112
  const coreController = new Controller({
113
113
  qrAuth,
114
114
  credentials: ctx.credentials,
@@ -126,7 +126,7 @@ export async function createTokenProductionController(ctx, config, internals, de
126
126
  ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
127
127
  imageInputPolicy: () => getImageInputSettingsStore(config).get(),
128
128
  });
129
- const modelCatalog = () => listModelCatalog(harness);
129
+ const modelCatalog = (options) => listModelCatalog(harness, options);
130
130
  // Assigned just below. A transport may need to write a rotated token back
131
131
  // through the controller, which is only constructible after createRuntime is
132
132
  // defined, so the reference is held in a slot rather than captured directly.
@@ -95,7 +95,7 @@ export async function createProductionController(ctx, config = {}, internals = {
95
95
  ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
96
96
  imageInputPolicy: () => getImageInputSettingsStore(config).get(),
97
97
  });
98
- const modelCatalog = () => listModelCatalog(harness);
98
+ const modelCatalog = (options) => listModelCatalog(harness, options);
99
99
  const coreController = new ResolvedController({
100
100
  credentials: ctx.credentials,
101
101
  configStore: observedConfigStore,
@@ -110,7 +110,7 @@ export async function createProductionController(ctx, config = {}, internals = {
110
110
  ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
111
111
  imageInputPolicy: () => getImageInputSettingsStore(config).get(),
112
112
  });
113
- const modelCatalog = () => listModelCatalog(harness);
113
+ const modelCatalog = (options) => listModelCatalog(harness, options);
114
114
  const coreController = new Controller({
115
115
  qrAuth,
116
116
  credentials: ctx.credentials,
@@ -144,7 +144,7 @@ export async function createProductionController(ctx, config = {}, internals = {
144
144
  ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
145
145
  imageInputPolicy: () => getImageInputSettingsStore(config).get(),
146
146
  });
147
- const modelCatalog = () => listModelCatalog(harness);
147
+ const modelCatalog = (options) => listModelCatalog(harness, options);
148
148
  const coreController = new Controller({
149
149
  credentials: ctx.credentials,
150
150
  configStore: observedConfigStore,
@@ -150,7 +150,7 @@ export async function createProductionController(ctx, config = {}, internals = {
150
150
  ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
151
151
  imageInputPolicy: () => getImageInputSettingsStore(config).get(),
152
152
  });
153
- const modelCatalog = () => listModelCatalog(harness);
153
+ const modelCatalog = (options) => listModelCatalog(harness, options);
154
154
  const coreController = new Controller({
155
155
  api,
156
156
  credentials: ctx.credentials,
@@ -116,7 +116,7 @@ export async function createProductionController(ctx, config = {}, internals = {
116
116
  ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
117
117
  imageInputPolicy: () => getImageInputSettingsStore(config).get(),
118
118
  });
119
- const modelCatalog = () => listModelCatalog(harness);
119
+ const modelCatalog = (options) => listModelCatalog(harness, options);
120
120
  const coreController = new Controller({
121
121
  configStore: observedConfigStore,
122
122
  authPath: paths.authPath,
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
- import { activeHarnessInteractionOwner, hasActiveHarnessInteractionOwner, harnessToolCallFromEvent, HarnessInteractionError } from '../../src/channels/shared/harness-client.mjs';
3
+ import { activeHarnessInteractionOwner, clearModelCatalogCache, hasActiveHarnessInteractionOwner, harnessToolCallFromEvent, HarnessInteractionError } from '../../src/channels/shared/harness-client.mjs';
4
4
  import { extractConnectionEvidence } from '../../src/channels/shared/connection-error.mjs';
5
5
 
6
6
  const modernApis = new WeakMap();
@@ -857,6 +857,7 @@ class ModernHarnessApi {
857
857
  dispose() {
858
858
  if (this.#disposed) return;
859
859
  this.#disposed = true;
860
+ clearModelCatalogCache(this);
860
861
  for (const subscription of [...this.#mux]) subscription.close();
861
862
  this.#assistantStreams.clear();
862
863
  for (const pending of [...this.#pendingApprovals.values()]) pending.settle('cancelled');
@@ -210,6 +210,19 @@ const STEP_PUSH_POST_CHUNK_MAX_BYTES = 24_000;
210
210
  const STEP_STREAM_PATCH_MIN_INTERVAL_MS = 1_000;
211
211
  /** Confirm missed boundaries from history; elapsed time is never completion. */
212
212
  const MIRROR_CHECK_MS = 30_000;
213
+ /** Retire orphan snapshots outside Feishu's message update window. */
214
+ const MIRROR_STALE_MS = 14 * 24 * 60 * 60_000;
215
+ const MIRROR_RECOVERY_MAX_FAILURES = 20;
216
+
217
+ function mirrorErrorText(error) {
218
+ return [error?.response?.data?.msg, error?.msg, error?.message]
219
+ .filter((value) => typeof value === 'string' && value.trim()).join('; ') || String(error);
220
+ }
221
+
222
+ function isExpiredMirrorError(error) {
223
+ return /Message has expired when updating message|Message can only be updated within fourteen days/i
224
+ .test(mirrorErrorText(error));
225
+ }
213
226
  /** One answer chunk inside the streaming card: small enough that the block
214
227
  * splitter can always distribute blocks across sealed/live cards. */
215
228
  const STEP_STREAM_ANSWER_CHUNK_MAX_BYTES = 18_000;
@@ -849,27 +862,48 @@ export class FeishuHarnessBridge {
849
862
  }
850
863
 
851
864
  /** Recover only a known finished turn, using its last successful card JSON. */
852
- async #sealOrphanMirrors() {
853
- for (const [key, entry] of this.#state.mirrorEntries?.() ?? []) {
854
- if (!entry?.chatId || !entry.sessionId || !Number.isSafeInteger(entry.turn)
855
- || !Array.isArray(entry.cardIds) || !entry.cardIds.length) continue;
856
- await this.#queueEventTask(entry.sessionId, async () => {
865
+ async #sealOrphanMirrors(onlyKey = null) {
866
+ for (const [key, queuedEntry] of this.#state.mirrorEntries?.() ?? []) {
867
+ if (onlyKey !== null && key !== onlyKey) continue;
868
+ if (!queuedEntry?.sessionId) continue;
869
+ await this.#queueEventTask(queuedEntry.sessionId, async () => {
857
870
  if (this.#signal?.aborted) return;
858
871
  // An adopted turn now owns this record and will finish through its queue.
859
872
  if (this.#sessionSyncTurns.has(key)) return;
873
+ // A queued timer must not resurrect a deleted record or overwrite a
874
+ // newer snapshot/failure count with the entry captured before queuing.
875
+ let entry = this.#state.mirrorEntries?.().find(([entryKey]) => entryKey === key)?.[1];
876
+ if (!entry?.chatId || !entry.sessionId || !Number.isSafeInteger(entry.turn)
877
+ || !Array.isArray(entry.cardIds) || !entry.cardIds.length) return;
878
+ const now = Date.now();
879
+ if (!Number.isFinite(entry.claimedAt) || entry.claimedAt <= 0 || entry.claimedAt > now) {
880
+ entry = { ...entry, claimedAt: now };
881
+ await this.#state.setMirror?.(key, entry);
882
+ }
883
+ if (now - entry.claimedAt >= MIRROR_STALE_MS) {
884
+ await this.#clearMirrorRecovery(key, 'snapshot expired');
885
+ return;
886
+ }
887
+ const failures = Number.isSafeInteger(entry.recoveryFailures) && entry.recoveryFailures > 0
888
+ ? entry.recoveryFailures : 0;
889
+ if (failures >= MIRROR_RECOVERY_MAX_FAILURES) {
890
+ await this.#clearMirrorRecovery(key, 'recovery failure limit reached');
891
+ return;
892
+ }
860
893
  try {
861
894
  const events = await this.#mirrorHistory(entry.sessionId, entry.turn);
895
+ if (this.#signal?.aborted) return;
862
896
  const outcome = extractCompletedTurnAnswer(events, { turn: entry.turn });
863
897
  if (outcome.endSeq < 0) {
864
- this.#scheduleMirrorCheck(`recovery\0${key}`, () => this.#sealOrphanMirrors());
898
+ this.#scheduleMirrorCheck(`recovery\0${key}`, () => this.#sealOrphanMirrors(key));
865
899
  return;
866
900
  }
867
901
  if (this.#restoreMirrorCard(key, entry)) {
868
902
  const result = await this.#finishStepCard(key, {
869
903
  answerText: outcome.text, stopped: outcome.reason !== 'completed',
870
904
  });
871
- if (!result?.ok) throw new Error('Recovered mirror could not deliver its final answer');
872
- await this.#state.clearMirror?.(key);
905
+ if (!result?.ok) throw result?.error ?? new Error('Recovered mirror could not deliver its final answer');
906
+ await this.#clearMirrorRecovery(key);
873
907
  return;
874
908
  }
875
909
  const content = JSON.parse(entry.lastContent);
@@ -881,15 +915,40 @@ export class FeishuHarnessBridge {
881
915
  else elements.push({ tag: 'markdown', content: status });
882
916
  // Earlier chunks are sealed history; update only the live card.
883
917
  await this.#patchStepCard(entry.cardIds.at(-1), JSON.stringify(content));
884
- await this.#state.clearMirror?.(key);
918
+ await this.#clearMirrorRecovery(key);
885
919
  } catch (error) {
886
- this.#logger.warn?.('[dsh-feishu] mirror recovery failed:', error?.message ?? error);
887
- this.#scheduleMirrorCheck(`recovery\0${key}`, () => this.#sealOrphanMirrors());
920
+ if (this.#signal?.aborted) return;
921
+ const current = this.#state.mirrorEntries?.().find(([entryKey]) => entryKey === key)?.[1];
922
+ if (!current) return;
923
+ if (isExpiredMirrorError(error)) {
924
+ await this.#clearMirrorRecovery(key, 'message expired');
925
+ return;
926
+ }
927
+ await this.#state.setMirror?.(key, { ...current, recoveryFailures: failures + 1 });
928
+ if (failures + 1 >= MIRROR_RECOVERY_MAX_FAILURES) {
929
+ await this.#clearMirrorRecovery(key, 'recovery failure limit reached');
930
+ return;
931
+ }
932
+ this.#logger.warn?.('[dsh-feishu] mirror recovery failed:', mirrorErrorText(error),
933
+ { sessionId: entry.sessionId, turn: entry.turn, recoveryFailures: failures + 1 });
934
+ this.#scheduleMirrorCheck(`recovery\0${key}`, () => this.#sealOrphanMirrors(key));
888
935
  }
889
936
  });
890
937
  }
891
938
  }
892
939
 
940
+ async #clearMirrorRecovery(key, reason = null) {
941
+ const timerKey = `recovery\0${key}`;
942
+ clearTimeout(this.#sessionSyncIdleTimers.get(timerKey));
943
+ this.#sessionSyncIdleTimers.delete(timerKey);
944
+ const entry = this.#state.mirrorEntries?.().find(([entryKey]) => entryKey === key)?.[1];
945
+ await this.#state.clearMirror?.(key);
946
+ if (reason && entry) {
947
+ this.#logger.warn?.('[dsh-feishu] mirror recovery stopped:', reason,
948
+ { sessionId: entry.sessionId, turn: entry.turn, recoveryFailures: entry.recoveryFailures ?? 0 });
949
+ }
950
+ }
951
+
893
952
  setGroupResponseMode(value) {
894
953
  this.#groupResponseMode = normalizeFeishuGroupResponseMode(value);
895
954
  }
@@ -4231,10 +4290,12 @@ export class FeishuHarnessBridge {
4231
4290
  mirror.result = { ok: result?.ok === true, text };
4232
4291
  mirror.finishedAt = Date.now();
4233
4292
  // Keep the last successful snapshot on failure so recovery can preserve it.
4234
- if (result?.ok) await this.#state.clearMirror?.(mirror.key);
4235
- else if (recovering) {
4293
+ if (result?.ok) await this.#clearMirrorRecovery(mirror.key);
4294
+ else if (isExpiredMirrorError(result?.error)) {
4295
+ await this.#clearMirrorRecovery(mirror.key, 'message expired');
4296
+ } else if (recovering) {
4236
4297
  this.#sessionSyncTurns.delete(mirror.key);
4237
- this.#scheduleMirrorCheck(`recovery\0${mirror.key}`, () => this.#sealOrphanMirrors());
4298
+ this.#scheduleMirrorCheck(`recovery\0${mirror.key}`, () => this.#sealOrphanMirrors(mirror.key));
4238
4299
  }
4239
4300
  return result?.ok === true;
4240
4301
  }
@@ -4924,13 +4985,14 @@ export class FeishuHarnessBridge {
4924
4985
  card.lastRenderAt = this.#stepPushClock.now();
4925
4986
  } catch (error) {
4926
4987
  card.broken = true;
4988
+ card.lastError = error;
4927
4989
  this.#logger.warn?.(
4928
4990
  '[dsh-feishu] step streaming card render failed; the turn continues without it:',
4929
- error?.message ?? String(error),
4991
+ mirrorErrorText(error),
4930
4992
  );
4931
4993
  if (card.deliveryViaOpenId) {
4932
4994
  this.#logger.warn?.('[dsh-feishu] session-sync mirror card render failed:',
4933
- error?.message ?? String(error),
4995
+ mirrorErrorText(error),
4934
4996
  { sessionId: card.sessionSyncSessionId, turn: card.sessionSyncTurn });
4935
4997
  }
4936
4998
  }
@@ -4940,9 +5002,8 @@ export class FeishuHarnessBridge {
4940
5002
  * Seal the turn's card with a terminal status and the final answer. The
4941
5003
  * streamed answer draft is rewritten in place with the delivery text; when
4942
5004
  * no draft exists the answer is appended. Returns `{ ok, cardIds }` when
4943
- * the answer is visible on the cards, or null when the caller must fall
4944
- * back to the post ladder (absent/broken card, or the answer never reached
4945
- * a successful render). Safe to call twice.
5005
+ * the answer is visible, `{ ok: false, error }` on failure, or null when
5006
+ * no card exists. Callers fall back unless ok is true. Safe to call twice.
4946
5007
  */
4947
5008
  async #finishStepCard(key, { stopped = false, answerText = null } = {}) {
4948
5009
  const card = this.#stepCards.get(key);
@@ -4951,7 +5012,7 @@ export class FeishuHarnessBridge {
4951
5012
  try {
4952
5013
  await card.renderChain?.catch?.(() => {});
4953
5014
  } catch { /* the seal below decides what the user sees */ }
4954
- if (card.broken) return null;
5015
+ if (card.broken) return { ok: false, error: card.lastError };
4955
5016
  const status = stopped ? 'stopped' : 'completed';
4956
5017
  const body = typeof answerText === 'string' ? answerText : '';
4957
5018
  // Whether the answer was already visible from a streamed draft render —
@@ -4982,10 +5043,10 @@ export class FeishuHarnessBridge {
4982
5043
  }
4983
5044
  this.#logger.warn?.(
4984
5045
  '[dsh-feishu] step streaming card finish failed:',
4985
- error?.message ?? String(error),
5046
+ mirrorErrorText(error),
4986
5047
  { sessionId: card.sessionSyncSessionId, turn: card.sessionSyncTurn },
4987
5048
  );
4988
- return null;
5049
+ return { ok: false, error };
4989
5050
  }
4990
5051
  }
4991
5052
 
@@ -5040,7 +5101,10 @@ export class FeishuHarnessBridge {
5040
5101
  data: { content: cardJson },
5041
5102
  }));
5042
5103
  if (response?.code && response.code !== 0) {
5043
- throw new Error(`Feishu step card patch failed: ${response.msg || response.code}`);
5104
+ const error = new Error(`Feishu step card patch failed: ${response.msg || response.code}`);
5105
+ error.code = response.code;
5106
+ error.feishuCode = response.code;
5107
+ throw error;
5044
5108
  }
5045
5109
  }
5046
5110
 
@@ -1469,9 +1469,9 @@ function resolveAgentPresetCatalog(catalog) {
1469
1469
  : normalizeAgentPresetCatalog(value);
1470
1470
  }
1471
1471
 
1472
- function resolveModelCatalog(catalog) {
1472
+ function resolveModelCatalog(catalog, options) {
1473
1473
  if (!catalog) return null;
1474
- const value = typeof catalog === 'function' ? catalog() : catalog;
1474
+ const value = typeof catalog === 'function' ? catalog(options) : catalog;
1475
1475
  return value && typeof value.then === 'function'
1476
1476
  ? value.then(normalizeModelCatalog)
1477
1477
  : normalizeModelCatalog(value);
@@ -2264,7 +2264,7 @@ export function createWorkspaceAwareController(controller, {
2264
2264
  throw error;
2265
2265
  }
2266
2266
  const catalog = model && modelCatalog
2267
- ? await resolveModelCatalog(modelCatalog)
2267
+ ? await resolveModelCatalog(modelCatalog, { fresh: true })
2268
2268
  : null;
2269
2269
  const entry = modelCatalogEntry(catalog, model);
2270
2270
  if (model && (!modelCatalog || !entry)) {
@@ -6,7 +6,7 @@ import { t } from './i18n.mjs';
6
6
  const reported = new WeakMap();
7
7
  const lastReport = new WeakMap();
8
8
  const stages = new WeakMap();
9
- const OWNED_REASONS = { 'connect-timeout': 'timeout', 'telegram-timeout': 'timeout', 'cli-timeout': 'timeout', 'provider-timeout': 'timeout', 'telegram-response-invalid': 'invalid-response', 'office-protocol-mismatch': 'invalid-response' };
9
+ const OWNED_REASONS = { 'harness-interaction-timeout': 'timeout', 'harness-response-invalid': 'invalid-response', 'connect-timeout': 'timeout', 'telegram-timeout': 'timeout', 'cli-timeout': 'timeout', 'provider-timeout': 'timeout', 'telegram-response-invalid': 'invalid-response', 'office-protocol-mismatch': 'invalid-response' };
10
10
  const STANDARD_NAMES = new Set(['Error', 'TypeError', 'SyntaxError', 'AggregateError', 'DOMException', 'AxiosError', 'TimeoutError', 'AbortError']);
11
11
  const KNOWN_MESSAGES = {
12
12
  'discord-401': 'Discord Bot Token 无效,请重新填写。',
@@ -54,20 +54,45 @@ export function extractConnectionEvidence(error) {
54
54
  const allReasons = [...new Set(errors.flatMap(e => e.reason === 'multiple-causes' ? e.reasons ?? [] : [e.reason]).filter(r => r && !['unknown', 'multiple-causes'].includes(r)))];
55
55
  const precise = allReasons.filter(r => !['timeout', 'http-error', 'provider-rejected', 'invalid-response'].includes(r));
56
56
  const reasons = precise.length ? precise : allReasons;
57
- const first = field => chain.map(e => normalizeDiagnosticDetails({ [field]: e[field] ?? e.details?.[field] })[field]).find(v => v !== undefined);
57
+ const first = field => chain.map(e => normalizeDiagnosticDetails({ [field]: stages.get(e)?.[field] ?? e[field] ?? e.details?.[field] })[field]).find(v => v !== undefined);
58
+ const correlation = {};
59
+ for (const field of ['sessionId', 'promptRpcId']) {
60
+ // Only locally attached identifiers belong in logs, never arbitrary RPC details.
61
+ const value = chain.map(e => stages.get(e)?.[field]).find(v => v !== undefined);
62
+ if (typeof value === 'string' && /^[A-Za-z0-9_-]{1,256}$/u.test(value)) correlation[field] = value;
63
+ }
58
64
  return {
59
- chain, errors,
65
+ chain, errors, correlation,
60
66
  details: normalizeDiagnosticDetails({
61
67
  reason: reasons.length > 1 ? 'multiple-causes' : reasons[0] ?? 'unknown',
62
68
  ...(reasons.length > 1 ? { reasons } : {}),
63
69
  httpStatus: errors.find(e => e.httpStatus !== undefined)?.httpStatus,
64
70
  providerCode: errors.find(e => e.providerCode !== undefined)?.providerCode,
65
71
  method: first('method'), transport: first('transport'),
72
+ stage: first('stage'), submissionState: first('submissionState'),
73
+ attempts: first('attempts'), lastFailureReason: first('lastFailureReason'),
66
74
  durationMs: first('durationMs'), timeoutMs: first('timeoutMs'), host: first('host'), truncated,
67
75
  }),
68
76
  };
69
77
  }
70
78
 
79
+ /** Keep request context off shared errors (for example a rejected model-catalog promise).
80
+ * Preserve the original prototype, properties and cause for existing business handlers. */
81
+ export function messageDiagnosticError(cause, context) {
82
+ if (cause?.name === 'AbortError') return cause;
83
+ const source = cause && typeof cause === 'object' ? cause : new Error('Message processing failed');
84
+ const error = source instanceof DOMException
85
+ ? Object.defineProperties(new DOMException(source.message, source.name), Object.getOwnPropertyDescriptors(source))
86
+ : Object.defineProperties(Object.setPrototypeOf(source instanceof Error ? new Error() : {}, Object.getPrototypeOf(source)),
87
+ Object.getOwnPropertyDescriptors(source));
88
+ stages.set(error, { ...context, ...stages.get(source) });
89
+ return error;
90
+ }
91
+
92
+ export function runtimeDiagnosticDetails() {
93
+ return { pluginVersion: packageInfo.version, nodeVersion: process.versions.node, platform: process.platform };
94
+ }
95
+
71
96
  export async function atConnectionStage(stage, operation, resource) {
72
97
  try { return await operation(); }
73
98
  catch (error) { throw connectionStageError(error, stage, resource); }
@@ -145,7 +170,7 @@ export function createConnectionDiagnostics({ channel, logger = console, now = D
145
170
  const details = normalizeDiagnosticDetails({
146
171
  ...evidence.details, ...context, ...safe.details, ...described.details, ...owned,
147
172
  channel, stage: owned.stage ?? described.details?.stage ?? safe.details?.stage ?? context.stage ?? operationStage(context.operation),
148
- pluginVersion: packageInfo.version, nodeVersion: process.versions.node, platform: process.platform,
173
+ ...runtimeDiagnosticDetails(),
149
174
  });
150
175
  const code = safe.code ?? context.code ?? `${channel}-operation-failed`;
151
176
  const hint = described.hint ?? safe.hint ?? safe.details?.hint ?? connectionHint(details);
@@ -7,11 +7,13 @@ const OPERATIONS = new Set([
7
7
  'bot.workspace.set', 'bot.model.set', 'bot.agent-preset.set', 'bot.context-enhancement.set', 'bot.access-policy.set', 'bot.alias.set',
8
8
  ]);
9
9
  const STAGES = new Set([
10
+ 'session.prepare', 'prompt.prepare', 'interaction.open', 'prompt.submit', 'reply.wait',
10
11
  'credential.verify', 'permission.check', 'sdk.load', 'operation', 'message.receive', 'message.send', 'connection.test', 'startup.load', 'qr.begin', 'qr.encode', 'qr.poll', 'qr.verify', 'qr.cancel', 'credential.read', 'credential.save', 'credential.remove',
11
12
  'account.save', 'account.remove', 'state.load', 'state.write', 'state.cleanup', 'workspace.write', 'workspace.cleanup',
12
13
  'runtime.prepare', 'activation', 'harness.check', 'connection.start', 'connection.poll', 'connection.stop', 'status.read', 'rollback', 'management.request',
13
14
  ]);
14
15
  const REASONS = new Set([
16
+ 'stream-closed-before-open', 'stream-failed-before-open', 'stream-failed',
15
17
  'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'ETIMEDOUT', 'EPIPE',
16
18
  'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', 'UND_ERR_SOCKET',
17
19
  'CERT_HAS_EXPIRED', 'CERT_NOT_YET_VALID', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN',
@@ -25,7 +27,8 @@ const RPC_METHODS = new Set([
25
27
  'session.history', 'session.prompt', 'session.rename', 'session.cancel', 'session.models',
26
28
  'session.permissions', 'session.selectModel', 'llm.models',
27
29
  ]);
28
- const TRANSPORTS = new Set(['host-api', 'http']);
30
+ const TRANSPORTS = new Set(['host-api', 'http', 'websocket']);
31
+ const SUBMISSION_STATES = new Set(['not-submitted', 'accepted', 'unknown']);
29
32
  const CONFIG_FILES = new Set(['config.json', 'workspaces.json']);
30
33
  export const CONFIG_ISSUE_LABELS = Object.freeze({
31
34
  'expected-object': '应为 JSON 对象。',
@@ -50,7 +53,7 @@ const CONFIG_FIELD = /^(?:\$|version|accounts(?:\[\d{1,10}\](?:\.(?:accountId|ow
50
53
  export function normalizeDiagnosticDetails(value) {
51
54
  if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
52
55
  const result = {};
53
- for (const [field, allowed] of [['operation', OPERATIONS], ['stage', STAGES], ['reason', REASONS], ['resource', RESOURCES], ['method', RPC_METHODS], ['transport', TRANSPORTS]]) {
56
+ for (const [field, allowed] of [['operation', OPERATIONS], ['stage', STAGES], ['reason', REASONS], ['resource', RESOURCES], ['method', RPC_METHODS], ['transport', TRANSPORTS], ['submissionState', SUBMISSION_STATES], ['lastFailureReason', REASONS]]) {
54
57
  if (allowed.has(value[field])) result[field] = value[field];
55
58
  }
56
59
  if (DIAGNOSTIC_CHANNELS.includes(value.channel)) result.channel = value.channel;
@@ -59,6 +62,7 @@ export function normalizeDiagnosticDetails(value) {
59
62
  if (reasons.length) result.reasons = reasons;
60
63
  }
61
64
  for (const field of ['durationMs', 'timeoutMs']) if (Number.isFinite(value[field]) && value[field] >= 0) result[field] = Math.round(value[field]);
65
+ if (Number.isSafeInteger(value.attempts) && value.attempts >= 0) result.attempts = value.attempts;
62
66
  for (const field of ['nodeVersion', 'dshVersion']) if (typeof value[field] === 'string' && /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(value[field]) && value[field].length < 80) result[field] = value[field];
63
67
  if (['darwin', 'linux', 'win32', 'freebsd'].includes(value.platform)) result.platform = value.platform;
64
68
  if (['ilinkai.weixin.qq.com', 'oapi.dingtalk.com', 'api.dingtalk.com', 'api.telegram.org', 'discord.com', 'slack.com', 'open.feishu.cn', 'qyapi.weixin.qq.com'].includes(value.host)) result.host = value.host;