@xmanrui/dsh-im 4.38.0 → 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.
Files changed (50) hide show
  1. package/PROACTIVE_DELIVERY.en.md +18 -2
  2. package/PROACTIVE_DELIVERY.md +18 -2
  3. package/README.en.md +2 -0
  4. package/README.md +2 -0
  5. package/lib/client.js +49 -5
  6. package/lib/index.js +320 -315
  7. package/locale/en.json +1 -1
  8. package/locale/zh.json +1 -1
  9. package/package.json +1 -1
  10. package/plugin-src/client/connection-error.js +12 -2
  11. package/plugin-src/host/channels/dingtalk/production.mjs +1 -1
  12. package/plugin-src/host/channels/feishu/production.mjs +1 -1
  13. package/plugin-src/host/channels/matrix/production.mjs +1 -1
  14. package/plugin-src/host/channels/qq/production.mjs +1 -1
  15. package/plugin-src/host/channels/shared/production.mjs +1 -1
  16. package/plugin-src/host/channels/slack/production.mjs +1 -1
  17. package/plugin-src/host/channels/wecom/production.mjs +1 -1
  18. package/plugin-src/host/channels/wecom-app/production.mjs +1 -1
  19. package/plugin-src/host/channels/weixin/production.mjs +1 -1
  20. package/plugin-src/host/channels/whatsapp/production.mjs +1 -1
  21. package/plugin-src/host/delivery-adapter.mjs +9 -1
  22. package/plugin-src/host/delivery-service.mjs +4 -3
  23. package/plugin-src/host/modern-harness-api.mjs +2 -1
  24. package/plugin-src/host/session-sync-coordinator.mjs +9 -5
  25. package/src/channels/dingtalk/dingtalk-api.mjs +41 -24
  26. package/src/channels/dingtalk/dingtalk-runtime.mjs +3 -2
  27. package/src/channels/discord/discord-runtime.mjs +13 -2
  28. package/src/channels/feishu/bridge.mjs +95 -24
  29. package/src/channels/matrix/matrix-runtime.mjs +16 -6
  30. package/src/channels/qq/markdown-reply.mjs +7 -340
  31. package/src/channels/qq/qq-runtime.mjs +11 -3
  32. package/src/channels/shared/bot-workspace-store.mjs +3 -3
  33. package/src/channels/shared/connection-error.mjs +29 -4
  34. package/src/channels/shared/diagnostic-details.mjs +6 -2
  35. package/src/channels/shared/harness-client.mjs +138 -19
  36. package/src/channels/shared/harness-mux.mjs +6 -4
  37. package/src/channels/shared/i18n-en/diagnostics.mjs +24 -0
  38. package/src/channels/shared/i18n-en/wecom.mjs +3 -0
  39. package/src/channels/shared/markdown-chunks.mjs +338 -0
  40. package/src/channels/shared/message-failure.mjs +31 -11
  41. package/src/channels/shared/text-harness-bridge.mjs +4 -1
  42. package/src/channels/shared/workspace-session.mjs +10 -1
  43. package/src/channels/slack/slack-api.mjs +2 -1
  44. package/src/channels/slack/slack-runtime.mjs +30 -0
  45. package/src/channels/telegram/telegram-runtime.mjs +1 -0
  46. package/src/channels/wecom/wecom-bridge.mjs +45 -10
  47. package/src/channels/wecom/wecom-runtime.mjs +11 -5
  48. package/src/channels/weixin/weixin-runtime.mjs +17 -10
  49. package/src/channels/whatsapp/whatsapp-markdown.mjs +59 -0
  50. package/src/channels/whatsapp/whatsapp-runtime.mjs +26 -1
@@ -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;
@@ -22,6 +22,7 @@ import { imSourceGuidance } from './im-source-guidance.mjs';
22
22
  import { outboundArtifactRegistry } from './semantic/artifact.mjs';
23
23
  import { t } from './i18n.mjs';
24
24
  import { watchHarnessMux } from './harness-mux.mjs';
25
+ import { extractConnectionEvidence, messageDiagnosticError } from './connection-error.mjs';
25
26
 
26
27
  // Every channel plugin runs in the same Host process. Sharing ownership by
27
28
  // Host identity (or an explicitly configured HTTP origin) prevents clients
@@ -29,10 +30,40 @@ import { watchHarnessMux } from './harness-mux.mjs';
29
30
  // from claiming or cancelling each other's interactions.
30
31
  const interactionRegistries = new Map();
31
32
  const hostInteractionRegistries = new WeakMap();
33
+ const modelCatalogCaches = new WeakMap();
34
+ const MODEL_CATALOG_TTL_MS = 30_000;
32
35
  const MAX_ERROR_CLASSIFICATION_BYTES = 64;
33
36
  const IM_INPUT_ORIGIN_TTL_MS = 30 * 60 * 1000;
34
37
  const MAX_IM_INPUT_ORIGINS = 4096;
35
38
 
39
+ function invalidateModelCatalog(entry) {
40
+ if (!entry) return;
41
+ entry.value = null;
42
+ entry.expiresAt = 0;
43
+ // Old readers may finish, but must not repopulate the cache after a fresh read.
44
+ entry.pending = null;
45
+ }
46
+
47
+ export function clearModelCatalogCache(apiProxy) {
48
+ const entry = modelCatalogCaches.get(apiProxy);
49
+ if (!entry) return;
50
+ modelCatalogCaches.delete(apiProxy);
51
+ invalidateModelCatalog(entry);
52
+ entry.controller.abort(new DOMException('Harness model catalog disposed', 'AbortError'));
53
+ }
54
+
55
+ function clientTimeZoneFields() {
56
+ try {
57
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
58
+ return typeof timeZone === 'string' && timeZone.trim()
59
+ ? { clientTimeZone: timeZone }
60
+ : {};
61
+ } catch {
62
+ // Time-zone metadata is optional; unresolved values are not JSON-safe.
63
+ return {};
64
+ }
65
+ }
66
+
36
67
  async function smallResponseText(response) {
37
68
  const stream = response?.body;
38
69
  if (!stream || typeof stream.getReader !== 'function') return null;
@@ -1175,9 +1206,46 @@ export class HarnessClient {
1175
1206
  }
1176
1207
 
1177
1208
  async listModels(options = {}) {
1209
+ options.signal?.throwIfAborted();
1178
1210
  await this.ensureRunning(options);
1179
- const value = await this.rpc('llm.models', {}, 30_000, options);
1180
- return validateModelCatalog(value, 'llm.models');
1211
+ options.signal?.throwIfAborted();
1212
+ if (!this.#apiProxy || options.fresh === true) {
1213
+ try {
1214
+ const value = await this.rpc('llm.models', {}, 30_000, options);
1215
+ return validateModelCatalog(value, 'llm.models');
1216
+ } finally {
1217
+ if (this.#apiProxy) invalidateModelCatalog(modelCatalogCaches.get(this.#apiProxy));
1218
+ }
1219
+ }
1220
+
1221
+ let entry = modelCatalogCaches.get(this.#apiProxy);
1222
+ if (!entry) {
1223
+ entry = { value: null, expiresAt: 0, pending: null, controller: new AbortController() };
1224
+ modelCatalogCaches.set(this.#apiProxy, entry);
1225
+ }
1226
+ if (entry.value && Date.now() < entry.expiresAt) return structuredClone(entry.value);
1227
+ if (!entry.pending) {
1228
+ // The Host owns this read; one caller cancelling must not cancel its peers.
1229
+ const pending = this.rpc('llm.models', {}, 30_000, {
1230
+ ...options, signal: entry.controller.signal,
1231
+ }).then((value) => {
1232
+ const catalog = structuredClone(validateModelCatalog(value, 'llm.models'));
1233
+ if (modelCatalogCaches.get(this.#apiProxy) === entry && entry.pending === pending) {
1234
+ entry.value = catalog;
1235
+ entry.expiresAt = Date.now() + MODEL_CATALOG_TTL_MS;
1236
+ }
1237
+ return catalog;
1238
+ }).finally(() => {
1239
+ if (entry.pending === pending) entry.pending = null;
1240
+ });
1241
+ entry.pending = pending;
1242
+ }
1243
+ const pending = entry.pending;
1244
+ const value = await callWithSignal(
1245
+ () => pending,
1246
+ options.signal ?? entry.controller.signal,
1247
+ );
1248
+ return structuredClone(value);
1181
1249
  }
1182
1250
 
1183
1251
  async getSessionModels(sessionId, options = {}) {
@@ -1212,13 +1280,18 @@ export class HarnessClient {
1212
1280
  : { reasoningEffort: selection.reasoningEffort }),
1213
1281
  }, 30_000, signal ? { ...options, signal } : options);
1214
1282
  };
1215
- const value = this.#sessionMaintenanceExecutor
1216
- ? await this.#sessionMaintenanceExecutor({ sessionId, operation })
1217
- : await operation();
1218
- if (!value || typeof value !== 'object' || !validModelSelection(value.selected)) {
1219
- throw new Error('Harness returned an invalid response for session.selectModel');
1283
+ try {
1284
+ const value = this.#sessionMaintenanceExecutor
1285
+ ? await this.#sessionMaintenanceExecutor({ sessionId, operation })
1286
+ : await operation();
1287
+ if (!value || typeof value !== 'object' || !validModelSelection(value.selected)) {
1288
+ throw new HarnessTransportError('harness-response-invalid', 'session.selectModel');
1289
+ }
1290
+ return value;
1291
+ } catch (error) {
1292
+ if (options.signal?.aborted) throw error;
1293
+ throw messageDiagnosticError(error, { stage: 'session.prepare', sessionId });
1220
1294
  }
1221
- return value;
1222
1295
  }
1223
1296
 
1224
1297
  async isSessionRunning(sessionId, options = {}) {
@@ -1267,6 +1340,9 @@ export class HarnessClient {
1267
1340
  const agentPreset = requestedPreset !== undefined ? requestedPreset : this.#agentPreset;
1268
1341
  if (agentPreset != null) payload.agentPreset = agentPreset;
1269
1342
  const created = await this.rpc('session.create', payload, 30_000, rpcOptions);
1343
+ if (typeof created?.sessionId !== 'string' || !created.sessionId) {
1344
+ throw new HarnessTransportError('harness-response-invalid', 'session.create');
1345
+ }
1270
1346
  return created.sessionId;
1271
1347
  }
1272
1348
 
@@ -1397,6 +1473,7 @@ export class HarnessClient {
1397
1473
  onSessionEvent,
1398
1474
  onOpen,
1399
1475
  ownership,
1476
+ onDiagnostic,
1400
1477
  } = {}) {
1401
1478
  if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
1402
1479
  if (!signal || typeof signal.addEventListener !== 'function') {
@@ -1414,8 +1491,17 @@ export class HarnessClient {
1414
1491
  if (onOpen !== undefined && typeof onOpen !== 'function') {
1415
1492
  throw new TypeError('onOpen must be a function');
1416
1493
  }
1417
-
1494
+ let attempts = 0;
1495
+ let lastFailureReason;
1496
+ const report = () => {
1497
+ try {
1498
+ Promise.resolve(onDiagnostic?.({ attempts, ...(lastFailureReason ? { lastFailureReason } : {}) }))
1499
+ .catch(() => undefined);
1500
+ } catch { /* Diagnostics cannot change stream/reconnect behavior. */ }
1501
+ };
1418
1502
  while (!signal.aborted) {
1503
+ attempts += 1;
1504
+ report();
1419
1505
  try {
1420
1506
  await this.#watchInteractionStream(sessionId, {
1421
1507
  signal,
@@ -1427,6 +1513,8 @@ export class HarnessClient {
1427
1513
  });
1428
1514
  } catch (error) {
1429
1515
  if (signal.aborted) return;
1516
+ lastFailureReason = extractConnectionEvidence(error).details.reason;
1517
+ report();
1430
1518
  console.warn('[dsh-im] Harness interaction stream disconnected:', this.#logPrefix, error.message);
1431
1519
  }
1432
1520
  if (signal.aborted) return;
@@ -1621,7 +1709,7 @@ export class HarnessClient {
1621
1709
  sessionId,
1622
1710
  mode: 'steer',
1623
1711
  content: [{ type: 'text', text }],
1624
- clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
1712
+ ...clientTimeZoneFields(),
1625
1713
  }, 30_000, { ...options, rpcId: inputRpcId });
1626
1714
  return true;
1627
1715
  } catch (error) {
@@ -1699,6 +1787,16 @@ export class HarnessClient {
1699
1787
 
1700
1788
  async ask(sessionId, prompt, options = {}) {
1701
1789
  if (typeof options === 'number') options = { timeoutMs: options };
1790
+ const diagnostic = { stage: 'prompt.prepare', submissionState: 'not-submitted', sessionId };
1791
+ try {
1792
+ return await this.#ask(sessionId, prompt, options, diagnostic);
1793
+ } catch (error) {
1794
+ if (options.signal?.aborted) throw error;
1795
+ throw messageDiagnosticError(error, diagnostic);
1796
+ }
1797
+ }
1798
+
1799
+ async #ask(sessionId, prompt, options, diagnostic) {
1702
1800
  const timeoutMs = options.timeoutMs ?? 600_000;
1703
1801
  const signal = options.signal;
1704
1802
  const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null;
@@ -1728,6 +1826,7 @@ export class HarnessClient {
1728
1826
  );
1729
1827
  const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1));
1730
1828
  const promptRpcId = `${this.#rpcIdPrefix}-${randomUUID()}`;
1829
+ diagnostic.promptRpcId = promptRpcId;
1731
1830
  const releasePromptInputOrigin = registerImInputOrigin(this.#interactionRegistry, promptRpcId);
1732
1831
  const tracker = new HarnessReplyTracker({
1733
1832
  promptRpcId, afterSeq: baselineSeq, reasoning, finalAnswerOnly: options.finalAnswerOnly,
@@ -1857,6 +1956,9 @@ export class HarnessClient {
1857
1956
  });
1858
1957
  }
1859
1958
  if (interactionSignal) {
1959
+ diagnostic.stage = 'interaction.open';
1960
+ const startedAt = performance.now();
1961
+ let streamDiagnostic = { attempts: 0 };
1860
1962
  let markOpen;
1861
1963
  const opened = new Promise((resolve) => { markOpen = resolve; });
1862
1964
  interactionTask = this.watchInteractions(sessionId, {
@@ -1868,16 +1970,25 @@ export class HarnessClient {
1868
1970
  : undefined,
1869
1971
  onOpen: markOpen,
1870
1972
  ownership,
1973
+ onDiagnostic: (value) => { streamDiagnostic = value; },
1871
1974
  });
1872
1975
  void interactionTask.catch(() => undefined);
1873
1976
  await Promise.race([
1874
1977
  opened,
1875
1978
  sleep(30_000, interactionSignal).then(() => {
1876
- throw new Error('Harness interaction stream did not open within 30 seconds');
1979
+ throw Object.assign(new HarnessInteractionError(
1980
+ 'harness-interaction-timeout',
1981
+ 'Harness interaction stream did not open within 30 seconds',
1982
+ ), {
1983
+ transport: this.#apiProxy ? 'host-api' : 'websocket',
1984
+ durationMs: Math.round(performance.now() - startedAt),
1985
+ timeoutMs: 30_000,
1986
+ ...streamDiagnostic,
1987
+ });
1877
1988
  }),
1878
1989
  ]);
1879
1990
  }
1880
-
1991
+ diagnostic.stage = 'prompt.prepare';
1881
1992
  const content = typeof prompt === 'string'
1882
1993
  ? [{ type: 'text', text: prompt }]
1883
1994
  : prompt;
@@ -1890,13 +2001,17 @@ export class HarnessClient {
1890
2001
  // `content`: the prompt also carries whatever the user typed, and a
1891
2002
  // message that merely looks like a guidance block is not configuration.
1892
2003
  imSourceGuidance.publish(sessionId, options.sourceGuidance);
1893
- const clientTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
1894
- const sendPrompt = (promptContent) => this.#sessionRpc('session.prompt', {
1895
- sessionId,
1896
- mode: 'queue',
1897
- content: promptContent,
1898
- clientTimeZone,
1899
- }, 30_000, { rpcId: promptRpcId, signal });
2004
+ const timeZoneFields = clientTimeZoneFields();
2005
+ const sendPrompt = (promptContent) => {
2006
+ diagnostic.stage = 'prompt.submit';
2007
+ diagnostic.submissionState = 'unknown';
2008
+ return this.#sessionRpc('session.prompt', {
2009
+ sessionId,
2010
+ mode: 'queue',
2011
+ content: promptContent,
2012
+ ...timeZoneFields,
2013
+ }, 30_000, { rpcId: promptRpcId, signal });
2014
+ };
1900
2015
  try {
1901
2016
  await sendPrompt(content);
1902
2017
  } catch (error) {
@@ -1936,6 +2051,8 @@ export class HarnessClient {
1936
2051
  await sendPrompt(fallbackPrompt);
1937
2052
  }
1938
2053
  promptAccepted = true;
2054
+ diagnostic.submissionState = 'accepted';
2055
+ diagnostic.stage = 'reply.wait';
1939
2056
 
1940
2057
  try {
1941
2058
  // Treat timeoutMs as a stall window rather than a hard runtime limit.
@@ -2020,6 +2137,8 @@ export class HarnessClient {
2020
2137
  continue;
2021
2138
  }
2022
2139
  const timeoutError = new HarnessTurnError('harness-reply-timeout');
2140
+ timeoutError.durationMs = Date.now() - lastProgressAt;
2141
+ timeoutError.timeoutMs = timeoutMs;
2023
2142
  // Data-only context for deferred delivery; timeout semantics unchanged.
2024
2143
  timeoutError.details = { sessionId, promptRpcId, baselineSeq, turn: tracker.turn, lastSeq: tracker.lastSeq };
2025
2144
  throw timeoutError;
@@ -97,12 +97,14 @@ function watchWebSocketMux({ baseUrl, createWebSocket, signal, onOpen, onEnvelop
97
97
  }
98
98
  invoke(onEnvelope, envelope, finish);
99
99
  };
100
- const handleClose = () => finish(opened ? undefined : new Error(
100
+ const handleClose = () => finish(opened ? undefined : Object.assign(new Error(
101
101
  'Harness event mux WebSocket closed before opening',
102
- ));
103
- const handleError = () => finish(new Error(opened
102
+ ), { code: 'stream-closed-before-open' }));
103
+ const handleError = () => finish(Object.assign(new Error(opened
104
104
  ? 'Harness event mux WebSocket failed'
105
- : 'Harness event mux WebSocket failed before opening'));
105
+ : 'Harness event mux WebSocket failed before opening'), {
106
+ code: opened ? 'stream-failed' : 'stream-failed-before-open',
107
+ }));
106
108
  const handleAbort = () => finish();
107
109
  socket.addEventListener('open', handleOpen);
108
110
  socket.addEventListener('message', handleMessage);
@@ -1,4 +1,28 @@
1
1
  export default Object.freeze({
2
+ '准备会话': 'Prepare session',
3
+ '准备消息': 'Prepare message',
4
+ '建立交互通道': 'Open interaction channel',
5
+ '提交消息': 'Submit message',
6
+ '等待处理结果': 'Wait for result',
7
+ '消息提交状态': 'Message submission status',
8
+ '未提交': 'Not submitted',
9
+ '已确认接收': 'Acceptance confirmed',
10
+ '提交结果未知': 'Submission outcome unknown',
11
+ '连接尝试次数': 'Connection attempts',
12
+ '最近一次连接失败': 'Last observed connection failure',
13
+ '打开前关闭': 'Closed before opening',
14
+ '打开前失败': 'Failed before opening',
15
+ '通道连接失败': 'Stream connection failed',
16
+ '与 DSH 的交互通道在 30 秒内未就绪,本条消息尚未提交执行。请稍后重试。':
17
+ 'The DSH interaction channel was not ready within 30 seconds. This message has not been submitted for execution. Please try again later.',
18
+ '消息提交前的操作超时,本条消息尚未提交执行。请稍后重试。':
19
+ 'An operation timed out before submission. This message has not been submitted for execution. Please try again later.',
20
+ '消息提交前的网络请求失败,本条消息尚未提交执行。请检查连接后重试。':
21
+ 'A network request failed before submission. This message has not been submitted for execution. Check the connection and try again.',
22
+ '消息在提交前处理失败,尚未提交执行。请重试;若持续发生,请将参考号提供给管理员。':
23
+ 'Message preparation failed before submission. It has not been submitted for execution. Try again; if this persists, share the reference with your administrator.',
24
+ '消息已尝试提交,但任务结果尚未确认。请先到 DSH 查看状态,不要立即重复提交;若持续发生,请将参考号提供给管理员。':
25
+ 'Message submission was attempted, but the task outcome is unconfirmed. Check its status in DSH before resubmitting; if this persists, share the reference with your administrator.',
2
26
  "DSH 版本": "DSH version",
3
27
  "依赖版本": "Dependency versions",
4
28
  "代理已配置": "Proxy configured",
@@ -30,6 +30,9 @@ export default {
30
30
  // Bot replies and status text (wecom-bridge.mjs)
31
31
 
32
32
  '当前待处理图片较多,请稍后重新发送。': 'Too many images are pending. Please send them again later.',
33
+ '图片 {indexes} 来自当前消息。': 'Images {indexes} are from the current message.',
34
+ '图片 {indexes} 来自被引用消息。图片序号与本轮附件清单一致。': 'Images {indexes} are from the quoted message. Image numbers match the attachment list for this turn.',
35
+ '有 {count} 张引用图片未提供可下载资源,未附上这些图片;需要看图时请用户重新发送。': '{count} quoted images have no downloadable resource and are not attached. Ask the user to send them again if needed.',
33
36
 
34
37
  '结果文件「{name}」已生成,但企业微信智能机器人缺少素材上传或文件消息能力,请检查机器人权限。': 'The result file "{name}" was generated, but the Enterprise WeChat bot lacks media upload or file message capability. Please check the bot permissions.',
35
38
  '结果文件「{name}」超过当前企业微信机器人可发送的文件大小,未发送。': 'The result file "{name}" exceeds the file size this Enterprise WeChat bot can send and was not sent.',