@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.
- package/README.en.md +23 -3
- package/README.md +23 -3
- package/assets/logo-dsh-im-chinese-readme-3x2.png +0 -0
- package/assets/logo_cn.png +0 -0
- package/lib/client.js +815 -560
- package/lib/index.js +163 -163
- package/package.json +1 -1
- package/plugin-src/client/agent-preset.js +15 -6
- package/plugin-src/client/channel-card-meta.js +48 -0
- package/plugin-src/client/channels/dingtalk/index.js +25 -19
- package/plugin-src/client/channels/dingtalk/styles.js +0 -6
- package/plugin-src/client/channels/feishu/index.js +41 -35
- package/plugin-src/client/channels/feishu/styles.js +0 -5
- package/plugin-src/client/channels/qq/index.js +24 -16
- package/plugin-src/client/channels/shared/token-channel.js +32 -24
- package/plugin-src/client/channels/wecom/index.js +24 -16
- package/plugin-src/client/channels/weixin/index.js +29 -23
- package/plugin-src/client/channels/weixin/styles.js +0 -5
- package/plugin-src/client/channels/whatsapp/api.js +11 -0
- package/plugin-src/client/channels/whatsapp/index.js +152 -23
- package/plugin-src/client/channels/whatsapp/styles.js +25 -0
- package/plugin-src/client/i18n.js +20 -0
- package/plugin-src/client/styles.js +23 -8
- package/plugin-src/host/channels/whatsapp/rpc.mjs +19 -1
- package/plugin-src/host/index.mjs +14 -1
- package/src/channels/dingtalk/dingtalk-api.mjs +215 -2
- package/src/channels/dingtalk/dingtalk-bridge.mjs +155 -4
- package/src/channels/discord/discord-api.mjs +134 -6
- package/src/channels/discord/discord-runtime.mjs +15 -4
- package/src/channels/feishu/bridge.mjs +223 -15
- package/src/channels/feishu/feishu-channel.mjs +227 -1
- package/src/channels/feishu/plugin-controller.mjs +1 -0
- package/src/channels/qq/qq-bridge.mjs +217 -10
- package/src/channels/shared/editable-message-stream.mjs +18 -1
- package/src/channels/shared/harness-client.mjs +99 -7
- package/src/channels/shared/semantic/artifact.mjs +748 -0
- package/src/channels/shared/semantic/delivery.mjs +153 -0
- package/src/channels/shared/text-harness-bridge.mjs +149 -3
- package/src/channels/shared/workspace-session.mjs +15 -1
- package/src/channels/slack/manifest.mjs +1 -0
- package/src/channels/slack/slack-api.mjs +167 -4
- package/src/channels/slack/slack-runtime.mjs +21 -5
- package/src/channels/telegram/telegram-api.mjs +111 -5
- package/src/channels/telegram/telegram-runtime.mjs +18 -4
- package/src/channels/wecom/wecom-bridge.mjs +260 -12
- package/src/channels/weixin/weixin-api.mjs +268 -2
- package/src/channels/weixin/weixin-bridge.mjs +134 -3
- package/src/channels/weixin/weixin-controller.mjs +5 -1
- package/src/channels/weixin/weixin-runtime.mjs +5 -1
- package/src/channels/whatsapp/config-store.mjs +43 -0
- package/src/channels/whatsapp/whatsapp-controller.mjs +22 -1
- 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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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))
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
}
|