@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.
@@ -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,28 @@ 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
+
36
55
  function clientTimeZoneFields() {
37
56
  try {
38
57
  const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -1187,9 +1206,46 @@ export class HarnessClient {
1187
1206
  }
1188
1207
 
1189
1208
  async listModels(options = {}) {
1209
+ options.signal?.throwIfAborted();
1190
1210
  await this.ensureRunning(options);
1191
- const value = await this.rpc('llm.models', {}, 30_000, options);
1192
- 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);
1193
1249
  }
1194
1250
 
1195
1251
  async getSessionModels(sessionId, options = {}) {
@@ -1224,13 +1280,18 @@ export class HarnessClient {
1224
1280
  : { reasoningEffort: selection.reasoningEffort }),
1225
1281
  }, 30_000, signal ? { ...options, signal } : options);
1226
1282
  };
1227
- const value = this.#sessionMaintenanceExecutor
1228
- ? await this.#sessionMaintenanceExecutor({ sessionId, operation })
1229
- : await operation();
1230
- if (!value || typeof value !== 'object' || !validModelSelection(value.selected)) {
1231
- 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 });
1232
1294
  }
1233
- return value;
1234
1295
  }
1235
1296
 
1236
1297
  async isSessionRunning(sessionId, options = {}) {
@@ -1279,6 +1340,9 @@ export class HarnessClient {
1279
1340
  const agentPreset = requestedPreset !== undefined ? requestedPreset : this.#agentPreset;
1280
1341
  if (agentPreset != null) payload.agentPreset = agentPreset;
1281
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
+ }
1282
1346
  return created.sessionId;
1283
1347
  }
1284
1348
 
@@ -1409,6 +1473,7 @@ export class HarnessClient {
1409
1473
  onSessionEvent,
1410
1474
  onOpen,
1411
1475
  ownership,
1476
+ onDiagnostic,
1412
1477
  } = {}) {
1413
1478
  if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
1414
1479
  if (!signal || typeof signal.addEventListener !== 'function') {
@@ -1426,8 +1491,17 @@ export class HarnessClient {
1426
1491
  if (onOpen !== undefined && typeof onOpen !== 'function') {
1427
1492
  throw new TypeError('onOpen must be a function');
1428
1493
  }
1429
-
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
+ };
1430
1502
  while (!signal.aborted) {
1503
+ attempts += 1;
1504
+ report();
1431
1505
  try {
1432
1506
  await this.#watchInteractionStream(sessionId, {
1433
1507
  signal,
@@ -1439,6 +1513,8 @@ export class HarnessClient {
1439
1513
  });
1440
1514
  } catch (error) {
1441
1515
  if (signal.aborted) return;
1516
+ lastFailureReason = extractConnectionEvidence(error).details.reason;
1517
+ report();
1442
1518
  console.warn('[dsh-im] Harness interaction stream disconnected:', this.#logPrefix, error.message);
1443
1519
  }
1444
1520
  if (signal.aborted) return;
@@ -1711,6 +1787,16 @@ export class HarnessClient {
1711
1787
 
1712
1788
  async ask(sessionId, prompt, options = {}) {
1713
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) {
1714
1800
  const timeoutMs = options.timeoutMs ?? 600_000;
1715
1801
  const signal = options.signal;
1716
1802
  const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null;
@@ -1740,6 +1826,7 @@ export class HarnessClient {
1740
1826
  );
1741
1827
  const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1));
1742
1828
  const promptRpcId = `${this.#rpcIdPrefix}-${randomUUID()}`;
1829
+ diagnostic.promptRpcId = promptRpcId;
1743
1830
  const releasePromptInputOrigin = registerImInputOrigin(this.#interactionRegistry, promptRpcId);
1744
1831
  const tracker = new HarnessReplyTracker({
1745
1832
  promptRpcId, afterSeq: baselineSeq, reasoning, finalAnswerOnly: options.finalAnswerOnly,
@@ -1869,6 +1956,9 @@ export class HarnessClient {
1869
1956
  });
1870
1957
  }
1871
1958
  if (interactionSignal) {
1959
+ diagnostic.stage = 'interaction.open';
1960
+ const startedAt = performance.now();
1961
+ let streamDiagnostic = { attempts: 0 };
1872
1962
  let markOpen;
1873
1963
  const opened = new Promise((resolve) => { markOpen = resolve; });
1874
1964
  interactionTask = this.watchInteractions(sessionId, {
@@ -1880,16 +1970,25 @@ export class HarnessClient {
1880
1970
  : undefined,
1881
1971
  onOpen: markOpen,
1882
1972
  ownership,
1973
+ onDiagnostic: (value) => { streamDiagnostic = value; },
1883
1974
  });
1884
1975
  void interactionTask.catch(() => undefined);
1885
1976
  await Promise.race([
1886
1977
  opened,
1887
1978
  sleep(30_000, interactionSignal).then(() => {
1888
- 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
+ });
1889
1988
  }),
1890
1989
  ]);
1891
1990
  }
1892
-
1991
+ diagnostic.stage = 'prompt.prepare';
1893
1992
  const content = typeof prompt === 'string'
1894
1993
  ? [{ type: 'text', text: prompt }]
1895
1994
  : prompt;
@@ -1903,12 +2002,16 @@ export class HarnessClient {
1903
2002
  // message that merely looks like a guidance block is not configuration.
1904
2003
  imSourceGuidance.publish(sessionId, options.sourceGuidance);
1905
2004
  const timeZoneFields = clientTimeZoneFields();
1906
- const sendPrompt = (promptContent) => this.#sessionRpc('session.prompt', {
1907
- sessionId,
1908
- mode: 'queue',
1909
- content: promptContent,
1910
- ...timeZoneFields,
1911
- }, 30_000, { rpcId: promptRpcId, signal });
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
+ };
1912
2015
  try {
1913
2016
  await sendPrompt(content);
1914
2017
  } catch (error) {
@@ -1948,6 +2051,8 @@ export class HarnessClient {
1948
2051
  await sendPrompt(fallbackPrompt);
1949
2052
  }
1950
2053
  promptAccepted = true;
2054
+ diagnostic.submissionState = 'accepted';
2055
+ diagnostic.stage = 'reply.wait';
1951
2056
 
1952
2057
  try {
1953
2058
  // Treat timeoutMs as a stall window rather than a hard runtime limit.
@@ -2032,6 +2137,8 @@ export class HarnessClient {
2032
2137
  continue;
2033
2138
  }
2034
2139
  const timeoutError = new HarnessTurnError('harness-reply-timeout');
2140
+ timeoutError.durationMs = Date.now() - lastProgressAt;
2141
+ timeoutError.timeoutMs = timeoutMs;
2035
2142
  // Data-only context for deferred delivery; timeout semantics unchanged.
2036
2143
  timeoutError.details = { sessionId, promptRpcId, baselineSeq, turn: tracker.turn, lastSeq: tracker.lastSeq };
2037
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",
@@ -1,4 +1,4 @@
1
- import { connectionHint, extractConnectionEvidence } from './connection-error.mjs';
1
+ import { connectionHint, extractConnectionEvidence, messageDiagnosticError, runtimeDiagnosticDetails } from './connection-error.mjs';
2
2
  import { diagnosticFields } from './diagnostic-details.mjs';
3
3
  import { randomUUID } from 'node:crypto';
4
4
 
@@ -26,6 +26,8 @@ const PROVIDER_FAILURES = Object.freeze({
26
26
  });
27
27
 
28
28
  const FAILURE_MESSAGES = Object.freeze({
29
+ HARNESS_INTERACTION_TIMEOUT:
30
+ '与 DSH 的交互通道在 30 秒内未就绪,本条消息尚未提交执行。请稍后重试。',
29
31
  HARNESS_CONNECT:
30
32
  '无法连接处理服务,消息尚未提交。请确认 DeepSeek Harness 正在运行后重试。',
31
33
  HARNESS_TIMEOUT:
@@ -107,18 +109,23 @@ function providerFailureCode(error) {
107
109
  return PROVIDER_FAILURES[value.trim().toUpperCase()] ?? null;
108
110
  }
109
111
 
110
- function failureCode(error) {
112
+ function failureCode(error, details) {
111
113
  const code = typeof error?.code === 'string' ? error.code : '';
112
114
  const providerCode = providerFailureCode(error);
113
115
  if (providerCode) return providerCode;
116
+ if (code === 'harness-interaction-timeout') return 'HARNESS_INTERACTION_TIMEOUT';
117
+
118
+ const uncertain = details.submissionState === undefined
119
+ ? ['session.prompt', 'session.history'].includes(error?.method)
120
+ : details.submissionState !== 'not-submitted';
114
121
 
115
122
  if (code === 'harness-connect-failed') {
116
- return ['session.prompt', 'session.history'].includes(error?.method)
123
+ return uncertain
117
124
  ? 'HARNESS_RESULT_UNCERTAIN'
118
125
  : 'HARNESS_CONNECT';
119
126
  }
120
127
  if (code === 'harness-timeout') {
121
- return ['session.prompt', 'session.history'].includes(error?.method)
128
+ return uncertain
122
129
  ? 'HARNESS_RESULT_UNCERTAIN'
123
130
  : 'HARNESS_TIMEOUT';
124
131
  }
@@ -205,25 +212,38 @@ export function classifyMessageFailure(error, {
205
212
  at = Date.now(),
206
213
  } = {}) {
207
214
  const safeReason = safeFailureReason(reason);
208
- const classifiedCode = failureCode(error);
215
+ const details = extractConnectionEvidence(error).details;
216
+ const classifiedCode = failureCode(error, details);
209
217
  const businessCode = classifiedCode === 'INTERNAL_UNKNOWN'
210
218
  && safeReason
211
219
  && typeof userMessage === 'string'
212
220
  && userMessage.trim()
213
221
  ? 'INPUT_INVALID'
214
222
  : classifiedCode;
215
- const details = extractConnectionEvidence(error).details;
216
223
  const code = businessCode === 'INTERNAL_UNKNOWN'
217
224
  ? transportFailureCode(error, details)
218
225
  : businessCode;
219
226
  if (code === 'REQUEST_TIMEOUT' || code === 'NETWORK_ERROR') details.hint = connectionHint(details);
227
+ if (details.stage || details.submissionState) Object.assign(details, runtimeDiagnosticDetails());
228
+ let message = FAILURE_MESSAGES[code];
229
+ if (['INTERNAL_UNKNOWN', 'REQUEST_TIMEOUT', 'NETWORK_ERROR'].includes(code)) {
230
+ if (details.submissionState === 'not-submitted') {
231
+ message = code === 'REQUEST_TIMEOUT'
232
+ ? '消息提交前的操作超时,本条消息尚未提交执行。请稍后重试。'
233
+ : code === 'NETWORK_ERROR'
234
+ ? '消息提交前的网络请求失败,本条消息尚未提交执行。请检查连接后重试。'
235
+ : '消息在提交前处理失败,尚未提交执行。请重试;若持续发生,请将参考号提供给管理员。';
236
+ } else if (details.submissionState) {
237
+ message = '消息已尝试提交,但任务结果尚未确认。请先到 DSH 查看状态,不要立即重复提交;若持续发生,请将参考号提供给管理员。';
238
+ }
239
+ }
220
240
  return Object.freeze({
221
- ...(code === 'INTERNAL_UNKNOWN' || details.reason !== 'unknown' || details.httpStatus || details.providerCode || details.method ? { details } : {}),
241
+ ...(code === 'INTERNAL_UNKNOWN' || details.reason !== 'unknown' || details.httpStatus || details.providerCode || details.method || details.stage || details.submissionState ? { details } : {}),
222
242
  code,
223
243
  reason: safeReason ?? safeFailureReason(error?.code) ?? code,
224
244
  message: typeof userMessage === 'string' && userMessage.trim()
225
245
  ? userMessage.trim()
226
- : t(FAILURE_MESSAGES[code]) + (code === 'SESSION_ARCHIVED' && error?.promptAccepted === true
246
+ : t(message) + (code === 'SESSION_ARCHIVED' && error?.promptAccepted === true
227
247
  ? `\n${t('本条消息可能仍在会话队列中,请先到 DSH 查看状态,避免重复提交。')}` : ''),
228
248
  referenceId: safeReferenceId(referenceId),
229
249
  at: Number.isFinite(at) ? at : Date.now(),
@@ -237,7 +257,7 @@ export function messageFailureText(failure) {
237
257
  export function messageFailureDiagnostic(error, failure) {
238
258
  const evidence = extractConnectionEvidence(error);
239
259
  return { code: failure.code, reason: failure.reason, referenceId: failure.referenceId,
240
- details: evidence.details, errors: evidence.errors };
260
+ details: failure.details ?? evidence.details, errors: evidence.errors, ...evidence.correlation };
241
261
  }
242
262
 
243
263
  export function setLastMessageFailure(status, error, options) {
@@ -262,7 +282,7 @@ export function channelDeliveryFailure(error, { uncertain = true } = {}) {
262
282
  ? 'channel-delivery-uncertain'
263
283
  : 'channel-delivery-failed';
264
284
  if (Number.isInteger(status)) wrapped.status = status;
265
- return wrapped;
285
+ return messageDiagnosticError(wrapped, { stage: 'message.send' });
266
286
  }
267
287
 
268
288
  export function publicMessageFailure(value) {
@@ -1,5 +1,6 @@
1
1
  import { withSessionBindingLock } from './session-binding-lock.mjs';
2
2
  import { initialSessionTitle } from './session-title.mjs';
3
+ import { messageDiagnosticError } from './connection-error.mjs';
3
4
 
4
5
  export const WORKSPACE_SESSION_STALE = 'workspace-session-stale';
5
6
 
@@ -114,16 +115,19 @@ export async function askInWorkspaceSession({
114
115
  ?? (typeof askOptions === 'object' ? askOptions?.signal : undefined);
115
116
  const renameOptions = renameSignal ? { signal: renameSignal } : undefined;
116
117
  while (true) {
118
+ const diagnostic = { stage: 'session.prepare', submissionState: 'not-submitted' };
117
119
  try {
118
120
  const binding = await withSessionBindingLock(state, key, async () => {
119
121
  await awaitPendingConversationSwitch(harness, key);
120
122
  let sessionId = state.sessionFor(key);
123
+ diagnostic.sessionId = sessionId;
121
124
  let session = sessionId ? workspaceSession(harness, sessionId, key) : null;
122
125
  if (!session || !(await sessionExists(session, existsOptions))) {
123
126
  sessionId = await createSession(harness, {
124
127
  conversationKey: key,
125
128
  ...(createOptions ?? {}),
126
129
  });
130
+ diagnostic.sessionId = sessionId;
127
131
  if (await state.setSession(key, sessionId) === false) return null;
128
132
  // Binding committed: capture the conversation's effective-workspace
129
133
  // generation together with the session, so the prompt below is fenced
@@ -150,6 +154,8 @@ export async function askInWorkspaceSession({
150
154
  return { sessionId, session };
151
155
  });
152
156
  if (!binding) continue;
157
+ diagnostic.sessionId = binding.sessionId;
158
+ diagnostic.stage = 'prompt.prepare';
153
159
  const artifacts = [];
154
160
  const originalOnArtifact = typeof askOptions === 'object'
155
161
  && typeof askOptions?.onArtifact === 'function'
@@ -172,6 +178,9 @@ export async function askInWorkspaceSession({
172
178
  ? await prepareContent({ sessionId: binding.sessionId, content, text })
173
179
  : content ?? text;
174
180
  try {
181
+ // An alternate Harness adapter may not supply finer ask() diagnostics.
182
+ diagnostic.stage = 'operation';
183
+ diagnostic.submissionState = 'unknown';
175
184
  answer = await binding.session.ask(prompt, artifactOptions);
176
185
  } catch (error) {
177
186
  if (error?.code === 'harness-reply-timeout' && deferredDelivery) {
@@ -190,7 +199,7 @@ export async function askInWorkspaceSession({
190
199
  ...(artifacts.length > 0 ? { artifacts } : {}),
191
200
  };
192
201
  } catch (error) {
193
- if (error?.code !== WORKSPACE_SESSION_STALE) throw error;
202
+ if (error?.code !== WORKSPACE_SESSION_STALE) throw messageDiagnosticError(error, diagnostic);
194
203
  }
195
204
  }
196
205
  }