@botcord/daemon 0.2.3 → 0.2.5
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/agent-discovery.d.ts +3 -3
- package/dist/agent-discovery.js +1 -1
- package/dist/agent-workspace.js +0 -2
- package/dist/control-channel.d.ts +1 -4
- package/dist/control-channel.js +1 -4
- package/dist/daemon-config-map.d.ts +2 -3
- package/dist/gateway/channels/botcord.d.ts +23 -0
- package/dist/gateway/channels/botcord.js +93 -3
- package/dist/gateway/dispatcher.d.ts +35 -0
- package/dist/gateway/dispatcher.js +253 -34
- package/dist/gateway/runtimes/claude-code.js +9 -3
- package/dist/provision.d.ts +1 -2
- package/dist/provision.js +3 -5
- package/dist/turn-text.js +20 -1
- package/dist/user-auth.js +0 -2
- package/dist/working-memory.js +1 -1
- package/package.json +1 -1
- package/src/agent-discovery.ts +4 -4
- package/src/agent-workspace.ts +0 -2
- package/src/control-channel.ts +1 -4
- package/src/daemon-config-map.ts +2 -3
- package/src/gateway/__tests__/botcord-channel.test.ts +10 -3
- package/src/gateway/__tests__/claude-code-adapter.test.ts +2 -2
- package/src/gateway/__tests__/dispatcher.test.ts +375 -2
- package/src/gateway/channels/botcord.ts +89 -3
- package/src/gateway/dispatcher.ts +292 -37
- package/src/gateway/runtimes/claude-code.ts +9 -3
- package/src/provision.ts +8 -11
- package/src/turn-text.ts +22 -1
- package/src/user-auth.ts +0 -2
- package/src/working-memory.ts +1 -1
package/dist/provision.js
CHANGED
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
* to this module with the parsed {@link ControlFrame}; we execute the
|
|
4
4
|
* side effects (register agent, write credentials, load route, add/remove
|
|
5
5
|
* gateway channel) and return an ack payload.
|
|
6
|
-
*
|
|
7
|
-
* See `docs/daemon-control-plane-plan.md` §4.3, §5.3, §8.
|
|
8
6
|
*/
|
|
9
7
|
import { existsSync, rmSync, unlinkSync } from "node:fs";
|
|
10
8
|
import { homedir } from "node:os";
|
|
@@ -222,9 +220,9 @@ async function provisionAgent(params, ctx) {
|
|
|
222
220
|
};
|
|
223
221
|
}
|
|
224
222
|
async function materializeCredentials(params, cfg, ctx, explicitCwd) {
|
|
225
|
-
// Runtime is an agent property
|
|
226
|
-
//
|
|
227
|
-
//
|
|
223
|
+
// Runtime is an agent property. Hub is authoritative; top-level `runtime`
|
|
224
|
+
// wins, `adapter` is a one-release alias, and `credentials.runtime` is the
|
|
225
|
+
// per-agent cached copy.
|
|
228
226
|
const runtime = pickRuntime(params);
|
|
229
227
|
if (runtime)
|
|
230
228
|
assertKnownRuntime(runtime);
|
package/dist/turn-text.js
CHANGED
|
@@ -4,6 +4,16 @@ const GROUP_HINT = '[In group chats, do NOT reply unless you are explicitly ment
|
|
|
4
4
|
'If no response is needed, reply with exactly "NO_REPLY" and nothing else.]';
|
|
5
5
|
const DIRECT_HINT = '[If the conversation has naturally concluded or no response is needed, ' +
|
|
6
6
|
'reply with exactly "NO_REPLY" and nothing else.]';
|
|
7
|
+
/**
|
|
8
|
+
* Reminder appended to every wrapped (non-owner-chat) inbound message. The
|
|
9
|
+
* dispatcher discards `result.text` for any room that is not `rm_oc_*`, so
|
|
10
|
+
* the agent must call the `botcord_send` tool (or the `botcord send` CLI
|
|
11
|
+
* via Bash) to actually deliver a reply. Plain assistant text in those
|
|
12
|
+
* rooms is logged and dropped.
|
|
13
|
+
*/
|
|
14
|
+
const NON_OWNER_REPLY_HINT = "[This room is NOT owner-chat. Plain text output WILL NOT be sent. " +
|
|
15
|
+
"To reply, call the `botcord_send` tool, or run " +
|
|
16
|
+
'`botcord send --room <room_id> --text "..."` via Bash.]';
|
|
7
17
|
/**
|
|
8
18
|
* Read the BotCord envelope type from a raw inbound message. Returns
|
|
9
19
|
* `undefined` when the message didn't come from the BotCord channel or the
|
|
@@ -116,6 +126,8 @@ export function composeBotCordUserTurn(msg) {
|
|
|
116
126
|
`</${tag}>`,
|
|
117
127
|
"",
|
|
118
128
|
hint,
|
|
129
|
+
"",
|
|
130
|
+
NON_OWNER_REPLY_HINT,
|
|
119
131
|
];
|
|
120
132
|
if (contactRequestHint) {
|
|
121
133
|
lines.push("", contactRequestHint);
|
|
@@ -160,7 +172,14 @@ function composeBatchedTurn(msg, batch) {
|
|
|
160
172
|
}
|
|
161
173
|
}
|
|
162
174
|
const hint = isGroup ? GROUP_HINT : DIRECT_HINT;
|
|
163
|
-
const lines = [
|
|
175
|
+
const lines = [
|
|
176
|
+
header.join(" | "),
|
|
177
|
+
blocks.join("\n"),
|
|
178
|
+
"",
|
|
179
|
+
hint,
|
|
180
|
+
"",
|
|
181
|
+
NON_OWNER_REPLY_HINT,
|
|
182
|
+
];
|
|
164
183
|
if (contactRequestSenders.length > 0) {
|
|
165
184
|
// Dedup + list — multiple distinct senders show as "A, B".
|
|
166
185
|
const unique = Array.from(new Set(contactRequestSenders));
|
package/dist/user-auth.js
CHANGED
|
@@ -5,8 +5,6 @@
|
|
|
5
5
|
* `~/.botcord/credentials/*.json`), the user-auth record is singular —
|
|
6
6
|
* the daemon only logs in as *one* user at a time. Stored at
|
|
7
7
|
* `~/.botcord/daemon/user-auth.json` with `0600` permissions.
|
|
8
|
-
*
|
|
9
|
-
* See `docs/daemon-control-plane-plan.md` §6–§7.
|
|
10
8
|
*/
|
|
11
9
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
12
10
|
import path from "node:path";
|
package/dist/working-memory.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Working memory — persistent, account-scoped notes injected into every turn.
|
|
3
3
|
*
|
|
4
4
|
* Stored at `~/.botcord/agents/{agentId}/state/working-memory.json` (the
|
|
5
|
-
* per-agent state dir owned by the daemon
|
|
5
|
+
* per-agent state dir owned by the daemon).
|
|
6
6
|
*
|
|
7
7
|
* Ported from plugin/src/memory.ts (dropping workspace + OpenClaw runtime
|
|
8
8
|
* branches) and plugin/src/memory-protocol.ts (prompt builder).
|
package/package.json
CHANGED
package/src/agent-discovery.ts
CHANGED
|
@@ -31,9 +31,9 @@ export interface DiscoveredAgentCredential {
|
|
|
31
31
|
hubUrl: string;
|
|
32
32
|
displayName?: string;
|
|
33
33
|
/**
|
|
34
|
-
* Runtime cached in the credentials file
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* Runtime cached in the credentials file. Null for legacy bind-code
|
|
35
|
+
* credentials without the field; the daemon falls back to `defaultRoute`
|
|
36
|
+
* in that case.
|
|
37
37
|
*/
|
|
38
38
|
runtime?: string;
|
|
39
39
|
/** Working directory cached alongside `runtime`. */
|
|
@@ -221,7 +221,7 @@ export function resolveBootAgents(
|
|
|
221
221
|
// Best-effort enrich with runtime/cwd cached in credentials. A missing
|
|
222
222
|
// or unreadable file is not fatal — the gateway channel will surface the
|
|
223
223
|
// real error at start. The fields we're after are purely for router
|
|
224
|
-
// fallback
|
|
224
|
+
// fallback.
|
|
225
225
|
const agents: DiscoveredAgentCredential[] = explicit.map((agentId) => {
|
|
226
226
|
const credentialsFile = defaultCredentialsFile(agentId);
|
|
227
227
|
const entry: DiscoveredAgentCredential = {
|
package/src/agent-workspace.ts
CHANGED
|
@@ -7,8 +7,6 @@
|
|
|
7
7
|
* codex-home/ — per-agent CODEX_HOME used by the codex adapter so codex
|
|
8
8
|
* reads a daemon-written AGENTS.md (systemContext carrier)
|
|
9
9
|
* and stores its sessions/ without touching ~/.codex.
|
|
10
|
-
*
|
|
11
|
-
* See docs/daemon-agent-workspace-plan.md §4 for the full layout rationale.
|
|
12
10
|
*/
|
|
13
11
|
import {
|
|
14
12
|
chmodSync,
|
package/src/control-channel.ts
CHANGED
|
@@ -5,8 +5,6 @@
|
|
|
5
5
|
* Independent from the agent data-plane WS: different auth (user access
|
|
6
6
|
* token vs agent JWT), different endpoint (`/daemon/ws`), different
|
|
7
7
|
* lifecycle (alive even when zero agents are bound).
|
|
8
|
-
*
|
|
9
|
-
* See `docs/daemon-control-plane-plan.md` §4.1, §4.3, §8.
|
|
10
8
|
*/
|
|
11
9
|
import WebSocket from "ws";
|
|
12
10
|
import {
|
|
@@ -31,8 +29,7 @@ const REPLAY_DEDUPE_CAP = 256;
|
|
|
31
29
|
|
|
32
30
|
/**
|
|
33
31
|
* Build the canonical signing input for a control frame: RFC 8785 (JCS)
|
|
34
|
-
* canonicalization of `{id, type, params, ts}`.
|
|
35
|
-
* `docs/daemon-control-plane-api-contract.md` §3.3 — the Hub uses Python
|
|
32
|
+
* canonicalization of `{id, type, params, ts}`. The Hub uses Python
|
|
36
33
|
* `jcs.canonicalize` over the same object before signing.
|
|
37
34
|
*
|
|
38
35
|
* Excludes `sig` by definition. `params` defaults to `{}` (empty object)
|
package/src/daemon-config-map.ts
CHANGED
|
@@ -19,9 +19,8 @@ export interface ToGatewayConfigOptions {
|
|
|
19
19
|
*/
|
|
20
20
|
agentIds?: string[];
|
|
21
21
|
/**
|
|
22
|
-
* Per-agent runtime/cwd cached from credentials
|
|
23
|
-
* `
|
|
24
|
-
* `toGatewayConfig` synthesizes a terminal route pinning that agent's
|
|
22
|
+
* Per-agent runtime/cwd cached from credentials. When present for an agent
|
|
23
|
+
* id, `toGatewayConfig` synthesizes a terminal route pinning that agent's
|
|
25
24
|
* turns to its runtime. Explicit `cfg.routes` entries still win because
|
|
26
25
|
* synthesized routes are appended after them.
|
|
27
26
|
*/
|
|
@@ -484,7 +484,11 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
484
484
|
traceId: "m_trace",
|
|
485
485
|
accountId: "ag_self",
|
|
486
486
|
conversationId: "rm_oc_1",
|
|
487
|
-
block: {
|
|
487
|
+
block: {
|
|
488
|
+
kind: "assistant_text",
|
|
489
|
+
seq: 3,
|
|
490
|
+
raw: { type: "assistant", message: { content: [{ type: "text", text: "partial" }] } },
|
|
491
|
+
},
|
|
488
492
|
log: silentLog,
|
|
489
493
|
});
|
|
490
494
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
@@ -493,10 +497,13 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
493
497
|
expect(init.method).toBe("POST");
|
|
494
498
|
const body = JSON.parse(init.body as string);
|
|
495
499
|
expect(body.trace_id).toBe("m_trace");
|
|
500
|
+
expect(body.seq).toBe(3);
|
|
501
|
+
// The channel remaps daemon-internal kinds into the shape the dashboard
|
|
502
|
+
// renders: `{ kind, payload, seq }` with `assistant_text` → `assistant`.
|
|
496
503
|
expect(body.block).toEqual({
|
|
497
|
-
kind: "
|
|
504
|
+
kind: "assistant",
|
|
498
505
|
seq: 3,
|
|
499
|
-
|
|
506
|
+
payload: { text: "partial" },
|
|
500
507
|
});
|
|
501
508
|
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer test-token");
|
|
502
509
|
} finally {
|
|
@@ -211,7 +211,7 @@ process.stdout.write(JSON.stringify({type:"result", subtype:"success", session_i
|
|
|
211
211
|
`,
|
|
212
212
|
);
|
|
213
213
|
|
|
214
|
-
it("owner → --permission-mode
|
|
214
|
+
it("owner → --permission-mode bypassPermissions", async () => {
|
|
215
215
|
const adapter = new ClaudeCodeAdapter({ binary: echoScript() });
|
|
216
216
|
const ctrl = new AbortController();
|
|
217
217
|
const res = await adapter.run({
|
|
@@ -225,7 +225,7 @@ process.stdout.write(JSON.stringify({type:"result", subtype:"success", session_i
|
|
|
225
225
|
const argv = JSON.parse(res.text) as string[];
|
|
226
226
|
const modeIdx = argv.indexOf("--permission-mode");
|
|
227
227
|
expect(modeIdx).toBeGreaterThanOrEqual(0);
|
|
228
|
-
expect(argv[modeIdx + 1]).toBe("
|
|
228
|
+
expect(argv[modeIdx + 1]).toBe("bypassPermissions");
|
|
229
229
|
});
|
|
230
230
|
|
|
231
231
|
it("public → --permission-mode default", async () => {
|
|
@@ -459,16 +459,19 @@ describe("Dispatcher", () => {
|
|
|
459
459
|
});
|
|
460
460
|
const { dispatcher, channel } = await scaffold({ config, runtimeFactory });
|
|
461
461
|
|
|
462
|
+
// Use an `rm_oc_` room so the dispatcher's reply gating does not drop
|
|
463
|
+
// the runtime text — non-owner-chat rooms intentionally suppress
|
|
464
|
+
// result.text since the agent is expected to use `botcord_send`.
|
|
462
465
|
const p1 = dispatcher.handle(
|
|
463
466
|
makeEnvelope({
|
|
464
467
|
id: "m1",
|
|
465
|
-
conversation: { id: "
|
|
468
|
+
conversation: { id: "rm_oc_g1", kind: "group" },
|
|
466
469
|
}),
|
|
467
470
|
);
|
|
468
471
|
const p2 = dispatcher.handle(
|
|
469
472
|
makeEnvelope({
|
|
470
473
|
id: "m2",
|
|
471
|
-
conversation: { id: "
|
|
474
|
+
conversation: { id: "rm_oc_g1", kind: "group" },
|
|
472
475
|
}),
|
|
473
476
|
);
|
|
474
477
|
await Promise.all([p1, p2]);
|
|
@@ -1196,4 +1199,374 @@ describe("Dispatcher", () => {
|
|
|
1196
1199
|
vi.useRealTimers();
|
|
1197
1200
|
}
|
|
1198
1201
|
});
|
|
1202
|
+
|
|
1203
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
1204
|
+
// Owner-chat reply gating
|
|
1205
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
1206
|
+
|
|
1207
|
+
it("non-owner-chat room: discards result.text, agent must use botcord_send", async () => {
|
|
1208
|
+
const runtime = new FakeRuntime({ reply: "would-be-reply", newSessionId: "sid-1" });
|
|
1209
|
+
const { dispatcher, channel, store } = await scaffold({
|
|
1210
|
+
runtimeFactory: () => runtime,
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
await dispatcher.handle(
|
|
1214
|
+
makeEnvelope({
|
|
1215
|
+
id: "msg_1",
|
|
1216
|
+
conversation: { id: "rm_g_other", kind: "group" },
|
|
1217
|
+
}),
|
|
1218
|
+
);
|
|
1219
|
+
|
|
1220
|
+
expect(runtime.calls.length).toBe(1);
|
|
1221
|
+
// Session is still persisted — only the channel send is gated.
|
|
1222
|
+
expect(store.all().length).toBe(1);
|
|
1223
|
+
expect(channel.sends.length).toBe(0);
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
it("non-owner-chat room: timeout reply is suppressed (logged only)", async () => {
|
|
1227
|
+
vi.useFakeTimers();
|
|
1228
|
+
try {
|
|
1229
|
+
const runtime = new FakeRuntime({ hang: true });
|
|
1230
|
+
const { dispatcher, channel } = await scaffold({
|
|
1231
|
+
runtimeFactory: () => runtime,
|
|
1232
|
+
turnTimeoutMs: 500,
|
|
1233
|
+
});
|
|
1234
|
+
const p = dispatcher.handle(
|
|
1235
|
+
makeEnvelope({
|
|
1236
|
+
id: "m_to",
|
|
1237
|
+
conversation: { id: "rm_g_other", kind: "group" },
|
|
1238
|
+
}),
|
|
1239
|
+
);
|
|
1240
|
+
await vi.advanceTimersByTimeAsync(501);
|
|
1241
|
+
await p;
|
|
1242
|
+
expect(runtime.calls[0].signal.aborted).toBe(true);
|
|
1243
|
+
expect(channel.sends.length).toBe(0);
|
|
1244
|
+
} finally {
|
|
1245
|
+
vi.useRealTimers();
|
|
1246
|
+
}
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
it("non-owner-chat room: runtime error reply is suppressed", async () => {
|
|
1250
|
+
const runtime = new FakeRuntime({ throwError: "boom" });
|
|
1251
|
+
const { dispatcher, channel } = await scaffold({
|
|
1252
|
+
runtimeFactory: () => runtime,
|
|
1253
|
+
});
|
|
1254
|
+
await dispatcher.handle(
|
|
1255
|
+
makeEnvelope({
|
|
1256
|
+
id: "m_err",
|
|
1257
|
+
conversation: { id: "rm_g_other", kind: "group" },
|
|
1258
|
+
}),
|
|
1259
|
+
);
|
|
1260
|
+
expect(channel.sends.length).toBe(0);
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
1264
|
+
// Serial coalesce-on-drain
|
|
1265
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
1266
|
+
|
|
1267
|
+
it("serial coalesce: messages arriving during a slow turn fold into ONE next turn", async () => {
|
|
1268
|
+
let callNo = 0;
|
|
1269
|
+
const runtimeFactory: RuntimeFactory = () => {
|
|
1270
|
+
callNo += 1;
|
|
1271
|
+
const tag = `r${callNo}`;
|
|
1272
|
+
return new FakeRuntime({
|
|
1273
|
+
id: "claude-code",
|
|
1274
|
+
reply: tag,
|
|
1275
|
+
delayMs: 30,
|
|
1276
|
+
newSessionId: `sid-${callNo}`,
|
|
1277
|
+
});
|
|
1278
|
+
};
|
|
1279
|
+
// Capture composer input so we can inspect what each turn was asked to render.
|
|
1280
|
+
const composeCalls: Array<{ id: string; batchSize: number }> = [];
|
|
1281
|
+
const { store, dir } = await makeStore();
|
|
1282
|
+
tempDirs.push(dir);
|
|
1283
|
+
const channel = new FakeChannel();
|
|
1284
|
+
const dispatcher = new Dispatcher({
|
|
1285
|
+
config: baseConfig({
|
|
1286
|
+
defaultRoute: { runtime: "claude-code", cwd: "/tmp/d", queueMode: "serial" },
|
|
1287
|
+
}),
|
|
1288
|
+
channels: new Map<string, ChannelAdapter>([[channel.id, channel]]),
|
|
1289
|
+
runtime: runtimeFactory,
|
|
1290
|
+
sessionStore: store,
|
|
1291
|
+
log: silentLogger(),
|
|
1292
|
+
composeUserTurn: (msg) => {
|
|
1293
|
+
const raw = msg.raw as { batch?: unknown[] } | null | undefined;
|
|
1294
|
+
const batch = Array.isArray(raw?.batch) ? raw!.batch : null;
|
|
1295
|
+
composeCalls.push({ id: msg.id, batchSize: batch ? batch.length : 1 });
|
|
1296
|
+
return msg.text ?? "";
|
|
1297
|
+
},
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
// m1 arrives → triggers immediate turn.
|
|
1301
|
+
const p1 = dispatcher.handle(
|
|
1302
|
+
makeEnvelope({
|
|
1303
|
+
id: "m1",
|
|
1304
|
+
text: "hello",
|
|
1305
|
+
raw: { hub_msg_id: "m1", text: "hello" },
|
|
1306
|
+
conversation: { id: "rm_grp_x", kind: "group" },
|
|
1307
|
+
}),
|
|
1308
|
+
);
|
|
1309
|
+
// Let the worker start runtime.run for m1.
|
|
1310
|
+
await Promise.resolve();
|
|
1311
|
+
await Promise.resolve();
|
|
1312
|
+
// m2 + m3 arrive while m1 is in flight → buffered, must coalesce.
|
|
1313
|
+
const p2 = dispatcher.handle(
|
|
1314
|
+
makeEnvelope({
|
|
1315
|
+
id: "m2",
|
|
1316
|
+
text: "second",
|
|
1317
|
+
raw: { hub_msg_id: "m2", text: "second" },
|
|
1318
|
+
conversation: { id: "rm_grp_x", kind: "group" },
|
|
1319
|
+
}),
|
|
1320
|
+
);
|
|
1321
|
+
const p3 = dispatcher.handle(
|
|
1322
|
+
makeEnvelope({
|
|
1323
|
+
id: "m3",
|
|
1324
|
+
text: "third",
|
|
1325
|
+
raw: { hub_msg_id: "m3", text: "third" },
|
|
1326
|
+
conversation: { id: "rm_grp_x", kind: "group" },
|
|
1327
|
+
}),
|
|
1328
|
+
);
|
|
1329
|
+
await Promise.all([p1, p2, p3]);
|
|
1330
|
+
|
|
1331
|
+
// Exactly two runtime turns: m1 alone, then a single coalesced turn merging m2+m3.
|
|
1332
|
+
expect(callNo).toBe(2);
|
|
1333
|
+
expect(composeCalls.length).toBe(2);
|
|
1334
|
+
expect(composeCalls[0]).toEqual({ id: "m1", batchSize: 1 });
|
|
1335
|
+
// Anchor of merged turn is the latest entry (m3); raw.batch holds both.
|
|
1336
|
+
expect(composeCalls[1].id).toBe("m3");
|
|
1337
|
+
expect(composeCalls[1].batchSize).toBe(2);
|
|
1338
|
+
// Sends gated for non-owner-chat room.
|
|
1339
|
+
expect(channel.sends.length).toBe(0);
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
it("serial coalesce: mentioned is OR'd across the merged batch", async () => {
|
|
1343
|
+
let captured: { mentioned?: boolean } = {};
|
|
1344
|
+
const runtimeFactory: RuntimeFactory = () =>
|
|
1345
|
+
new FakeRuntime({ reply: "ok", delayMs: 20, newSessionId: "sid" });
|
|
1346
|
+
const { store, dir } = await makeStore();
|
|
1347
|
+
tempDirs.push(dir);
|
|
1348
|
+
const channel = new FakeChannel();
|
|
1349
|
+
const dispatcher = new Dispatcher({
|
|
1350
|
+
config: baseConfig({
|
|
1351
|
+
defaultRoute: { runtime: "claude-code", cwd: "/tmp/d", queueMode: "serial" },
|
|
1352
|
+
}),
|
|
1353
|
+
channels: new Map<string, ChannelAdapter>([[channel.id, channel]]),
|
|
1354
|
+
runtime: runtimeFactory,
|
|
1355
|
+
sessionStore: store,
|
|
1356
|
+
log: silentLogger(),
|
|
1357
|
+
composeUserTurn: (msg) => {
|
|
1358
|
+
if (msg.id === "m_b") captured = { mentioned: msg.mentioned };
|
|
1359
|
+
return msg.text ?? "";
|
|
1360
|
+
},
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
const p1 = dispatcher.handle(
|
|
1364
|
+
makeEnvelope({
|
|
1365
|
+
id: "m_a",
|
|
1366
|
+
text: "a",
|
|
1367
|
+
mentioned: false,
|
|
1368
|
+
conversation: { id: "rm_grp_y", kind: "group" },
|
|
1369
|
+
}),
|
|
1370
|
+
);
|
|
1371
|
+
await Promise.resolve();
|
|
1372
|
+
await Promise.resolve();
|
|
1373
|
+
// Two new entries arrive during turn 1; one of them is mentioned.
|
|
1374
|
+
const p2 = dispatcher.handle(
|
|
1375
|
+
makeEnvelope({
|
|
1376
|
+
id: "m_a2",
|
|
1377
|
+
text: "a2",
|
|
1378
|
+
mentioned: true,
|
|
1379
|
+
conversation: { id: "rm_grp_y", kind: "group" },
|
|
1380
|
+
}),
|
|
1381
|
+
);
|
|
1382
|
+
const p3 = dispatcher.handle(
|
|
1383
|
+
makeEnvelope({
|
|
1384
|
+
id: "m_b",
|
|
1385
|
+
text: "b",
|
|
1386
|
+
mentioned: false,
|
|
1387
|
+
conversation: { id: "rm_grp_y", kind: "group" },
|
|
1388
|
+
}),
|
|
1389
|
+
);
|
|
1390
|
+
await Promise.all([p1, p2, p3]);
|
|
1391
|
+
expect(captured.mentioned).toBe(true);
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
it("serial coalesce: buffer overflow drops oldest entries (>40 backlog)", async () => {
|
|
1395
|
+
let firstTurnDone!: () => void;
|
|
1396
|
+
const firstTurnGate = new Promise<void>((resolve) => {
|
|
1397
|
+
firstTurnDone = resolve;
|
|
1398
|
+
});
|
|
1399
|
+
let callNo = 0;
|
|
1400
|
+
const runtimeFactory: RuntimeFactory = () => {
|
|
1401
|
+
callNo += 1;
|
|
1402
|
+
const tag = `r${callNo}`;
|
|
1403
|
+
// Turn 1 hangs until firstTurnGate is resolved, so we can pile up the
|
|
1404
|
+
// overflowing backlog while it's in flight. Turns 2+ just complete.
|
|
1405
|
+
if (callNo === 1) {
|
|
1406
|
+
return new FakeRuntime({
|
|
1407
|
+
reply: tag,
|
|
1408
|
+
newSessionId: `sid-${callNo}`,
|
|
1409
|
+
observeRun: () => {
|
|
1410
|
+
void firstTurnGate.then(() => undefined);
|
|
1411
|
+
},
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
return new FakeRuntime({
|
|
1415
|
+
reply: tag,
|
|
1416
|
+
newSessionId: `sid-${callNo}`,
|
|
1417
|
+
delayMs: 0,
|
|
1418
|
+
});
|
|
1419
|
+
};
|
|
1420
|
+
const composedIds: string[][] = [];
|
|
1421
|
+
const { store, dir } = await makeStore();
|
|
1422
|
+
tempDirs.push(dir);
|
|
1423
|
+
const channel = new FakeChannel();
|
|
1424
|
+
const dispatcher = new Dispatcher({
|
|
1425
|
+
config: baseConfig({
|
|
1426
|
+
defaultRoute: { runtime: "claude-code", cwd: "/tmp/d", queueMode: "serial" },
|
|
1427
|
+
}),
|
|
1428
|
+
channels: new Map<string, ChannelAdapter>([[channel.id, channel]]),
|
|
1429
|
+
runtime: runtimeFactory,
|
|
1430
|
+
sessionStore: store,
|
|
1431
|
+
log: silentLogger(),
|
|
1432
|
+
composeUserTurn: (msg) => {
|
|
1433
|
+
const raw = msg.raw as { batch?: Array<{ hub_msg_id?: string }> } | null;
|
|
1434
|
+
const ids = Array.isArray(raw?.batch)
|
|
1435
|
+
? raw!.batch!.map((b) => b.hub_msg_id ?? "?")
|
|
1436
|
+
: [msg.id];
|
|
1437
|
+
composedIds.push(ids);
|
|
1438
|
+
return msg.text ?? "";
|
|
1439
|
+
},
|
|
1440
|
+
});
|
|
1441
|
+
// Use a slow-runtime trigger that only resolves once we've enqueued
|
|
1442
|
+
// enough overflow. Easier path: switch to delayMs and use timers.
|
|
1443
|
+
// Simplification: just push 50 messages serially via Promise.all so that
|
|
1444
|
+
// arrivals 2-50 buffer while arrival 1 is mid-run.
|
|
1445
|
+
const arrivals: Array<Promise<void>> = [];
|
|
1446
|
+
arrivals.push(
|
|
1447
|
+
dispatcher.handle(
|
|
1448
|
+
makeEnvelope({
|
|
1449
|
+
id: "m_first",
|
|
1450
|
+
text: "first",
|
|
1451
|
+
raw: { hub_msg_id: "m_first", text: "first" },
|
|
1452
|
+
conversation: { id: "rm_grp_overflow", kind: "group" },
|
|
1453
|
+
}),
|
|
1454
|
+
),
|
|
1455
|
+
);
|
|
1456
|
+
// Yield twice so the worker observes the first entry and starts runtime.
|
|
1457
|
+
await Promise.resolve();
|
|
1458
|
+
await Promise.resolve();
|
|
1459
|
+
// Push 50 more — backlog cap is 40, so the oldest 10 must be dropped.
|
|
1460
|
+
for (let i = 0; i < 50; i++) {
|
|
1461
|
+
arrivals.push(
|
|
1462
|
+
dispatcher.handle(
|
|
1463
|
+
makeEnvelope({
|
|
1464
|
+
id: `m_${i}`,
|
|
1465
|
+
text: `msg-${i}`,
|
|
1466
|
+
raw: { hub_msg_id: `m_${i}`, text: `msg-${i}` },
|
|
1467
|
+
conversation: { id: "rm_grp_overflow", kind: "group" },
|
|
1468
|
+
}),
|
|
1469
|
+
),
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
// Release turn 1 so the worker drains the (capped) backlog.
|
|
1473
|
+
firstTurnDone();
|
|
1474
|
+
await Promise.all(arrivals);
|
|
1475
|
+
|
|
1476
|
+
// Two compose calls: first turn (m_first), second turn (40 surviving
|
|
1477
|
+
// backlog entries — m_10 through m_49 if drop-oldest was applied).
|
|
1478
|
+
expect(composedIds.length).toBe(2);
|
|
1479
|
+
expect(composedIds[0]).toEqual(["m_first"]);
|
|
1480
|
+
expect(composedIds[1].length).toBe(40);
|
|
1481
|
+
expect(composedIds[1][0]).toBe("m_10");
|
|
1482
|
+
expect(composedIds[1][39]).toBe("m_49");
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
it("serial coalesce: char-cap drops oldest individual messages from merged batch", async () => {
|
|
1486
|
+
let callNo = 0;
|
|
1487
|
+
const runtimeFactory: RuntimeFactory = () => {
|
|
1488
|
+
callNo += 1;
|
|
1489
|
+
const tag = `r${callNo}`;
|
|
1490
|
+
return new FakeRuntime({
|
|
1491
|
+
reply: tag,
|
|
1492
|
+
newSessionId: `sid-${callNo}`,
|
|
1493
|
+
// Turn 1 holds until released so turns 2-N pile up.
|
|
1494
|
+
delayMs: callNo === 1 ? 50 : 0,
|
|
1495
|
+
});
|
|
1496
|
+
};
|
|
1497
|
+
const composedItemCounts: number[] = [];
|
|
1498
|
+
const { store, dir } = await makeStore();
|
|
1499
|
+
tempDirs.push(dir);
|
|
1500
|
+
const channel = new FakeChannel();
|
|
1501
|
+
const dispatcher = new Dispatcher({
|
|
1502
|
+
config: baseConfig({
|
|
1503
|
+
defaultRoute: { runtime: "claude-code", cwd: "/tmp/d", queueMode: "serial" },
|
|
1504
|
+
}),
|
|
1505
|
+
channels: new Map<string, ChannelAdapter>([[channel.id, channel]]),
|
|
1506
|
+
runtime: runtimeFactory,
|
|
1507
|
+
sessionStore: store,
|
|
1508
|
+
log: silentLogger(),
|
|
1509
|
+
composeUserTurn: (msg) => {
|
|
1510
|
+
const raw = msg.raw as { batch?: unknown[] } | null;
|
|
1511
|
+
const items = Array.isArray(raw?.batch) ? raw!.batch!.length : 1;
|
|
1512
|
+
composedItemCounts.push(items);
|
|
1513
|
+
return msg.text ?? "";
|
|
1514
|
+
},
|
|
1515
|
+
});
|
|
1516
|
+
// Each pile-up message carries ~2000 chars; 20 messages = ~40000 chars,
|
|
1517
|
+
// well over the 16000 cap. The merger must drop oldest until ≤16000.
|
|
1518
|
+
const big = "x".repeat(2000);
|
|
1519
|
+
const p0 = dispatcher.handle(
|
|
1520
|
+
makeEnvelope({
|
|
1521
|
+
id: "m_lead",
|
|
1522
|
+
text: "lead",
|
|
1523
|
+
raw: { hub_msg_id: "m_lead", text: "lead" },
|
|
1524
|
+
conversation: { id: "rm_grp_chars", kind: "group" },
|
|
1525
|
+
}),
|
|
1526
|
+
);
|
|
1527
|
+
await Promise.resolve();
|
|
1528
|
+
await Promise.resolve();
|
|
1529
|
+
const arrivals: Array<Promise<void>> = [p0];
|
|
1530
|
+
for (let i = 0; i < 20; i++) {
|
|
1531
|
+
arrivals.push(
|
|
1532
|
+
dispatcher.handle(
|
|
1533
|
+
makeEnvelope({
|
|
1534
|
+
id: `mb_${i}`,
|
|
1535
|
+
text: big,
|
|
1536
|
+
raw: { hub_msg_id: `mb_${i}`, text: big },
|
|
1537
|
+
conversation: { id: "rm_grp_chars", kind: "group" },
|
|
1538
|
+
}),
|
|
1539
|
+
),
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
await Promise.all(arrivals);
|
|
1543
|
+
|
|
1544
|
+
// Two composer calls. The second is the merged drain — it must contain
|
|
1545
|
+
// strictly fewer than 20 items because oldest were dropped to fit cap.
|
|
1546
|
+
expect(composedItemCounts.length).toBe(2);
|
|
1547
|
+
expect(composedItemCounts[0]).toBe(1);
|
|
1548
|
+
expect(composedItemCounts[1]).toBeGreaterThan(0);
|
|
1549
|
+
expect(composedItemCounts[1]).toBeLessThan(20);
|
|
1550
|
+
// Cap is 16000 chars; each item is 2000 → at most 8 items survive.
|
|
1551
|
+
expect(composedItemCounts[1]).toBeLessThanOrEqual(8);
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
it("owner-chat detection: dashboard_user_chat in non-rm_oc room still sends reply", async () => {
|
|
1555
|
+
const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
|
|
1556
|
+
const { dispatcher, channel } = await scaffold({
|
|
1557
|
+
runtimeFactory: () => runtime,
|
|
1558
|
+
});
|
|
1559
|
+
await dispatcher.handle(
|
|
1560
|
+
makeEnvelope({
|
|
1561
|
+
id: "m_dash",
|
|
1562
|
+
// Note: room id does NOT start with rm_oc_ but raw.source_type marks
|
|
1563
|
+
// this as a dashboard user chat — must be treated as owner-chat by
|
|
1564
|
+
// the gating predicate.
|
|
1565
|
+
conversation: { id: "rm_dashroom", kind: "direct" },
|
|
1566
|
+
raw: { source_type: "dashboard_user_chat" },
|
|
1567
|
+
}),
|
|
1568
|
+
);
|
|
1569
|
+
expect(channel.sends.length).toBe(1);
|
|
1570
|
+
expect(channel.sends[0].message.text).toBe("ok");
|
|
1571
|
+
});
|
|
1199
1572
|
});
|