@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.
- package/dist/daemon-config-map.js +4 -3
- package/dist/daemon.js +35 -1
- package/dist/gateway/channels/botcord.d.ts +11 -0
- package/dist/gateway/channels/botcord.js +86 -6
- package/dist/gateway/dispatcher.d.ts +8 -1
- package/dist/gateway/dispatcher.js +14 -0
- package/dist/gateway/gateway.d.ts +6 -1
- package/dist/gateway/gateway.js +1 -0
- package/dist/gateway/router.d.ts +8 -3
- package/dist/gateway/router.js +14 -4
- package/dist/gateway/types.d.ts +6 -0
- package/dist/loop-risk.d.ts +65 -0
- package/dist/loop-risk.js +286 -0
- package/dist/system-context.d.ts +6 -0
- package/dist/system-context.js +21 -2
- package/dist/turn-text.js +120 -3
- package/package.json +2 -2
- package/src/__tests__/loop-risk.test.ts +172 -0
- package/src/__tests__/system-context.test.ts +35 -0
- package/src/__tests__/turn-text.test.ts +143 -0
- package/src/daemon-config-map.ts +4 -3
- package/src/daemon.ts +42 -1
- package/src/gateway/__tests__/botcord-channel.test.ts +89 -0
- package/src/gateway/__tests__/dispatcher.test.ts +40 -0
- package/src/gateway/__tests__/router.test.ts +27 -1
- package/src/gateway/channels/botcord.ts +102 -6
- package/src/gateway/dispatcher.ts +20 -0
- package/src/gateway/gateway.ts +7 -0
- package/src/gateway/router.ts +18 -4
- package/src/gateway/types.ts +9 -0
- package/src/loop-risk.ts +322 -0
- package/src/system-context.ts +26 -2
- package/src/turn-text.ts +151 -3
|
@@ -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({
|
package/src/daemon-config-map.ts
CHANGED
|
@@ -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
|
|
154
|
-
//
|
|
155
|
-
// defaultRoute
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
385
|
+
hubMsgIds: hubIds,
|
|
290
386
|
err: String(err),
|
|
291
387
|
});
|
|
292
388
|
}
|