@botcord/daemon 0.2.4 → 0.2.6
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 +7 -3
- package/dist/agent-discovery.js +9 -1
- package/dist/agent-workspace.d.ts +62 -0
- package/dist/agent-workspace.js +140 -10
- package/dist/config.d.ts +49 -1
- package/dist/config.js +57 -1
- package/dist/control-channel.d.ts +1 -4
- package/dist/control-channel.js +1 -4
- package/dist/daemon-config-map.d.ts +29 -12
- package/dist/daemon-config-map.js +105 -8
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +52 -5
- package/dist/doctor.d.ts +27 -1
- package/dist/doctor.js +22 -1
- package/dist/gateway/cli-resolver.d.ts +34 -0
- package/dist/gateway/cli-resolver.js +74 -0
- package/dist/gateway/dispatcher.d.ts +66 -1
- package/dist/gateway/dispatcher.js +583 -56
- package/dist/gateway/gateway.d.ts +29 -1
- package/dist/gateway/gateway.js +10 -0
- package/dist/gateway/index.d.ts +2 -0
- package/dist/gateway/index.js +2 -0
- package/dist/gateway/policy-resolver.d.ts +57 -0
- package/dist/gateway/policy-resolver.js +123 -0
- package/dist/gateway/runtimes/acp-stream.d.ts +99 -0
- package/dist/gateway/runtimes/acp-stream.js +394 -0
- package/dist/gateway/runtimes/codex.js +7 -0
- package/dist/gateway/runtimes/hermes-agent.d.ts +83 -0
- package/dist/gateway/runtimes/hermes-agent.js +180 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +7 -2
- package/dist/gateway/runtimes/ndjson-stream.js +16 -3
- package/dist/gateway/runtimes/openclaw-acp.d.ts +44 -0
- package/dist/gateway/runtimes/openclaw-acp.js +500 -0
- package/dist/gateway/runtimes/registry.d.ts +4 -0
- package/dist/gateway/runtimes/registry.js +22 -0
- package/dist/gateway/transcript-paths.d.ts +30 -0
- package/dist/gateway/transcript-paths.js +114 -0
- package/dist/gateway/transcript.d.ts +123 -0
- package/dist/gateway/transcript.js +147 -0
- package/dist/gateway/types.d.ts +31 -0
- package/dist/index.js +286 -27
- package/dist/mention-scan.d.ts +22 -0
- package/dist/mention-scan.js +35 -0
- package/dist/provision.d.ts +73 -3
- package/dist/provision.js +373 -12
- package/dist/system-context.d.ts +5 -4
- package/dist/system-context.js +35 -5
- package/dist/turn-text.js +20 -1
- package/dist/url-utils.d.ts +9 -0
- package/dist/url-utils.js +18 -0
- package/dist/user-auth.js +0 -2
- package/dist/working-memory.js +1 -1
- package/package.json +2 -1
- package/src/__tests__/agent-workspace.test.ts +93 -0
- package/src/__tests__/daemon-config-map.test.ts +79 -0
- package/src/__tests__/openclaw-acp.test.ts +234 -0
- package/src/__tests__/policy-resolver.test.ts +124 -0
- package/src/__tests__/policy-updated-handler.test.ts +144 -0
- package/src/__tests__/provision.test.ts +160 -0
- package/src/__tests__/system-context.test.ts +52 -0
- package/src/__tests__/url-utils.test.ts +37 -0
- package/src/agent-discovery.ts +12 -4
- package/src/agent-workspace.ts +173 -9
- package/src/config.ts +132 -4
- package/src/control-channel.ts +1 -4
- package/src/daemon-config-map.ts +156 -12
- package/src/daemon.ts +66 -5
- package/src/doctor.ts +49 -2
- package/src/gateway/__tests__/dispatcher.test.ts +440 -2
- package/src/gateway/__tests__/hermes-agent-adapter.test.ts +302 -0
- package/src/gateway/__tests__/transcript.test.ts +496 -0
- package/src/gateway/cli-resolver.ts +92 -0
- package/src/gateway/dispatcher.ts +681 -58
- package/src/gateway/gateway.ts +46 -0
- package/src/gateway/index.ts +25 -0
- package/src/gateway/policy-resolver.ts +171 -0
- package/src/gateway/runtimes/acp-stream.ts +535 -0
- package/src/gateway/runtimes/codex.ts +7 -0
- package/src/gateway/runtimes/hermes-agent.ts +206 -0
- package/src/gateway/runtimes/ndjson-stream.ts +16 -3
- package/src/gateway/runtimes/openclaw-acp.ts +606 -0
- package/src/gateway/runtimes/registry.ts +24 -0
- package/src/gateway/transcript-paths.ts +145 -0
- package/src/gateway/transcript.ts +300 -0
- package/src/gateway/types.ts +32 -0
- package/src/index.ts +295 -30
- package/src/mention-scan.ts +38 -0
- package/src/provision.ts +446 -20
- package/src/system-context.ts +41 -9
- package/src/turn-text.ts +22 -1
- package/src/url-utils.ts +17 -0
- package/src/user-auth.ts +0 -2
- package/src/working-memory.ts +1 -1
package/src/daemon.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
CONTROL_FRAME_TYPES,
|
|
3
|
+
shouldWake,
|
|
4
|
+
type AttentionPolicy,
|
|
5
|
+
} from "@botcord/protocol-core";
|
|
2
6
|
import {
|
|
3
7
|
Gateway,
|
|
4
8
|
createBotCordChannel,
|
|
9
|
+
resolveTranscriptEnabled,
|
|
5
10
|
sanitizeUntrustedContent,
|
|
6
11
|
type ChannelAdapter,
|
|
7
12
|
type GatewayChannelConfig,
|
|
@@ -31,6 +36,8 @@ import {
|
|
|
31
36
|
} from "./loop-risk.js";
|
|
32
37
|
import { composeBotCordUserTurn } from "./turn-text.js";
|
|
33
38
|
import { UserAuthManager } from "./user-auth.js";
|
|
39
|
+
import { PolicyResolver } from "./gateway/policy-resolver.js";
|
|
40
|
+
import { scanMention } from "./mention-scan.js";
|
|
34
41
|
|
|
35
42
|
/**
|
|
36
43
|
* Matches the 10-minute turn timeout the legacy daemon dispatcher used, so
|
|
@@ -222,6 +229,18 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
222
229
|
|
|
223
230
|
const gwConfig = toGatewayConfig(opts.config, { agentIds, agentRuntimes });
|
|
224
231
|
|
|
232
|
+
// Per-agent hub URL — read from each credential file at boot. Used to
|
|
233
|
+
// populate `BOTCORD_HUB` for runtime CLI subprocesses so the bundled
|
|
234
|
+
// `botcord` CLI talks to the same hub the agent is registered against,
|
|
235
|
+
// even when a single daemon hosts agents from different hubs.
|
|
236
|
+
const hubUrlByAgentId = new Map<string, string>();
|
|
237
|
+
for (const a of boot.agents) {
|
|
238
|
+
if (a.hubUrl) hubUrlByAgentId.set(a.agentId, a.hubUrl);
|
|
239
|
+
}
|
|
240
|
+
const fallbackHubUrl = opts.hubBaseUrl;
|
|
241
|
+
const resolveHubUrl = (accountId: string): string | undefined =>
|
|
242
|
+
hubUrlByAgentId.get(accountId) ?? fallbackHubUrl;
|
|
243
|
+
|
|
225
244
|
// ActivityTracker lives at the daemon layer (not the gateway core). We
|
|
226
245
|
// expose it to the gateway via (a) the `buildSystemContext` hook so the
|
|
227
246
|
// cross-room digest reflects current activity, and (b) the `onInbound`
|
|
@@ -319,6 +338,40 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
319
338
|
});
|
|
320
339
|
};
|
|
321
340
|
|
|
341
|
+
// Per-agent attention policy cache (PR3, design §4.2 / §5). Seeded from
|
|
342
|
+
// the optional `defaultAttention` / `attentionKeywords` carried by
|
|
343
|
+
// `provision_agent`, refreshed in-place by the `policy_updated` control
|
|
344
|
+
// frame. PR2 will plug per-room overrides into `fetchEffective`; PR3
|
|
345
|
+
// leaves it absent so the resolver collapses to per-agent state.
|
|
346
|
+
const policyResolver = new PolicyResolver({
|
|
347
|
+
fetchGlobal: async (_agentId: string) => undefined,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Display-name lookup for the mention text-fallback. Populated from boot
|
|
351
|
+
// credentials; multi-agent daemons can reuse the same map via accountId.
|
|
352
|
+
const displayNameByAgent = new Map<string, string>();
|
|
353
|
+
for (const a of boot.agents) {
|
|
354
|
+
if (a.displayName) displayNameByAgent.set(a.agentId, a.displayName);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Attention gate: compose `messages.mentioned` (sender-supplied — distrust)
|
|
358
|
+
// with a local `@<display_name>` / `@<agent_id>` text scan, resolve the
|
|
359
|
+
// effective policy, then defer to the protocol-core `shouldWake` decision.
|
|
360
|
+
const attentionGate = async (msg: GatewayInboundMessage): Promise<boolean> => {
|
|
361
|
+
const policy: AttentionPolicy = await policyResolver.resolve(
|
|
362
|
+
msg.accountId,
|
|
363
|
+
msg.conversation.id,
|
|
364
|
+
);
|
|
365
|
+
const localMention = scanMention(msg.text, {
|
|
366
|
+
agentId: msg.accountId,
|
|
367
|
+
displayName: displayNameByAgent.get(msg.accountId),
|
|
368
|
+
});
|
|
369
|
+
return shouldWake(policy, {
|
|
370
|
+
mentioned: msg.mentioned === true || localMention,
|
|
371
|
+
text: msg.text,
|
|
372
|
+
});
|
|
373
|
+
};
|
|
374
|
+
|
|
322
375
|
const gateway = new Gateway({
|
|
323
376
|
config: gwConfig,
|
|
324
377
|
sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
|
|
@@ -340,6 +393,12 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
340
393
|
onInbound,
|
|
341
394
|
onOutbound,
|
|
342
395
|
composeUserTurn: composeBotCordUserTurn,
|
|
396
|
+
attentionGate,
|
|
397
|
+
resolveHubUrl,
|
|
398
|
+
transcriptEnabled: resolveTranscriptEnabled(
|
|
399
|
+
process.env.BOTCORD_TRANSCRIPT,
|
|
400
|
+
opts.config.transcript?.enabled === true,
|
|
401
|
+
),
|
|
343
402
|
});
|
|
344
403
|
|
|
345
404
|
logger.info("daemon starting", {
|
|
@@ -356,7 +415,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
356
415
|
logger.warn("daemon starting with no channels", {
|
|
357
416
|
source: boot.source,
|
|
358
417
|
credentialsDir: boot.credentialsDir,
|
|
359
|
-
hint: "drop a credentials JSON in the discovery dir and restart, or run `botcord-daemon
|
|
418
|
+
hint: "drop a credentials JSON in the discovery dir and restart, or run `botcord-daemon start --agent <ag_xxx>` (only seeds config on first run)",
|
|
360
419
|
});
|
|
361
420
|
}
|
|
362
421
|
|
|
@@ -376,7 +435,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
376
435
|
userId: userAuth.current.userId,
|
|
377
436
|
hubUrl: userAuth.current.hubUrl,
|
|
378
437
|
});
|
|
379
|
-
const provisioner = createProvisioner({ gateway });
|
|
438
|
+
const provisioner = createProvisioner({ gateway, policyResolver });
|
|
380
439
|
controlChannel = new ControlChannel({
|
|
381
440
|
auth: userAuth,
|
|
382
441
|
handle: provisioner,
|
|
@@ -443,7 +502,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
443
502
|
*/
|
|
444
503
|
export interface BootBackfillResult {
|
|
445
504
|
credentialPathByAgentId: Map<string, string>;
|
|
446
|
-
agentRuntimes: Record<string, { runtime?: string; cwd?: string }>;
|
|
505
|
+
agentRuntimes: Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string }>;
|
|
447
506
|
}
|
|
448
507
|
|
|
449
508
|
/**
|
|
@@ -466,10 +525,12 @@ export function backfillBootAgents(
|
|
|
466
525
|
const failed: string[] = [];
|
|
467
526
|
for (const a of agents) {
|
|
468
527
|
if (a.credentialsFile) credentialPathByAgentId.set(a.agentId, a.credentialsFile);
|
|
469
|
-
if (a.runtime || a.cwd) {
|
|
528
|
+
if (a.runtime || a.cwd || a.openclawGateway || a.openclawAgent) {
|
|
470
529
|
agentRuntimes[a.agentId] = {
|
|
471
530
|
...(a.runtime ? { runtime: a.runtime } : {}),
|
|
472
531
|
...(a.cwd ? { cwd: a.cwd } : {}),
|
|
532
|
+
...(a.openclawGateway ? { openclawGateway: a.openclawGateway } : {}),
|
|
533
|
+
...(a.openclawAgent ? { openclawAgent: a.openclawAgent } : {}),
|
|
473
534
|
};
|
|
474
535
|
}
|
|
475
536
|
// Seed files are written only when missing (see `ensureAgentWorkspace`),
|
package/src/doctor.ts
CHANGED
|
@@ -31,9 +31,34 @@ export interface DoctorHttpResult {
|
|
|
31
31
|
error?: string;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** One endpoint probe entry, mirrored from `RuntimeEndpointProbe`. */
|
|
35
|
+
export interface DoctorRuntimeEndpoint {
|
|
36
|
+
name: string;
|
|
37
|
+
url: string;
|
|
38
|
+
reachable: boolean;
|
|
39
|
+
version?: string;
|
|
40
|
+
error?: string;
|
|
41
|
+
agents?: Array<{
|
|
42
|
+
id: string;
|
|
43
|
+
name?: string;
|
|
44
|
+
workspace?: string;
|
|
45
|
+
model?: { name?: string; provider?: string };
|
|
46
|
+
}>;
|
|
47
|
+
/**
|
|
48
|
+
* Optional warning surfaced by the doctor: e.g. botcord plugin loaded on
|
|
49
|
+
* the gateway (would form a daemon → openclaw → botcord → Hub loop).
|
|
50
|
+
*/
|
|
51
|
+
warnings?: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Augmented runtime entry that may carry endpoint probe results. */
|
|
55
|
+
export interface DoctorRuntimeEntry extends RuntimeProbeEntry {
|
|
56
|
+
endpoints?: DoctorRuntimeEndpoint[];
|
|
57
|
+
}
|
|
58
|
+
|
|
34
59
|
/** Input for the rendered doctor output. */
|
|
35
60
|
export interface DoctorInput {
|
|
36
|
-
runtimes:
|
|
61
|
+
runtimes: DoctorRuntimeEntry[];
|
|
37
62
|
channels: ChannelProbeResult[];
|
|
38
63
|
}
|
|
39
64
|
|
|
@@ -226,10 +251,32 @@ export function renderDoctor(input: DoctorInput): string {
|
|
|
226
251
|
lines.push(
|
|
227
252
|
`${pad("RUNTIME", widths.runtime)} ${pad("NAME", widths.name)} ${pad("STATUS", widths.status)} ${pad("VERSION", widths.version)} PATH`,
|
|
228
253
|
);
|
|
229
|
-
for (
|
|
254
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
255
|
+
const r = rows[i];
|
|
256
|
+
const e = input.runtimes[i];
|
|
230
257
|
lines.push(
|
|
231
258
|
`${pad(r.runtime, widths.runtime)} ${pad(r.name, widths.name)} ${pad(r.status, widths.status)} ${pad(r.version, widths.version)} ${r.path}`,
|
|
232
259
|
);
|
|
260
|
+
if (e.endpoints && e.endpoints.length > 0) {
|
|
261
|
+
for (const ep of e.endpoints) {
|
|
262
|
+
const mark = ep.reachable ? "✓" : "✗";
|
|
263
|
+
const detail = ep.reachable
|
|
264
|
+
? ep.version ?? "ok"
|
|
265
|
+
: ep.error ?? "unreachable";
|
|
266
|
+
lines.push(` gateway ${pad(`"${ep.name}"`, 16)} ${pad(ep.url, 40)} ${mark} ${detail}`);
|
|
267
|
+
if (ep.agents && ep.agents.length > 0) {
|
|
268
|
+
// RFC §3.8.4: list by `id` (stable key); show display name when distinct.
|
|
269
|
+
lines.push(
|
|
270
|
+
` agents (id): ${ep.agents
|
|
271
|
+
.map((a) => (a.name && a.name !== a.id ? `${a.id} (${a.name})` : a.id))
|
|
272
|
+
.join(", ")}`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (ep.warnings) {
|
|
276
|
+
for (const w of ep.warnings) lines.push(` WARN: ${w}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
233
280
|
}
|
|
234
281
|
const available = input.runtimes.filter((e) => e.result.available).length;
|
|
235
282
|
lines.push(`\n${available}/${input.runtimes.length} runtimes available`);
|
|
@@ -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,439 @@ 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("attentionGate=false skips the runtime turn but still acks and runs onInbound (PR3)", async () => {
|
|
1555
|
+
const runtime = new FakeRuntime();
|
|
1556
|
+
const { store, dir } = await makeStore();
|
|
1557
|
+
tempDirs.push(dir);
|
|
1558
|
+
const channel = new FakeChannel();
|
|
1559
|
+
const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
|
|
1560
|
+
const accept = vi.fn(async () => {});
|
|
1561
|
+
const onInbound = vi.fn();
|
|
1562
|
+
const attentionGate = vi.fn(async () => false);
|
|
1563
|
+
const dispatcher = new Dispatcher({
|
|
1564
|
+
config: baseConfig(),
|
|
1565
|
+
channels,
|
|
1566
|
+
runtime: () => runtime,
|
|
1567
|
+
sessionStore: store,
|
|
1568
|
+
log: silentLogger(),
|
|
1569
|
+
onInbound,
|
|
1570
|
+
attentionGate,
|
|
1571
|
+
});
|
|
1572
|
+
await dispatcher.handle(makeEnvelope({ id: "m_gated", text: "hello" }, { accept }));
|
|
1573
|
+
expect(accept).toHaveBeenCalledTimes(1);
|
|
1574
|
+
expect(onInbound).toHaveBeenCalledTimes(1);
|
|
1575
|
+
expect(attentionGate).toHaveBeenCalledTimes(1);
|
|
1576
|
+
expect(runtime.calls.length).toBe(0);
|
|
1577
|
+
expect(channel.sends.length).toBe(0);
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
it("attentionGate=true wakes the runtime as usual (PR3)", async () => {
|
|
1581
|
+
const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
|
|
1582
|
+
const { store, dir } = await makeStore();
|
|
1583
|
+
tempDirs.push(dir);
|
|
1584
|
+
const channel = new FakeChannel();
|
|
1585
|
+
const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
|
|
1586
|
+
const dispatcher = new Dispatcher({
|
|
1587
|
+
config: baseConfig(),
|
|
1588
|
+
channels,
|
|
1589
|
+
runtime: () => runtime,
|
|
1590
|
+
sessionStore: store,
|
|
1591
|
+
log: silentLogger(),
|
|
1592
|
+
attentionGate: () => true,
|
|
1593
|
+
});
|
|
1594
|
+
await dispatcher.handle(makeEnvelope({ id: "m_wake" }));
|
|
1595
|
+
expect(runtime.calls.length).toBe(1);
|
|
1596
|
+
expect(channel.sends.length).toBe(1);
|
|
1597
|
+
});
|
|
1598
|
+
|
|
1599
|
+
it("attentionGate throwing fails open and runs the turn (PR3)", async () => {
|
|
1600
|
+
const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
|
|
1601
|
+
const { store, dir } = await makeStore();
|
|
1602
|
+
tempDirs.push(dir);
|
|
1603
|
+
const channel = new FakeChannel();
|
|
1604
|
+
const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
|
|
1605
|
+
const dispatcher = new Dispatcher({
|
|
1606
|
+
config: baseConfig(),
|
|
1607
|
+
channels,
|
|
1608
|
+
runtime: () => runtime,
|
|
1609
|
+
sessionStore: store,
|
|
1610
|
+
log: silentLogger(),
|
|
1611
|
+
attentionGate: () => {
|
|
1612
|
+
throw new Error("boom");
|
|
1613
|
+
},
|
|
1614
|
+
});
|
|
1615
|
+
await dispatcher.handle(makeEnvelope({ id: "m_wake_throw" }));
|
|
1616
|
+
expect(runtime.calls.length).toBe(1);
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
it("owner-chat detection: dashboard_user_chat in non-rm_oc room still sends reply", async () => {
|
|
1620
|
+
const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
|
|
1621
|
+
const { dispatcher, channel } = await scaffold({
|
|
1622
|
+
runtimeFactory: () => runtime,
|
|
1623
|
+
});
|
|
1624
|
+
await dispatcher.handle(
|
|
1625
|
+
makeEnvelope({
|
|
1626
|
+
id: "m_dash",
|
|
1627
|
+
// Note: room id does NOT start with rm_oc_ but raw.source_type marks
|
|
1628
|
+
// this as a dashboard user chat — must be treated as owner-chat by
|
|
1629
|
+
// the gating predicate.
|
|
1630
|
+
conversation: { id: "rm_dashroom", kind: "direct" },
|
|
1631
|
+
raw: { source_type: "dashboard_user_chat" },
|
|
1632
|
+
}),
|
|
1633
|
+
);
|
|
1634
|
+
expect(channel.sends.length).toBe(1);
|
|
1635
|
+
expect(channel.sends[0].message.text).toBe("ok");
|
|
1636
|
+
});
|
|
1199
1637
|
});
|