akemon 0.3.2 → 0.3.4

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.
@@ -1,99 +0,0 @@
1
- import { describe, it } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { EngineQueue } from "./engine-queue.js";
4
- // Helpers
5
- const tick = () => new Promise((r) => setImmediate(r));
6
- async function sleep(ms) {
7
- return new Promise((r) => setTimeout(r, ms));
8
- }
9
- describe("EngineQueue", () => {
10
- it("free slot: acquire resolves immediately and isBusy becomes true", async () => {
11
- const q = new EngineQueue();
12
- assert.equal(q.isBusy(), false);
13
- await q.acquire("high", 1000);
14
- assert.equal(q.isBusy(), true);
15
- q.release();
16
- assert.equal(q.isBusy(), false);
17
- });
18
- it("tryAcquire: succeeds when free, returns false when busy", () => {
19
- const q = new EngineQueue();
20
- assert.equal(q.tryAcquire(), true);
21
- assert.equal(q.isBusy(), true);
22
- assert.equal(q.tryAcquire(), false);
23
- q.release();
24
- });
25
- it("priority ordering: high waiter beats normal when slot is released", async () => {
26
- const q = new EngineQueue();
27
- await q.acquire("high", 1000); // take the slot
28
- const order = [];
29
- const p1 = q.acquire("normal", 2000).then(() => { order.push("normal"); q.release(); });
30
- await tick();
31
- const p2 = q.acquire("high", 2000).then(() => { order.push("high"); q.release(); });
32
- await tick();
33
- assert.equal(q.queueDepth(), 2);
34
- q.release(); // hand off to highest-priority waiter
35
- await Promise.all([p1, p2]);
36
- assert.deepEqual(order, ["high", "normal"]);
37
- });
38
- it("FIFO within same priority: earlier enqueuer wins", async () => {
39
- const q = new EngineQueue();
40
- await q.acquire("high", 1000);
41
- const order = [];
42
- const p1 = q.acquire("normal", 2000).then(() => { order.push("first"); q.release(); });
43
- await sleep(5); // ensure different enqueuedAt timestamps
44
- const p2 = q.acquire("normal", 2000).then(() => { order.push("second"); q.release(); });
45
- await tick();
46
- q.release();
47
- await Promise.all([p1, p2]);
48
- assert.deepEqual(order, ["first", "second"]);
49
- });
50
- it("deadline timeout: waiter is removed and rejects with busy-timeout error", async () => {
51
- const q = new EngineQueue();
52
- await q.acquire("high", 1000); // hold the slot
53
- let caught = null;
54
- const p = q.acquire("low", 30).catch((e) => { caught = e; });
55
- await sleep(60); // let the 30ms deadline fire
56
- assert.equal(q.queueDepth(), 0, "waiter must be removed after timeout");
57
- await p;
58
- assert.ok(caught !== null && typeof caught === "object", "should have rejected with an Error");
59
- const msg = caught.message;
60
- assert.ok(msg.includes("Engine busy timeout"), msg);
61
- q.release();
62
- });
63
- it("release with no waiters makes slot free", () => {
64
- const q = new EngineQueue();
65
- assert.equal(q.tryAcquire(), true);
66
- q.release();
67
- assert.equal(q.isBusy(), false);
68
- assert.equal(q.heldMs(), 0);
69
- });
70
- it("queueDepth tracks waiters correctly", async () => {
71
- const q = new EngineQueue();
72
- await q.acquire("high", 1000);
73
- assert.equal(q.queueDepth(), 0);
74
- const p1 = q.acquire("normal", 2000);
75
- await tick();
76
- assert.equal(q.queueDepth(), 1);
77
- const p2 = q.acquire("low", 2000);
78
- await tick();
79
- assert.equal(q.queueDepth(), 2);
80
- q.release(); // hand to normal (higher priority)
81
- await tick();
82
- assert.equal(q.queueDepth(), 1);
83
- const holder = await p1; // p1 resolved — release it
84
- void holder; // suppress unused warning
85
- q.release();
86
- await p2;
87
- q.release();
88
- assert.equal(q.queueDepth(), 0);
89
- });
90
- it("heldMs: returns 0 when free, positive when busy", async () => {
91
- const q = new EngineQueue();
92
- assert.equal(q.heldMs(), 0);
93
- await q.acquire("high", 1000);
94
- await sleep(10);
95
- assert.ok(q.heldMs() >= 10, `heldMs should be >= 10, got ${q.heldMs()}`);
96
- q.release();
97
- assert.equal(q.heldMs(), 0);
98
- });
99
- });
@@ -1,122 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { resolveEngineConfig, deriveChildOrigin, downgradeForRetry, } from "./engine-routing.js";
4
- // ---------------------------------------------------------------------------
5
- // resolveEngineConfig
6
- // ---------------------------------------------------------------------------
7
- describe("resolveEngineConfig", () => {
8
- const claudeEntry = { engine: "claude", model: "claude-opus-4-5" };
9
- const rawEntry = { engine: "raw", rawApiUrl: "https://api.deepseek.com/v1", model: "deepseek-chat", rawApiKeyEnv: "DEEPSEEK_API_KEY" };
10
- const defaultEntry = { engine: "raw", rawApiUrl: "https://api.anthropic.com/v1", model: "claude-haiku-4-5", rawApiKeyEnv: "ANTHROPIC_API_KEY" };
11
- it("returns exact origin entry when routing has that origin", () => {
12
- const routing = {
13
- user_manual: claudeEntry,
14
- platform: rawEntry,
15
- default: defaultEntry,
16
- };
17
- const result = resolveEngineConfig(routing, "user_manual");
18
- assert.deepEqual(result, claudeEntry);
19
- });
20
- it("returns platform entry for platform origin", () => {
21
- const routing = {
22
- user_manual: claudeEntry,
23
- platform: rawEntry,
24
- default: defaultEntry,
25
- };
26
- const result = resolveEngineConfig(routing, "platform");
27
- assert.deepEqual(result, rawEntry);
28
- });
29
- it("falls back to default when origin not in routing", () => {
30
- const routing = {
31
- user_manual: claudeEntry,
32
- default: defaultEntry,
33
- };
34
- // self_cycle not in routing → fallback to default
35
- const result = resolveEngineConfig(routing, "self_cycle");
36
- assert.deepEqual(result, defaultEntry);
37
- });
38
- it("falls back to default when origin is undefined", () => {
39
- const routing = { default: defaultEntry };
40
- const result = resolveEngineConfig(routing, undefined);
41
- assert.deepEqual(result, defaultEntry);
42
- });
43
- it("returns null when routing is undefined (backward-compat: use base config)", () => {
44
- const result = resolveEngineConfig(undefined, "user_manual");
45
- assert.equal(result, null);
46
- });
47
- it("returns null when routing is null", () => {
48
- const result = resolveEngineConfig(null, "user_manual");
49
- assert.equal(result, null);
50
- });
51
- it("returns null when routing has no matching entry and no default", () => {
52
- const routing = { user_manual: claudeEntry };
53
- // self_cycle not in routing, no default
54
- const result = resolveEngineConfig(routing, "self_cycle");
55
- assert.equal(result, null);
56
- });
57
- it("returns null when routing is empty object and origin is undefined", () => {
58
- const result = resolveEngineConfig({}, undefined);
59
- assert.equal(result, null);
60
- });
61
- it("retry origin resolves to its own routing entry when configured", () => {
62
- const retryEntry = { engine: "raw", rawApiUrl: "https://api.deepseek.com/v1", model: "deepseek-chat" };
63
- const routing = {
64
- user_manual: claudeEntry,
65
- retry: retryEntry,
66
- default: defaultEntry,
67
- };
68
- const result = resolveEngineConfig(routing, "retry");
69
- assert.deepEqual(result, retryEntry);
70
- });
71
- it("retry origin falls back to default when no retry entry configured", () => {
72
- const routing = {
73
- user_manual: claudeEntry,
74
- default: defaultEntry,
75
- };
76
- const result = resolveEngineConfig(routing, "retry");
77
- assert.deepEqual(result, defaultEntry);
78
- });
79
- it("reflection origin resolves correctly", () => {
80
- const reflEntry = { engine: "raw", model: "gemma3:4b" };
81
- const routing = { reflection: reflEntry, default: defaultEntry };
82
- const result = resolveEngineConfig(routing, "reflection");
83
- assert.deepEqual(result, reflEntry);
84
- });
85
- });
86
- // ---------------------------------------------------------------------------
87
- // downgradeForRetry
88
- // ---------------------------------------------------------------------------
89
- describe("downgradeForRetry", () => {
90
- const origins = ["user_manual", "self_cycle", "platform", "retry", "reflection"];
91
- it("always returns 'retry' regardless of input", () => {
92
- for (const origin of origins) {
93
- assert.equal(downgradeForRetry(origin), "retry", `downgradeForRetry(${origin}) should be 'retry'`);
94
- }
95
- });
96
- it("user_manual + isRetry=true → 'retry' (not user_manual)", () => {
97
- // This is the spec's explicit test case for the downgrade rule
98
- const original = "user_manual";
99
- const downgraded = downgradeForRetry(original);
100
- assert.equal(downgraded, "retry");
101
- assert.notEqual(downgraded, "user_manual");
102
- });
103
- });
104
- // ---------------------------------------------------------------------------
105
- // deriveChildOrigin
106
- // ---------------------------------------------------------------------------
107
- describe("deriveChildOrigin", () => {
108
- const origins = ["user_manual", "self_cycle", "platform", "retry", "reflection"];
109
- it("always returns 'platform' regardless of parent", () => {
110
- for (const origin of origins) {
111
- assert.equal(deriveChildOrigin(origin), "platform", `deriveChildOrigin(${origin}) should be 'platform'`);
112
- }
113
- });
114
- it("user_manual parent does NOT propagate to child (anti-contamination rule)", () => {
115
- const child = deriveChildOrigin("user_manual");
116
- assert.equal(child, "platform");
117
- assert.notEqual(child, "user_manual");
118
- });
119
- it("self_cycle parent → child is platform, not self_cycle", () => {
120
- assert.equal(deriveChildOrigin("self_cycle"), "platform");
121
- });
122
- });
@@ -1,103 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { EventEmitter } from "node:events";
3
- import { describe, it } from "node:test";
4
- import { PassThrough } from "node:stream";
5
- import { EnginePeripheral } from "./engine-peripheral.js";
6
- function createFakeChild() {
7
- const child = new EventEmitter();
8
- child.stdin = new PassThrough();
9
- child.stdout = new PassThrough();
10
- child.stderr = new PassThrough();
11
- Object.defineProperty(child, "pid", { value: 12345, configurable: true });
12
- child.unref = () => child;
13
- return child;
14
- }
15
- describe("Engine stream publish", () => {
16
- it("publishes task lifecycle and stdout/stderr chunks", async () => {
17
- const events = [];
18
- let spawnedChild = null;
19
- const engine = new EnginePeripheral({
20
- engine: "claude",
21
- workdir: "/tmp",
22
- spawnImpl: ((cmd, args) => {
23
- assert.equal(cmd, "claude");
24
- assert.deepEqual(args, ["--print"]);
25
- const child = createFakeChild();
26
- spawnedChild = child;
27
- queueMicrotask(() => {
28
- child.stdout?.emit("data", Buffer.from("hello "));
29
- child.stderr?.emit("data", Buffer.from("warn"));
30
- child.stdout?.emit("data", Buffer.from("world"));
31
- child.emit("close", 0, null);
32
- });
33
- return child;
34
- }),
35
- taskRelay: {
36
- sendTaskStart(taskId, origin, cmd) {
37
- events.push({ type: "start", taskId, origin, cmd });
38
- },
39
- sendTaskStream(taskId, stream, chunk) {
40
- events.push({ type: "stream", taskId, stream, chunk });
41
- },
42
- sendTaskEnd(taskId, exitCode, durationMs) {
43
- events.push({ type: "end", taskId, exitCode, durationMs });
44
- },
45
- },
46
- });
47
- const result = await engine.runEngine("say hello", false, undefined, undefined, "user_manual", undefined, "order-123");
48
- assert.equal(result, "hello world");
49
- assert.ok(spawnedChild, "spawn should be called");
50
- assert.deepEqual(events.slice(0, 4), [
51
- { type: "start", taskId: "order-123", origin: "user_manual", cmd: "claude --print" },
52
- { type: "stream", taskId: "order-123", stream: "stdout", chunk: "hello " },
53
- { type: "stream", taskId: "order-123", stream: "stderr", chunk: "warn" },
54
- { type: "stream", taskId: "order-123", stream: "stdout", chunk: "world" },
55
- ]);
56
- const end = events[4];
57
- assert.equal(end?.type, "end");
58
- if (end?.type === "end") {
59
- assert.equal(end.taskId, "order-123");
60
- assert.equal(end.exitCode, 0);
61
- assert.ok(end.durationMs >= 0);
62
- }
63
- });
64
- it("generates a task id when caller does not provide one", async () => {
65
- const events = [];
66
- const engine = new EnginePeripheral({
67
- engine: "opencode",
68
- workdir: "/tmp",
69
- spawnImpl: (() => {
70
- const child = createFakeChild();
71
- queueMicrotask(() => {
72
- child.stdout?.emit("data", Buffer.from("done"));
73
- child.emit("close", 0, null);
74
- });
75
- return child;
76
- }),
77
- taskRelay: {
78
- sendTaskStart(taskId, origin, cmd) {
79
- events.push({ type: "start", taskId, origin, cmd });
80
- },
81
- sendTaskStream(taskId, stream, chunk) {
82
- events.push({ type: "stream", taskId, stream, chunk });
83
- },
84
- sendTaskEnd(taskId, exitCode, durationMs) {
85
- events.push({ type: "end", taskId, exitCode, durationMs });
86
- },
87
- },
88
- });
89
- const result = await engine.runEngine("say hello", false, undefined, undefined, "platform");
90
- assert.equal(result, "done");
91
- assert.equal(events[0]?.type, "start");
92
- if (events[0]?.type === "start") {
93
- assert.match(events[0].taskId, /^task_/);
94
- assert.equal(events[0].origin, "platform");
95
- assert.equal(events[0].cmd, "opencode run say hello");
96
- }
97
- assert.equal(events[2]?.type, "end");
98
- if (events[2]?.type === "end" && events[0]?.type === "start") {
99
- assert.equal(events[2].taskId, events[0].taskId);
100
- assert.equal(events[2].exitCode, 0);
101
- }
102
- });
103
- });
@@ -1,51 +0,0 @@
1
- import { describe, it, beforeEach, afterEach } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { mkdtemp, rm, readFile, appendFile } from "node:fs/promises";
4
- import { tmpdir } from "node:os";
5
- import { join } from "node:path";
6
- import { FileEventLog, PersistentEventBus } from "./event-bus.js";
7
- import { SIG, sig } from "./types.js";
8
- describe("PersistentEventBus", () => {
9
- let tmpDir;
10
- beforeEach(async () => {
11
- tmpDir = await mkdtemp(join(tmpdir(), "akemon-event-bus-"));
12
- });
13
- afterEach(async () => {
14
- await rm(tmpDir, { recursive: true, force: true });
15
- });
16
- it("appends emitted events and still dispatches handlers", async () => {
17
- const path = join(tmpDir, "events.jsonl");
18
- const bus = new PersistentEventBus(new FileEventLog(path));
19
- const seen = [];
20
- bus.on(SIG.AGENT_START, (signal) => {
21
- seen.push(String(signal.data.agentName));
22
- });
23
- bus.emit(SIG.AGENT_START, sig(SIG.AGENT_START, { agentName: "momo" }, "test"));
24
- assert.deepEqual(seen, ["momo"]);
25
- const lines = (await readFile(path, "utf-8")).trim().split("\n");
26
- assert.equal(lines.length, 1);
27
- const logged = JSON.parse(lines[0]);
28
- assert.equal(logged.e, SIG.AGENT_START);
29
- assert.equal(logged.s.type, SIG.AGENT_START);
30
- assert.equal(logged.s.data.agentName, "momo");
31
- assert.equal(logged.s.source, "test");
32
- });
33
- it("recovers valid logged events without appending duplicates", async () => {
34
- const path = join(tmpDir, "events.jsonl");
35
- await appendFile(path, JSON.stringify({
36
- e: "custom:event",
37
- s: { type: "custom:event", data: { n: 1 }, source: "fixture" },
38
- }) + "\n");
39
- await appendFile(path, "not-json\n");
40
- const bus = new PersistentEventBus(new FileEventLog(path));
41
- const seen = [];
42
- bus.on("custom:event", (signal) => {
43
- seen.push(Number(signal.data.n));
44
- });
45
- const count = await bus.recover();
46
- assert.equal(count, 1);
47
- assert.deepEqual(seen, [1]);
48
- const lines = (await readFile(path, "utf-8")).trim().split("\n");
49
- assert.equal(lines.length, 2);
50
- });
51
- });
@@ -1,81 +0,0 @@
1
- import { describe, it } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { parseProcessList } from "./orphan-scan.js";
4
- describe("parseProcessList", () => {
5
- it("empty string returns empty array", () => {
6
- assert.deepStrictEqual(parseProcessList(""), []);
7
- });
8
- it("whitespace-only string returns empty array", () => {
9
- assert.deepStrictEqual(parseProcessList("\n\n \n"), []);
10
- });
11
- it("header line (PID PPID COMMAND) is silently skipped", () => {
12
- const output = " PID PPID COMMAND\n 123 1 opencode run --flag\n";
13
- const result = parseProcessList(output);
14
- assert.strictEqual(result.length, 1);
15
- assert.strictEqual(result[0].pid, 123);
16
- });
17
- it("ppid=1 + command matches 'opencode run' → hit", () => {
18
- const output = " 123 1 opencode run --headless\n";
19
- const result = parseProcessList(output);
20
- assert.strictEqual(result.length, 1);
21
- assert.strictEqual(result[0].pid, 123);
22
- assert.strictEqual(result[0].ppid, 1);
23
- assert.ok(result[0].command.includes("opencode run"));
24
- });
25
- it("ppid=1 + command is 'opencode install' → not hit (install is not agent mode)", () => {
26
- const output = " 456 1 opencode install some-plugin\n";
27
- const result = parseProcessList(output);
28
- assert.strictEqual(result.length, 0);
29
- });
30
- it("ppid=1 + command is 'opencode update' → not hit", () => {
31
- const output = " 789 1 opencode update\n";
32
- const result = parseProcessList(output);
33
- assert.strictEqual(result.length, 0);
34
- });
35
- it("ppid != 1 but command matches → not hit (never kill non-orphans)", () => {
36
- const output = " 999 5678 opencode run --headless\n";
37
- const result = parseProcessList(output);
38
- assert.strictEqual(result.length, 0);
39
- });
40
- it("ppid=1 + 'claude -p' → hit", () => {
41
- const output = " 100 1 claude -p some prompt text\n";
42
- const result = parseProcessList(output);
43
- assert.strictEqual(result.length, 1);
44
- assert.strictEqual(result[0].pid, 100);
45
- });
46
- it("ppid=1 + 'codex exec' → hit", () => {
47
- const output = " 200 1 codex exec --flag\n";
48
- const result = parseProcessList(output);
49
- assert.strictEqual(result.length, 1);
50
- });
51
- it("ppid=1 + 'gemini -p' → hit", () => {
52
- const output = " 300 1 gemini -p --output-format json\n";
53
- const result = parseProcessList(output);
54
- assert.strictEqual(result.length, 1);
55
- });
56
- it("command with full path still matches", () => {
57
- const output = " 400 1 /usr/local/bin/opencode run --headless task\n";
58
- const result = parseProcessList(output);
59
- assert.strictEqual(result.length, 1);
60
- assert.strictEqual(result[0].pid, 400);
61
- });
62
- it("multiple orphans parsed correctly, non-orphan interleaved is skipped", () => {
63
- const output = [
64
- " PID PPID COMMAND",
65
- " 111 1 opencode run",
66
- " 222 3456 opencode run", // ppid != 1, not orphan
67
- " 333 1 claude -p task",
68
- " 444 1 bash", // not a known pattern
69
- ].join("\n");
70
- const result = parseProcessList(output);
71
- assert.strictEqual(result.length, 2);
72
- assert.strictEqual(result[0].pid, 111);
73
- assert.strictEqual(result[1].pid, 333);
74
- });
75
- it("large PID and PPID values parse correctly", () => {
76
- const output = " 99999 1 opencode run\n";
77
- const result = parseProcessList(output);
78
- assert.strictEqual(result.length, 1);
79
- assert.strictEqual(result[0].pid, 99999);
80
- });
81
- });
@@ -1,180 +0,0 @@
1
- import { describe, it } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
4
- import { tmpdir } from "node:os";
5
- import { join } from "node:path";
6
- import { ReflectionModule } from "./reflection-module.js";
7
- import { loadDiscoveries } from "./self.js";
8
- import { SIG } from "./types.js";
9
- // ---------------------------------------------------------------------------
10
- // Fake helpers
11
- // ---------------------------------------------------------------------------
12
- class FakeBus {
13
- handlers = new Map();
14
- emitted = [];
15
- on(event, fn) {
16
- const arr = this.handlers.get(event) ?? [];
17
- arr.push(fn);
18
- this.handlers.set(event, arr);
19
- }
20
- off(event, fn) {
21
- const arr = this.handlers.get(event) ?? [];
22
- this.handlers.set(event, arr.filter(h => h !== fn));
23
- }
24
- emit(event, s) {
25
- this.emitted.push(s);
26
- (this.handlers.get(event) ?? []).forEach(fn => fn(s));
27
- }
28
- }
29
- function makeCtx(workdir, agentName, bus, computeResponses) {
30
- let callIdx = 0;
31
- const computeCalls = [];
32
- const ctx = {
33
- workdir,
34
- agentName,
35
- bus,
36
- requestCompute: async (req) => {
37
- computeCalls.push({ context: req.context, question: req.question, priority: req.priority });
38
- return (computeResponses[callIdx++] ?? { success: false });
39
- },
40
- getPeripherals: (_capability) => [],
41
- sendTo: async (_capability, _signal) => null,
42
- getPromptContributions: () => [],
43
- };
44
- return { ctx, computeCalls };
45
- }
46
- /** Drain pending microtasks + I/O by waiting n setImmediate ticks. */
47
- async function flush(n = 8) {
48
- for (let i = 0; i < n; i++) {
49
- await new Promise(r => setImmediate(r));
50
- }
51
- }
52
- /** Wait ms milliseconds — for fire-and-forget chains with multiple sequential I/O steps. */
53
- function wait(ms) {
54
- return new Promise(r => setTimeout(r, ms));
55
- }
56
- // ---------------------------------------------------------------------------
57
- // Tests
58
- // ---------------------------------------------------------------------------
59
- describe("ReflectionModule reflect integration", () => {
60
- it("self_cycle=false → start does not schedule reflect, stop does not throw", async () => {
61
- const tmpDir = await mkdtemp(join(tmpdir(), "akemon-refl-"));
62
- try {
63
- const agentName = "test-agent";
64
- const agentDir = join(tmpDir, ".akemon", "agents", agentName);
65
- await mkdir(agentDir, { recursive: true });
66
- await writeFile(join(agentDir, "config.json"), JSON.stringify({ self_cycle: false }));
67
- const bus = new FakeBus();
68
- const { ctx, computeCalls } = makeCtx(tmpDir, agentName, bus, []);
69
- const mod = new ReflectionModule();
70
- await mod.start(ctx);
71
- await flush();
72
- assert.strictEqual(computeCalls.length, 0, "no compute calls expected when self_cycle=false");
73
- await assert.doesNotReject(() => mod.stop());
74
- }
75
- finally {
76
- await rm(tmpDir, { recursive: true, force: true });
77
- }
78
- });
79
- it("single TASK_FAILED does not trigger reflect (below threshold of 2)", async () => {
80
- const tmpDir = await mkdtemp(join(tmpdir(), "akemon-refl-"));
81
- try {
82
- const agentName = "test-agent";
83
- const bus = new FakeBus();
84
- const { ctx, computeCalls } = makeCtx(tmpDir, agentName, bus, []);
85
- const mod = new ReflectionModule();
86
- await mod.start(ctx);
87
- bus.emit(SIG.TASK_FAILED, { type: SIG.TASK_FAILED, data: { taskLabel: "a", error: "oops" } });
88
- await flush();
89
- assert.strictEqual(computeCalls.length, 0, "one failure must not trigger reflect");
90
- await mod.stop();
91
- }
92
- finally {
93
- await rm(tmpDir, { recursive: true, force: true });
94
- }
95
- });
96
- it("two TASK_FAILED events trigger reflect and save discoveries to disk", async () => {
97
- const tmpDir = await mkdtemp(join(tmpdir(), "akemon-refl-"));
98
- try {
99
- const agentName = "test-agent";
100
- // Self dir must exist for saveDiscoveries to write successfully
101
- await mkdir(join(tmpDir, ".akemon", "agents", agentName, "self"), { recursive: true });
102
- const bus = new FakeBus();
103
- const response = JSON.stringify({
104
- discoveries: [{ capability: "X", confidence: 0.7, evidence: "Y" }],
105
- });
106
- const { ctx, computeCalls } = makeCtx(tmpDir, agentName, bus, [
107
- { success: true, response },
108
- ]);
109
- const mod = new ReflectionModule();
110
- await mod.start(ctx);
111
- bus.emit(SIG.TASK_FAILED, { type: SIG.TASK_FAILED, data: { taskLabel: "task1", error: "err1" } });
112
- bus.emit(SIG.TASK_FAILED, { type: SIG.TASK_FAILED, data: { taskLabel: "task2", error: "err2" } });
113
- await flush();
114
- assert.strictEqual(computeCalls.length, 1, "reflect should call requestCompute exactly once");
115
- const discoveries = await loadDiscoveries(tmpDir, agentName);
116
- assert.ok(discoveries.length > 0, "at least one discovery should be saved");
117
- assert.ok(discoveries.some(d => d.capability === "X"), "saved discovery should have capability='X'");
118
- await mod.stop();
119
- }
120
- finally {
121
- await rm(tmpDir, { recursive: true, force: true });
122
- }
123
- });
124
- it("unparseable compute response → no crash, recentFailures cleared, no discoveries saved", async () => {
125
- const tmpDir = await mkdtemp(join(tmpdir(), "akemon-refl-"));
126
- try {
127
- const agentName = "test-agent";
128
- const bus = new FakeBus();
129
- const { ctx, computeCalls } = makeCtx(tmpDir, agentName, bus, [
130
- { success: true, response: "this is not json at all" },
131
- ]);
132
- const mod = new ReflectionModule();
133
- await mod.start(ctx);
134
- bus.emit(SIG.TASK_FAILED, { type: SIG.TASK_FAILED, data: { taskLabel: "x1", error: "e1" } });
135
- bus.emit(SIG.TASK_FAILED, { type: SIG.TASK_FAILED, data: { taskLabel: "x2", error: "e2" } });
136
- await flush();
137
- assert.strictEqual(computeCalls.length, 1, "reflect should still have run");
138
- const state = mod.getState();
139
- assert.strictEqual(state["recentFailures"], 0, "recentFailures should be cleared even when response is unparseable");
140
- const discoveries = await loadDiscoveries(tmpDir, agentName);
141
- assert.strictEqual(discoveries.length, 0, "no discoveries should be saved for bad response");
142
- await mod.stop();
143
- }
144
- finally {
145
- await rm(tmpDir, { recursive: true, force: true });
146
- }
147
- });
148
- it("TASK_COMPLETED with success=true and productName appends experience to playbook", async () => {
149
- const tmpDir = await mkdtemp(join(tmpdir(), "akemon-refl-"));
150
- try {
151
- const agentName = "test-agent";
152
- const selfBase = join(tmpDir, ".akemon", "agents", agentName, "self");
153
- const pbDir = join(selfBase, "playbooks");
154
- const prodDir = join(selfBase, "products");
155
- await mkdir(pbDir, { recursive: true });
156
- await mkdir(prodDir, { recursive: true });
157
- await writeFile(join(prodDir, "widget.md"), "# Widget\n\n## playbook\nwidget-pb\n\n## products\n- p_w1\n");
158
- await writeFile(join(pbDir, "widget-pb.md"), "# Widget Playbook\n\n## 经验\n");
159
- const bus = new FakeBus();
160
- const { ctx } = makeCtx(tmpDir, agentName, bus, []);
161
- const mod = new ReflectionModule();
162
- await mod.start(ctx);
163
- bus.emit(SIG.TASK_COMPLETED, {
164
- type: SIG.TASK_COMPLETED,
165
- data: { success: true, productName: "widget", taskLabel: "deliver-logo", creditsEarned: 3 },
166
- });
167
- // Fire-and-forget handler — appendPlaybookExperience chains several sequential I/O ops
168
- // (readdir + readFile per directory in loadMdFiles). Use a time-based wait to be safe.
169
- await wait(100);
170
- const content = await readFile(join(pbDir, "widget-pb.md"), "utf-8");
171
- assert.ok(content.includes("widget"), "playbook should contain productName");
172
- assert.ok(content.includes("deliver-logo"), "playbook should contain taskLabel");
173
- assert.ok(content.includes("earned 3¢"), "playbook should contain credits");
174
- await mod.stop();
175
- }
176
- finally {
177
- await rm(tmpDir, { recursive: true, force: true });
178
- }
179
- });
180
- });