@hellcoder/companion 0.96.0
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/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- package/server/ws-bridge.ts +1279 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
|
|
5
|
+
let tempDir: string;
|
|
6
|
+
let agentStore: typeof import("./agent-store.js");
|
|
7
|
+
|
|
8
|
+
const mockHomedir = vi.hoisted(() => {
|
|
9
|
+
let dir = "";
|
|
10
|
+
return {
|
|
11
|
+
get: () => dir,
|
|
12
|
+
set: (d: string) => {
|
|
13
|
+
dir = d;
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
vi.mock("node:os", async (importOriginal) => {
|
|
19
|
+
const actual = await importOriginal<typeof import("node:os")>();
|
|
20
|
+
return {
|
|
21
|
+
...actual,
|
|
22
|
+
homedir: () => mockHomedir.get(),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
tempDir = mkdtempSync(join(tmpdir(), "agent-test-"));
|
|
28
|
+
mockHomedir.set(tempDir);
|
|
29
|
+
vi.resetModules();
|
|
30
|
+
agentStore = await import("./agent-store.js");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function agentsDir(): string {
|
|
38
|
+
return join(tempDir, ".companion", "agents");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Helper to build a valid AgentConfigCreateInput with sensible defaults.
|
|
43
|
+
* Pass overrides to customise specific fields.
|
|
44
|
+
*/
|
|
45
|
+
function makeAgentInput(overrides: Record<string, unknown> = {}) {
|
|
46
|
+
return {
|
|
47
|
+
name: "Test Agent",
|
|
48
|
+
prompt: "Do something useful",
|
|
49
|
+
description: "A test agent",
|
|
50
|
+
version: 1 as const,
|
|
51
|
+
backendType: "claude" as const,
|
|
52
|
+
model: "claude-sonnet-4-6",
|
|
53
|
+
cwd: "/tmp/test-repo",
|
|
54
|
+
enabled: true,
|
|
55
|
+
permissionMode: "bypassPermissions",
|
|
56
|
+
...overrides,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ===========================================================================
|
|
61
|
+
// listAgents
|
|
62
|
+
// ===========================================================================
|
|
63
|
+
describe("listAgents", () => {
|
|
64
|
+
it("returns empty array when no agents exist", () => {
|
|
65
|
+
// The agents directory does not exist yet; listAgents should
|
|
66
|
+
// create it and return an empty list without throwing.
|
|
67
|
+
expect(agentStore.listAgents()).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns agents sorted alphabetically by name", () => {
|
|
71
|
+
agentStore.createAgent(makeAgentInput({ name: "Zebra Agent" }));
|
|
72
|
+
agentStore.createAgent(makeAgentInput({ name: "Alpha Agent" }));
|
|
73
|
+
agentStore.createAgent(makeAgentInput({ name: "Mango Agent" }));
|
|
74
|
+
|
|
75
|
+
const result = agentStore.listAgents();
|
|
76
|
+
expect(result.map((a) => a.name)).toEqual(["Alpha Agent", "Mango Agent", "Zebra Agent"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("skips corrupt JSON files", () => {
|
|
80
|
+
agentStore.createAgent(makeAgentInput({ name: "Valid Agent" }));
|
|
81
|
+
writeFileSync(join(agentsDir(), "corrupt.json"), "NOT VALID JSON{{{", "utf-8");
|
|
82
|
+
|
|
83
|
+
const result = agentStore.listAgents();
|
|
84
|
+
expect(result).toHaveLength(1);
|
|
85
|
+
expect(result[0].name).toBe("Valid Agent");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("strips legacy triggers.chat block from agents loaded from disk", () => {
|
|
89
|
+
// Simulate an agent saved with the old Chat SDK schema that has
|
|
90
|
+
// platform credentials embedded in triggers.chat. The store should
|
|
91
|
+
// strip this block on load to prevent leaking secrets via the API.
|
|
92
|
+
const agent = agentStore.createAgent(makeAgentInput({ name: "Legacy Chat Agent" }));
|
|
93
|
+
const agentFile = join(agentsDir(), `${agent.id}.json`);
|
|
94
|
+
const raw = JSON.parse(readFileSync(agentFile, "utf-8"));
|
|
95
|
+
raw.triggers = {
|
|
96
|
+
...raw.triggers,
|
|
97
|
+
chat: {
|
|
98
|
+
enabled: true,
|
|
99
|
+
platforms: [{
|
|
100
|
+
adapter: "github",
|
|
101
|
+
autoSubscribe: true,
|
|
102
|
+
credentials: { token: "ghp_secret123", webhookSecret: "wh_secret456" },
|
|
103
|
+
}],
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
writeFileSync(agentFile, JSON.stringify(raw), "utf-8");
|
|
107
|
+
|
|
108
|
+
const loaded = agentStore.listAgents();
|
|
109
|
+
const found = loaded.find((a) => a.id === agent.id);
|
|
110
|
+
expect(found).toBeDefined();
|
|
111
|
+
// The triggers.chat block should be stripped
|
|
112
|
+
expect((found!.triggers as Record<string, unknown>)?.chat).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("skips non-JSON files in the agents directory", () => {
|
|
116
|
+
agentStore.createAgent(makeAgentInput({ name: "Legit Agent" }));
|
|
117
|
+
writeFileSync(join(agentsDir(), "readme.txt"), "not an agent", "utf-8");
|
|
118
|
+
writeFileSync(join(agentsDir(), "notes.md"), "# notes", "utf-8");
|
|
119
|
+
|
|
120
|
+
const agents = agentStore.listAgents();
|
|
121
|
+
expect(agents).toHaveLength(1);
|
|
122
|
+
expect(agents[0].name).toBe("Legit Agent");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ===========================================================================
|
|
127
|
+
// createAgent
|
|
128
|
+
// ===========================================================================
|
|
129
|
+
describe("createAgent", () => {
|
|
130
|
+
it("creates a valid agent with auto-generated slug ID", () => {
|
|
131
|
+
const before = Date.now();
|
|
132
|
+
const agent = agentStore.createAgent(makeAgentInput({ name: "My Cool Agent" }));
|
|
133
|
+
const after = Date.now();
|
|
134
|
+
|
|
135
|
+
// The ID is a slugified version of the name
|
|
136
|
+
expect(agent.id).toBe("my-cool-agent");
|
|
137
|
+
expect(agent.name).toBe("My Cool Agent");
|
|
138
|
+
expect(agent.prompt).toBe("Do something useful");
|
|
139
|
+
expect(agent.description).toBe("A test agent");
|
|
140
|
+
expect(agent.backendType).toBe("claude");
|
|
141
|
+
expect(agent.permissionMode).toBe("bypassPermissions");
|
|
142
|
+
// Auto-initialised tracking fields
|
|
143
|
+
expect(agent.totalRuns).toBe(0);
|
|
144
|
+
expect(agent.consecutiveFailures).toBe(0);
|
|
145
|
+
// Timestamps should bracket the call
|
|
146
|
+
expect(agent.createdAt).toBeGreaterThanOrEqual(before);
|
|
147
|
+
expect(agent.createdAt).toBeLessThanOrEqual(after);
|
|
148
|
+
expect(agent.updatedAt).toBe(agent.createdAt);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("persists the agent to disk as JSON", () => {
|
|
152
|
+
agentStore.createAgent(makeAgentInput({ name: "Disk Agent" }));
|
|
153
|
+
|
|
154
|
+
const raw = readFileSync(join(agentsDir(), "disk-agent.json"), "utf-8");
|
|
155
|
+
const parsed = JSON.parse(raw);
|
|
156
|
+
expect(parsed.name).toBe("Disk Agent");
|
|
157
|
+
expect(parsed.id).toBe("disk-agent");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("throws on missing name (empty string)", () => {
|
|
161
|
+
expect(() => agentStore.createAgent(makeAgentInput({ name: "" }))).toThrow(
|
|
162
|
+
"Agent name is required",
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("throws on missing name (whitespace only)", () => {
|
|
167
|
+
expect(() => agentStore.createAgent(makeAgentInput({ name: " " }))).toThrow(
|
|
168
|
+
"Agent name is required",
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("throws on missing prompt (empty string)", () => {
|
|
173
|
+
expect(() => agentStore.createAgent(makeAgentInput({ prompt: "" }))).toThrow(
|
|
174
|
+
"Agent prompt is required",
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("throws on missing prompt (whitespace only)", () => {
|
|
179
|
+
expect(() => agentStore.createAgent(makeAgentInput({ prompt: " " }))).toThrow(
|
|
180
|
+
"Agent prompt is required",
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("throws on duplicate names (slug collision)", () => {
|
|
185
|
+
agentStore.createAgent(makeAgentInput({ name: "Duplicate Test" }));
|
|
186
|
+
expect(() => agentStore.createAgent(makeAgentInput({ name: "Duplicate Test" }))).toThrow(
|
|
187
|
+
'An agent with a similar name already exists ("duplicate-test")',
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("throws when name contains no alphanumeric characters", () => {
|
|
192
|
+
expect(() => agentStore.createAgent(makeAgentInput({ name: "@#$%^&" }))).toThrow(
|
|
193
|
+
"Agent name must contain alphanumeric characters",
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("trims the name before saving", () => {
|
|
198
|
+
const agent = agentStore.createAgent(makeAgentInput({ name: " Spacey Name " }));
|
|
199
|
+
expect(agent.name).toBe("Spacey Name");
|
|
200
|
+
expect(agent.id).toBe("spacey-name");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("trims prompt and description", () => {
|
|
204
|
+
const agent = agentStore.createAgent(
|
|
205
|
+
makeAgentInput({
|
|
206
|
+
name: "Trim Fields Agent",
|
|
207
|
+
prompt: " some prompt ",
|
|
208
|
+
description: " some desc ",
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
expect(agent.prompt).toBe("some prompt");
|
|
212
|
+
expect(agent.description).toBe("some desc");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("auto-generates webhook secret when webhook trigger is enabled without a secret", () => {
|
|
216
|
+
// When triggers.webhook is provided but has no secret, createAgent
|
|
217
|
+
// should auto-generate one (a 48-char hex string from 24 random bytes).
|
|
218
|
+
const agent = agentStore.createAgent(
|
|
219
|
+
makeAgentInput({
|
|
220
|
+
name: "Webhook Agent",
|
|
221
|
+
triggers: { webhook: { enabled: true } },
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
expect(agent.triggers?.webhook?.secret).toBeDefined();
|
|
225
|
+
expect(agent.triggers!.webhook!.secret).toHaveLength(48);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("preserves an explicitly provided webhook secret", () => {
|
|
229
|
+
const customSecret = "my-custom-secret-value";
|
|
230
|
+
const agent = agentStore.createAgent(
|
|
231
|
+
makeAgentInput({
|
|
232
|
+
name: "Custom Secret Agent",
|
|
233
|
+
triggers: { webhook: { enabled: true, secret: customSecret } },
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
expect(agent.triggers!.webhook!.secret).toBe(customSecret);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ===========================================================================
|
|
241
|
+
// getAgent
|
|
242
|
+
// ===========================================================================
|
|
243
|
+
describe("getAgent", () => {
|
|
244
|
+
it("returns the created agent", () => {
|
|
245
|
+
agentStore.createAgent(makeAgentInput({ name: "Findable Agent" }));
|
|
246
|
+
|
|
247
|
+
const result = agentStore.getAgent("findable-agent");
|
|
248
|
+
expect(result).not.toBeNull();
|
|
249
|
+
expect(result!.name).toBe("Findable Agent");
|
|
250
|
+
expect(result!.id).toBe("findable-agent");
|
|
251
|
+
expect(result!.prompt).toBe("Do something useful");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("returns null for non-existent ID", () => {
|
|
255
|
+
expect(agentStore.getAgent("nonexistent-id")).toBeNull();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("strips legacy triggers.chat block when loading a single agent", () => {
|
|
259
|
+
// Same as the listAgents test but verifies getAgent also strips chat.
|
|
260
|
+
const agent = agentStore.createAgent(makeAgentInput({ name: "Legacy Single" }));
|
|
261
|
+
const agentFile = join(agentsDir(), `${agent.id}.json`);
|
|
262
|
+
const raw = JSON.parse(readFileSync(agentFile, "utf-8"));
|
|
263
|
+
raw.triggers = {
|
|
264
|
+
...raw.triggers,
|
|
265
|
+
chat: {
|
|
266
|
+
enabled: true,
|
|
267
|
+
platforms: [{ adapter: "github", credentials: { token: "secret" } }],
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
writeFileSync(agentFile, JSON.stringify(raw), "utf-8");
|
|
271
|
+
|
|
272
|
+
const loaded = agentStore.getAgent(agent.id);
|
|
273
|
+
expect(loaded).not.toBeNull();
|
|
274
|
+
expect((loaded!.triggers as Record<string, unknown>)?.chat).toBeUndefined();
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// ===========================================================================
|
|
279
|
+
// updateAgent
|
|
280
|
+
// ===========================================================================
|
|
281
|
+
describe("updateAgent", () => {
|
|
282
|
+
it("updates fields correctly and preserves createdAt", async () => {
|
|
283
|
+
const agent = agentStore.createAgent(makeAgentInput({ name: "Update Target" }));
|
|
284
|
+
const originalCreatedAt = agent.createdAt;
|
|
285
|
+
|
|
286
|
+
// Small delay so updatedAt differs from createdAt
|
|
287
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
288
|
+
|
|
289
|
+
const updated = agentStore.updateAgent("update-target", {
|
|
290
|
+
prompt: "Updated prompt",
|
|
291
|
+
description: "Updated description",
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(updated).not.toBeNull();
|
|
295
|
+
expect(updated!.prompt).toBe("Updated prompt");
|
|
296
|
+
expect(updated!.description).toBe("Updated description");
|
|
297
|
+
// createdAt must be preserved
|
|
298
|
+
expect(updated!.createdAt).toBe(originalCreatedAt);
|
|
299
|
+
// updatedAt should advance
|
|
300
|
+
expect(updated!.updatedAt).toBeGreaterThan(originalCreatedAt);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("handles name change (new slug) — renames file on disk", () => {
|
|
304
|
+
agentStore.createAgent(makeAgentInput({ name: "Old Agent Name" }));
|
|
305
|
+
|
|
306
|
+
const updated = agentStore.updateAgent("old-agent-name", { name: "New Agent Name" });
|
|
307
|
+
|
|
308
|
+
expect(updated).not.toBeNull();
|
|
309
|
+
expect(updated!.id).toBe("new-agent-name");
|
|
310
|
+
expect(updated!.name).toBe("New Agent Name");
|
|
311
|
+
|
|
312
|
+
// Old file should be gone, new file should exist
|
|
313
|
+
expect(() => readFileSync(join(agentsDir(), "old-agent-name.json"), "utf-8")).toThrow();
|
|
314
|
+
const parsed = JSON.parse(readFileSync(join(agentsDir(), "new-agent-name.json"), "utf-8"));
|
|
315
|
+
expect(parsed.name).toBe("New Agent Name");
|
|
316
|
+
expect(parsed.id).toBe("new-agent-name");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("throws on slug collision during rename", () => {
|
|
320
|
+
agentStore.createAgent(makeAgentInput({ name: "Agent Alpha" }));
|
|
321
|
+
agentStore.createAgent(makeAgentInput({ name: "Agent Beta" }));
|
|
322
|
+
|
|
323
|
+
expect(() => agentStore.updateAgent("agent-alpha", { name: "Agent Beta" })).toThrow(
|
|
324
|
+
'An agent with a similar name already exists ("agent-beta")',
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("returns null for a non-existent ID", () => {
|
|
329
|
+
expect(agentStore.updateAgent("ghost-agent", { name: "New" })).toBeNull();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("updates tracking fields like consecutiveFailures and totalRuns", () => {
|
|
333
|
+
agentStore.createAgent(makeAgentInput({ name: "Tracked Agent" }));
|
|
334
|
+
|
|
335
|
+
const updated = agentStore.updateAgent("tracked-agent", {
|
|
336
|
+
consecutiveFailures: 5,
|
|
337
|
+
totalRuns: 20,
|
|
338
|
+
lastRunAt: Date.now(),
|
|
339
|
+
lastSessionId: "session-xyz",
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
expect(updated!.consecutiveFailures).toBe(5);
|
|
343
|
+
expect(updated!.totalRuns).toBe(20);
|
|
344
|
+
expect(updated!.lastSessionId).toBe("session-xyz");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("does not allow overriding createdAt", () => {
|
|
348
|
+
const agent = agentStore.createAgent(makeAgentInput({ name: "Immutable Create" }));
|
|
349
|
+
const originalCreatedAt = agent.createdAt;
|
|
350
|
+
|
|
351
|
+
agentStore.updateAgent("immutable-create", {
|
|
352
|
+
createdAt: 0,
|
|
353
|
+
} as Partial<import("./agent-types.js").AgentConfig>);
|
|
354
|
+
|
|
355
|
+
const refreshed = agentStore.getAgent("immutable-create");
|
|
356
|
+
expect(refreshed!.createdAt).toBe(originalCreatedAt);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ===========================================================================
|
|
361
|
+
// deleteAgent
|
|
362
|
+
// ===========================================================================
|
|
363
|
+
describe("deleteAgent", () => {
|
|
364
|
+
it("removes the agent and returns true", () => {
|
|
365
|
+
agentStore.createAgent(makeAgentInput({ name: "Delete Me Agent" }));
|
|
366
|
+
expect(agentStore.deleteAgent("delete-me-agent")).toBe(true);
|
|
367
|
+
expect(agentStore.getAgent("delete-me-agent")).toBeNull();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("returns false for non-existent ID", () => {
|
|
371
|
+
expect(agentStore.deleteAgent("missing-agent")).toBe(false);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("removes the file from disk", () => {
|
|
375
|
+
agentStore.createAgent(makeAgentInput({ name: "Disk Remove Agent" }));
|
|
376
|
+
// File should exist before delete
|
|
377
|
+
expect(() => readFileSync(join(agentsDir(), "disk-remove-agent.json"), "utf-8")).not.toThrow();
|
|
378
|
+
|
|
379
|
+
agentStore.deleteAgent("disk-remove-agent");
|
|
380
|
+
// File should be gone after delete
|
|
381
|
+
expect(() => readFileSync(join(agentsDir(), "disk-remove-agent.json"), "utf-8")).toThrow();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("does not affect other agents when deleting one", () => {
|
|
385
|
+
agentStore.createAgent(makeAgentInput({ name: "Keep This Agent" }));
|
|
386
|
+
agentStore.createAgent(makeAgentInput({ name: "Remove This Agent" }));
|
|
387
|
+
|
|
388
|
+
agentStore.deleteAgent("remove-this-agent");
|
|
389
|
+
|
|
390
|
+
expect(agentStore.getAgent("keep-this-agent")).not.toBeNull();
|
|
391
|
+
expect(agentStore.listAgents()).toHaveLength(1);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// ===========================================================================
|
|
396
|
+
// regenerateWebhookSecret
|
|
397
|
+
// ===========================================================================
|
|
398
|
+
describe("regenerateWebhookSecret", () => {
|
|
399
|
+
it("generates a new secret that differs from the original", () => {
|
|
400
|
+
const agent = agentStore.createAgent(
|
|
401
|
+
makeAgentInput({
|
|
402
|
+
name: "Webhook Regen Agent",
|
|
403
|
+
triggers: { webhook: { enabled: true, secret: "original-secret" } },
|
|
404
|
+
}),
|
|
405
|
+
);
|
|
406
|
+
expect(agent.triggers!.webhook!.secret).toBe("original-secret");
|
|
407
|
+
|
|
408
|
+
const updated = agentStore.regenerateWebhookSecret("webhook-regen-agent");
|
|
409
|
+
|
|
410
|
+
expect(updated).not.toBeNull();
|
|
411
|
+
expect(updated!.triggers!.webhook!.secret).not.toBe("original-secret");
|
|
412
|
+
// The new secret should be a 48-char hex string (24 random bytes)
|
|
413
|
+
expect(updated!.triggers!.webhook!.secret).toHaveLength(48);
|
|
414
|
+
expect(updated!.triggers!.webhook!.secret).toMatch(/^[0-9a-f]{48}$/);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("returns null for non-existent agent", () => {
|
|
418
|
+
expect(agentStore.regenerateWebhookSecret("nonexistent-agent")).toBeNull();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("creates a webhook trigger object if none existed", () => {
|
|
422
|
+
// Agent created without any triggers
|
|
423
|
+
agentStore.createAgent(makeAgentInput({ name: "No Trigger Agent" }));
|
|
424
|
+
|
|
425
|
+
const updated = agentStore.regenerateWebhookSecret("no-trigger-agent");
|
|
426
|
+
|
|
427
|
+
expect(updated).not.toBeNull();
|
|
428
|
+
expect(updated!.triggers).toBeDefined();
|
|
429
|
+
expect(updated!.triggers!.webhook).toBeDefined();
|
|
430
|
+
expect(updated!.triggers!.webhook!.secret).toHaveLength(48);
|
|
431
|
+
// When no previous webhook existed, enabled should default to false
|
|
432
|
+
expect(updated!.triggers!.webhook!.enabled).toBe(false);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("preserves the enabled state of the webhook trigger", () => {
|
|
436
|
+
agentStore.createAgent(
|
|
437
|
+
makeAgentInput({
|
|
438
|
+
name: "Enabled Webhook Agent",
|
|
439
|
+
triggers: { webhook: { enabled: true, secret: "old-secret" } },
|
|
440
|
+
}),
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
const updated = agentStore.regenerateWebhookSecret("enabled-webhook-agent");
|
|
444
|
+
|
|
445
|
+
// The enabled flag should remain true even after secret regeneration
|
|
446
|
+
expect(updated!.triggers!.webhook!.enabled).toBe(true);
|
|
447
|
+
expect(updated!.triggers!.webhook!.secret).not.toBe("old-secret");
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// ===========================================================================
|
|
452
|
+
// Slugification (tested indirectly via createAgent)
|
|
453
|
+
// ===========================================================================
|
|
454
|
+
describe("slugification via createAgent", () => {
|
|
455
|
+
it("converts spaces to hyphens and lowercases", () => {
|
|
456
|
+
const agent = agentStore.createAgent(makeAgentInput({ name: "My Daily Agent" }));
|
|
457
|
+
expect(agent.id).toBe("my-daily-agent");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("strips special characters", () => {
|
|
461
|
+
const agent = agentStore.createAgent(makeAgentInput({ name: "Check PRs! @#$%" }));
|
|
462
|
+
expect(agent.id).toBe("check-prs");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("collapses consecutive hyphens", () => {
|
|
466
|
+
const agent = agentStore.createAgent(makeAgentInput({ name: "a --- b" }));
|
|
467
|
+
expect(agent.id).toBe("a-b");
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// ===========================================================================
|
|
472
|
+
// Edge cases & integration
|
|
473
|
+
// ===========================================================================
|
|
474
|
+
describe("edge cases", () => {
|
|
475
|
+
it("handles unicode in agent names by stripping non-alphanumeric", () => {
|
|
476
|
+
// Unicode characters get stripped, leaving only alphanumeric + hyphens
|
|
477
|
+
const agent = agentStore.createAgent(makeAgentInput({ name: "café résumé" }));
|
|
478
|
+
expect(agent.id).toBe("caf-rsum");
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("handles very long names by preserving full slug", () => {
|
|
482
|
+
const longName = "a".repeat(200);
|
|
483
|
+
const agent = agentStore.createAgent(makeAgentInput({ name: longName }));
|
|
484
|
+
expect(agent.id).toBe(longName.toLowerCase());
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("preserves all AgentConfig fields through create -> get round-trip", () => {
|
|
488
|
+
// Every field in the AgentConfig interface should survive serialization
|
|
489
|
+
const input = makeAgentInput({
|
|
490
|
+
name: "Full Round Trip Agent",
|
|
491
|
+
prompt: "Complex prompt\nwith newlines\nand special chars: @#$%",
|
|
492
|
+
description: "A comprehensive test agent",
|
|
493
|
+
icon: "robot",
|
|
494
|
+
backendType: "codex",
|
|
495
|
+
model: "gpt-5.3-codex",
|
|
496
|
+
cwd: "/home/user/project",
|
|
497
|
+
envSlug: "production",
|
|
498
|
+
enabled: false,
|
|
499
|
+
permissionMode: "plan",
|
|
500
|
+
codexInternetAccess: true,
|
|
501
|
+
allowedTools: ["Bash", "Read"],
|
|
502
|
+
env: { MY_VAR: "hello" },
|
|
503
|
+
branch: "feature/test",
|
|
504
|
+
createBranch: true,
|
|
505
|
+
useWorktree: false,
|
|
506
|
+
skills: ["skill-a", "skill-b"],
|
|
507
|
+
triggers: {
|
|
508
|
+
webhook: { enabled: true, secret: "abc123" },
|
|
509
|
+
schedule: { enabled: true, expression: "0 8 * * *", recurring: true },
|
|
510
|
+
},
|
|
511
|
+
container: {
|
|
512
|
+
image: "ubuntu:22.04",
|
|
513
|
+
ports: [3000, 8080],
|
|
514
|
+
volumes: ["/data:/data"],
|
|
515
|
+
initScript: "apt-get update",
|
|
516
|
+
},
|
|
517
|
+
mcpServers: {
|
|
518
|
+
myServer: { type: "stdio" as const, command: "node", args: ["server.js"] },
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const created = agentStore.createAgent(input);
|
|
523
|
+
const retrieved = agentStore.getAgent(created.id);
|
|
524
|
+
|
|
525
|
+
expect(retrieved).not.toBeNull();
|
|
526
|
+
expect(retrieved!.name).toBe("Full Round Trip Agent");
|
|
527
|
+
expect(retrieved!.prompt).toBe(input.prompt);
|
|
528
|
+
expect(retrieved!.description).toBe("A comprehensive test agent");
|
|
529
|
+
expect(retrieved!.icon).toBe("robot");
|
|
530
|
+
expect(retrieved!.backendType).toBe("codex");
|
|
531
|
+
expect(retrieved!.model).toBe("gpt-5.3-codex");
|
|
532
|
+
expect(retrieved!.cwd).toBe("/home/user/project");
|
|
533
|
+
expect(retrieved!.envSlug).toBe("production");
|
|
534
|
+
expect(retrieved!.enabled).toBe(false);
|
|
535
|
+
expect(retrieved!.permissionMode).toBe("plan");
|
|
536
|
+
expect(retrieved!.codexInternetAccess).toBe(true);
|
|
537
|
+
expect(retrieved!.allowedTools).toEqual(["Bash", "Read"]);
|
|
538
|
+
expect(retrieved!.env).toEqual({ MY_VAR: "hello" });
|
|
539
|
+
expect(retrieved!.branch).toBe("feature/test");
|
|
540
|
+
expect(retrieved!.createBranch).toBe(true);
|
|
541
|
+
expect(retrieved!.useWorktree).toBe(false);
|
|
542
|
+
expect(retrieved!.skills).toEqual(["skill-a", "skill-b"]);
|
|
543
|
+
expect(retrieved!.triggers!.webhook!.enabled).toBe(true);
|
|
544
|
+
expect(retrieved!.triggers!.webhook!.secret).toBe("abc123");
|
|
545
|
+
expect(retrieved!.triggers!.schedule!.enabled).toBe(true);
|
|
546
|
+
expect(retrieved!.triggers!.schedule!.expression).toBe("0 8 * * *");
|
|
547
|
+
expect(retrieved!.triggers!.schedule!.recurring).toBe(true);
|
|
548
|
+
expect(retrieved!.container!.image).toBe("ubuntu:22.04");
|
|
549
|
+
expect(retrieved!.container!.ports).toEqual([3000, 8080]);
|
|
550
|
+
expect(retrieved!.container!.volumes).toEqual(["/data:/data"]);
|
|
551
|
+
expect(retrieved!.container!.initScript).toBe("apt-get update");
|
|
552
|
+
expect(retrieved!.mcpServers!.myServer).toEqual({
|
|
553
|
+
type: "stdio",
|
|
554
|
+
command: "node",
|
|
555
|
+
args: ["server.js"],
|
|
556
|
+
});
|
|
557
|
+
expect(retrieved!.consecutiveFailures).toBe(0);
|
|
558
|
+
expect(retrieved!.totalRuns).toBe(0);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("can create multiple agents and list them all", () => {
|
|
562
|
+
for (let i = 0; i < 10; i++) {
|
|
563
|
+
agentStore.createAgent(makeAgentInput({ name: `Agent ${i}` }));
|
|
564
|
+
}
|
|
565
|
+
expect(agentStore.listAgents()).toHaveLength(10);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it("handles delete then re-create of same name", () => {
|
|
569
|
+
agentStore.createAgent(makeAgentInput({ name: "Recyclable Agent" }));
|
|
570
|
+
agentStore.deleteAgent("recyclable-agent");
|
|
571
|
+
// Should not throw — slot is now free
|
|
572
|
+
const agent = agentStore.createAgent(makeAgentInput({ name: "Recyclable Agent" }));
|
|
573
|
+
expect(agent.id).toBe("recyclable-agent");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("defaults description and cwd to empty string when not provided", () => {
|
|
577
|
+
const agent = agentStore.createAgent(
|
|
578
|
+
makeAgentInput({
|
|
579
|
+
name: "Minimal Agent",
|
|
580
|
+
description: undefined,
|
|
581
|
+
cwd: undefined,
|
|
582
|
+
}),
|
|
583
|
+
);
|
|
584
|
+
expect(agent.description).toBe("");
|
|
585
|
+
expect(agent.cwd).toBe("");
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
|