@botcord/daemon 0.1.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -288,6 +288,41 @@ describe("createDaemonSystemContextBuilder", () => {
288
288
  expect(out).toBeUndefined();
289
289
  });
290
290
 
291
+ it("appends loopRiskBuilder output at the end of the system context", async () => {
292
+ const builder = createDaemonSystemContextBuilder({
293
+ agentId: "ag_me",
294
+ roomContextBuilder: async () => null,
295
+ loopRiskBuilder: () => "[BotCord loop-risk check]\nObserved signals:\n- x",
296
+ });
297
+ const out = await builder(
298
+ makeMessage({ conversation: { id: "rm_team", kind: "group" } }),
299
+ );
300
+ expect(typeof out).toBe("string");
301
+ expect(out).toContain("[BotCord loop-risk check]");
302
+ });
303
+
304
+ it("also injects loopRiskBuilder in the sync (no roomContextBuilder) branch", () => {
305
+ const builder = createDaemonSystemContextBuilder({
306
+ agentId: "ag_me",
307
+ loopRiskBuilder: () => "[BotCord loop-risk check]\nObserved signals:\n- y",
308
+ });
309
+ const out = builder(makeMessage()) as string | undefined;
310
+ expect(typeof out).toBe("string");
311
+ expect(out).toContain("[BotCord loop-risk check]");
312
+ });
313
+
314
+ it("skips loopRiskBuilder gracefully when it throws", async () => {
315
+ const builder = createDaemonSystemContextBuilder({
316
+ agentId: "ag_me",
317
+ roomContextBuilder: async () => null,
318
+ loopRiskBuilder: () => {
319
+ throw new Error("oops");
320
+ },
321
+ });
322
+ const out = await builder(makeMessage());
323
+ expect(out).toBeUndefined();
324
+ });
325
+
291
326
  it("translates GatewayInboundMessage.conversation.id → old `room_id` for the digest exclude key", () => {
292
327
  const tracker = new ActivityTracker({
293
328
  filePath: path.join(tmpDir, "activity.json"),
@@ -99,6 +99,149 @@ describe("composeBotCordUserTurn", () => {
99
99
  expect(out).toBe("");
100
100
  });
101
101
 
102
+ it("appends the contact-request notify-owner hint when envelope.type is contact_request", () => {
103
+ const out = composeBotCordUserTurn(
104
+ makeMessage({
105
+ text: "Hi, please add me",
106
+ sender: { id: "ag_stranger", kind: "agent" },
107
+ conversation: { id: "rm_dm_x", kind: "direct" },
108
+ raw: { envelope: { type: "contact_request" } },
109
+ }),
110
+ );
111
+ expect(out).toContain("contact request from ag_stranger");
112
+ expect(out).toContain("botcord_notify tool");
113
+ // Base direct-chat hint should still appear above the contact-request hint.
114
+ expect(out).toContain("naturally concluded");
115
+ const baseIdx = out.indexOf("naturally concluded");
116
+ const crIdx = out.indexOf("contact request from");
117
+ expect(crIdx).toBeGreaterThan(baseIdx);
118
+ });
119
+
120
+ it("does NOT append the contact-request hint for a plain message envelope", () => {
121
+ const out = composeBotCordUserTurn(
122
+ makeMessage({
123
+ raw: { envelope: { type: "message" } },
124
+ }),
125
+ );
126
+ expect(out).not.toContain("contact request from");
127
+ });
128
+
129
+ it("does NOT append the contact-request hint when msg.raw has no envelope", () => {
130
+ const out = composeBotCordUserTurn(makeMessage({ raw: {} }));
131
+ expect(out).not.toContain("contact request from");
132
+ });
133
+
134
+ it("renders a multi-message batch as [BotCord Messages (N new)] with one block per sender", () => {
135
+ const batch = [
136
+ {
137
+ hub_msg_id: "m1",
138
+ text: "first message",
139
+ envelope: { from: "ag_alice", type: "message" },
140
+ },
141
+ {
142
+ hub_msg_id: "m2",
143
+ text: "second message",
144
+ envelope: { from: "ag_bob", type: "message" },
145
+ mentioned: true,
146
+ },
147
+ ];
148
+ const out = composeBotCordUserTurn(
149
+ makeMessage({
150
+ text: "second message",
151
+ sender: { id: "ag_bob", kind: "agent" },
152
+ conversation: { id: "rm_team", kind: "group", title: "Ouraca" },
153
+ mentioned: true,
154
+ raw: { batch, envelope: { type: "message", from: "ag_bob" } },
155
+ }),
156
+ );
157
+ expect(out).toContain("[BotCord Messages (2 new)]");
158
+ expect(out).toContain("room: Ouraca");
159
+ expect(out).toContain("mentioned: true");
160
+ expect(out).toContain('<agent-message sender="ag_alice" sender_kind="agent">');
161
+ expect(out).toContain("first message");
162
+ expect(out).toContain('<agent-message sender="ag_bob" sender_kind="agent">');
163
+ expect(out).toContain("second message");
164
+ // Single-message header must NOT appear in batch mode.
165
+ expect(out).not.toContain("[BotCord Message]");
166
+ // Group hint still appears after the blocks.
167
+ expect(out).toContain("do NOT reply unless");
168
+ });
169
+
170
+ it("batched path tags dashboard_human_room senders as human-message", () => {
171
+ const batch = [
172
+ {
173
+ hub_msg_id: "m1",
174
+ text: "hi bot",
175
+ envelope: { from: "ag_me", type: "message" },
176
+ source_type: "dashboard_human_room",
177
+ source_user_name: "Alice",
178
+ },
179
+ {
180
+ hub_msg_id: "m2",
181
+ text: "你好",
182
+ envelope: { from: "ag_peer", type: "message" },
183
+ },
184
+ ];
185
+ const out = composeBotCordUserTurn(
186
+ makeMessage({
187
+ text: "你好",
188
+ sender: { id: "ag_peer", kind: "agent" },
189
+ conversation: { id: "rm_team", kind: "group" },
190
+ raw: { batch, envelope: { type: "message", from: "ag_peer" } },
191
+ }),
192
+ );
193
+ expect(out).toContain('<human-message sender="Alice" sender_kind="human">');
194
+ expect(out).toContain("hi bot");
195
+ expect(out).toContain('<agent-message sender="ag_peer" sender_kind="agent">');
196
+ });
197
+
198
+ it("batched path appends a single notify-owner hint listing every contact_request sender", () => {
199
+ const batch = [
200
+ {
201
+ hub_msg_id: "m1",
202
+ text: "please add me",
203
+ envelope: { from: "ag_stranger_a", type: "contact_request" },
204
+ },
205
+ {
206
+ hub_msg_id: "m2",
207
+ text: "add me too",
208
+ envelope: { from: "ag_stranger_b", type: "contact_request" },
209
+ },
210
+ {
211
+ hub_msg_id: "m3",
212
+ text: "normal reply",
213
+ envelope: { from: "ag_old_friend", type: "message" },
214
+ },
215
+ ];
216
+ const out = composeBotCordUserTurn(
217
+ makeMessage({
218
+ text: "normal reply",
219
+ sender: { id: "ag_old_friend", kind: "agent" },
220
+ conversation: { id: "rm_dm_x", kind: "direct" },
221
+ raw: { batch, envelope: { type: "message", from: "ag_old_friend" } },
222
+ }),
223
+ );
224
+ expect(out).toContain("contact request from ag_stranger_a, ag_stranger_b");
225
+ // Direct hint (not group) for a DM room.
226
+ expect(out).toContain("naturally concluded");
227
+ });
228
+
229
+ it("falls back to the single-message path when raw.batch has only one entry", () => {
230
+ const out = composeBotCordUserTurn(
231
+ makeMessage({
232
+ raw: {
233
+ batch: [
234
+ { hub_msg_id: "m1", text: "solo", envelope: { from: "ag_x", type: "message" } },
235
+ ],
236
+ envelope: { type: "message", from: "ag_x" },
237
+ },
238
+ }),
239
+ );
240
+ // batch length 1 → readBatch returns null → single-message header.
241
+ expect(out).toContain("[BotCord Message]");
242
+ expect(out).not.toContain("[BotCord Messages (");
243
+ });
244
+
102
245
  it("sanitizes room names so newline-based injection can't reshape the header", () => {
103
246
  const out = composeBotCordUserTurn(
104
247
  makeMessage({
@@ -150,9 +150,10 @@ export function toGatewayConfig(
150
150
 
151
151
  // Synthesize a per-agent route for every bound agent and hand it to the
152
152
  // gateway via the managed-routes bucket (plan §10.1). User-authored
153
- // `cfg.routes[]` stay untouched so an explicit operator override still
154
- // wins on conflict the gateway matches `routes[] → managedRoutes →
155
- // defaultRoute` in that order.
153
+ // `cfg.routes[]` stay untouched. Match priority (see router.ts):
154
+ // `routes[] with explicit accountId managedRoutes other routes[] →
155
+ // defaultRoute`. Broad prefix/kind rules no longer clobber the agent's
156
+ // chosen runtime — only routes that name the agent by `accountId` do.
156
157
  const managedMap = buildManagedRoutes(
157
158
  agentIds,
158
159
  opts.agentRuntimes ?? {},
package/src/daemon.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  type ChannelAdapter,
7
7
  type GatewayChannelConfig,
8
8
  type GatewayInboundMessage,
9
+ type GatewayOutboundMessage,
9
10
  type GatewayLogger,
10
11
  type GatewayRuntimeSnapshot,
11
12
  } from "./gateway/index.js";
@@ -22,6 +23,12 @@ import { SnapshotWriter } from "./snapshot-writer.js";
22
23
  import { createDaemonSystemContextBuilder } from "./system-context.js";
23
24
  import { createRoomStaticContextBuilder } from "./room-context.js";
24
25
  import { createRoomContextFetcher } from "./room-context-fetcher.js";
26
+ import {
27
+ buildLoopRiskPrompt,
28
+ loopRiskSessionKey,
29
+ recordInboundText as recordLoopRiskInbound,
30
+ recordOutboundText as recordLoopRiskOutbound,
31
+ } from "./loop-risk.js";
25
32
  import { composeBotCordUserTurn } from "./turn-text.js";
26
33
  import { UserAuthManager } from "./user-auth.js";
27
34
 
@@ -245,6 +252,14 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
245
252
  msg: GatewayInboundMessage,
246
253
  ) => Promise<string | undefined> | string | undefined;
247
254
  const scBuilders = new Map<string, PerAgentBuilder>();
255
+ const loopRiskBuilder = (msg: GatewayInboundMessage): string | null =>
256
+ buildLoopRiskPrompt({
257
+ sessionKey: loopRiskSessionKey({
258
+ accountId: msg.accountId,
259
+ conversationId: msg.conversation.id,
260
+ threadId: msg.conversation.threadId ?? null,
261
+ }),
262
+ });
248
263
  for (const aid of agentIds) {
249
264
  scBuilders.set(
250
265
  aid,
@@ -252,6 +267,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
252
267
  agentId: aid,
253
268
  activityTracker,
254
269
  roomContextBuilder,
270
+ loopRiskBuilder,
255
271
  }),
256
272
  );
257
273
  }
@@ -274,10 +290,34 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
274
290
  // outside the system-context builder (option A) means the builder stays
275
291
  // pure — a cleaner contract the gateway can also expose to non-daemon
276
292
  // callers in the future.
277
- const onInbound = createActivityRecorder({
293
+ const recordActivity = createActivityRecorder({
278
294
  activityTracker,
279
295
  ...(agentIds[0] ? { fallbackAgentId: agentIds[0] } : {}),
280
296
  });
297
+ const onInbound = (msg: GatewayInboundMessage): void => {
298
+ recordActivity(msg);
299
+ // Feed the loop-risk tracker with the sanitized inbound text so
300
+ // detectShortAckTail + detectHighTurnRate have a timeline.
301
+ recordLoopRiskInbound({
302
+ sessionKey: loopRiskSessionKey({
303
+ accountId: msg.accountId,
304
+ conversationId: msg.conversation.id,
305
+ threadId: msg.conversation.threadId ?? null,
306
+ }),
307
+ text: msg.text,
308
+ timestamp: msg.receivedAt,
309
+ });
310
+ };
311
+ const onOutbound = (out: GatewayOutboundMessage): void => {
312
+ recordLoopRiskOutbound({
313
+ sessionKey: loopRiskSessionKey({
314
+ accountId: out.accountId,
315
+ conversationId: out.conversationId,
316
+ threadId: out.threadId ?? null,
317
+ }),
318
+ text: out.text,
319
+ });
320
+ };
281
321
 
282
322
  const gateway = new Gateway({
283
323
  config: gwConfig,
@@ -298,6 +338,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
298
338
  turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
299
339
  buildSystemContext,
300
340
  onInbound,
341
+ onOutbound,
301
342
  composeUserTurn: composeBotCordUserTurn,
302
343
  });
303
344
 
@@ -255,6 +255,95 @@ describe("createBotCordChannel — inbox normalization", () => {
255
255
  }
256
256
  });
257
257
 
258
+ it("lets contact_request envelopes through so the composer can add the notify-owner hint", async () => {
259
+ const { emits, server } = await startWithInbox([
260
+ makeInbox({
261
+ hub_msg_id: "m_cr",
262
+ room_id: "rm_dm_peer",
263
+ text: "Hi, please add me",
264
+ envelope: {
265
+ type: "contact_request",
266
+ from: "ag_stranger",
267
+ payload: { text: "Hi, please add me" },
268
+ } as unknown as InboxMessage["envelope"],
269
+ }),
270
+ ]);
271
+ try {
272
+ expect(emits).toHaveLength(1);
273
+ const env = emits[0].message;
274
+ expect(env.sender.id).toBe("ag_stranger");
275
+ expect(env.text).toBe("Hi, please add me");
276
+ // Raw preserves envelope so turn-text can detect the type.
277
+ const raw = env.raw as { envelope?: { type?: string } };
278
+ expect(raw?.envelope?.type).toBe("contact_request");
279
+ } finally {
280
+ await server.close();
281
+ }
282
+ });
283
+
284
+ it("groups two messages in the same room/topic into one batched envelope", async () => {
285
+ const server = await startAuthOkServer();
286
+ try {
287
+ const polled = [
288
+ makeInbox({
289
+ hub_msg_id: "m_b1",
290
+ room_id: "rm_team",
291
+ room_name: "Team",
292
+ text: "hi all",
293
+ envelope: { from: "ag_alice" } as InboxMessage["envelope"],
294
+ }),
295
+ makeInbox({
296
+ hub_msg_id: "m_b2",
297
+ room_id: "rm_team",
298
+ room_name: "Team",
299
+ text: "yeah",
300
+ envelope: { from: "ag_bob" } as InboxMessage["envelope"],
301
+ mentioned: true,
302
+ }),
303
+ ];
304
+ const client = makeClient({
305
+ pollInbox: vi.fn().mockResolvedValue({ messages: polled, count: 2, has_more: false }),
306
+ getHubUrl: vi.fn().mockReturnValue(server.url),
307
+ });
308
+ const channel = createBotCordChannel({
309
+ id: "botcord-main",
310
+ accountId: "ag_self",
311
+ agentId: "ag_self",
312
+ client,
313
+ hubBaseUrl: server.url,
314
+ });
315
+ const abort = new AbortController();
316
+ const emits: GatewayInboundEnvelope[] = [];
317
+ const startP = channel.start({
318
+ config: stubConfig,
319
+ accountId: "ag_self",
320
+ abortSignal: abort.signal,
321
+ log: silentLog,
322
+ emit: async (env) => {
323
+ emits.push(env);
324
+ },
325
+ setStatus: () => {},
326
+ });
327
+ await vi.waitFor(() => expect(emits).toHaveLength(1));
328
+ const env = emits[0]!.message;
329
+ // Last sender wins for representative metadata; mentioned is sticky.
330
+ expect(env.sender.id).toBe("ag_bob");
331
+ expect(env.mentioned).toBe(true);
332
+ const raw = env.raw as { batch?: Array<{ hub_msg_id: string }> };
333
+ expect(Array.isArray(raw.batch)).toBe(true);
334
+ expect(raw.batch!.map((m) => m.hub_msg_id)).toEqual(["m_b1", "m_b2"]);
335
+
336
+ // One accept() call acks BOTH hub ids together.
337
+ await emits[0]!.ack!.accept();
338
+ expect(client.ackMessages).toHaveBeenCalledWith(["m_b1", "m_b2"]);
339
+
340
+ abort.abort();
341
+ await startP;
342
+ } finally {
343
+ await server.close();
344
+ }
345
+ });
346
+
258
347
  it("sanitizes prompt-injection markers in untrusted text but not in owner-chat", async () => {
259
348
  const { emits, server } = await startWithInbox([
260
349
  makeInbox({
@@ -350,6 +350,46 @@ describe("Dispatcher", () => {
350
350
  expect(runtime.calls[0].text).toBe("hello");
351
351
  });
352
352
 
353
+ it("fires onOutbound after a reply is dispatched", async () => {
354
+ const runtime = new FakeRuntime({ reply: "hello back", newSessionId: "sid-1" });
355
+ const { store, dir } = await makeStore();
356
+ tempDirs.push(dir);
357
+ const channel = new FakeChannel();
358
+ const outbound: string[] = [];
359
+ const dispatcher = new Dispatcher({
360
+ config: baseConfig(),
361
+ channels: new Map<string, ChannelAdapter>([[channel.id, channel]]),
362
+ runtime: () => runtime,
363
+ sessionStore: store,
364
+ log: silentLogger(),
365
+ onOutbound: (msg) => {
366
+ outbound.push(msg.text);
367
+ },
368
+ });
369
+ await dispatcher.handle(makeEnvelope({ id: "msg_1", text: "hi" }));
370
+ expect(outbound).toEqual(["hello back"]);
371
+ });
372
+
373
+ it("does not crash when onOutbound throws", async () => {
374
+ const runtime = new FakeRuntime({ reply: "hello back", newSessionId: "sid-1" });
375
+ const { store, dir } = await makeStore();
376
+ tempDirs.push(dir);
377
+ const channel = new FakeChannel();
378
+ const dispatcher = new Dispatcher({
379
+ config: baseConfig(),
380
+ channels: new Map<string, ChannelAdapter>([[channel.id, channel]]),
381
+ runtime: () => runtime,
382
+ sessionStore: store,
383
+ log: silentLogger(),
384
+ onOutbound: () => {
385
+ throw new Error("boom");
386
+ },
387
+ });
388
+ await dispatcher.handle(makeEnvelope({ id: "msg_1", text: "hi" }));
389
+ // Reply still went out; no assertion needed beyond the absence of a throw.
390
+ expect(channel.sends.length).toBe(1);
391
+ });
392
+
353
393
  it("does not crash when an errored turn has no prior session entry", async () => {
354
394
  const runtime = new FakeRuntime({ newSessionId: "", errorText: "boom" });
355
395
  const { dispatcher, store } = await scaffold({ runtimeFactory: () => runtime });
@@ -78,13 +78,39 @@ describe("resolveRoute", () => {
78
78
  });
79
79
 
80
80
  describe("managedRoutes", () => {
81
- it("user cfg.routes match wins over managed for same accountId", () => {
81
+ it("user route with explicit accountId wins over managed for same agent", () => {
82
82
  const user = makeRoute({ runtime: "user", match: { accountId: "ag_1" } });
83
83
  const managed = makeRoute({ runtime: "managed", match: { accountId: "ag_1" } });
84
84
  const msg = makeMessage({ accountId: "ag_1" });
85
85
  expect(resolveRoute(msg, { defaultRoute, routes: [user] }, [managed])).toBe(user);
86
86
  });
87
87
 
88
+ it("broad user route (no accountId) does NOT override managed per-agent route", () => {
89
+ const broad = makeRoute({
90
+ runtime: "broad",
91
+ match: { conversationPrefix: "rm_oc_" },
92
+ });
93
+ const managed = makeRoute({ runtime: "managed", match: { accountId: "ag_1" } });
94
+ const msg = makeMessage({
95
+ accountId: "ag_1",
96
+ conversation: { id: "rm_oc_abc", kind: "group" },
97
+ });
98
+ expect(resolveRoute(msg, { defaultRoute, routes: [broad] }, [managed])).toBe(managed);
99
+ });
100
+
101
+ it("broad user route applies when no managed route matches the agent", () => {
102
+ const broad = makeRoute({
103
+ runtime: "broad",
104
+ match: { conversationPrefix: "rm_oc_" },
105
+ });
106
+ const managed = makeRoute({ runtime: "managed", match: { accountId: "ag_other" } });
107
+ const msg = makeMessage({
108
+ accountId: "ag_1",
109
+ conversation: { id: "rm_oc_abc", kind: "group" },
110
+ });
111
+ expect(resolveRoute(msg, { defaultRoute, routes: [broad] }, [managed])).toBe(broad);
112
+ });
113
+
88
114
  it("no user match + managed match → managed wins", () => {
89
115
  const managed = makeRoute({ runtime: "managed", match: { accountId: "ag_1" } });
90
116
  const msg = makeMessage({ accountId: "ag_1" });
@@ -147,7 +147,13 @@ function normalizeInbox(
147
147
  ): GatewayInboundMessage | null {
148
148
  const env = msg.envelope;
149
149
  if (!env) return null;
150
- if (env.type !== "message") return null;
150
+ // `message` is the normal conversational envelope; `contact_request` is
151
+ // a lightweight inbound asking the agent to notify its owner (the
152
+ // composer appends the notify-owner hint). All other envelope types
153
+ // (notification, system, contact_added/removed, …) are still filtered
154
+ // out here — they belong in a separate push-notification path that
155
+ // daemon does not yet implement.
156
+ if (env.type !== "message" && env.type !== "contact_request") return null;
151
157
  if (!msg.room_id) return null;
152
158
 
153
159
  const rawText =
@@ -190,6 +196,66 @@ function normalizeInbox(
190
196
  };
191
197
  }
192
198
 
199
+ /**
200
+ * Shape of the `raw` field when the channel batches multiple messages into
201
+ * one envelope. Keeps the latest message's InboxMessage fields at top level
202
+ * so existing accesses (`raw.envelope.type`, `raw.source_type`, …) still
203
+ * work, and exposes the full list via `raw.batch`. `composeBotCordUserTurn`
204
+ * reads `raw.batch` to build one `<agent-message>` / `<human-message>` block
205
+ * per entry.
206
+ */
207
+ export interface BatchedInboxRaw extends InboxMessage {
208
+ batch: InboxMessage[];
209
+ }
210
+
211
+ /**
212
+ * Normalize a group of InboxMessages for the same `(room, topic)` into a
213
+ * single `GatewayInboundMessage`. The envelope carries the latest msg's
214
+ * metadata (routing, session key, trace) and a `raw.batch` array the
215
+ * composer uses to render per-sender blocks.
216
+ *
217
+ * `mentioned` is sticky: true if ANY message in the group is a mention.
218
+ * Returns null if no message in the group is normalizable on its own.
219
+ */
220
+ function normalizeInboxBatch(
221
+ msgs: InboxMessage[],
222
+ options: { channelId: string; accountId: string },
223
+ ): GatewayInboundMessage | null {
224
+ if (msgs.length === 0) return null;
225
+ if (msgs.length === 1) return normalizeInbox(msgs[0]!, options);
226
+
227
+ const latest = msgs[msgs.length - 1]!;
228
+ const base = normalizeInbox(latest, options);
229
+ if (!base) return null;
230
+
231
+ // Fold sibling metadata into the base envelope. `text` is kept non-empty
232
+ // when at least one batched member has a body, so the dispatcher's empty-
233
+ // text skip rule doesn't drop the whole batch just because the latest
234
+ // envelope was e.g. a zero-payload contact_request.
235
+ const anyMentioned = msgs.some((m) => m.mentioned === true);
236
+ let representativeText = base.text ?? "";
237
+ if (!representativeText.trim()) {
238
+ for (let i = msgs.length - 1; i >= 0; i--) {
239
+ const m = msgs[i]!;
240
+ const candidate =
241
+ m.text ??
242
+ (typeof m.envelope?.payload?.text === "string"
243
+ ? (m.envelope.payload.text as string)
244
+ : "");
245
+ if (candidate && candidate.trim()) {
246
+ representativeText = candidate;
247
+ break;
248
+ }
249
+ }
250
+ }
251
+ return {
252
+ ...base,
253
+ text: representativeText,
254
+ mentioned: anyMentioned,
255
+ raw: { ...latest, batch: msgs } satisfies BatchedInboxRaw,
256
+ };
257
+ }
258
+
193
259
  /**
194
260
  * Construct a BotCord channel adapter.
195
261
  *
@@ -244,9 +310,14 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
244
310
  log.info("botcord inbox drained", { count: msgs.length });
245
311
  if (msgs.length === 0) return;
246
312
 
313
+ // First pass: ack duplicates/skipped messages so Hub stops requeueing,
314
+ // and collect eligible messages preserving poll order. Grouping by
315
+ // `(room_id, topic)` mirrors plugin's `handleInboxMessageBatch` — the
316
+ // same conversation thread folds into one turn so the agent sees all
317
+ // new messages at once instead of running N turns back-to-back.
318
+ const eligible: InboxMessage[] = [];
247
319
  for (const msg of msgs) {
248
320
  if (!rememberSeen(msg.hub_msg_id)) {
249
- // Already emitted; ack again so Hub stops requeueing.
250
321
  try {
251
322
  await client.ackMessages([msg.hub_msg_id]);
252
323
  } catch (err) {
@@ -259,7 +330,6 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
259
330
  accountId: options.accountId,
260
331
  });
261
332
  if (!normalized) {
262
- // Not eligible (wrong type, missing room, etc.) — ack so it drops.
263
333
  try {
264
334
  await client.ackMessages([msg.hub_msg_id]);
265
335
  } catch (err) {
@@ -267,15 +337,41 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
267
337
  }
268
338
  continue;
269
339
  }
340
+ eligible.push(msg);
341
+ }
342
+
343
+ if (eligible.length === 0) return;
344
+
345
+ // Group by `(room_id, topic)`. Insertion order is the poll order, so
346
+ // iterating the map yields groups with the same external chronology.
347
+ const groups = new Map<string, InboxMessage[]>();
348
+ for (const msg of eligible) {
349
+ const topic = msg.topic_id ?? msg.topic ?? "";
350
+ const key = `${msg.room_id ?? ""}:${topic}`;
351
+ const list = groups.get(key);
352
+ if (list) list.push(msg);
353
+ else groups.set(key, [msg]);
354
+ }
355
+
356
+ for (const group of groups.values()) {
357
+ const normalized = normalizeInboxBatch(group, {
358
+ channelId: options.id,
359
+ accountId: options.accountId,
360
+ });
361
+ if (!normalized) continue;
362
+
363
+ const hubIds = group.map((m) => m.hub_msg_id);
270
364
  const envelope: GatewayInboundEnvelope = {
271
365
  message: normalized,
272
366
  ack: {
273
367
  accept: async () => {
274
368
  try {
275
- await client.ackMessages([msg.hub_msg_id]);
369
+ // Ack the entire batch together so Hub never re-delivers any
370
+ // member of this turn if the agent succeeds on the group.
371
+ await client.ackMessages(hubIds);
276
372
  } catch (err) {
277
373
  log.warn("botcord ack failed — relying on seen-cache dedup", {
278
- hubMsgId: msg.hub_msg_id,
374
+ hubMsgIds: hubIds,
279
375
  err: String(err),
280
376
  });
281
377
  }
@@ -286,7 +382,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
286
382
  await emit(envelope);
287
383
  } catch (err) {
288
384
  log.error("botcord emit threw", {
289
- hubMsgId: msg.hub_msg_id,
385
+ hubMsgIds: hubIds,
290
386
  err: String(err),
291
387
  });
292
388
  }