@xmanrui/dsh-im 0.9.0 → 0.11.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 (69) hide show
  1. package/README.en.md +7 -3
  2. package/README.md +7 -3
  3. package/lib/client.js +378 -27
  4. package/lib/index.js +134 -131
  5. package/package.json +1 -1
  6. package/plugin-src/client/channels/dingtalk/api.js +19 -0
  7. package/plugin-src/client/channels/dingtalk/index.js +67 -10
  8. package/plugin-src/client/channels/feishu/index.js +35 -5
  9. package/plugin-src/client/channels/qq/api.js +21 -0
  10. package/plugin-src/client/channels/qq/index.js +39 -1
  11. package/plugin-src/client/channels/shared/token-channel.js +29 -3
  12. package/plugin-src/client/channels/wecom/api.js +11 -0
  13. package/plugin-src/client/channels/wecom/index.js +71 -1
  14. package/plugin-src/client/channels/weixin/api.js +11 -0
  15. package/plugin-src/client/channels/weixin/index.js +40 -4
  16. package/plugin-src/client/channels/whatsapp/index.js +41 -2
  17. package/plugin-src/client/i18n.js +13 -0
  18. package/plugin-src/host/channels/dingtalk/production.mjs +1 -1
  19. package/plugin-src/host/channels/dingtalk/rpc.mjs +29 -3
  20. package/plugin-src/host/channels/feishu/production.mjs +1 -1
  21. package/plugin-src/host/channels/feishu/rpc.mjs +34 -3
  22. package/plugin-src/host/channels/qq/production.mjs +1 -1
  23. package/plugin-src/host/channels/qq/rpc.mjs +35 -2
  24. package/plugin-src/host/channels/shared/production.mjs +1 -1
  25. package/plugin-src/host/channels/shared/rpc.mjs +25 -1
  26. package/plugin-src/host/channels/slack/production.mjs +1 -1
  27. package/plugin-src/host/channels/slack/rpc.mjs +28 -2
  28. package/plugin-src/host/channels/wecom/production.mjs +1 -1
  29. package/plugin-src/host/channels/wecom/rpc.mjs +31 -2
  30. package/plugin-src/host/channels/weixin/production.mjs +1 -1
  31. package/plugin-src/host/channels/weixin/rpc.mjs +29 -3
  32. package/plugin-src/host/channels/whatsapp/production.mjs +1 -1
  33. package/plugin-src/host/channels/whatsapp/rpc.mjs +31 -3
  34. package/src/channels/dingtalk/dingtalk-api.mjs +82 -1
  35. package/src/channels/dingtalk/dingtalk-bridge.mjs +160 -20
  36. package/src/channels/dingtalk/dingtalk-controller.mjs +18 -0
  37. package/src/channels/dingtalk/dingtalk-runtime.mjs +21 -0
  38. package/src/channels/discord/discord-api.mjs +1 -1
  39. package/src/channels/discord/discord-runtime.mjs +54 -1
  40. package/src/channels/feishu/bridge.mjs +45 -18
  41. package/src/channels/feishu/feishu-runtime.mjs +45 -0
  42. package/src/channels/feishu/message-utils.mjs +227 -6
  43. package/src/channels/feishu/multi-bot-controller.mjs +21 -0
  44. package/src/channels/feishu/plugin-controller.mjs +1 -0
  45. package/src/channels/qq/qq-bridge.mjs +107 -20
  46. package/src/channels/qq/qq-controller.mjs +18 -0
  47. package/src/channels/qq/qq-runtime.mjs +26 -0
  48. package/src/channels/shared/bot-workspace-store.mjs +2 -0
  49. package/src/channels/shared/connection-test.mjs +54 -0
  50. package/src/channels/shared/harness-client.mjs +14 -8
  51. package/src/channels/shared/image-prompt.mjs +268 -0
  52. package/src/channels/shared/text-harness-bridge.mjs +62 -16
  53. package/src/channels/shared/token-bot-controller.mjs +21 -0
  54. package/src/channels/shared/workspace-session.mjs +2 -1
  55. package/src/channels/slack/manifest.mjs +1 -0
  56. package/src/channels/slack/slack-api.mjs +95 -0
  57. package/src/channels/slack/slack-controller.mjs +19 -0
  58. package/src/channels/slack/slack-runtime.mjs +34 -3
  59. package/src/channels/telegram/telegram-api.mjs +37 -0
  60. package/src/channels/telegram/telegram-runtime.mjs +81 -9
  61. package/src/channels/wecom/wecom-bridge.mjs +160 -23
  62. package/src/channels/wecom/wecom-controller.mjs +18 -0
  63. package/src/channels/wecom/wecom-runtime.mjs +18 -0
  64. package/src/channels/weixin/weixin-api.mjs +98 -2
  65. package/src/channels/weixin/weixin-bridge.mjs +52 -19
  66. package/src/channels/weixin/weixin-controller.mjs +18 -0
  67. package/src/channels/weixin/weixin-runtime.mjs +25 -0
  68. package/src/channels/whatsapp/whatsapp-controller.mjs +20 -0
  69. package/src/channels/whatsapp/whatsapp-runtime.mjs +160 -1
@@ -1,5 +1,6 @@
1
1
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
2
2
  import { runCompactCommand } from '../shared/compact-command.mjs';
3
+ import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
3
4
  import {
4
5
  harnessAnswerForQuestion,
5
6
  harnessQuestionText,
@@ -7,13 +8,30 @@ import {
7
8
  } from '../shared/harness-question.mjs';
8
9
  import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
9
10
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
11
+ import {
12
+ fetchImageBuffer,
13
+ hasInboundImages,
14
+ imagePromptUserMessage,
15
+ promptContentForMessage,
16
+ } from '../shared/image-prompt.mjs';
10
17
 
11
18
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
12
19
 
20
+ export const QQ_IMAGE_HOSTS = Object.freeze([
21
+ '.myqcloud.com',
22
+ '.qpic.cn',
23
+ '.qq.com',
24
+ '.qq.com.cn',
25
+ '.tencentcos.com',
26
+ '.ugcimg.cn',
27
+ ]);
28
+
29
+ const QQ_IMAGE_FILENAME = /\.(?:gif|jpe?g|png|webp)$/i;
30
+
13
31
  const HELP_TEXT = [
14
32
  'QQ 机器人已连接 DeepSeek Harness。',
15
33
  '',
16
- '直接发送文字即可继续当前会话。',
34
+ '直接发送文字或图片即可继续当前会话。',
17
35
  '/new 开启一个全新会话',
18
36
  '/compact 压缩当前会话的较早上下文',
19
37
  '/workspace 工作区绝对路径 切换工作区',
@@ -32,6 +50,51 @@ function safeText(message) {
32
50
  return typeof message?.content === 'string' ? message.content.trim() : '';
33
51
  }
34
52
 
53
+ function attachmentMediaType(attachment) {
54
+ const value = nonEmptyString(attachment?.content_type ?? attachment?.contentType);
55
+ if (!value) return null;
56
+ return value.split(';', 1)[0].trim().toLowerCase();
57
+ }
58
+
59
+ function isQqImageAttachment(attachment) {
60
+ const mediaType = attachmentMediaType(attachment);
61
+ return mediaType?.startsWith('image/') === true
62
+ || QQ_IMAGE_FILENAME.test(nonEmptyString(attachment?.filename) ?? '');
63
+ }
64
+
65
+ function hasQqImageAttachments(message) {
66
+ return Array.isArray(message?.attachments)
67
+ && message.attachments.some(isQqImageAttachment);
68
+ }
69
+
70
+ /** Convert QQ's attachment metadata into lazily downloaded image references. */
71
+ export function qqInboundMessage(message, { fetchImpl = fetch } = {}) {
72
+ if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
73
+ const images = [];
74
+ for (const attachment of message?.attachments ?? []) {
75
+ if (!isQqImageAttachment(attachment)) continue;
76
+ const url = nonEmptyString(attachment?.url);
77
+ const name = nonEmptyString(attachment?.filename) ?? undefined;
78
+ const mediaType = attachmentMediaType(attachment);
79
+ const declaredSize = Number(attachment?.size);
80
+ images.push({
81
+ ...(name ? { name } : {}),
82
+ ...(mediaType?.startsWith('image/') ? { mediaType } : {}),
83
+ ...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
84
+ load: ({ signal, maxBytes }) => {
85
+ if (!url) throw new Error('QQ image attachment has no download URL');
86
+ return fetchImageBuffer(url, {
87
+ fetchImpl,
88
+ signal,
89
+ maxBytes,
90
+ allowedHosts: QQ_IMAGE_HOSTS,
91
+ });
92
+ },
93
+ });
94
+ }
95
+ return { content: safeText(message), images };
96
+ }
97
+
35
98
  function nonEmptyString(value) {
36
99
  return typeof value === 'string' && value.trim() ? value.trim() : null;
37
100
  }
@@ -40,6 +103,7 @@ function canClaimInteractionReply(message, pending) {
40
103
  return pending.questions[pending.index]
41
104
  && nonEmptyString(message?.senderId) === pending.actor
42
105
  && (message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE')
106
+ && !hasQqImageAttachments(message)
43
107
  && nonEmptyString(safeText(message));
44
108
  }
45
109
 
@@ -64,6 +128,7 @@ export class QqHarnessBridge {
64
128
  #logger;
65
129
  #replyTimeoutMs;
66
130
  #signal;
131
+ #fetchImpl;
67
132
  #queues = new Map();
68
133
  #pendingInteractions = new Map();
69
134
  #interactionKeys = new Map();
@@ -80,10 +145,12 @@ export class QqHarnessBridge {
80
145
  logger = console,
81
146
  replyTimeoutMs = 600_000,
82
147
  signal,
148
+ fetchImpl = fetch,
83
149
  }) {
84
150
  if (!bot || typeof bot.sendText !== 'function') throw new TypeError('QQ bot client is required');
85
151
  if (!ownerUserOpenid) throw new TypeError('QQ scanner identity is required');
86
152
  if (!harness || !state) throw new TypeError('Harness client and state store are required');
153
+ if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
87
154
  this.#bot = bot;
88
155
  this.#ownerUserOpenid = ownerUserOpenid;
89
156
  this.#harness = harness;
@@ -92,6 +159,7 @@ export class QqHarnessBridge {
92
159
  this.#logger = logger;
93
160
  this.#replyTimeoutMs = replyTimeoutMs;
94
161
  this.#signal = signal;
162
+ this.#fetchImpl = fetchImpl;
95
163
  this.#approvals = new HarnessApprovalQueue({ label: 'qq', logger });
96
164
  }
97
165
 
@@ -109,12 +177,18 @@ export class QqHarnessBridge {
109
177
  || this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
110
178
  const key = conversationKey(message);
111
179
  this.#acceptedMessageIds.add(messageId);
180
+ if (message.kind === 'c2c'
181
+ && (this.#ownerUserOpenid === '*' || sender === this.#ownerUserOpenid)
182
+ && message.replyTarget?.scope === 'c2c'
183
+ && nonEmptyString(message.replyTarget.targetId) === sender) {
184
+ rememberConnectionTestTarget(this.#state, message.replyTarget);
185
+ }
112
186
  const pending = this.#pendingInteractions.get(key);
113
187
  const approval = this.#approvals.claimReply({
114
188
  key,
115
189
  actor: sender,
116
190
  messageId,
117
- text: safeText(message),
191
+ text: hasQqImageAttachments(message) ? '' : safeText(message),
118
192
  addressed: message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE',
119
193
  hasPendingQuestion: Boolean(pending),
120
194
  questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@@ -210,32 +284,37 @@ export class QqHarnessBridge {
210
284
  if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
211
285
 
212
286
  const target = message.replyTarget;
213
- const text = safeText(message);
287
+ const promptMessage = qqInboundMessage(message, { fetchImpl: this.#fetchImpl });
288
+ const text = promptMessage.content;
289
+ const hasImages = hasInboundImages(promptMessage);
290
+ let stream = null;
214
291
  try {
215
- if (!text) {
216
- await this.#bot.sendText(target, '目前仅支持文字消息。');
292
+ if (!text && !hasImages) {
293
+ await this.#bot.sendText(target, '目前支持文字和图片消息。');
217
294
  await this.#state.markSeen(messageId);
218
295
  return;
219
296
  }
220
297
  const command = text.toLowerCase();
221
- if (command === '/help') {
298
+ if (!hasImages && command === '/help') {
222
299
  await this.#bot.sendText(target, HELP_TEXT);
223
300
  await this.#state.markSeen(messageId);
224
301
  return;
225
302
  }
226
- if (command === '/status') {
303
+ if (!hasImages && command === '/status') {
227
304
  await this.#harness.ensureRunning({ signal: this.#signal });
228
305
  await this.#bot.sendText(target, 'QQ 机器人与 DeepSeek Harness 连接正常。');
229
306
  await this.#state.markSeen(messageId);
230
307
  return;
231
308
  }
232
- if (command === '/new') {
309
+ if (!hasImages && command === '/new') {
233
310
  await this.#state.clearSession(key);
234
311
  await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
235
312
  await this.#state.markSeen(messageId);
236
313
  return;
237
314
  }
238
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
315
+ const workspaceCommand = hasImages
316
+ ? null
317
+ : await runWorkspaceCommand(text, this.#harness, key);
239
318
  if (workspaceCommand) {
240
319
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
241
320
  await this.#bot.sendText(target, reply);
@@ -243,20 +322,24 @@ export class QqHarnessBridge {
243
322
  await this.#state.markSeen(messageId);
244
323
  return;
245
324
  }
246
- const compactCommand = await runCompactCommand(
247
- text,
248
- this.#harness,
249
- this.#state,
250
- key,
251
- { signal: this.#signal },
252
- );
325
+ const compactCommand = hasImages
326
+ ? null
327
+ : await runCompactCommand(
328
+ text,
329
+ this.#harness,
330
+ this.#state,
331
+ key,
332
+ { signal: this.#signal },
333
+ );
253
334
  if (compactCommand) {
254
335
  await this.#bot.sendText(target, compactCommand.message);
255
336
  await this.#state.markSeen(messageId);
256
337
  return;
257
338
  }
258
339
 
259
- let stream = null;
340
+ const content = hasImages
341
+ ? await promptContentForMessage(promptMessage, { signal: this.#signal })
342
+ : undefined;
260
343
  let streamFinished = false;
261
344
  if (message.kind === 'c2c' && target?.msgId && typeof this.#bot.openStream === 'function') {
262
345
  try {
@@ -271,7 +354,7 @@ export class QqHarnessBridge {
271
354
  harness: this.#harness,
272
355
  state: this.#state,
273
356
  key,
274
- text,
357
+ ...(hasImages ? { content } : { text }),
275
358
  createOptions: { signal: this.#signal },
276
359
  existsOptions: { signal: this.#signal },
277
360
  askOptions: {
@@ -316,11 +399,15 @@ export class QqHarnessBridge {
316
399
  this.#status.lastReplyAt = new Date().toISOString();
317
400
  this.#status.lastError = null;
318
401
  } catch (error) {
402
+ stream?.cancel?.();
319
403
  if (this.#signal?.aborted) return;
320
404
  this.#status.lastError = error?.message ?? String(error);
321
405
  this.#logger.error?.('[dsh-im:qq] failed to process an inbound message:', error);
322
406
  try {
323
- await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
407
+ await this.#bot.sendText(
408
+ target,
409
+ imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。',
410
+ );
324
411
  await this.#state.markSeen(messageId);
325
412
  } catch (sendError) {
326
413
  this.#logger.error?.('[dsh-im:qq] failed to send the safe error reply:', sendError);
@@ -345,7 +432,7 @@ export class QqHarnessBridge {
345
432
 
346
433
  if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
347
434
  const text = nonEmptyString(safeText(message));
348
- if (!text) {
435
+ if (!text || hasQqImageAttachments(message)) {
349
436
  await this.#bot.sendText(message.replyTarget, '请用文字回答当前问题。');
350
437
  return;
351
438
  }
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
+ import { connectionTestMessage } from '../shared/connection-test.mjs';
3
4
  import { deriveQqBotIdentity, maskQqAppId } from './config-store.mjs';
4
5
 
5
6
  const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting']);
@@ -248,6 +249,23 @@ export class QqController {
248
249
  return this.status();
249
250
  }
250
251
 
252
+ async sendConnectionTest(botId) {
253
+ const config = this.#configStore.get(botId);
254
+ if (!config) throw new Error('Unknown QQ bot');
255
+ return this.#withBotTransition(botId, async () => {
256
+ const runtime = this.#runtimes.get(botId);
257
+ if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
258
+ const error = new Error('QQ机器人尚未连接');
259
+ error.code = 'test-target-unavailable';
260
+ throw error;
261
+ }
262
+ await runtime.sendConnectionTest(connectionTestMessage(
263
+ `QQ 机器人(${maskQqAppId(config.appId)})`,
264
+ ));
265
+ return { sent: true };
266
+ });
267
+ }
268
+
251
269
  async deleteBot(botId) {
252
270
  const config = this.#configStore.get(botId);
253
271
  if (!config) throw new Error('Unknown QQ bot');
@@ -1,5 +1,9 @@
1
1
  import { QQBot, typingIndicator } from '@tencent-connect/qqbot-nodejs';
2
2
 
3
+ import {
4
+ connectionTestTarget,
5
+ connectionTestTargetUnavailable,
6
+ } from '../shared/connection-test.mjs';
3
7
  import { createQqBridgeStatus, QqHarnessBridge } from './qq-bridge.mjs';
4
8
 
5
9
  function timeoutError() {
@@ -67,6 +71,28 @@ export class QqRuntime {
67
71
  return structuredClone(this.#status);
68
72
  }
69
73
 
74
+ async sendConnectionTest(text) {
75
+ if (!this.#status.ready || !this.#bot) {
76
+ throw connectionTestTargetUnavailable('QQ机器人');
77
+ }
78
+ const ownerUserOpenid = typeof this.#config.ownerUserOpenid === 'string'
79
+ ? this.#config.ownerUserOpenid.trim()
80
+ : '';
81
+ const remembered = connectionTestTarget(this.#state);
82
+ const rememberedUserOpenid = remembered?.scope === 'c2c'
83
+ && typeof remembered.targetId === 'string'
84
+ ? remembered.targetId.trim()
85
+ : '';
86
+ const target = rememberedUserOpenid
87
+ ? { scope: 'c2c', targetId: rememberedUserOpenid }
88
+ : (ownerUserOpenid && ownerUserOpenid !== '*'
89
+ ? { scope: 'c2c', targetId: ownerUserOpenid }
90
+ : null);
91
+ if (!target) throw connectionTestTargetUnavailable('QQ机器人');
92
+ await this.#bot.sendText(target, text);
93
+ return { sent: true };
94
+ }
95
+
70
96
  async start() {
71
97
  if (this.#status.ready && this.#bot) return this.status;
72
98
  if (this.#starting) return this.#starting;
@@ -9,6 +9,7 @@ import {
9
9
  } from 'node:fs/promises';
10
10
  import { dirname, isAbsolute, resolve } from 'node:path';
11
11
 
12
+ import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs';
12
13
  import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
13
14
 
14
15
  const EMPTY_DOCUMENT = Object.freeze({ version: 1, workspaces: Object.freeze({}) });
@@ -687,6 +688,7 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
687
688
  });
688
689
  const scopedState = new Proxy(state, {
689
690
  get(target, property) {
691
+ if (property === CONNECTION_TEST_STATE_IDENTITY) return target;
690
692
  if (property === 'sessionFor') {
691
693
  return (key, ...args) => {
692
694
  if (!isCurrentScope()) return null;
@@ -0,0 +1,54 @@
1
+ const targets = new WeakMap();
2
+
3
+ export const CONNECTION_TEST_STATE_IDENTITY = Symbol('dsh-im.connection-test-state-identity');
4
+
5
+ function stateIdentity(state) {
6
+ return state?.[CONNECTION_TEST_STATE_IDENTITY] ?? state;
7
+ }
8
+
9
+ function cleanText(value) {
10
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
11
+ }
12
+
13
+ export function rememberConnectionTestTarget(state, target) {
14
+ if (!state || !target || typeof target !== 'object') return false;
15
+ try {
16
+ targets.set(stateIdentity(state), structuredClone(target));
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ export function connectionTestTarget(state) {
24
+ const target = state ? targets.get(stateIdentity(state)) : null;
25
+ return target ? structuredClone(target) : null;
26
+ }
27
+
28
+ export function connectionTestMessage(botName, channelLabel = '机器人') {
29
+ const name = cleanText(botName) ?? channelLabel;
30
+ return `✅ DeepSeek Harness 连接测试成功\n这条消息由插件页面中的“${name}”机器人卡片发出。`;
31
+ }
32
+
33
+ export function connectionTestTargetUnavailable(channelLabel = '机器人') {
34
+ const error = new Error(`${channelLabel}尚未收到可用于测试的私聊消息。`);
35
+ error.code = 'test-target-unavailable';
36
+ return error;
37
+ }
38
+
39
+ export async function sendRememberedConnectionTest({ state, send, text, channelLabel }) {
40
+ const target = connectionTestTarget(state);
41
+ if (!target) throw connectionTestTargetUnavailable(channelLabel);
42
+ await send(target, text);
43
+ return { sent: true };
44
+ }
45
+
46
+ export function publicConnectionTestResult(error) {
47
+ if (!error) return Object.freeze({ sent: true });
48
+ return Object.freeze({
49
+ sent: false,
50
+ code: error?.code === 'test-target-unavailable'
51
+ ? 'test-target-unavailable'
52
+ : 'test-message-failed',
53
+ });
54
+ }
@@ -327,7 +327,7 @@ export class HarnessClient {
327
327
  constructor({
328
328
  baseUrl,
329
329
  workspace,
330
- agentPreset = 'standard',
330
+ agentPreset,
331
331
  autostart = false,
332
332
  dshBin = 'dsh',
333
333
  fetchImpl = fetch,
@@ -354,7 +354,8 @@ export class HarnessClient {
354
354
  }
355
355
  this.#baseUrl = new URL(baseUrl);
356
356
  this.#workspace = workspace;
357
- this.#agentPreset = agentPreset;
357
+ // Keep an omitted preset absent so session.create resolves the Host's current default.
358
+ this.#agentPreset = agentPreset ?? undefined;
358
359
  this.#autostart = autostart;
359
360
  this.#dshBin = dshBin;
360
361
  this.#fetch = fetchImpl;
@@ -458,10 +459,9 @@ export class HarnessClient {
458
459
  async createSession(options = {}) {
459
460
  await this.ensureRunning(options);
460
461
  const workspaceId = await this.workspaceId(options);
461
- const created = await this.rpc('session.create', {
462
- workspaceId,
463
- agentPreset: this.#agentPreset,
464
- }, 30_000, options);
462
+ const payload = { workspaceId };
463
+ if (this.#agentPreset !== undefined) payload.agentPreset = this.#agentPreset;
464
+ const created = await this.rpc('session.create', payload, 30_000, options);
465
465
  return created.sessionId;
466
466
  }
467
467
 
@@ -623,7 +623,7 @@ export class HarnessClient {
623
623
  return ownership ? { ownership, recovered: true } : null;
624
624
  }
625
625
 
626
- async ask(sessionId, text, options = {}) {
626
+ async ask(sessionId, prompt, options = {}) {
627
627
  if (typeof options === 'number') options = { timeoutMs: options };
628
628
  const timeoutMs = options.timeoutMs ?? 600_000;
629
629
  const signal = options.signal;
@@ -693,10 +693,16 @@ export class HarnessClient {
693
693
  ]);
694
694
  }
695
695
 
696
+ const content = typeof prompt === 'string'
697
+ ? [{ type: 'text', text: prompt }]
698
+ : prompt;
699
+ if (!Array.isArray(content) || content.length === 0) {
700
+ throw new TypeError('Harness prompt content is required');
701
+ }
696
702
  await this.rpc('session.prompt', {
697
703
  sessionId,
698
704
  mode: 'queue',
699
- content: [{ type: 'text', text }],
705
+ content,
700
706
  clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
701
707
  }, 30_000, { rpcId: promptRpcId, signal });
702
708