akemon 0.3.2 → 0.3.3

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,208 +0,0 @@
1
- import { describe, it, before, after } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
4
- import { tmpdir } from "node:os";
5
- import { join } from "node:path";
6
- import { parseRole, parseProduct, resolveRoles, resolveProduct, buildRoleContext, } from "./role-module.js";
7
- // ---------------------------------------------------------------------------
8
- // Helpers
9
- // ---------------------------------------------------------------------------
10
- function makeRole(name, triggers = [], extras = {}) {
11
- return { name, description: "", triggers, include: [], exclude: [], customRules: "", raw: "", ...extras };
12
- }
13
- function makeProduct(name, extras = {}) {
14
- return { name, playbook: "", productIds: [], raw: "", ...extras };
15
- }
16
- function makePlaybook(name, raw = "") {
17
- return { name, raw };
18
- }
19
- // ---------------------------------------------------------------------------
20
- // Pure parsing — parseRole
21
- // ---------------------------------------------------------------------------
22
- describe("parseRole — pure parsing", () => {
23
- it("extracts description, triggers, include, exclude from standard role md", () => {
24
- const raw = `# Sales Rep
25
-
26
- Expert in closing deals and building client relationships.
27
-
28
- ## 激活
29
- - trigger:order
30
- - trigger:chat:public
31
-
32
- ## 上下文范围
33
- include: buyer history, product catalog
34
- exclude: owner notes, private diary
35
- `;
36
- const role = parseRole("sales-rep", raw);
37
- assert.equal(role.name, "sales-rep");
38
- assert.equal(role.description, "Expert in closing deals and building client relationships.");
39
- assert.deepEqual(role.triggers, ["trigger:order", "trigger:chat:public"]);
40
- assert.deepEqual(role.include, ["buyer history", "product catalog"]);
41
- assert.deepEqual(role.exclude, ["owner notes", "private diary"]);
42
- });
43
- it("returns empty arrays for include/exclude/triggers when sections are missing", () => {
44
- const raw = `# Simple Role
45
-
46
- A minimal role with no activation or context sections.
47
- `;
48
- const role = parseRole("simple", raw);
49
- assert.deepEqual(role.triggers, []);
50
- assert.deepEqual(role.include, []);
51
- assert.deepEqual(role.exclude, []);
52
- assert.equal(role.description, "A minimal role with no activation or context sections.");
53
- });
54
- it("collects content outside 激活 and 上下文范围 into customRules", () => {
55
- const raw = `# Worker
56
-
57
- Efficient task executor.
58
-
59
- ## 激活
60
- - trigger:agent_call
61
-
62
- ## Operating Principles
63
- Always reply in the language the user writes in.
64
- Be concise and accurate.
65
-
66
- ## Tone
67
- Professional and neutral.
68
- `;
69
- const role = parseRole("worker", raw);
70
- assert.ok(role.customRules.includes("Always reply in the language the user writes in."), "should include content from ## Operating Principles");
71
- assert.ok(role.customRules.includes("Professional and neutral."), "should include content from ## Tone");
72
- // triggers and standard sections should not bleed in
73
- assert.deepEqual(role.triggers, ["trigger:agent_call"]);
74
- });
75
- });
76
- // ---------------------------------------------------------------------------
77
- // Pure parsing — parseProduct
78
- // ---------------------------------------------------------------------------
79
- describe("parseProduct — pure parsing", () => {
80
- it("extracts playbook and multiple productIds correctly", () => {
81
- const raw = `# Widget Pro
82
-
83
- ## playbook
84
- widget-strategy
85
-
86
- ## products
87
- - p_abc123
88
- - p_def456
89
- - p_ghi789
90
- `;
91
- const product = parseProduct("widget-pro", raw);
92
- assert.equal(product.name, "widget-pro");
93
- assert.equal(product.playbook, "widget-strategy");
94
- assert.deepEqual(product.productIds, ["p_abc123", "p_def456", "p_ghi789"]);
95
- });
96
- it("returns empty productIds when ## products section is absent", () => {
97
- const raw = `# No Products
98
-
99
- ## playbook
100
- some-playbook
101
- `;
102
- const product = parseProduct("no-products", raw);
103
- assert.deepEqual(product.productIds, []);
104
- assert.equal(product.playbook, "some-playbook");
105
- });
106
- it("returns empty string for playbook when ## playbook section is absent", () => {
107
- const raw = `# No Playbook
108
-
109
- ## products
110
- - p_xyz
111
- `;
112
- const product = parseProduct("no-playbook", raw);
113
- assert.equal(product.playbook, "");
114
- assert.deepEqual(product.productIds, ["p_xyz"]);
115
- });
116
- });
117
- // ---------------------------------------------------------------------------
118
- // Resolution — resolveRoles
119
- // ---------------------------------------------------------------------------
120
- describe("resolveRoles — resolution", () => {
121
- it("partial trigger match: first match is primary, remaining are secondary", () => {
122
- const roles = [
123
- makeRole("general-chat", ["trigger:chat"]),
124
- makeRole("owner-companion", ["trigger:chat:owner"]),
125
- ];
126
- // "trigger:chat:owner" includes "trigger:chat" → both match
127
- const { primary, secondary } = resolveRoles(roles, "trigger:chat:owner");
128
- assert.equal(primary?.name, "general-chat");
129
- assert.equal(secondary.length, 1);
130
- assert.equal(secondary[0].name, "owner-companion");
131
- });
132
- it("returns primary=null and empty secondary when no trigger matches", () => {
133
- const roles = [
134
- makeRole("merchant", ["trigger:order"]),
135
- makeRole("companion", ["trigger:chat:owner"]),
136
- ];
137
- const { primary, secondary } = resolveRoles(roles, "trigger:nonexistent");
138
- assert.equal(primary, null);
139
- assert.deepEqual(secondary, []);
140
- });
141
- });
142
- // ---------------------------------------------------------------------------
143
- // Resolution — resolveProduct
144
- // ---------------------------------------------------------------------------
145
- describe("resolveProduct — resolution", () => {
146
- const products = [
147
- makeProduct("Alpha Product", { productIds: ["p_alpha"], playbook: "alpha-pb" }),
148
- makeProduct("Beta Service", { productIds: ["p_beta"], playbook: "beta-pb" }),
149
- ];
150
- const playbooks = [
151
- makePlaybook("alpha-pb", "# Alpha Playbook\nContent."),
152
- makePlaybook("beta-pb", "# Beta Playbook\nContent."),
153
- ];
154
- it("productId takes priority over productName when both are provided", () => {
155
- // productId matches "Alpha Product", productName matches "Beta Service"
156
- const result = resolveProduct(products, playbooks, "Beta Service", "p_alpha");
157
- assert.ok(result !== null);
158
- assert.equal(result.product.name, "Alpha Product");
159
- });
160
- it("fuzzy name match is case- and separator-insensitive", () => {
161
- // "alpha_product" → normalized "alphaproduct"
162
- // "Alpha Product" → normalized "alphaproduct" → exact match
163
- const result = resolveProduct(products, playbooks, "alpha_product");
164
- assert.ok(result !== null);
165
- assert.equal(result.product.name, "Alpha Product");
166
- });
167
- it("returns null when neither productId nor productName matches", () => {
168
- const result = resolveProduct(products, playbooks, "Completely Unknown");
169
- assert.equal(result, null);
170
- });
171
- it("playbook lookup is case-insensitive", () => {
172
- const prods = [makeProduct("my-widget", { playbook: "My-Playbook", productIds: [] })];
173
- const pbs = [makePlaybook("my-playbook", "# PB Content")];
174
- const result = resolveProduct(prods, pbs, "my-widget");
175
- assert.ok(result !== null);
176
- assert.ok(result.playbook !== null);
177
- assert.equal(result.playbook?.name, "my-playbook");
178
- });
179
- });
180
- // ---------------------------------------------------------------------------
181
- // buildRoleContext — real fs with tmp dir
182
- // ---------------------------------------------------------------------------
183
- describe("buildRoleContext — fs integration", () => {
184
- let tmpDir;
185
- const agentName = "test-agent";
186
- before(async () => {
187
- tmpDir = await mkdtemp(join(tmpdir(), "akemon-test-"));
188
- const selfBase = join(tmpDir, ".akemon", "agents", agentName, "self");
189
- await mkdir(join(selfBase, "roles"), { recursive: true });
190
- await mkdir(join(selfBase, "playbooks"), { recursive: true });
191
- await mkdir(join(selfBase, "products"), { recursive: true });
192
- await writeFile(join(selfBase, "roles", "worker.md"), `# 工人\n执行任务的专业角色。\n\n## 激活\n- trigger:order\n`);
193
- await writeFile(join(selfBase, "playbooks", "strategy.md"), `# Strategy\n核心策略内容。\n`);
194
- await writeFile(join(selfBase, "products", "widget.md"), `# Widget\n\n## playbook\nstrategy\n\n## products\n- p_1234\n`);
195
- });
196
- after(async () => {
197
- await rm(tmpDir, { recursive: true, force: true });
198
- });
199
- it("matching trigger produces output with [Active role: ...]", async () => {
200
- const result = await buildRoleContext(tmpDir, agentName, "trigger:order:001");
201
- assert.ok(result.includes("[Active role: worker]"), `expected '[Active role: worker]' in:\n${result}`);
202
- });
203
- it("passing productName produces output with [Product: ...] and [Playbook: ...]", async () => {
204
- const result = await buildRoleContext(tmpDir, agentName, "trigger:other", "widget");
205
- assert.ok(result.includes("[Product: widget]"), `expected '[Product: widget]' in:\n${result}`);
206
- assert.ok(result.includes("[Playbook: strategy]"), `expected '[Playbook: strategy]' in:\n${result}`);
207
- });
208
- });
@@ -1,108 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { Readable, Writable } from "node:stream";
3
- import { describe, it } from "node:test";
4
- import { handleSoftwareAgentRunHttp } from "./server.js";
5
- class TestResponse extends Writable {
6
- statusCode = 200;
7
- headers = {};
8
- body = "";
9
- writeHead(statusCode, headers) {
10
- this.statusCode = statusCode;
11
- this.headers = headers || {};
12
- return this;
13
- }
14
- end(chunk, encoding, callback) {
15
- if (chunk)
16
- this.body += Buffer.isBuffer(chunk) ? chunk.toString("utf-8") : String(chunk);
17
- return super.end(callback || (typeof encoding === "function" ? encoding : undefined));
18
- }
19
- _write(chunk, _encoding, callback) {
20
- this.body += chunk.toString("utf-8");
21
- callback();
22
- }
23
- }
24
- async function callSoftwareAgentEndpoint(softwareAgent, body, token) {
25
- const rawBody = JSON.stringify(body);
26
- const req = Readable.from([Buffer.from(rawBody)]);
27
- Object.assign(req, {
28
- method: "POST",
29
- url: "/self/software-agent/run",
30
- headers: {
31
- ...(token ? { authorization: `Bearer ${token}` } : {}),
32
- "content-type": "application/json",
33
- },
34
- });
35
- const res = new TestResponse();
36
- await handleSoftwareAgentRunHttp(req, res, {
37
- options: { secretKey: "owner-secret", key: "legacy-owner-key" },
38
- workdir: "/repo",
39
- softwareAgent,
40
- });
41
- return {
42
- statusCode: res.statusCode,
43
- body: res.body ? JSON.parse(res.body) : {},
44
- };
45
- }
46
- describe("software-agent HTTP endpoint", () => {
47
- it("requires an owner token", async () => {
48
- let calls = 0;
49
- const res = await callSoftwareAgentEndpoint({
50
- async sendTask(envelope) {
51
- calls++;
52
- return successResult(envelope);
53
- },
54
- }, { goal: "inspect repo" });
55
- assert.equal(res.statusCode, 401);
56
- assert.match(res.body.error, /Owner token required/);
57
- assert.equal(calls, 0);
58
- });
59
- it("forwards a validated owner envelope to the software agent", async () => {
60
- let received = null;
61
- const res = await callSoftwareAgentEndpoint({
62
- async sendTask(envelope) {
63
- received = envelope;
64
- return successResult(envelope);
65
- },
66
- }, {
67
- goal: " inspect repo ",
68
- roleScope: "owner",
69
- memoryScope: "owner",
70
- riskLevel: "low",
71
- forbiddenActions: ["make network requests"],
72
- timeoutMs: 5000,
73
- }, "owner-secret");
74
- assert.equal(res.statusCode, 200);
75
- assert.equal(res.body.success, true);
76
- assert.ok(received);
77
- const envelope = received;
78
- assert.equal(envelope.goal, "inspect repo");
79
- assert.equal(envelope.riskLevel, "low");
80
- assert.equal(envelope.timeoutMs, 5000);
81
- assert.deepEqual(envelope.forbiddenActions, [
82
- "read Akemon private memory outside this envelope",
83
- "access files outside the stated workdir unless explicitly needed and reported",
84
- "make network requests",
85
- ]);
86
- });
87
- it("rejects invalid envelope fields before calling the software agent", async () => {
88
- let calls = 0;
89
- const res = await callSoftwareAgentEndpoint({
90
- async sendTask(envelope) {
91
- calls++;
92
- return successResult(envelope);
93
- },
94
- }, { goal: "inspect repo", roleScope: "friend" }, "owner-secret");
95
- assert.equal(res.statusCode, 400);
96
- assert.match(res.body.error, /Invalid roleScope/);
97
- assert.equal(calls, 0);
98
- });
99
- });
100
- function successResult(envelope) {
101
- return {
102
- success: true,
103
- taskId: envelope.taskId || "task-from-test",
104
- output: "ok",
105
- exitCode: 0,
106
- durationMs: 1,
107
- };
108
- }
@@ -1,187 +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 { CodexSoftwareAgentPeripheral, buildTaskEnvelopePrompt, createOwnerTaskEnvelope, } from "./software-agent-peripheral.js";
6
- import { SimpleEventBus } from "./event-bus.js";
7
- import { SIG } from "./types.js";
8
- function createFakeChild() {
9
- const child = new EventEmitter();
10
- child.stdin = new PassThrough();
11
- child.stdout = new PassThrough();
12
- child.stderr = new PassThrough();
13
- Object.defineProperty(child, "pid", { value: 23456, configurable: true });
14
- child.unref = () => child;
15
- return child;
16
- }
17
- function baseEnvelope(overrides = {}) {
18
- return {
19
- taskId: "sw-test-1",
20
- sourceModule: "task",
21
- purpose: "dogfood coding task",
22
- goal: "Inspect the repo and summarize the event bus implementation.",
23
- workdir: "/tmp/akemon",
24
- roleScope: "owner",
25
- memoryScope: "owner",
26
- riskLevel: "medium",
27
- allowedActions: ["read repository files", "run tests"],
28
- forbiddenActions: ["read owner private notes outside this envelope"],
29
- memorySummary: "Visible context only.",
30
- deliverable: "Concise engineering report.",
31
- ...overrides,
32
- };
33
- }
34
- describe("buildTaskEnvelopePrompt", () => {
35
- it("renders envelope fields, visible memory, boundaries, and deliverable", () => {
36
- const prompt = buildTaskEnvelopePrompt(baseEnvelope());
37
- assert.match(prompt, /Task ID: sw-test-1/);
38
- assert.match(prompt, /Source module: task/);
39
- assert.match(prompt, /Role scope: owner/);
40
- assert.match(prompt, /Memory scope: owner/);
41
- assert.match(prompt, /Risk level: medium/);
42
- assert.match(prompt, /Workdir: \/tmp\/akemon/);
43
- assert.match(prompt, /Visible context only\./);
44
- assert.match(prompt, /- read repository files/);
45
- assert.match(prompt, /- read owner private notes outside this envelope/);
46
- assert.match(prompt, /Concise engineering report\./);
47
- assert.match(prompt, /Do not attempt to read Akemon private memory outside the visible context/);
48
- });
49
- });
50
- describe("createOwnerTaskEnvelope", () => {
51
- it("builds conservative owner defaults from a minimal body", () => {
52
- const envelope = createOwnerTaskEnvelope({ goal: " inspect repo " }, "/repo");
53
- assert.equal(envelope.sourceModule, "owner-http");
54
- assert.equal(envelope.goal, "inspect repo");
55
- assert.equal(envelope.workdir, "/repo");
56
- assert.equal(envelope.roleScope, "owner");
57
- assert.equal(envelope.memoryScope, "owner");
58
- assert.equal(envelope.riskLevel, "medium");
59
- assert.deepEqual(envelope.allowedActions, ["read repository files", "edit files in workdir", "run project tests"]);
60
- assert.ok(envelope.forbiddenActions?.some((item) => item.includes("private memory")));
61
- });
62
- it("keeps baseline forbidden boundaries when caller adds restrictions", () => {
63
- const envelope = createOwnerTaskEnvelope({
64
- goal: "inspect repo",
65
- forbiddenActions: ["make network requests", "read Akemon private memory outside this envelope"],
66
- }, "/repo");
67
- assert.deepEqual(envelope.forbiddenActions, [
68
- "read Akemon private memory outside this envelope",
69
- "access files outside the stated workdir unless explicitly needed and reported",
70
- "make network requests",
71
- ]);
72
- });
73
- it("rejects empty goals", () => {
74
- assert.throws(() => createOwnerTaskEnvelope({ goal: " " }, "/repo"), /Missing required string field: goal/);
75
- });
76
- it("rejects invalid scope, action, and timeout fields", () => {
77
- assert.throws(() => createOwnerTaskEnvelope({ goal: "inspect repo", roleScope: "friend" }, "/repo"), /Invalid roleScope/);
78
- assert.throws(() => createOwnerTaskEnvelope({ goal: "inspect repo", memoryScope: "private" }, "/repo"), /Invalid memoryScope/);
79
- assert.throws(() => createOwnerTaskEnvelope({ goal: "inspect repo", riskLevel: "critical" }, "/repo"), /Invalid riskLevel/);
80
- assert.throws(() => createOwnerTaskEnvelope({ goal: "inspect repo", allowedActions: ["read", ""] }, "/repo"), /Invalid allowedActions\[1\]/);
81
- assert.throws(() => createOwnerTaskEnvelope({ goal: "inspect repo", timeoutMs: 0 }, "/repo"), /Invalid timeoutMs/);
82
- });
83
- });
84
- describe("CodexSoftwareAgentPeripheral", () => {
85
- it("runs codex exec with an envelope over stdin and streams lifecycle events", async () => {
86
- const streamEvents = [];
87
- const busEvents = [];
88
- let writtenPrompt = "";
89
- let spawnedChild = null;
90
- const bus = new SimpleEventBus();
91
- bus.on(SIG.TASK_STARTED, (signal) => {
92
- busEvents.push(`${signal.type}:${signal.data.taskId}`);
93
- });
94
- bus.on(SIG.TASK_COMPLETED, (signal) => {
95
- busEvents.push(`${signal.type}:${signal.data.taskId}`);
96
- });
97
- const peripheral = new CodexSoftwareAgentPeripheral({
98
- workdir: "/tmp/akemon",
99
- command: "codex",
100
- spawnImpl: ((cmd, args, opts) => {
101
- assert.equal(cmd, "codex");
102
- assert.deepEqual(args, [
103
- "exec",
104
- "--skip-git-repo-check",
105
- "--color", "never",
106
- "-s", "workspace-write",
107
- "-C", "/tmp/akemon",
108
- "-",
109
- ]);
110
- assert.equal(opts?.cwd, "/tmp/akemon");
111
- const child = createFakeChild();
112
- spawnedChild = child;
113
- child.stdin?.on("data", (chunk) => {
114
- writtenPrompt += chunk.toString("utf8");
115
- });
116
- queueMicrotask(() => {
117
- child.stdout?.emit("data", Buffer.from("result "));
118
- child.stderr?.emit("data", Buffer.from("note"));
119
- child.stdout?.emit("data", Buffer.from("ok"));
120
- child.emit("close", 0);
121
- });
122
- return child;
123
- }),
124
- taskRelay: {
125
- sendTaskStart(taskId, origin, cmd) {
126
- streamEvents.push({ type: "start", taskId, origin, cmd });
127
- },
128
- sendTaskStream(taskId, stream, chunk) {
129
- streamEvents.push({ type: "stream", taskId, stream, chunk });
130
- },
131
- sendTaskEnd(taskId, exitCode, durationMs) {
132
- streamEvents.push({ type: "end", taskId, exitCode, durationMs });
133
- },
134
- },
135
- });
136
- await peripheral.start(bus);
137
- const result = await peripheral.sendTask(baseEnvelope());
138
- assert.ok(spawnedChild, "spawn should be called");
139
- assert.equal(result.success, true);
140
- assert.equal(result.taskId, "sw-test-1");
141
- assert.equal(result.output, "result ok");
142
- assert.equal(result.exitCode, 0);
143
- assert.match(writtenPrompt, /Akemon Software Peripheral Task Envelope/);
144
- assert.match(writtenPrompt, /Goal:\nInspect the repo/);
145
- assert.match(writtenPrompt, /Visible context only\./);
146
- assert.deepEqual(streamEvents.slice(0, 4), [
147
- {
148
- type: "start",
149
- taskId: "sw-test-1",
150
- origin: "software_agent",
151
- cmd: "codex exec --skip-git-repo-check --color never -s workspace-write -C /tmp/akemon -",
152
- },
153
- { type: "stream", taskId: "sw-test-1", stream: "stdout", chunk: "result " },
154
- { type: "stream", taskId: "sw-test-1", stream: "stderr", chunk: "note" },
155
- { type: "stream", taskId: "sw-test-1", stream: "stdout", chunk: "ok" },
156
- ]);
157
- const end = streamEvents[4];
158
- assert.equal(end?.type, "end");
159
- if (end?.type === "end") {
160
- assert.equal(end.taskId, "sw-test-1");
161
- assert.equal(end.exitCode, 0);
162
- assert.ok(end.durationMs >= 0);
163
- }
164
- assert.deepEqual(busEvents, [
165
- "task:started:sw-test-1",
166
- "task:completed:sw-test-1",
167
- ]);
168
- });
169
- it("rejects concurrent tasks while a codex run is active", async () => {
170
- const peripheral = new CodexSoftwareAgentPeripheral({
171
- workdir: "/tmp/akemon",
172
- spawnImpl: (() => {
173
- const child = createFakeChild();
174
- setTimeout(() => child.emit("close", 0), 10);
175
- return child;
176
- }),
177
- taskRelay: {
178
- sendTaskStart() { },
179
- sendTaskStream() { },
180
- sendTaskEnd() { },
181
- },
182
- });
183
- const first = peripheral.sendTask(baseEnvelope({ taskId: "first" }));
184
- await assert.rejects(() => peripheral.sendTask(baseEnvelope({ taskId: "second" })), /Software agent busy/);
185
- await first;
186
- });
187
- });
@@ -1,88 +0,0 @@
1
- import { describe, it } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { sortByQuadrant, dedupeWorkItems, computeRetryDelay, RETRY_INTERVALS } from "./task-helpers.js";
4
- describe("sortByQuadrant", () => {
5
- it("empty array returns empty array", () => {
6
- assert.deepStrictEqual(sortByQuadrant([]), []);
7
- });
8
- it("does not modify the input array", () => {
9
- const items = [
10
- { quadrant: 3, id: "a" },
11
- { quadrant: 1, id: "b" },
12
- ];
13
- const original = [...items];
14
- sortByQuadrant(items);
15
- assert.deepStrictEqual(items, original, "input array must not be mutated");
16
- });
17
- it("sorts ascending by quadrant", () => {
18
- const items = [
19
- { quadrant: 4, id: "d" },
20
- { quadrant: 2, id: "b" },
21
- { quadrant: 3, id: "c" },
22
- { quadrant: 1, id: "a" },
23
- ];
24
- const result = sortByQuadrant(items);
25
- assert.deepStrictEqual(result.map(i => i.quadrant), [1, 2, 3, 4]);
26
- });
27
- it("is stable: equal-quadrant items preserve original order", () => {
28
- const items = [
29
- { quadrant: 2, id: "first" },
30
- { quadrant: 1, id: "solo" },
31
- { quadrant: 2, id: "second" },
32
- ];
33
- const result = sortByQuadrant(items);
34
- assert.strictEqual(result[0].id, "solo");
35
- assert.strictEqual(result[1].id, "first", "first Q2 item should come before second Q2 item");
36
- assert.strictEqual(result[2].id, "second");
37
- });
38
- });
39
- describe("dedupeWorkItems", () => {
40
- it("different type, same id are kept (not considered duplicates)", () => {
41
- const items = [
42
- { type: "order", id: "abc" },
43
- { type: "user_task", id: "abc" },
44
- ];
45
- const result = dedupeWorkItems(items);
46
- assert.strictEqual(result.length, 2, "order:abc and user_task:abc must both survive");
47
- });
48
- it("same type+id: only first occurrence is kept", () => {
49
- const items = [
50
- { type: "order", id: "x", extra: 1 },
51
- { type: "order", id: "x", extra: 2 },
52
- ];
53
- const result = dedupeWorkItems(items);
54
- assert.strictEqual(result.length, 1);
55
- assert.strictEqual(result[0].extra, 1, "first occurrence must be preserved");
56
- });
57
- it("returns empty array for empty input", () => {
58
- assert.deepStrictEqual(dedupeWorkItems([]), []);
59
- });
60
- });
61
- describe("computeRetryDelay", () => {
62
- it("count=0 returns 0 (immediate first retry)", () => {
63
- assert.strictEqual(computeRetryDelay(0), 0);
64
- });
65
- it("count=1 returns 30_000", () => {
66
- assert.strictEqual(computeRetryDelay(1), 30_000);
67
- });
68
- it("count=4 returns 7_200_000 (2h)", () => {
69
- assert.strictEqual(computeRetryDelay(4), 2 * 3600_000);
70
- });
71
- it("count=5 returns null (exhausted)", () => {
72
- assert.strictEqual(computeRetryDelay(5), null);
73
- });
74
- it("count=-1 returns null (negative treated as exhausted)", () => {
75
- assert.strictEqual(computeRetryDelay(-1), null);
76
- });
77
- it("uses RETRY_INTERVALS as default intervals", () => {
78
- for (let i = 0; i < RETRY_INTERVALS.length; i++) {
79
- assert.strictEqual(computeRetryDelay(i), RETRY_INTERVALS[i]);
80
- }
81
- });
82
- it("accepts a custom intervals array", () => {
83
- const custom = [100, 200, 300];
84
- assert.strictEqual(computeRetryDelay(0, custom), 100);
85
- assert.strictEqual(computeRetryDelay(2, custom), 300);
86
- assert.strictEqual(computeRetryDelay(3, custom), null);
87
- });
88
- });