@xmanrui/dsh-im 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.en.md +23 -3
  2. package/README.md +23 -3
  3. package/assets/logo-dsh-im-chinese-readme-3x2.png +0 -0
  4. package/assets/logo_cn.png +0 -0
  5. package/lib/client.js +815 -560
  6. package/lib/index.js +163 -163
  7. package/package.json +1 -1
  8. package/plugin-src/client/agent-preset.js +15 -6
  9. package/plugin-src/client/channel-card-meta.js +48 -0
  10. package/plugin-src/client/channels/dingtalk/index.js +25 -19
  11. package/plugin-src/client/channels/dingtalk/styles.js +0 -6
  12. package/plugin-src/client/channels/feishu/index.js +41 -35
  13. package/plugin-src/client/channels/feishu/styles.js +0 -5
  14. package/plugin-src/client/channels/qq/index.js +24 -16
  15. package/plugin-src/client/channels/shared/token-channel.js +32 -24
  16. package/plugin-src/client/channels/wecom/index.js +24 -16
  17. package/plugin-src/client/channels/weixin/index.js +29 -23
  18. package/plugin-src/client/channels/weixin/styles.js +0 -5
  19. package/plugin-src/client/channels/whatsapp/api.js +11 -0
  20. package/plugin-src/client/channels/whatsapp/index.js +152 -23
  21. package/plugin-src/client/channels/whatsapp/styles.js +25 -0
  22. package/plugin-src/client/i18n.js +20 -0
  23. package/plugin-src/client/styles.js +23 -8
  24. package/plugin-src/host/channels/whatsapp/rpc.mjs +19 -1
  25. package/plugin-src/host/index.mjs +14 -1
  26. package/src/channels/dingtalk/dingtalk-api.mjs +215 -2
  27. package/src/channels/dingtalk/dingtalk-bridge.mjs +155 -4
  28. package/src/channels/discord/discord-api.mjs +134 -6
  29. package/src/channels/discord/discord-runtime.mjs +15 -4
  30. package/src/channels/feishu/bridge.mjs +223 -15
  31. package/src/channels/feishu/feishu-channel.mjs +227 -1
  32. package/src/channels/feishu/plugin-controller.mjs +1 -0
  33. package/src/channels/qq/qq-bridge.mjs +217 -10
  34. package/src/channels/shared/editable-message-stream.mjs +18 -1
  35. package/src/channels/shared/harness-client.mjs +99 -7
  36. package/src/channels/shared/semantic/artifact.mjs +748 -0
  37. package/src/channels/shared/semantic/delivery.mjs +153 -0
  38. package/src/channels/shared/text-harness-bridge.mjs +149 -3
  39. package/src/channels/shared/workspace-session.mjs +15 -1
  40. package/src/channels/slack/manifest.mjs +1 -0
  41. package/src/channels/slack/slack-api.mjs +167 -4
  42. package/src/channels/slack/slack-runtime.mjs +21 -5
  43. package/src/channels/telegram/telegram-api.mjs +111 -5
  44. package/src/channels/telegram/telegram-runtime.mjs +18 -4
  45. package/src/channels/wecom/wecom-bridge.mjs +260 -12
  46. package/src/channels/weixin/weixin-api.mjs +268 -2
  47. package/src/channels/weixin/weixin-bridge.mjs +134 -3
  48. package/src/channels/weixin/weixin-controller.mjs +5 -1
  49. package/src/channels/weixin/weixin-runtime.mjs +5 -1
  50. package/src/channels/whatsapp/config-store.mjs +43 -0
  51. package/src/channels/whatsapp/whatsapp-controller.mjs +22 -1
  52. package/src/channels/whatsapp/whatsapp-runtime.mjs +149 -5
@@ -26,8 +26,20 @@ import {
26
26
  imagePromptUserMessage,
27
27
  promptContentForMessage,
28
28
  } from '../shared/image-prompt.mjs';
29
+ import {
30
+ materializeOutboundArtifact,
31
+ releaseOutboundArtifact,
32
+ trackOutboundArtifactProviderPromise,
33
+ } from '../shared/semantic/artifact.mjs';
34
+ import {
35
+ createArtifactFailureReceipt,
36
+ createDeliveryReceipt,
37
+ mergeDeliveryReceipts,
38
+ providerMessageIdsFor,
39
+ } from '../shared/semantic/delivery.mjs';
29
40
 
30
41
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
42
+ const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
31
43
 
32
44
  export const QQ_IMAGE_HOSTS = Object.freeze([
33
45
  '.myqcloud.com',
@@ -120,6 +132,82 @@ function nonEmptyString(value) {
120
132
  return typeof value === 'string' && value.trim() ? value.trim() : null;
121
133
  }
122
134
 
135
+ function artifactFailureText(fileName, error) {
136
+ const name = String(fileName ?? '结果文件').replace(/[\r\n]+/g, ' ').trim() || '结果文件';
137
+ if (error?.name === 'UploadDailyLimitExceededError') {
138
+ return `结果文件「${name}」已生成,但 QQ 今日文件上传额度已用完,请稍后重试。`;
139
+ }
140
+ switch (error?.code) {
141
+ case 'artifact-delivery-uncertain':
142
+ return `结果文件「${name}」的发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
143
+ case 'artifact-permission-required':
144
+ return `结果文件「${name}」已生成,但当前 QQ 机器人没有文件消息权限。`;
145
+ case 'artifact-too-large':
146
+ return `结果文件「${name}」超过当前 QQ 机器人可发送的文件大小,未发送。`;
147
+ case 'artifact-empty':
148
+ return `结果文件「${name}」为空,QQ 不允许发送空文件。`;
149
+ case 'artifact-changed':
150
+ case 'artifact-invalid':
151
+ case 'artifact-unavailable':
152
+ return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
153
+ case 'artifact-rate-limited':
154
+ return `结果文件「${name}」暂时被 QQ 限流,未能发送,请稍后重试。`;
155
+ case 'artifact-provider-rejected':
156
+ return `结果文件「${name}」已生成,但 QQ 拒绝了该文件或文件消息。`;
157
+ default:
158
+ return `结果文件「${name}」已生成,但暂时未能通过 QQ 发送,请稍后重试。`;
159
+ }
160
+ }
161
+
162
+ function answerTextForDelivery(answer, artifacts) {
163
+ if (typeof answer === 'string' && answer.trim()) return answer;
164
+ return artifacts.length > 0 ? '结果文件已生成。' : answer;
165
+ }
166
+
167
+ function qqArtifactError(error, { dispatched = false } = {}) {
168
+ if (error?.code?.startsWith?.('artifact-') || error?.name === 'UploadDailyLimitExceededError') {
169
+ return error;
170
+ }
171
+ const status = Number(error?.httpStatus);
172
+ const wrapped = new Error('QQ file delivery failed', { cause: error });
173
+ if (status === 401 || status === 403) wrapped.code = 'artifact-permission-required';
174
+ else if (status === 413) wrapped.code = 'artifact-too-large';
175
+ else if (status === 429) wrapped.code = 'artifact-rate-limited';
176
+ else if (status === 400 || status === 404) {
177
+ wrapped.code = 'artifact-provider-rejected';
178
+ } else {
179
+ wrapped.code = dispatched ? 'artifact-delivery-uncertain' : 'artifact-provider-failed';
180
+ }
181
+ return wrapped;
182
+ }
183
+
184
+ function abortReason(signal) {
185
+ return signal?.reason instanceof Error
186
+ ? signal.reason
187
+ : new DOMException('The operation was aborted', 'AbortError');
188
+ }
189
+
190
+ function waitWithSignal(promise, signal) {
191
+ if (!signal) return promise;
192
+ signal.throwIfAborted();
193
+ return new Promise((resolve, reject) => {
194
+ let settled = false;
195
+ const finish = (callback, value) => {
196
+ if (settled) return;
197
+ settled = true;
198
+ signal.removeEventListener('abort', onAbort);
199
+ callback(value);
200
+ };
201
+ const onAbort = () => finish(reject, abortReason(signal));
202
+ signal.addEventListener('abort', onAbort, { once: true });
203
+ Promise.resolve(promise).then(
204
+ (value) => finish(resolve, value),
205
+ (error) => finish(reject, error),
206
+ );
207
+ if (signal.aborted) onAbort();
208
+ });
209
+ }
210
+
123
211
  function canClaimInteractionReply(message, pending) {
124
212
  return pending.questions[pending.index]
125
213
  && nonEmptyString(message?.senderId) === pending.actor
@@ -133,6 +221,8 @@ export function createQqBridgeStatus() {
133
221
  messagesReceived: 0,
134
222
  messagesReplied: 0,
135
223
  messagesRejected: 0,
224
+ artifactsSent: 0,
225
+ artifactSendErrors: 0,
136
226
  lastMessageAt: null,
137
227
  lastReplyAt: null,
138
228
  lastRejectedAt: null,
@@ -150,6 +240,7 @@ export class QqHarnessBridge {
150
240
  #replyTimeoutMs;
151
241
  #signal;
152
242
  #fetchImpl;
243
+ #fileUploadTimeoutMs;
153
244
  #queues = new Map();
154
245
  #pendingInteractions = new Map();
155
246
  #interactionKeys = new Map();
@@ -168,11 +259,15 @@ export class QqHarnessBridge {
168
259
  replyTimeoutMs = 600_000,
169
260
  signal,
170
261
  fetchImpl = fetch,
262
+ fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
171
263
  }) {
172
264
  if (!bot || typeof bot.sendText !== 'function') throw new TypeError('QQ bot client is required');
173
265
  if (!ownerUserOpenid) throw new TypeError('QQ scanner identity is required');
174
266
  if (!harness || !state) throw new TypeError('Harness client and state store are required');
175
267
  if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
268
+ if (!Number.isInteger(fileUploadTimeoutMs) || fileUploadTimeoutMs < 1) {
269
+ throw new TypeError('fileUploadTimeoutMs must be a positive integer');
270
+ }
176
271
  this.#bot = bot;
177
272
  this.#ownerUserOpenid = ownerUserOpenid;
178
273
  this.#harness = harness;
@@ -182,6 +277,7 @@ export class QqHarnessBridge {
182
277
  this.#replyTimeoutMs = replyTimeoutMs;
183
278
  this.#signal = signal;
184
279
  this.#fetchImpl = fetchImpl;
280
+ this.#fileUploadTimeoutMs = Math.min(fileUploadTimeoutMs, DEFAULT_FILE_UPLOAD_TIMEOUT_MS);
185
281
  this.#approvals = new HarnessApprovalQueue({ label: 'qq', logger });
186
282
  }
187
283
 
@@ -343,6 +439,87 @@ export class QqHarnessBridge {
343
439
  this.#status.lastError = null;
344
440
  }
345
441
 
442
+ async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt = null) {
443
+ if (artifacts.length === 0) {
444
+ return { receipt: baseReceipt, failureNoticeVisible: false };
445
+ }
446
+ const receipts = baseReceipt ? [baseReceipt] : [];
447
+ let failureNoticeVisible = false;
448
+ for (const artifact of artifacts) {
449
+ this.#signal?.throwIfAborted();
450
+ try {
451
+ if (typeof this.#bot.sendFile !== 'function') {
452
+ const unavailable = new Error('QQ file delivery is unavailable');
453
+ unavailable.code = 'artifact-provider-unavailable';
454
+ throw unavailable;
455
+ }
456
+ const file = await materializeOutboundArtifact(artifact, {
457
+ signal: this.#signal,
458
+ });
459
+ this.#signal?.throwIfAborted();
460
+ let result;
461
+ try {
462
+ const timeout = AbortSignal.timeout(this.#fileUploadTimeoutMs);
463
+ const waitSignal = this.#signal ? AbortSignal.any([this.#signal, timeout]) : timeout;
464
+ const pending = this.#bot.sendFile(
465
+ target,
466
+ { buffer: file.bytes },
467
+ {
468
+ fileName: file.fileName,
469
+ onProgress: () => this.#signal?.throwIfAborted(),
470
+ },
471
+ );
472
+ trackOutboundArtifactProviderPromise(file, pending);
473
+ result = await waitWithSignal(pending, waitSignal);
474
+ } catch (error) {
475
+ if (this.#signal?.aborted) throw abortReason(this.#signal);
476
+ throw qqArtifactError(error, { dispatched: true });
477
+ }
478
+ this.#signal?.throwIfAborted();
479
+ const messageId = nonEmptyString(result?.message?.id);
480
+ receipts.push(createDeliveryReceipt({
481
+ deliveryId: file.deliveryKey,
482
+ presentation: 'qq-file',
483
+ providerMessageIds: messageId ? [messageId] : [],
484
+ artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
485
+ }));
486
+ this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
487
+ } catch (rawError) {
488
+ if (this.#signal?.aborted) throw rawError;
489
+ const error = qqArtifactError(rawError);
490
+ this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
491
+ this.#logger.warn?.(
492
+ `[dsh-im:qq] result file delivery failed (${error?.code ?? error?.name ?? 'unknown'})`,
493
+ );
494
+ let providerMessageIds = [];
495
+ try {
496
+ const notice = await this.#bot.sendText(target, artifactFailureText(artifact?.fileName, error));
497
+ failureNoticeVisible = true;
498
+ providerMessageIds = providerMessageIdsFor(notice);
499
+ } catch (noticeError) {
500
+ if (this.#signal?.aborted) throw noticeError;
501
+ this.#logger.warn?.('[dsh-im:qq] unable to send the safe result-file failure notice');
502
+ }
503
+ receipts.push(createArtifactFailureReceipt({
504
+ artifactId: artifact?.artifactId ?? 'unknown',
505
+ deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
506
+ error,
507
+ providerMessageIds,
508
+ }));
509
+ } finally {
510
+ releaseOutboundArtifact(artifact);
511
+ }
512
+ }
513
+ return {
514
+ receipt: mergeDeliveryReceipts({
515
+ deliveryId: replyTo,
516
+ presentation: baseReceipt ? 'qq-text-and-files' : 'qq-files',
517
+ receipts,
518
+ }),
519
+ failureNoticeVisible,
520
+ };
521
+ }
522
+
346
523
  async #process(message, key, { alreadyRecorded = false } = {}) {
347
524
  if (this.#signal?.aborted) return;
348
525
  const messageId = nonEmptyString(message?.messageId);
@@ -427,8 +604,9 @@ export class QqHarnessBridge {
427
604
  }
428
605
  }
429
606
  let answer;
607
+ let artifacts = [];
430
608
  try {
431
- ({ answer } = await askInWorkspaceSession({
609
+ ({ answer, artifacts = [] } = await askInWorkspaceSession({
432
610
  harness: this.#harness,
433
611
  state: this.#state,
434
612
  key,
@@ -462,21 +640,50 @@ export class QqHarnessBridge {
462
640
  this.#approvals.closeRoute(key),
463
641
  ]);
464
642
  }
465
- if (stream) {
466
- try {
467
- await stream.update(answer);
468
- await stream.complete();
469
- streamFinished = true;
470
- } catch (error) {
471
- stream.cancel?.();
472
- this.#logger.warn?.('[dsh-im:qq] QQ stream finalization failed; using a text reply:', error);
643
+ this.#signal?.throwIfAborted();
644
+ const displayAnswer = answerTextForDelivery(answer, artifacts);
645
+ let textReceipt = null;
646
+ let textSendError = null;
647
+ try {
648
+ if (stream) {
649
+ try {
650
+ await stream.update(displayAnswer);
651
+ await stream.complete();
652
+ streamFinished = true;
653
+ textReceipt = createDeliveryReceipt({
654
+ deliveryId: messageId,
655
+ presentation: 'qq-text',
656
+ providerMessageIds: providerMessageIdsFor(stream),
657
+ });
658
+ } catch (error) {
659
+ stream.cancel?.();
660
+ this.#logger.warn?.('[dsh-im:qq] QQ stream finalization failed; using a text reply:', error);
661
+ }
473
662
  }
663
+ if (!streamFinished) {
664
+ const sent = await this.#bot.sendText(target, displayAnswer);
665
+ textReceipt = createDeliveryReceipt({
666
+ deliveryId: messageId,
667
+ presentation: 'qq-text',
668
+ providerMessageIds: providerMessageIdsFor(sent),
669
+ });
670
+ }
671
+ } catch (error) {
672
+ textSendError = error;
673
+ this.#logger.warn?.('[dsh-im:qq] final text delivery failed; continuing with result files:', error);
674
+ }
675
+ const delivery = await this.#deliverArtifacts(target, messageId, artifacts, textReceipt);
676
+ const artifactDispatched = delivery.receipt?.artifacts?.some(
677
+ ({ outcome }) => outcome === 'sent' || outcome === 'unknown',
678
+ );
679
+ if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
680
+ throw textSendError;
474
681
  }
475
- if (!streamFinished) await this.#bot.sendText(target, answer);
476
682
  await this.#state.markSeen(messageId);
477
683
  this.#status.messagesReplied += 1;
478
684
  this.#status.lastReplyAt = new Date().toISOString();
479
685
  this.#status.lastError = null;
686
+ return delivery.receipt;
480
687
  } catch (error) {
481
688
  if (error?.code === 'turn-stopped') {
482
689
  if (stream) {
@@ -21,9 +21,11 @@ export function createEditableMessageStream({
21
21
  create,
22
22
  edit,
23
23
  sendRemainder,
24
+ messageIdForResult = () => null,
24
25
  logger = console,
25
26
  }) {
26
27
  let messageId;
28
+ const providerMessageIds = [];
27
29
  let pending = null;
28
30
  let timer = null;
29
31
  let inFlight = null;
@@ -49,8 +51,17 @@ export function createEditableMessageStream({
49
51
  };
50
52
 
51
53
  return {
54
+ get messageId() {
55
+ return messageId;
56
+ },
57
+ get providerMessageIds() {
58
+ return [...providerMessageIds];
59
+ },
52
60
  async start() {
53
61
  messageId = await create(initialText);
62
+ if ((typeof messageId === 'string' && messageId.trim()) || Number.isSafeInteger(messageId)) {
63
+ providerMessageIds.push(String(messageId));
64
+ }
54
65
  return this;
55
66
  },
56
67
  update(text) {
@@ -69,7 +80,13 @@ export function createEditableMessageStream({
69
80
  const first = chunks[0] ?? '处理完成。';
70
81
  if (first !== lastSent) await edit(messageId, first);
71
82
  lastSent = first;
72
- for (const chunk of chunks.slice(1)) await sendRemainder(chunk);
83
+ for (const chunk of chunks.slice(1)) {
84
+ const result = await sendRemainder(chunk);
85
+ const id = messageIdForResult(result);
86
+ if ((typeof id === 'string' && id.trim()) || Number.isSafeInteger(id)) {
87
+ providerMessageIds.push(String(id));
88
+ }
89
+ }
73
90
  },
74
91
  cancel() {
75
92
  closed = true;
@@ -3,11 +3,71 @@ import { randomUUID } from 'node:crypto';
3
3
  import { isAbsolute } from 'node:path';
4
4
 
5
5
  import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
6
+ import { outboundArtifactRegistry } from './semantic/artifact.mjs';
6
7
 
7
8
  // Every channel plugin runs in the same Host process. Sharing ownership by
8
9
  // Harness origin prevents two channel-specific clients bound to one Session
9
10
  // from claiming or cancelling each other's interactions.
10
11
  const interactionRegistries = new Map();
12
+ const MAX_ERROR_CLASSIFICATION_BYTES = 64;
13
+
14
+ async function smallResponseText(response) {
15
+ const stream = response?.body;
16
+ if (!stream || typeof stream.getReader !== 'function') return null;
17
+
18
+ const reader = stream.getReader();
19
+ const chunks = [];
20
+ let length = 0;
21
+ try {
22
+ while (true) {
23
+ const { done, value } = await reader.read();
24
+ if (done) break;
25
+ if (!(value instanceof Uint8Array)
26
+ || length + value.byteLength > MAX_ERROR_CLASSIFICATION_BYTES) return null;
27
+ chunks.push(value);
28
+ length += value.byteLength;
29
+ }
30
+ } catch {
31
+ return null;
32
+ } finally {
33
+ try {
34
+ await reader.cancel();
35
+ } catch {
36
+ // The response body is diagnostic-only; cancellation failures do not
37
+ // replace the HTTP status that caused the transport error.
38
+ }
39
+ }
40
+
41
+ const bytes = new Uint8Array(length);
42
+ let offset = 0;
43
+ for (const chunk of chunks) {
44
+ bytes.set(chunk, offset);
45
+ offset += chunk.byteLength;
46
+ }
47
+ return new TextDecoder().decode(bytes);
48
+ }
49
+
50
+ function isLoopbackHarnessHostname(hostname) {
51
+ if (hostname === 'localhost' || hostname === '[::1]') return true;
52
+ const parts = hostname.split('.');
53
+ return parts.length === 4
54
+ && parts[0] === '127'
55
+ && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
56
+ }
57
+
58
+ async function harnessHttpErrorCode(response, hostname) {
59
+ if (response.status === 401) return 'harness-auth-required';
60
+ if (response.status === 407) return 'harness-proxy-auth-required';
61
+ if (response.status === 403) {
62
+ const body = await smallResponseText(response);
63
+ if (body?.trim() !== 'forbidden') return 'harness-request-forbidden';
64
+ return isLoopbackHarnessHostname(hostname)
65
+ ? 'harness-loopback-forbidden'
66
+ : 'harness-host-untrusted';
67
+ }
68
+ if (response.status === 404) return 'harness-api-not-found';
69
+ return 'harness-http-failed';
70
+ }
11
71
 
12
72
  function interactionRegistry(origin) {
13
73
  let registry = interactionRegistries.get(origin);
@@ -494,11 +554,7 @@ export class HarnessClient {
494
554
  );
495
555
  }
496
556
  if (!response.ok) {
497
- const code = response.status === 401 || response.status === 403
498
- ? 'harness-access-denied'
499
- : response.status === 404
500
- ? 'harness-api-not-found'
501
- : 'harness-http-failed';
557
+ const code = await harnessHttpErrorCode(response, this.#baseUrl.hostname);
502
558
  throw new HarnessTransportError(code, method, { status: response.status });
503
559
  }
504
560
  let body;
@@ -970,6 +1026,7 @@ export class HarnessClient {
970
1026
  const timeoutMs = options.timeoutMs ?? 600_000;
971
1027
  const signal = options.signal;
972
1028
  const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null;
1029
+ const onArtifact = typeof options.onArtifact === 'function' ? options.onArtifact : null;
973
1030
  const onInteraction = typeof options.onInteraction === 'function'
974
1031
  ? options.onInteraction
975
1032
  : undefined;
@@ -1016,11 +1073,34 @@ export class HarnessClient {
1016
1073
  }
1017
1074
  : null;
1018
1075
  let interactionTask = null;
1076
+ let artifactsDelivered = false;
1077
+ let deliveredArtifactCount = 0;
1078
+
1079
+ const deliverArtifacts = async () => {
1080
+ if (!onArtifact || artifactsDelivered || tracker.turn === null) {
1081
+ return deliveredArtifactCount;
1082
+ }
1083
+ artifactsDelivered = true;
1084
+ const artifacts = outboundArtifactRegistry.take(sessionId, tracker.turn, { signal });
1085
+ for (const artifact of artifacts) {
1086
+ try {
1087
+ await onArtifact(artifact);
1088
+ deliveredArtifactCount += 1;
1089
+ } catch (error) {
1090
+ outboundArtifactRegistry.release(artifact);
1091
+ console.warn(`[${this.#logPrefix}] ignored an artifact handoff failure:`, error.message);
1092
+ }
1093
+ }
1094
+ return deliveredArtifactCount;
1095
+ };
1019
1096
 
1020
1097
  if (ownership) {
1021
1098
  this.#registerInteractionOwnership(sessionId, ownership);
1022
1099
  this.#registerControlOwnership(ownership);
1023
1100
  }
1101
+ // This is resource ownership, not a feature Gate: it lets the Host retain
1102
+ // this Turn's snapshots until the channel has polled and claimed them.
1103
+ const closeArtifactConsumer = outboundArtifactRegistry.openConsumer(sessionId, promptRpcId);
1024
1104
 
1025
1105
  try {
1026
1106
  if (interactionSignal) {
@@ -1079,7 +1159,15 @@ export class HarnessClient {
1079
1159
  }
1080
1160
  }
1081
1161
  if (!tracker.finished) continue;
1082
- if (tracker.answer) return tracker.answer;
1162
+ // An accepted /stop revokes attachment delivery even when Harness
1163
+ // preserved a useful partial text answer for the existing UX.
1164
+ const artifactCount = ownership?.stopRequested
1165
+ ? 0
1166
+ : await deliverArtifacts();
1167
+ if (tracker.answer) {
1168
+ return tracker.answer;
1169
+ }
1170
+ if (artifactCount > 0) return '';
1083
1171
  if (ownership?.stopRequested) throw turnStoppedError();
1084
1172
  throw new Error(
1085
1173
  `Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ''}`,
@@ -1090,15 +1178,19 @@ export class HarnessClient {
1090
1178
  // Once cancellation was accepted, transport/poll failures and timeouts
1091
1179
  // describe the convergence of that stop, not an unrelated ask failure.
1092
1180
  if (!ownership?.stopRequested) throw error;
1093
- if (tracker.answer) return tracker.answer;
1181
+ if (tracker.answer) {
1182
+ return tracker.answer;
1183
+ }
1094
1184
  if (error?.code === 'turn-stopped') throw error;
1095
1185
  throw turnStoppedError();
1096
1186
  }
1097
1187
  } finally {
1188
+ closeArtifactConsumer();
1098
1189
  if (ownership) {
1099
1190
  this.#unregisterControlOwnership(ownership);
1100
1191
  this.#unregisterInteractionOwnership(sessionId, ownership);
1101
1192
  }
1193
+ if (tracker.turn !== null) outboundArtifactRegistry.discard(sessionId, tracker.turn);
1102
1194
  interactionController?.abort(new DOMException('Harness turn finished', 'AbortError'));
1103
1195
  if (interactionTask) await interactionTask.catch(() => undefined);
1104
1196
  }