@botcord/daemon 0.2.58 → 0.2.60

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 (53) hide show
  1. package/dist/config.d.ts +4 -1
  2. package/dist/config.js +2 -2
  3. package/dist/cross-room.js +3 -1
  4. package/dist/daemon-config-map.js +6 -0
  5. package/dist/daemon.js +21 -1
  6. package/dist/diagnostics.d.ts +1 -0
  7. package/dist/diagnostics.js +35 -6
  8. package/dist/gateway/channels/feishu-registration.d.ts +35 -0
  9. package/dist/gateway/channels/feishu-registration.js +101 -0
  10. package/dist/gateway/channels/feishu.d.ts +16 -0
  11. package/dist/gateway/channels/feishu.js +459 -0
  12. package/dist/gateway/channels/index.d.ts +2 -0
  13. package/dist/gateway/channels/index.js +2 -0
  14. package/dist/gateway/channels/login-session.d.ts +9 -1
  15. package/dist/gateway/channels/login-session.js +1 -1
  16. package/dist/gateway/channels/wechat.js +26 -2
  17. package/dist/gateway/dispatcher.d.ts +3 -0
  18. package/dist/gateway/dispatcher.js +190 -30
  19. package/dist/gateway/policy-resolver.d.ts +10 -6
  20. package/dist/gateway/types.d.ts +1 -1
  21. package/dist/gateway-control.d.ts +8 -1
  22. package/dist/gateway-control.js +171 -18
  23. package/dist/index.js +9 -3
  24. package/dist/log.d.ts +9 -0
  25. package/dist/log.js +89 -1
  26. package/dist/provision.js +7 -1
  27. package/package.json +2 -1
  28. package/src/__tests__/cross-room.test.ts +2 -0
  29. package/src/__tests__/diagnostics.test.ts +37 -1
  30. package/src/__tests__/gateway-control.test.ts +84 -0
  31. package/src/__tests__/log.test.ts +28 -1
  32. package/src/__tests__/policy-updated-handler.test.ts +5 -7
  33. package/src/__tests__/third-party-gateway.test.ts +28 -0
  34. package/src/__tests__/wechat-channel.test.ts +47 -0
  35. package/src/config.ts +6 -3
  36. package/src/cross-room.ts +3 -1
  37. package/src/daemon-config-map.ts +3 -0
  38. package/src/daemon.ts +24 -3
  39. package/src/diagnostics.ts +36 -6
  40. package/src/gateway/__tests__/dispatcher.test.ts +62 -4
  41. package/src/gateway/__tests__/feishu-channel.test.ts +306 -0
  42. package/src/gateway/channels/feishu-registration.ts +155 -0
  43. package/src/gateway/channels/feishu.ts +554 -0
  44. package/src/gateway/channels/index.ts +6 -0
  45. package/src/gateway/channels/login-session.ts +10 -2
  46. package/src/gateway/channels/wechat.ts +29 -2
  47. package/src/gateway/dispatcher.ts +216 -29
  48. package/src/gateway/policy-resolver.ts +19 -11
  49. package/src/gateway/types.ts +1 -1
  50. package/src/gateway-control.ts +188 -17
  51. package/src/index.ts +9 -3
  52. package/src/log.ts +100 -1
  53. package/src/provision.ts +13 -1
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { formatLogLine } from "../log.js";
2
+ import { mkdtempSync, readdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { formatLogLine, listDaemonLogFiles, rotateLogIfNeeded } from "../log.js";
3
6
 
4
7
  describe("formatLogLine", () => {
5
8
  it("renders compact text with level, message, details, and trailing timestamp", () => {
@@ -27,4 +30,28 @@ describe("formatLogLine", () => {
27
30
  '[INFO] botcord ws server error msg={"type":"error","code":503} ts=2026-05-01T00:22:07.131Z',
28
31
  );
29
32
  });
33
+
34
+ it("rotates oversized logs and keeps the newest 20 rotated files", () => {
35
+ const tmp = mkdtempSync(path.join(tmpdir(), "botcord-log-test-"));
36
+ try {
37
+ const logFile = path.join(tmp, "daemon.log");
38
+ writeFileSync(logFile, "active log line\n");
39
+ for (let i = 0; i < 20; i += 1) {
40
+ const rotated = path.join(tmp, `daemon.log.old-${String(i).padStart(2, "0")}`);
41
+ writeFileSync(rotated, `old ${i}\n`);
42
+ const t = new Date(1_700_000_000_000 + i * 1000);
43
+ utimesSync(rotated, t, t);
44
+ }
45
+
46
+ rotateLogIfNeeded(logFile, 1, 10, 20);
47
+ const logs = listDaemonLogFiles(logFile);
48
+ const rotated = logs.filter((entry) => !entry.active);
49
+
50
+ expect(rotated).toHaveLength(20);
51
+ expect(rotated.some((entry) => entry.name === "daemon.log.old-00")).toBe(false);
52
+ expect(rotated.some((entry) => entry.name.startsWith("daemon.log."))).toBe(true);
53
+ } finally {
54
+ rmSync(tmp, { recursive: true, force: true });
55
+ }
56
+ });
30
57
  });
@@ -48,16 +48,13 @@ function makeFakeGateway(): unknown {
48
48
  };
49
49
  }
50
50
 
51
- function makeFakeResolver(): PolicyResolverLike & {
52
- invalidate: ReturnType<typeof vi.fn>;
53
- put: ReturnType<typeof vi.fn>;
54
- resolve: ReturnType<typeof vi.fn>;
55
- } {
56
- return {
57
- resolve: vi.fn(async () => ({ mode: "always", keywords: [] })),
51
+ function makeFakeResolver() {
52
+ const resolver = {
53
+ resolve: vi.fn(async () => ({ mode: "always" as const, keywords: [] })),
58
54
  invalidate: vi.fn(),
59
55
  put: vi.fn(),
60
56
  };
57
+ return resolver as PolicyResolverLike & typeof resolver;
61
58
  }
62
59
 
63
60
  describe("policy_updated control-frame handler", () => {
@@ -110,6 +107,7 @@ describe("policy_updated control-frame handler", () => {
110
107
  expect(resolver.put).toHaveBeenCalledWith("ag_a", null, {
111
108
  mode: "keyword",
112
109
  keywords: ["foo", "bar"],
110
+ allowedSenderIds: [],
113
111
  muted_until: 123,
114
112
  });
115
113
  expect(resolver.invalidate).not.toHaveBeenCalled();
@@ -37,6 +37,15 @@ describe("toGatewayConfig + thirdPartyGateways", () => {
37
37
  allowedSenderIds: ["abc@im.wechat"],
38
38
  splitAt: 1800,
39
39
  },
40
+ {
41
+ id: "gw_fs_1",
42
+ type: "feishu",
43
+ accountId: "ag_daemon",
44
+ appId: "cli_xxx",
45
+ domain: "feishu",
46
+ allowedSenderIds: ["ou_alice"],
47
+ allowedChatIds: ["oc_team"],
48
+ },
40
49
  ],
41
50
  });
42
51
  const gw = toGatewayConfig(cfg);
@@ -44,6 +53,7 @@ describe("toGatewayConfig + thirdPartyGateways", () => {
44
53
  { id: "ag_daemon", type: BOTCORD_CHANNEL_TYPE },
45
54
  { id: "gw_tg_1", type: TELEGRAM_CHANNEL_TYPE },
46
55
  { id: "gw_wx_1", type: WECHAT_CHANNEL_TYPE },
56
+ { id: "gw_fs_1", type: "feishu" },
47
57
  ]);
48
58
  const tg = gw.channels[1]!;
49
59
  expect(tg.accountId).toBe("ag_daemon");
@@ -52,6 +62,11 @@ describe("toGatewayConfig + thirdPartyGateways", () => {
52
62
  expect(wx.baseUrl).toBe("https://ilinkai.weixin.qq.com");
53
63
  expect(wx.allowedSenderIds).toEqual(["abc@im.wechat"]);
54
64
  expect(wx.splitAt).toBe(1800);
65
+ const fs = gw.channels[3]!;
66
+ expect(fs.appId).toBe("cli_xxx");
67
+ expect(fs.domain).toBe("feishu");
68
+ expect(fs.allowedSenderIds).toEqual(["ou_alice"]);
69
+ expect(fs.allowedChatIds).toEqual(["oc_team"]);
55
70
  });
56
71
 
57
72
  it("filters out gateways with enabled === false", () => {
@@ -115,6 +130,19 @@ describe("createDaemonChannel", () => {
115
130
  expect(adapter.id).toBe("gw_wx_1");
116
131
  });
117
132
 
133
+ it("dispatches feishu type to the Feishu adapter", () => {
134
+ const chCfg: GatewayChannelConfig = {
135
+ id: "gw_fs_1",
136
+ type: "feishu",
137
+ accountId: "ag_x",
138
+ appId: "cli_xxx",
139
+ domain: "feishu",
140
+ };
141
+ const adapter = createDaemonChannel(chCfg, deps);
142
+ expect(adapter.type).toBe("feishu");
143
+ expect(adapter.id).toBe("gw_fs_1");
144
+ });
145
+
118
146
  it("throws on unknown channel type", () => {
119
147
  const chCfg: GatewayChannelConfig = {
120
148
  id: "gw_x",
@@ -222,6 +222,53 @@ describe("wechat channel adapter", () => {
222
222
  expect(JSON.parse(stateRaw).cursor).toBe("cursor-after-1");
223
223
  });
224
224
 
225
+ it("normalizes media-only inbound items so dispatcher can defer them", async () => {
226
+ const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
227
+ const fetchImpl = buildFetchStub(
228
+ [
229
+ {
230
+ match: "getupdates",
231
+ respond: (idx) => {
232
+ if (idx === 0) {
233
+ return {
234
+ body: {
235
+ ret: 0,
236
+ get_updates_buf: "cursor-after-media",
237
+ msgs: [
238
+ {
239
+ message_type: 1,
240
+ from_user_id: "alice@im.wechat",
241
+ context_token: "ctx-media",
242
+ client_id: "wechat-media-1",
243
+ item_list: [{ type: 4, file_item: { file_name: "report.pdf", len: 123 } }],
244
+ },
245
+ ],
246
+ },
247
+ };
248
+ }
249
+ return { body: { ret: 0, get_updates_buf: "cursor-after-media", msgs: [] } };
250
+ },
251
+ },
252
+ ],
253
+ calls,
254
+ );
255
+ const adapter = createWechatChannel({
256
+ id: "gw_wx_media",
257
+ accountId: "ag_test",
258
+ botToken: "tok-123",
259
+ stateFile: path.join(tmp, "state.json"),
260
+ fetchImpl,
261
+ stateDebounceMs: 0,
262
+ allowedSenderIds: ["alice@im.wechat"],
263
+ });
264
+ const h = startAdapter(adapter, { stopAfterEnvelopes: 1 });
265
+ await h.pollDone;
266
+
267
+ expect(h.envelopes).toHaveLength(1);
268
+ expect(h.envelopes[0]!.message.id).toBe("wechat-media-1");
269
+ expect(h.envelopes[0]!.message.text).toBe("[File: report.pdf]");
270
+ });
271
+
225
272
  it("drops messages missing context_token", async () => {
226
273
  const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
227
274
  const fetchImpl = buildFetchStub(
package/src/config.ts CHANGED
@@ -100,7 +100,7 @@ export interface OpenclawDiscoveryConfig {
100
100
  }
101
101
 
102
102
  /** Third-party messaging provider supported by the daemon's channel factory. */
103
- export type ThirdPartyGatewayType = "telegram" | "wechat";
103
+ export type ThirdPartyGatewayType = "telegram" | "wechat" | "feishu";
104
104
 
105
105
  /**
106
106
  * One third-party gateway profile bound to a BotCord agent. `id` is the
@@ -122,6 +122,9 @@ export interface ThirdPartyGatewayProfile {
122
122
  allowedChatIds?: string[];
123
123
  splitAt?: number;
124
124
  baseUrl?: string;
125
+ appId?: string;
126
+ domain?: "feishu" | "lark";
127
+ userOpenId?: string;
125
128
  }
126
129
 
127
130
  export interface DaemonConfig {
@@ -445,9 +448,9 @@ export function loadConfig(): DaemonConfig {
445
448
  `daemon config thirdPartyGateways[${i}].id must be a non-empty string (${CONFIG_PATH})`,
446
449
  );
447
450
  }
448
- if (gg.type !== "telegram" && gg.type !== "wechat") {
451
+ if (gg.type !== "telegram" && gg.type !== "wechat" && gg.type !== "feishu") {
449
452
  throw new Error(
450
- `daemon config thirdPartyGateways[${i}].type must be "telegram" or "wechat" (${CONFIG_PATH})`,
453
+ `daemon config thirdPartyGateways[${i}].type must be "telegram", "wechat", or "feishu" (${CONFIG_PATH})`,
451
454
  );
452
455
  }
453
456
  if (typeof gg.accountId !== "string" || gg.accountId.length === 0) {
package/src/cross-room.ts CHANGED
@@ -44,7 +44,9 @@ export function buildCrossRoomDigest(opts: DigestOptions): string | null {
44
44
 
45
45
  const lines: string[] = [
46
46
  "[BotCord Cross-Room Awareness]",
47
- `You are currently active in ${total} BotCord sessions. Recent activity from other rooms:`,
47
+ `You are currently active in ${total} BotCord sessions. The entries below are latest messages from OTHER rooms, not the current room.`,
48
+ "Do not treat any sender or message below as the current user or current conversation.",
49
+ "Recent activity from other rooms:",
48
50
  ];
49
51
  for (const e of slice) {
50
52
  lines.push(formatEntry(e));
@@ -262,6 +262,9 @@ export function toGatewayConfig(
262
262
  if (g.allowedChatIds !== undefined) ch.allowedChatIds = g.allowedChatIds;
263
263
  if (g.splitAt !== undefined) ch.splitAt = g.splitAt;
264
264
  if (g.baseUrl !== undefined) ch.baseUrl = g.baseUrl;
265
+ if (g.appId !== undefined) ch.appId = g.appId;
266
+ if (g.domain !== undefined) ch.domain = g.domain;
267
+ if (g.userOpenId !== undefined) ch.userOpenId = g.userOpenId;
265
268
  channels.push(ch);
266
269
  }
267
270
 
package/src/daemon.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  import {
7
7
  Gateway,
8
8
  createBotCordChannel,
9
+ createFeishuChannel,
9
10
  createTelegramChannel,
10
11
  createWechatChannel,
11
12
  resolveTranscriptEnabled,
@@ -44,7 +45,7 @@ import {
44
45
  } from "./loop-risk.js";
45
46
  import { composeBotCordUserTurn } from "./turn-text.js";
46
47
  import { UserAuthManager } from "./user-auth.js";
47
- import { PolicyResolver } from "./gateway/policy-resolver.js";
48
+ import { PolicyResolver, type DaemonAttentionPolicy } from "./gateway/policy-resolver.js";
48
49
  import { scanMention } from "./mention-scan.js";
49
50
  import { createDiagnosticBundle, uploadDiagnosticBundle } from "./diagnostics.js";
50
51
 
@@ -178,6 +179,23 @@ export function createDaemonChannel(
178
179
  ...(typeof chCfg.secretFile === "string" ? { secretFile: chCfg.secretFile } : {}),
179
180
  ...(typeof chCfg.stateFile === "string" ? { stateFile: chCfg.stateFile } : {}),
180
181
  });
182
+ case "feishu":
183
+ return createFeishuChannel({
184
+ id: chCfg.id,
185
+ accountId: chCfg.accountId,
186
+ ...(typeof chCfg.appId === "string" ? { appId: chCfg.appId } : {}),
187
+ ...(chCfg.domain === "feishu" || chCfg.domain === "lark"
188
+ ? { domain: chCfg.domain }
189
+ : {}),
190
+ ...(Array.isArray(chCfg.allowedSenderIds)
191
+ ? { allowedSenderIds: chCfg.allowedSenderIds as string[] }
192
+ : {}),
193
+ ...(Array.isArray(chCfg.allowedChatIds)
194
+ ? { allowedChatIds: chCfg.allowedChatIds as string[] }
195
+ : {}),
196
+ ...(typeof chCfg.splitAt === "number" ? { splitAt: chCfg.splitAt } : {}),
197
+ ...(typeof chCfg.secretFile === "string" ? { secretFile: chCfg.secretFile } : {}),
198
+ });
181
199
  default:
182
200
  throw new Error(`unknown channel type "${chCfg.type}"`);
183
201
  }
@@ -436,15 +454,18 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
436
454
  // with a local `@<display_name>` / `@<agent_id>` text scan, resolve the
437
455
  // effective policy, then defer to the protocol-core `shouldWake` decision.
438
456
  const attentionGate = async (msg: GatewayInboundMessage): Promise<boolean> => {
439
- const policy: AttentionPolicy = await policyResolver.resolve(
457
+ const policy: DaemonAttentionPolicy = await policyResolver.resolve(
440
458
  msg.accountId,
441
459
  msg.conversation.id,
442
460
  );
461
+ if (policy.mode === "allowed_senders") {
462
+ return (policy.allowedSenderIds ?? []).includes(msg.sender.id);
463
+ }
443
464
  const localMention = scanMention(msg.text, {
444
465
  agentId: msg.accountId,
445
466
  displayName: displayNameByAgent.get(msg.accountId),
446
467
  });
447
- return shouldWake(policy, {
468
+ return shouldWake(policy as AttentionPolicy, {
448
469
  mentioned: msg.mentioned === true || localMention,
449
470
  text: msg.text,
450
471
  });
@@ -16,7 +16,7 @@ import {
16
16
  loadConfig,
17
17
  type DaemonConfig,
18
18
  } from "./config.js";
19
- import { LOG_FILE_PATH } from "./log.js";
19
+ import { listDaemonLogFiles, LOG_FILE_PATH, type LogFileEntry } from "./log.js";
20
20
  import {
21
21
  channelsFromDaemonConfig,
22
22
  defaultHttpFetcher,
@@ -29,6 +29,7 @@ import { detectRuntimes } from "./adapters/runtimes.js";
29
29
 
30
30
  const DIAGNOSTICS_DIR = path.join(homedir(), ".botcord", "diagnostics");
31
31
  const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
32
+ const DEFAULT_ROTATED_LOGS_IN_BUNDLE = 5;
32
33
 
33
34
  export interface CreateDiagnosticBundleOptions {
34
35
  diagnosticsDir?: string;
@@ -36,6 +37,7 @@ export interface CreateDiagnosticBundleOptions {
36
37
  configFile?: string;
37
38
  snapshotFile?: string;
38
39
  doctor?: { text: string; json: unknown };
40
+ includeAllLogs?: boolean;
39
41
  }
40
42
 
41
43
  export interface DiagnosticBundleResult {
@@ -273,6 +275,16 @@ function diagnosticBundleCommands(filePath: string): {
273
275
  };
274
276
  }
275
277
 
278
+ function bundledLogs(logFile: string, includeAllLogs: boolean): LogFileEntry[] {
279
+ const all = listDaemonLogFiles(logFile);
280
+ const active = all.filter((entry) => entry.active);
281
+ const rotated = all.filter((entry) => !entry.active);
282
+ return [
283
+ ...active,
284
+ ...(includeAllLogs ? rotated : rotated.slice(0, DEFAULT_ROTATED_LOGS_IN_BUNDLE)),
285
+ ];
286
+ }
287
+
276
288
  export async function createDiagnosticBundle(
277
289
  opts: CreateDiagnosticBundleOptions = {},
278
290
  ): Promise<DiagnosticBundleResult> {
@@ -283,6 +295,8 @@ export async function createDiagnosticBundle(
283
295
  const logFile = opts.logFile ?? LOG_FILE_PATH;
284
296
  const configFile = opts.configFile ?? CONFIG_FILE_PATH;
285
297
  const snapshotFile = opts.snapshotFile ?? SNAPSHOT_PATH;
298
+ const includeAllLogs = opts.includeAllLogs === true;
299
+ const logs = bundledLogs(logFile, includeAllLogs);
286
300
  mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 });
287
301
 
288
302
  const doctor = opts.doctor ?? await buildDoctorEntries();
@@ -298,6 +312,13 @@ export async function createDiagnosticBundle(
298
312
  configPath: configFile,
299
313
  snapshotPath: snapshotFile,
300
314
  logPath: logFile,
315
+ logsBundled: logs.map((entry) => ({
316
+ name: entry.name,
317
+ path: entry.path,
318
+ sizeBytes: entry.sizeBytes,
319
+ active: entry.active,
320
+ })),
321
+ logsBundleMode: includeAllLogs ? "all" : `active_plus_${DEFAULT_ROTATED_LOGS_IN_BUNDLE}_rotated`,
301
322
  diagnosticsDir,
302
323
  userAuth: readUserAuthSummary(),
303
324
  };
@@ -308,11 +329,20 @@ export async function createDiagnosticBundle(
308
329
  { name: "doctor.txt", data: doctor.text + "\n" },
309
330
  { name: "doctor.json", data: JSON.stringify(doctor.json, null, 2) + "\n" },
310
331
  ];
311
- const log = safeReadText(logFile);
312
- entries.push({
313
- name: "daemon.log",
314
- data: log ?? `no log file at ${logFile}\n`,
315
- });
332
+ if (logs.length === 0) {
333
+ entries.push({
334
+ name: "daemon.log",
335
+ data: `no log file at ${logFile}\n`,
336
+ });
337
+ } else {
338
+ for (const entry of logs) {
339
+ const log = safeReadText(entry.path);
340
+ entries.push({
341
+ name: entry.active ? "daemon.log" : `logs/${entry.name}`,
342
+ data: log ?? `no log file at ${entry.path}\n`,
343
+ });
344
+ }
345
+ }
316
346
  const config = safeReadText(configFile);
317
347
  entries.push({
318
348
  name: "config.json.redacted",
@@ -362,6 +362,58 @@ describe("Dispatcher", () => {
362
362
  expect(runtime.calls[0].text).toBe("WRAPPED:hello");
363
363
  });
364
364
 
365
+ it("defers multimodal-only BotCord messages until the next text turn and preserves order", async () => {
366
+ const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
367
+ const { store, dir } = await makeStore();
368
+ tempDirs.push(dir);
369
+ const channel = new FakeChannel();
370
+ const dispatcher = new Dispatcher({
371
+ config: baseConfig(),
372
+ channels: new Map<string, ChannelAdapter>([[channel.id, channel]]),
373
+ runtime: () => runtime,
374
+ sessionStore: store,
375
+ log: silentLogger(),
376
+ composeUserTurn: (msg) => {
377
+ const raw = msg.raw as { batch?: Array<{ text?: string }> };
378
+ return (raw.batch ?? [{ text: msg.text }]).map((m) => m.text).join("\n");
379
+ },
380
+ });
381
+ const acceptMedia = vi.fn(async () => {});
382
+ const acceptText = vi.fn(async () => {});
383
+
384
+ await dispatcher.handle(makeEnvelope({
385
+ id: "h_media",
386
+ text: '{"attachments":[{"filename":"a.png"}]}\nAttachments\na.png',
387
+ raw: {
388
+ hub_msg_id: "h_media",
389
+ text: '{"attachments":[{"filename":"a.png"}]}\nAttachments\na.png',
390
+ envelope: {
391
+ type: "message",
392
+ payload: { attachments: [{ filename: "a.png", url: "/hub/files/f_1" }] },
393
+ },
394
+ },
395
+ }, { accept: acceptMedia }));
396
+
397
+ expect(acceptMedia).toHaveBeenCalledTimes(1);
398
+ expect(runtime.calls.length).toBe(0);
399
+
400
+ await dispatcher.handle(makeEnvelope({
401
+ id: "h_text",
402
+ text: "please inspect this",
403
+ raw: {
404
+ hub_msg_id: "h_text",
405
+ text: "please inspect this",
406
+ envelope: { type: "message", payload: { text: "please inspect this" } },
407
+ },
408
+ }, { accept: acceptText }));
409
+
410
+ expect(acceptText).toHaveBeenCalledTimes(1);
411
+ expect(runtime.calls.length).toBe(1);
412
+ expect(runtime.calls[0].text).toBe(
413
+ '{"attachments":[{"filename":"a.png"}]}\nAttachments\na.png\nplease inspect this',
414
+ );
415
+ });
416
+
365
417
  it("falls back to raw text when composeUserTurn throws", async () => {
366
418
  const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
367
419
  const { store, dir } = await makeStore();
@@ -1851,7 +1903,7 @@ describe("Dispatcher", () => {
1851
1903
  expect(channel.sends.length).toBe(0);
1852
1904
  });
1853
1905
 
1854
- it("non-owner-chat room: timeout reply is suppressed (logged only)", async () => {
1906
+ it("non-owner-chat room: timeout sends a diagnostic reply", async () => {
1855
1907
  vi.useFakeTimers();
1856
1908
  try {
1857
1909
  const runtime = new FakeRuntime({ hang: true });
@@ -1868,13 +1920,16 @@ describe("Dispatcher", () => {
1868
1920
  await vi.advanceTimersByTimeAsync(501);
1869
1921
  await p;
1870
1922
  expect(runtime.calls[0].signal.aborted).toBe(true);
1871
- expect(channel.sends.length).toBe(0);
1923
+ expect(channel.sends.length).toBe(1);
1924
+ expect(channel.sends[0].message.text).toMatch(/Runtime timeout/);
1925
+ expect(channel.sends[0].message.conversationId).toBe("rm_g_other");
1926
+ expect(channel.sends[0].message.replyTo).toBe("m_to");
1872
1927
  } finally {
1873
1928
  vi.useRealTimers();
1874
1929
  }
1875
1930
  });
1876
1931
 
1877
- it("non-owner-chat room: runtime error reply is suppressed", async () => {
1932
+ it("non-owner-chat room: runtime error sends a diagnostic reply", async () => {
1878
1933
  const runtime = new FakeRuntime({ throwError: "boom" });
1879
1934
  const { dispatcher, channel } = await scaffold({
1880
1935
  runtimeFactory: () => runtime,
@@ -1885,7 +1940,10 @@ describe("Dispatcher", () => {
1885
1940
  conversation: { id: "rm_g_other", kind: "group" },
1886
1941
  }),
1887
1942
  );
1888
- expect(channel.sends.length).toBe(0);
1943
+ expect(channel.sends.length).toBe(1);
1944
+ expect(channel.sends[0].message.text).toContain("Runtime error: boom");
1945
+ expect(channel.sends[0].message.conversationId).toBe("rm_g_other");
1946
+ expect(channel.sends[0].message.replyTo).toBe("m_err");
1889
1947
  });
1890
1948
 
1891
1949
  // ─────────────────────────────────────────────────────────────────────