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.
- package/dist/cli.js +54 -19
- package/dist/server.js +49 -0
- package/package.json +4 -4
- package/dist/context.test.js +0 -90
- package/dist/dashboard.html +0 -552
- package/dist/engine-queue.test.js +0 -99
- package/dist/engine-routing.test.js +0 -122
- package/dist/engine-stream.test.js +0 -103
- package/dist/event-bus.test.js +0 -51
- package/dist/orphan-scan.test.js +0 -81
- package/dist/reflection-module.integration.test.js +0 -180
- package/dist/reflection-module.test.js +0 -66
- package/dist/role-module.test.js +0 -208
- package/dist/software-agent-http.test.js +0 -108
- package/dist/software-agent-peripheral.test.js +0 -187
- package/dist/task-helpers.test.js +0 -88
package/dist/role-module.test.js
DELETED
|
@@ -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
|
-
});
|