@botcord/daemon 0.2.5 → 0.2.8
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 +4 -0
- package/dist/agent-discovery.js +8 -0
- package/dist/agent-workspace.d.ts +62 -0
- package/dist/agent-workspace.js +140 -8
- package/dist/config.d.ts +64 -1
- package/dist/config.js +73 -1
- package/dist/daemon-config-map.d.ts +27 -9
- package/dist/daemon-config-map.js +105 -8
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +76 -6
- 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 +31 -1
- package/dist/gateway/dispatcher.js +337 -29
- 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 +309 -27
- package/dist/mention-scan.d.ts +22 -0
- package/dist/mention-scan.js +35 -0
- package/dist/openclaw-discovery.d.ts +28 -0
- package/dist/openclaw-discovery.js +228 -0
- package/dist/provision.d.ts +113 -1
- package/dist/provision.js +564 -12
- package/dist/system-context.d.ts +5 -4
- package/dist/system-context.js +35 -5
- package/dist/url-utils.d.ts +9 -0
- package/dist/url-utils.js +18 -0
- package/package.json +3 -2
- 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__/openclaw-discovery.test.ts +150 -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 +265 -0
- package/src/__tests__/system-context.test.ts +52 -0
- package/src/__tests__/url-utils.test.ts +37 -0
- package/src/agent-discovery.ts +8 -0
- package/src/agent-workspace.ts +173 -7
- package/src/config.ts +168 -4
- package/src/daemon-config-map.ts +154 -9
- package/src/daemon.ts +96 -6
- package/src/doctor.ts +49 -2
- package/src/gateway/__tests__/dispatcher.test.ts +65 -0
- 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 +394 -26
- 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 +321 -30
- package/src/mention-scan.ts +38 -0
- package/src/openclaw-discovery.ts +262 -0
- package/src/provision.ts +682 -14
- package/src/system-context.ts +41 -9
- package/src/url-utils.ts +17 -0
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { Dispatcher, type RuntimeFactory } from "../dispatcher.js";
|
|
7
|
+
import { SessionStore } from "../session-store.js";
|
|
8
|
+
import {
|
|
9
|
+
createTranscriptWriter,
|
|
10
|
+
resolveTranscriptEnabled,
|
|
11
|
+
TRANSCRIPT_TEXT_LIMIT,
|
|
12
|
+
truncateTextField,
|
|
13
|
+
type TranscriptRecord,
|
|
14
|
+
} from "../transcript.js";
|
|
15
|
+
import { safePathSegment, transcriptFilePath } from "../transcript-paths.js";
|
|
16
|
+
import type {
|
|
17
|
+
ChannelAdapter,
|
|
18
|
+
ChannelSendContext,
|
|
19
|
+
ChannelSendResult,
|
|
20
|
+
GatewayConfig,
|
|
21
|
+
GatewayInboundEnvelope,
|
|
22
|
+
GatewayInboundMessage,
|
|
23
|
+
RuntimeAdapter,
|
|
24
|
+
RuntimeRunOptions,
|
|
25
|
+
RuntimeRunResult,
|
|
26
|
+
} from "../types.js";
|
|
27
|
+
import type { GatewayLogger } from "../log.js";
|
|
28
|
+
|
|
29
|
+
function silentLogger(): GatewayLogger {
|
|
30
|
+
return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class FakeChannel implements ChannelAdapter {
|
|
34
|
+
readonly id = "botcord";
|
|
35
|
+
readonly type = "fake";
|
|
36
|
+
readonly sends: ChannelSendContext[] = [];
|
|
37
|
+
sendImpl?: (ctx: ChannelSendContext) => Promise<ChannelSendResult> | ChannelSendResult;
|
|
38
|
+
async start(): Promise<void> {}
|
|
39
|
+
async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
|
|
40
|
+
this.sends.push(ctx);
|
|
41
|
+
if (this.sendImpl) return this.sendImpl(ctx);
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface FakeRuntimeOptions {
|
|
47
|
+
reply?: string;
|
|
48
|
+
newSessionId?: string;
|
|
49
|
+
delayMs?: number;
|
|
50
|
+
throwError?: Error | string;
|
|
51
|
+
hang?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
class FakeRuntime implements RuntimeAdapter {
|
|
55
|
+
readonly id = "claude-code";
|
|
56
|
+
constructor(private readonly opts: FakeRuntimeOptions = {}) {}
|
|
57
|
+
async run(options: RuntimeRunOptions): Promise<RuntimeRunResult> {
|
|
58
|
+
if (this.opts.hang) {
|
|
59
|
+
await new Promise<void>((_, reject) => {
|
|
60
|
+
options.signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (this.opts.delayMs) {
|
|
64
|
+
await new Promise<void>((resolve, reject) => {
|
|
65
|
+
const t = setTimeout(resolve, this.opts.delayMs);
|
|
66
|
+
options.signal.addEventListener(
|
|
67
|
+
"abort",
|
|
68
|
+
() => {
|
|
69
|
+
clearTimeout(t);
|
|
70
|
+
reject(new Error("aborted"));
|
|
71
|
+
},
|
|
72
|
+
{ once: true },
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (this.opts.throwError) {
|
|
77
|
+
throw typeof this.opts.throwError === "string"
|
|
78
|
+
? new Error(this.opts.throwError)
|
|
79
|
+
: this.opts.throwError;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
text: this.opts.reply ?? "hello",
|
|
83
|
+
newSessionId: this.opts.newSessionId ?? "sid-1",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function makeMessage(p: Partial<GatewayInboundMessage> = {}): GatewayInboundMessage {
|
|
89
|
+
return {
|
|
90
|
+
id: p.id ?? "msg_1",
|
|
91
|
+
channel: p.channel ?? "botcord",
|
|
92
|
+
accountId: p.accountId ?? "ag_me",
|
|
93
|
+
conversation: p.conversation ?? { id: "rm_oc_1", kind: "direct" },
|
|
94
|
+
sender: p.sender ?? { id: "ag_peer", kind: "user", name: "peer" },
|
|
95
|
+
text: p.text ?? "hello",
|
|
96
|
+
raw: p.raw ?? {},
|
|
97
|
+
replyTo: null,
|
|
98
|
+
receivedAt: Date.now(),
|
|
99
|
+
trace: p.trace,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function makeEnvelope(p: Partial<GatewayInboundMessage> = {}): GatewayInboundEnvelope {
|
|
104
|
+
return { message: makeMessage(p) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function baseConfig(): GatewayConfig {
|
|
108
|
+
return {
|
|
109
|
+
channels: [{ id: "botcord", type: "botcord", accountId: "ag_me" }],
|
|
110
|
+
defaultRoute: { runtime: "claude-code", cwd: "/tmp/default" },
|
|
111
|
+
routes: [],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function readRecords(file: string): Promise<TranscriptRecord[]> {
|
|
116
|
+
if (!existsSync(file)) return [];
|
|
117
|
+
const data = await readFile(file, "utf8");
|
|
118
|
+
return data
|
|
119
|
+
.split("\n")
|
|
120
|
+
.filter((l) => l.length > 0)
|
|
121
|
+
.map((l) => JSON.parse(l) as TranscriptRecord);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface Scaffold {
|
|
125
|
+
dispatcher: Dispatcher;
|
|
126
|
+
channel: FakeChannel;
|
|
127
|
+
store: SessionStore;
|
|
128
|
+
rootDir: string;
|
|
129
|
+
recordsForRoom: (roomId: string, topicId?: string | null) => Promise<TranscriptRecord[]>;
|
|
130
|
+
cleanup: () => Promise<void>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function scaffold(opts: {
|
|
134
|
+
runtimeFactory?: RuntimeFactory;
|
|
135
|
+
turnTimeoutMs?: number;
|
|
136
|
+
attentionGate?: (msg: GatewayInboundMessage) => boolean | Promise<boolean>;
|
|
137
|
+
composeUserTurn?: (msg: GatewayInboundMessage) => string;
|
|
138
|
+
channel?: FakeChannel;
|
|
139
|
+
agentId?: string;
|
|
140
|
+
} = {}): Promise<Scaffold> {
|
|
141
|
+
const tmp = await mkdtemp(path.join(tmpdir(), "transcript-test-"));
|
|
142
|
+
const sessionsPath = path.join(tmp, "sessions.json");
|
|
143
|
+
const store = new SessionStore({ path: sessionsPath });
|
|
144
|
+
await store.load();
|
|
145
|
+
const rootDir = path.join(tmp, "agents");
|
|
146
|
+
const channel = opts.channel ?? new FakeChannel();
|
|
147
|
+
const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
|
|
148
|
+
const transcript = createTranscriptWriter({ rootDir, log: silentLogger(), enabled: true });
|
|
149
|
+
const dispatcher = new Dispatcher({
|
|
150
|
+
config: baseConfig(),
|
|
151
|
+
channels,
|
|
152
|
+
runtime: opts.runtimeFactory ?? (() => new FakeRuntime()),
|
|
153
|
+
sessionStore: store,
|
|
154
|
+
log: silentLogger(),
|
|
155
|
+
turnTimeoutMs: opts.turnTimeoutMs,
|
|
156
|
+
attentionGate: opts.attentionGate,
|
|
157
|
+
composeUserTurn: opts.composeUserTurn,
|
|
158
|
+
transcript,
|
|
159
|
+
});
|
|
160
|
+
return {
|
|
161
|
+
dispatcher,
|
|
162
|
+
channel,
|
|
163
|
+
store,
|
|
164
|
+
rootDir,
|
|
165
|
+
recordsForRoom: async (roomId: string, topicId: string | null = null) => {
|
|
166
|
+
const file = transcriptFilePath(rootDir, opts.agentId ?? "ag_me", roomId, topicId);
|
|
167
|
+
return readRecords(file);
|
|
168
|
+
},
|
|
169
|
+
cleanup: () => rm(tmp, { recursive: true, force: true }),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
describe("safePathSegment", () => {
|
|
174
|
+
it("fast path for plain ids", () => {
|
|
175
|
+
expect(safePathSegment("rm_abc-123")).toBe("rm_abc-123");
|
|
176
|
+
expect(safePathSegment("ag_01HXYZ")).toBe("ag_01HXYZ");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("invalid → _invalid_<sha256-8>", () => {
|
|
180
|
+
const dot = safePathSegment("..");
|
|
181
|
+
expect(dot).toMatch(/^_invalid_[0-9a-f]{8}$/);
|
|
182
|
+
expect(safePathSegment(".")).toMatch(/^_invalid_[0-9a-f]{8}$/);
|
|
183
|
+
expect(safePathSegment("")).toMatch(/^_invalid_[0-9a-f]{8}$/);
|
|
184
|
+
// Different inputs hash to different files.
|
|
185
|
+
expect(safePathSegment("..")).not.toBe(safePathSegment("."));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("Windows reserved names take precedence over fast path", () => {
|
|
189
|
+
expect(safePathSegment("CON")).toBe("_win_CON");
|
|
190
|
+
expect(safePathSegment("con")).toBe("_win_con");
|
|
191
|
+
expect(safePathSegment("COM1")).toBe("_win_COM1");
|
|
192
|
+
expect(safePathSegment("LPT9")).toBe("_win_LPT9");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("escapes path-bearing chars but keeps `%` literal", () => {
|
|
196
|
+
expect(safePathSegment("rm/with/slash")).toBe("rm%2Fwith%2Fslash");
|
|
197
|
+
// `a..b` falls out of fast path (`.` is not whitelisted) so dots get encoded.
|
|
198
|
+
expect(safePathSegment("a..b")).toBe("a%2E%2Eb");
|
|
199
|
+
// `%` itself is preserved literally.
|
|
200
|
+
expect(safePathSegment("100%pure")).toBe("100%pure");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("truncates long escaped names without splitting %XX", () => {
|
|
204
|
+
const long = "%".repeat(0) + "a/".repeat(150); // long enough to need truncation after escape
|
|
205
|
+
const s = safePathSegment(long);
|
|
206
|
+
expect(s.length).toBeLessThanOrEqual(200);
|
|
207
|
+
// Ensure the result does not end mid-`%XX` (last 3 chars are either non-`%` block or `_<hash>`).
|
|
208
|
+
expect(s).toMatch(/_[0-9a-f]{8}$/);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("different long inputs sharing prefix get different hashes", () => {
|
|
212
|
+
const a = "/".repeat(150) + "tail-A";
|
|
213
|
+
const b = "/".repeat(150) + "tail-B";
|
|
214
|
+
const sa = safePathSegment(a);
|
|
215
|
+
const sb = safePathSegment(b);
|
|
216
|
+
expect(sa).not.toBe(sb);
|
|
217
|
+
expect(sa.length).toBeLessThanOrEqual(200);
|
|
218
|
+
expect(sb.length).toBeLessThanOrEqual(200);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("resolveTranscriptEnabled", () => {
|
|
223
|
+
it("env=1 forces on", () => {
|
|
224
|
+
expect(resolveTranscriptEnabled("1", false)).toBe(true);
|
|
225
|
+
expect(resolveTranscriptEnabled("1", true)).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
it("env=0 forces off", () => {
|
|
228
|
+
expect(resolveTranscriptEnabled("0", true)).toBe(false);
|
|
229
|
+
expect(resolveTranscriptEnabled("0", false)).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
it("unset / other strings fall through to config", () => {
|
|
232
|
+
expect(resolveTranscriptEnabled(undefined, true)).toBe(true);
|
|
233
|
+
expect(resolveTranscriptEnabled(undefined, false)).toBe(false);
|
|
234
|
+
expect(resolveTranscriptEnabled("yes", true)).toBe(true);
|
|
235
|
+
expect(resolveTranscriptEnabled("yes", false)).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("truncateTextField", () => {
|
|
240
|
+
it("passes through short text", () => {
|
|
241
|
+
const r = truncateTextField("hi");
|
|
242
|
+
expect(r.text).toBe("hi");
|
|
243
|
+
expect(r.truncated).toBe(false);
|
|
244
|
+
});
|
|
245
|
+
it("truncates oversize", () => {
|
|
246
|
+
const big = "a".repeat(TRANSCRIPT_TEXT_LIMIT + 100);
|
|
247
|
+
const r = truncateTextField(big);
|
|
248
|
+
expect(r.truncated).toBe(true);
|
|
249
|
+
expect(r.text.length).toBe(TRANSCRIPT_TEXT_LIMIT);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("Dispatcher transcript integration", () => {
|
|
254
|
+
let cleanups: Array<() => Promise<void>>;
|
|
255
|
+
beforeEach(() => {
|
|
256
|
+
cleanups = [];
|
|
257
|
+
});
|
|
258
|
+
afterEach(async () => {
|
|
259
|
+
for (const c of cleanups) await c();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
function track(s: Scaffold): Scaffold {
|
|
263
|
+
cleanups.push(s.cleanup);
|
|
264
|
+
return s;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
it("happy path: inbound + dispatched + outbound{delivered}, all share turnId", async () => {
|
|
268
|
+
const s = track(await scaffold({ runtimeFactory: () => new FakeRuntime({ reply: "ok" }) }));
|
|
269
|
+
await s.dispatcher.handle(makeEnvelope({ conversation: { id: "rm_oc_1", kind: "direct" } }));
|
|
270
|
+
const recs = await s.recordsForRoom("rm_oc_1");
|
|
271
|
+
expect(recs.map((r) => r.kind)).toEqual(["inbound", "dispatched", "outbound"]);
|
|
272
|
+
expect(new Set(recs.map((r) => r.turnId)).size).toBe(1);
|
|
273
|
+
const out = recs[2] as Extract<TranscriptRecord, { kind: "outbound" }>;
|
|
274
|
+
expect(out.deliveryStatus).toBe("delivered");
|
|
275
|
+
expect(out.finalText).toBe("ok");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("non-owner-chat: outbound{gated_non_owner_chat}, channel never sends", async () => {
|
|
279
|
+
const s = track(await scaffold({ runtimeFactory: () => new FakeRuntime({ reply: "ok" }) }));
|
|
280
|
+
await s.dispatcher.handle(
|
|
281
|
+
makeEnvelope({ conversation: { id: "rm_normal", kind: "group" } }),
|
|
282
|
+
);
|
|
283
|
+
const recs = await s.recordsForRoom("rm_normal");
|
|
284
|
+
const out = recs.find((r) => r.kind === "outbound") as Extract<TranscriptRecord, { kind: "outbound" }>;
|
|
285
|
+
expect(out.deliveryStatus).toBe("gated_non_owner_chat");
|
|
286
|
+
expect(s.channel.sends.length).toBe(0);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("dashboard_user_chat raw.source_type → delivered even outside rm_oc_", async () => {
|
|
290
|
+
const s = track(await scaffold({ runtimeFactory: () => new FakeRuntime({ reply: "yo" }) }));
|
|
291
|
+
await s.dispatcher.handle(
|
|
292
|
+
makeEnvelope({
|
|
293
|
+
conversation: { id: "rm_dash", kind: "direct" },
|
|
294
|
+
raw: { source_type: "dashboard_user_chat" },
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
const recs = await s.recordsForRoom("rm_dash");
|
|
298
|
+
const out = recs.find((r) => r.kind === "outbound") as Extract<TranscriptRecord, { kind: "outbound" }>;
|
|
299
|
+
expect(out.deliveryStatus).toBe("delivered");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("empty runtime text → outbound{empty_text}", async () => {
|
|
303
|
+
const s = track(await scaffold({ runtimeFactory: () => new FakeRuntime({ reply: " " }) }));
|
|
304
|
+
await s.dispatcher.handle(makeEnvelope());
|
|
305
|
+
const recs = await s.recordsForRoom("rm_oc_1");
|
|
306
|
+
const out = recs.find((r) => r.kind === "outbound") as Extract<TranscriptRecord, { kind: "outbound" }>;
|
|
307
|
+
expect(out.deliveryStatus).toBe("empty_text");
|
|
308
|
+
expect(s.channel.sends.length).toBe(0);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("channel.send throws → outbound{send_failed} with deliveryReason", async () => {
|
|
312
|
+
const channel = new FakeChannel();
|
|
313
|
+
channel.sendImpl = () => {
|
|
314
|
+
throw new Error("boom");
|
|
315
|
+
};
|
|
316
|
+
const s = track(await scaffold({
|
|
317
|
+
runtimeFactory: () => new FakeRuntime({ reply: "ok" }),
|
|
318
|
+
channel,
|
|
319
|
+
}));
|
|
320
|
+
await s.dispatcher.handle(makeEnvelope());
|
|
321
|
+
const recs = await s.recordsForRoom("rm_oc_1");
|
|
322
|
+
const out = recs.find((r) => r.kind === "outbound") as Extract<TranscriptRecord, { kind: "outbound" }>;
|
|
323
|
+
expect(out.deliveryStatus).toBe("send_failed");
|
|
324
|
+
expect(out.deliveryReason).toBe("boom");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("runtime throws → turn_error{phase:runtime}, no outbound", async () => {
|
|
328
|
+
const s = track(await scaffold({
|
|
329
|
+
runtimeFactory: () => new FakeRuntime({ throwError: "kaboom" }),
|
|
330
|
+
}));
|
|
331
|
+
await s.dispatcher.handle(makeEnvelope());
|
|
332
|
+
const recs = await s.recordsForRoom("rm_oc_1");
|
|
333
|
+
const kinds = recs.map((r) => r.kind);
|
|
334
|
+
expect(kinds).toContain("turn_error");
|
|
335
|
+
expect(kinds).not.toContain("outbound");
|
|
336
|
+
const err = recs.find((r) => r.kind === "turn_error") as Extract<TranscriptRecord, { kind: "turn_error" }>;
|
|
337
|
+
expect(err.phase).toBe("runtime");
|
|
338
|
+
expect(err.error).toBe("kaboom");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("attention gate false → inbound + attention_skipped only", async () => {
|
|
342
|
+
const s = track(await scaffold({ attentionGate: () => false }));
|
|
343
|
+
await s.dispatcher.handle(makeEnvelope());
|
|
344
|
+
const recs = await s.recordsForRoom("rm_oc_1");
|
|
345
|
+
expect(recs.map((r) => r.kind)).toEqual(["inbound", "attention_skipped"]);
|
|
346
|
+
expect(new Set(recs.map((r) => r.turnId)).size).toBe(1);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("compose_failed (cancel-previous mode) emits non-terminal record then proceeds", async () => {
|
|
350
|
+
const s = track(await scaffold({
|
|
351
|
+
composeUserTurn: () => {
|
|
352
|
+
throw new Error("compose boom");
|
|
353
|
+
},
|
|
354
|
+
runtimeFactory: () => new FakeRuntime({ reply: "ok" }),
|
|
355
|
+
}));
|
|
356
|
+
await s.dispatcher.handle(makeEnvelope({ conversation: { id: "rm_oc_1", kind: "direct" } }));
|
|
357
|
+
const recs = await s.recordsForRoom("rm_oc_1");
|
|
358
|
+
expect(recs.map((r) => r.kind)).toEqual([
|
|
359
|
+
"inbound",
|
|
360
|
+
"compose_failed",
|
|
361
|
+
"dispatched",
|
|
362
|
+
"outbound",
|
|
363
|
+
]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("pre-skip branches do not write any record", async () => {
|
|
367
|
+
const s = track(await scaffold());
|
|
368
|
+
// empty text
|
|
369
|
+
await s.dispatcher.handle(makeEnvelope({ text: " " }));
|
|
370
|
+
// own-agent echo
|
|
371
|
+
await s.dispatcher.handle(makeEnvelope({ sender: { id: "ag_me", kind: "agent" } }));
|
|
372
|
+
const recs = await s.recordsForRoom("rm_oc_1");
|
|
373
|
+
expect(recs).toEqual([]);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("text/finalText truncation marks truncated.<field>", async () => {
|
|
377
|
+
const big = "X".repeat(TRANSCRIPT_TEXT_LIMIT + 50);
|
|
378
|
+
const s = track(await scaffold({
|
|
379
|
+
runtimeFactory: () => new FakeRuntime({ reply: big }),
|
|
380
|
+
}));
|
|
381
|
+
await s.dispatcher.handle(makeEnvelope({ text: big }));
|
|
382
|
+
const recs = await s.recordsForRoom("rm_oc_1");
|
|
383
|
+
const inbound = recs[0] as Extract<TranscriptRecord, { kind: "inbound" }>;
|
|
384
|
+
expect(inbound.truncated?.text).toBe(true);
|
|
385
|
+
expect(inbound.text.length).toBe(TRANSCRIPT_TEXT_LIMIT);
|
|
386
|
+
const outbound = recs[recs.length - 1] as Extract<TranscriptRecord, { kind: "outbound" }>;
|
|
387
|
+
expect(outbound.truncated?.finalText).toBe(true);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("sender kind variants serialize correctly", async () => {
|
|
391
|
+
for (const kind of ["user", "agent", "system"] as const) {
|
|
392
|
+
const s = track(await scaffold({
|
|
393
|
+
runtimeFactory: () => new FakeRuntime({ reply: "ok" }),
|
|
394
|
+
}));
|
|
395
|
+
// for kind=agent we need a peer id to avoid own-echo skip
|
|
396
|
+
await s.dispatcher.handle(
|
|
397
|
+
makeEnvelope({ sender: { id: "ag_other", kind, name: "Bob" } }),
|
|
398
|
+
);
|
|
399
|
+
const recs = await s.recordsForRoom("rm_oc_1");
|
|
400
|
+
const inbound = recs[0] as Extract<TranscriptRecord, { kind: "inbound" }>;
|
|
401
|
+
expect(inbound.sender.kind).toBe(kind);
|
|
402
|
+
expect(inbound.sender.name).toBe("Bob");
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("file rotation when crossing maxFileBytes", async () => {
|
|
407
|
+
const tmp = await mkdtemp(path.join(tmpdir(), "transcript-rotate-"));
|
|
408
|
+
cleanups.push(() => rm(tmp, { recursive: true, force: true }));
|
|
409
|
+
const writer = createTranscriptWriter({
|
|
410
|
+
rootDir: tmp,
|
|
411
|
+
log: silentLogger(),
|
|
412
|
+
enabled: true,
|
|
413
|
+
maxFileBytes: 200, // tiny
|
|
414
|
+
});
|
|
415
|
+
const base = {
|
|
416
|
+
ts: new Date().toISOString(),
|
|
417
|
+
turnId: "tn_x",
|
|
418
|
+
agentId: "ag_me",
|
|
419
|
+
roomId: "rm_x",
|
|
420
|
+
topicId: null,
|
|
421
|
+
} as const;
|
|
422
|
+
for (let i = 0; i < 10; i++) {
|
|
423
|
+
writer.write({
|
|
424
|
+
...base,
|
|
425
|
+
kind: "attention_skipped",
|
|
426
|
+
reason: "padding-" + i + "-" + "z".repeat(40),
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
const dir = path.join(tmp, "ag_me", "transcripts", "rm_x");
|
|
430
|
+
const { readdirSync } = await import("node:fs");
|
|
431
|
+
const files = readdirSync(dir);
|
|
432
|
+
// Should be at least one rotated (.YYYYMMDD-HHMMSS.jsonl) plus active
|
|
433
|
+
expect(files.length).toBeGreaterThan(1);
|
|
434
|
+
expect(files.some((f) => /_default\.\d{8}-\d{6}\.jsonl$/.test(f))).toBe(true);
|
|
435
|
+
expect(files).toContain("_default.jsonl");
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("disabled writer does not create files", async () => {
|
|
439
|
+
const tmp = await mkdtemp(path.join(tmpdir(), "transcript-off-"));
|
|
440
|
+
cleanups.push(() => rm(tmp, { recursive: true, force: true }));
|
|
441
|
+
const writer = createTranscriptWriter({ rootDir: tmp, log: silentLogger(), enabled: false });
|
|
442
|
+
expect(writer.enabled).toBe(false);
|
|
443
|
+
writer.write({
|
|
444
|
+
ts: new Date().toISOString(),
|
|
445
|
+
kind: "attention_skipped",
|
|
446
|
+
turnId: "tn_x",
|
|
447
|
+
agentId: "ag_me",
|
|
448
|
+
roomId: "rm_x",
|
|
449
|
+
topicId: null,
|
|
450
|
+
reason: "test",
|
|
451
|
+
});
|
|
452
|
+
expect(existsSync(path.join(tmp, "ag_me"))).toBe(false);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("FsTranscriptWriter absorbs filesystem errors — turn still completes", async () => {
|
|
456
|
+
// Point the writer at a path inside a regular file (mkdir will fail).
|
|
457
|
+
const tmp = await mkdtemp(path.join(tmpdir(), "transcript-fail-"));
|
|
458
|
+
cleanups.push(() => rm(tmp, { recursive: true, force: true }));
|
|
459
|
+
const blocker = path.join(tmp, "blocker");
|
|
460
|
+
const { writeFileSync } = await import("node:fs");
|
|
461
|
+
writeFileSync(blocker, "x");
|
|
462
|
+
// rootDir below `blocker` (a file, not a dir) → mkdir/append fail every time.
|
|
463
|
+
const writer = createTranscriptWriter({
|
|
464
|
+
rootDir: path.join(blocker, "nope"),
|
|
465
|
+
log: silentLogger(),
|
|
466
|
+
enabled: true,
|
|
467
|
+
});
|
|
468
|
+
const sessionsPath = path.join(tmp, "sessions.json");
|
|
469
|
+
const store = new SessionStore({ path: sessionsPath });
|
|
470
|
+
await store.load();
|
|
471
|
+
const channel = new FakeChannel();
|
|
472
|
+
const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
|
|
473
|
+
const dispatcher = new Dispatcher({
|
|
474
|
+
config: baseConfig(),
|
|
475
|
+
channels,
|
|
476
|
+
runtime: () => new FakeRuntime({ reply: "ok" }),
|
|
477
|
+
sessionStore: store,
|
|
478
|
+
log: silentLogger(),
|
|
479
|
+
transcript: writer,
|
|
480
|
+
});
|
|
481
|
+
await expect(dispatcher.handle(makeEnvelope())).resolves.not.toThrow();
|
|
482
|
+
expect(channel.sends.length).toBe(1);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("CLI path helper resolves to the same file the writer used", async () => {
|
|
486
|
+
const s = track(await scaffold({ runtimeFactory: () => new FakeRuntime({ reply: "ok" }) }));
|
|
487
|
+
await s.dispatcher.handle(
|
|
488
|
+
makeEnvelope({
|
|
489
|
+
conversation: { id: "rm_oc_1", kind: "direct", threadId: "tp_ABC" },
|
|
490
|
+
}),
|
|
491
|
+
);
|
|
492
|
+
const file = transcriptFilePath(s.rootDir, "ag_me", "rm_oc_1", "tp_ABC");
|
|
493
|
+
expect(existsSync(file)).toBe(true);
|
|
494
|
+
expect(statSync(file).size).toBeGreaterThan(0);
|
|
495
|
+
});
|
|
496
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { consoleLogger } from "./log.js";
|
|
5
|
+
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
|
|
8
|
+
// Tri-state cache: `undefined` means "not yet attempted"; `null` means
|
|
9
|
+
// "attempted and unavailable" (don't retry, don't re-log).
|
|
10
|
+
let cached: { binDir: string; binPath: string } | null | undefined;
|
|
11
|
+
|
|
12
|
+
export interface BundledCliBin {
|
|
13
|
+
/** Directory containing the `botcord` symlink — safe to prepend to PATH. */
|
|
14
|
+
binDir: string;
|
|
15
|
+
/** Absolute path to the CLI's JS entry — for direct spawn (not via PATH). */
|
|
16
|
+
binPath: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the bundled `@botcord/cli` package and return both the
|
|
21
|
+
* `<install-root>/node_modules/.bin` directory (for PATH injection so
|
|
22
|
+
* `botcord` shows up to runtimes) and the absolute JS entry (for callers
|
|
23
|
+
* that want to spawn the CLI directly without depending on the symlink).
|
|
24
|
+
*
|
|
25
|
+
* Returns `null` when `@botcord/cli` is not installed alongside the daemon
|
|
26
|
+
* — callers should fall back to whatever `botcord` is on the user's PATH.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveBundledCliBin(): BundledCliBin | null {
|
|
29
|
+
if (cached !== undefined) return cached;
|
|
30
|
+
try {
|
|
31
|
+
const pkgJsonPath = require.resolve("@botcord/cli/package.json");
|
|
32
|
+
const pkgRoot = path.dirname(pkgJsonPath);
|
|
33
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")) as {
|
|
34
|
+
bin?: string | Record<string, string>;
|
|
35
|
+
};
|
|
36
|
+
const binRel =
|
|
37
|
+
typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.botcord;
|
|
38
|
+
if (!binRel) {
|
|
39
|
+
consoleLogger.warn("cli-resolver: @botcord/cli has no bin.botcord entry");
|
|
40
|
+
cached = null;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const binPath = path.resolve(pkgRoot, binRel);
|
|
44
|
+
// PATH must point at `<install-root>/node_modules/.bin` (where npm puts
|
|
45
|
+
// the `botcord` shim), not the package's own `dist/` — there is no
|
|
46
|
+
// executable named `botcord` inside the package directory.
|
|
47
|
+
const binDir = path.resolve(pkgRoot, "..", "..", ".bin");
|
|
48
|
+
cached = { binDir, binPath };
|
|
49
|
+
return cached;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
consoleLogger.warn(
|
|
52
|
+
"cli-resolver: bundled @botcord/cli not resolvable; runtimes will fall back to PATH",
|
|
53
|
+
{ error: err instanceof Error ? err.message : String(err) },
|
|
54
|
+
);
|
|
55
|
+
cached = null;
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Test-only: clear the cached resolution. */
|
|
61
|
+
export function __resetBundledCliBinCache(): void {
|
|
62
|
+
cached = undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Return env additions that point a runtime CLI subprocess at the right
|
|
67
|
+
* BotCord identity:
|
|
68
|
+
* - `BOTCORD_HUB` — hub URL the agent is registered against
|
|
69
|
+
* - `BOTCORD_AGENT_ID` — default `--agent` for `botcord ...` invocations
|
|
70
|
+
* - `PATH` — prepended with the bundled CLI's `.bin` dir so
|
|
71
|
+
* `botcord` resolves to the version daemon shipped
|
|
72
|
+
* with (avoiding protocol-core drift). Falls
|
|
73
|
+
* through to whatever the user already has on PATH
|
|
74
|
+
* when the bundled CLI can't be resolved.
|
|
75
|
+
*/
|
|
76
|
+
export function buildCliEnv(opts: {
|
|
77
|
+
hubUrl?: string;
|
|
78
|
+
accountId?: string;
|
|
79
|
+
basePath?: string | undefined;
|
|
80
|
+
}): NodeJS.ProcessEnv {
|
|
81
|
+
const env: NodeJS.ProcessEnv = {};
|
|
82
|
+
if (opts.hubUrl) env.BOTCORD_HUB = opts.hubUrl;
|
|
83
|
+
if (opts.accountId) env.BOTCORD_AGENT_ID = opts.accountId;
|
|
84
|
+
const cli = resolveBundledCliBin();
|
|
85
|
+
if (cli) {
|
|
86
|
+
const existing = opts.basePath ?? "";
|
|
87
|
+
env.PATH = existing
|
|
88
|
+
? `${cli.binDir}${path.delimiter}${existing}`
|
|
89
|
+
: cli.binDir;
|
|
90
|
+
}
|
|
91
|
+
return env;
|
|
92
|
+
}
|