@desplega.ai/agent-swarm 1.74.4 → 1.76.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/README.md +1 -1
- package/openapi.json +1264 -46
- package/package.json +2 -2
- package/src/be/db.ts +563 -9
- package/src/be/memory/edges-store.ts +69 -0
- package/src/be/memory/providers/sqlite-store.ts +4 -0
- package/src/be/memory/raters/explicit-self.ts +22 -0
- package/src/be/memory/raters/implicit-citation.ts +44 -0
- package/src/be/memory/raters/llm-client.ts +172 -0
- package/src/be/memory/raters/llm-summarizer.ts +218 -0
- package/src/be/memory/raters/llm.ts +375 -0
- package/src/be/memory/raters/noop.ts +14 -0
- package/src/be/memory/raters/registry.ts +86 -0
- package/src/be/memory/raters/retrieval.ts +88 -0
- package/src/be/memory/raters/run-server-raters.ts +97 -0
- package/src/be/memory/raters/store.ts +228 -0
- package/src/be/memory/raters/types.ts +101 -0
- package/src/be/memory/reranker.ts +32 -2
- package/src/be/memory/retrieval-store.ts +116 -0
- package/src/be/memory/types.ts +3 -0
- package/src/be/migrations/051_memory_posteriors_and_retrieval.sql +67 -0
- package/src/be/migrations/052_memory_edges.sql +36 -0
- package/src/be/migrations/053_agent_waiting_for_credentials_status.sql +61 -0
- package/src/be/migrations/054_agent_harness_provider.sql +21 -0
- package/src/be/migrations/055_agent_cred_status.sql +15 -0
- package/src/be/migrations/056_drop_agent_tasks_source_check.sql +139 -0
- package/src/be/migrations/057_inbox_item_state.sql +27 -0
- package/src/be/migrations/058_task_templates.sql +31 -0
- package/src/be/swarm-config-guard.ts +24 -0
- package/src/commands/credential-wait.ts +186 -0
- package/src/commands/provider-credentials.ts +434 -0
- package/src/commands/runner.ts +253 -21
- package/src/hooks/hook.ts +143 -66
- package/src/http/agents.ts +191 -1
- package/src/http/config.ts +11 -1
- package/src/http/core.ts +5 -0
- package/src/http/inbox-state.ts +89 -0
- package/src/http/index.ts +10 -0
- package/src/http/memory.ts +230 -1
- package/src/http/sessions.ts +86 -0
- package/src/http/status.ts +665 -0
- package/src/http/task-templates.ts +51 -0
- package/src/http/tasks.ts +85 -5
- package/src/http/users.ts +134 -0
- package/src/prompts/memories.ts +62 -0
- package/src/providers/claude-adapter.ts +22 -0
- package/src/providers/claude-managed-adapter.ts +24 -0
- package/src/providers/codex-adapter.ts +43 -1
- package/src/providers/devin-adapter.ts +18 -0
- package/src/providers/index.ts +7 -0
- package/src/providers/opencode-adapter.ts +60 -0
- package/src/providers/pi-mono-adapter.ts +71 -0
- package/src/providers/types.ts +34 -0
- package/src/server.ts +2 -0
- package/src/slack/handlers.ts +0 -1
- package/src/tests/agents-harness-provider.test.ts +333 -0
- package/src/tests/credential-check.test.ts +367 -0
- package/src/tests/credential-status-api.test.ts +223 -0
- package/src/tests/credential-status-routing.test.ts +150 -0
- package/src/tests/credential-wait.test.ts +282 -0
- package/src/tests/harness-provider-resolution.test.ts +242 -0
- package/src/tests/jira-sync.test.ts +1 -1
- package/src/tests/memory-edges.test.ts +722 -0
- package/src/tests/memory-rate-endpoint.test.ts +330 -0
- package/src/tests/memory-rate-tool.test.ts +252 -0
- package/src/tests/memory-rater-e2e.test.ts +578 -0
- package/src/tests/memory-rater-implicit-citation.test.ts +304 -0
- package/src/tests/memory-rater-llm-summarizer.test.ts +317 -0
- package/src/tests/memory-rater-llm.test.ts +964 -0
- package/src/tests/memory-rater-store.test.ts +249 -0
- package/src/tests/memory-reranker.test.ts +161 -2
- package/src/tests/migration-runner-regressions.test.ts +17 -2
- package/src/tests/mocks/mock-llm-rater-client.ts +35 -0
- package/src/tests/run-server-raters.test.ts +291 -0
- package/src/tests/sessions.test.ts +141 -0
- package/src/tests/status.test.ts +843 -0
- package/src/tests/stop-hook-task-resolution.test.ts +98 -0
- package/src/tests/template-recommendations.test.ts +148 -0
- package/src/tests/tool-annotations.test.ts +2 -2
- package/src/tests/use-dismissible-card.test.ts +140 -0
- package/src/tools/memory-rate.ts +166 -0
- package/src/tools/memory-search.ts +18 -0
- package/src/tools/store-progress.ts +37 -0
- package/src/tools/swarm-config/set-config.ts +17 -1
- package/src/tools/tool-config.ts +1 -0
- package/src/types.ts +122 -1
- package/src/utils/harness-provider.ts +32 -0
- package/tsconfig.json +0 -2
|
@@ -28,6 +28,8 @@ import { createSwarmHooksExtension } from "./pi-mono-extension";
|
|
|
28
28
|
import { McpHttpClient } from "./pi-mono-mcp-client";
|
|
29
29
|
import type {
|
|
30
30
|
CostData,
|
|
31
|
+
CredCheckOptions,
|
|
32
|
+
CredStatus,
|
|
31
33
|
ProviderAdapter,
|
|
32
34
|
ProviderEvent,
|
|
33
35
|
ProviderResult,
|
|
@@ -35,6 +37,75 @@ import type {
|
|
|
35
37
|
ProviderSessionConfig,
|
|
36
38
|
} from "./types";
|
|
37
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Map a `MODEL_OVERRIDE` string to the env var that satisfies it. Mirrors
|
|
42
|
+
* `resolveModel` above (shortname → anthropic, `provider/model-id` → that
|
|
43
|
+
* provider). Returns `null` when the override is empty (boot-loop should
|
|
44
|
+
* treat it as the permissive case) or the provider can't be inferred.
|
|
45
|
+
*/
|
|
46
|
+
function modelToCredKey(modelStr: string | undefined): string | null {
|
|
47
|
+
if (!modelStr) return null;
|
|
48
|
+
const lower = modelStr.toLowerCase();
|
|
49
|
+
// Hard-coded shortnames map straight to anthropic.
|
|
50
|
+
if (lower === "opus" || lower === "sonnet" || lower === "haiku") {
|
|
51
|
+
return "ANTHROPIC_API_KEY";
|
|
52
|
+
}
|
|
53
|
+
if (modelStr.includes("/")) {
|
|
54
|
+
const provider = modelStr.slice(0, modelStr.indexOf("/")).toLowerCase();
|
|
55
|
+
if (provider === "anthropic") return "ANTHROPIC_API_KEY";
|
|
56
|
+
if (provider === "openrouter") return "OPENROUTER_API_KEY";
|
|
57
|
+
if (provider === "openai") return "OPENAI_API_KEY";
|
|
58
|
+
if (provider === "google") return "GOOGLE_API_KEY";
|
|
59
|
+
}
|
|
60
|
+
// Bare model name with no provider prefix — adapter falls through to a
|
|
61
|
+
// best-effort resolution against multiple providers, so the boot loop
|
|
62
|
+
// accepts any one of them.
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Pi-mono is satisfied by ANY of:
|
|
68
|
+
* 1. `~/.pi/agent/auth.json` exists.
|
|
69
|
+
* 2. `MODEL_OVERRIDE` is set to a provider-prefixed model — only the
|
|
70
|
+
* matching provider's key is required.
|
|
71
|
+
* 3. `MODEL_OVERRIDE` is empty / unprefixed — any one of the supported
|
|
72
|
+
* keys (ANTHROPIC_API_KEY / OPENROUTER_API_KEY / OPENAI_API_KEY) is
|
|
73
|
+
* enough.
|
|
74
|
+
*/
|
|
75
|
+
export function checkPiMonoCredentials(
|
|
76
|
+
env: Record<string, string | undefined>,
|
|
77
|
+
opts: CredCheckOptions = {},
|
|
78
|
+
): CredStatus {
|
|
79
|
+
const homeDir = opts.homeDir ?? env.HOME ?? "/root";
|
|
80
|
+
const probe = opts.fs?.existsSync ?? existsSync;
|
|
81
|
+
const authFile = `${homeDir}/.pi/agent/auth.json`;
|
|
82
|
+
if (probe(authFile)) {
|
|
83
|
+
return { ready: true, missing: [], satisfiedBy: "file" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const requiredKey = modelToCredKey(env.MODEL_OVERRIDE);
|
|
87
|
+
if (requiredKey) {
|
|
88
|
+
if (env[requiredKey]) {
|
|
89
|
+
return { ready: true, missing: [], satisfiedBy: "env" };
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
ready: false,
|
|
93
|
+
missing: [requiredKey, authFile],
|
|
94
|
+
hint: `MODEL_OVERRIDE=${env.MODEL_OVERRIDE} requires ${requiredKey}; or run \`pi auth login\` to create ${authFile}.`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Permissive case: any one supported key works.
|
|
99
|
+
if (env.ANTHROPIC_API_KEY || env.OPENROUTER_API_KEY || env.OPENAI_API_KEY) {
|
|
100
|
+
return { ready: true, missing: [], satisfiedBy: "env" };
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
ready: false,
|
|
104
|
+
missing: ["ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY", authFile],
|
|
105
|
+
hint: "Set one of ANTHROPIC_API_KEY / OPENROUTER_API_KEY / OPENAI_API_KEY (any one suffices), or run `pi auth login` to create ~/.pi/agent/auth.json.",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
38
109
|
/** Convert a JSON Schema object to a TypeBox TSchema using Type.Unsafe */
|
|
39
110
|
function jsonSchemaToTypeBox(schema: Record<string, unknown>): TSchema {
|
|
40
111
|
// Type.Unsafe wraps a plain JSON Schema as a TypeBox-compatible TSchema
|
package/src/providers/types.ts
CHANGED
|
@@ -111,3 +111,37 @@ export interface ProviderAdapter {
|
|
|
111
111
|
canResume(sessionId: string): Promise<boolean>;
|
|
112
112
|
formatCommand(commandName: string): string;
|
|
113
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Status returned by per-adapter `checkCredentials(env, opts)` predicates.
|
|
117
|
+
*
|
|
118
|
+
* `ready=false` means the worker should park in the credential-wait loop
|
|
119
|
+
* (Phase 2). `missing` lists env-var names (or absolute file paths) the
|
|
120
|
+
* adapter would accept; the dashboard surfaces this list as the "blocked
|
|
121
|
+
* on …" hint.
|
|
122
|
+
*
|
|
123
|
+
* `satisfiedBy`:
|
|
124
|
+
* - `'env'` — env-var(s) directly satisfy the adapter
|
|
125
|
+
* - `'file'` — an existing on-disk auth.json was found
|
|
126
|
+
* - `'side-effect-pending'` — env-vars are present but a follow-up step
|
|
127
|
+
* (e.g. `codex login --with-api-key`) still needs to run before the
|
|
128
|
+
* adapter can use them. Workers should treat this as "ready" for the
|
|
129
|
+
* purposes of the boot loop — the side-effect is the entrypoint's job.
|
|
130
|
+
*/
|
|
131
|
+
export interface CredStatus {
|
|
132
|
+
ready: boolean;
|
|
133
|
+
missing: string[];
|
|
134
|
+
satisfiedBy?: "env" | "file" | "side-effect-pending";
|
|
135
|
+
hint?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Options threaded into `checkCredentials` for testability — the codex and
|
|
140
|
+
* pi/opencode predicates probe the filesystem for `~/.codex/auth.json`,
|
|
141
|
+
* `~/.pi/agent/auth.json`, `~/.local/share/opencode/auth.json`. Tests inject
|
|
142
|
+
* a fake `fs` + `homeDir` to exercise the file-vs-env branches deterministically.
|
|
143
|
+
*/
|
|
144
|
+
export interface CredCheckOptions {
|
|
145
|
+
homeDir?: string;
|
|
146
|
+
fs?: { existsSync(p: string): boolean };
|
|
147
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
// Memory capability
|
|
30
30
|
import { registerMemoryDeleteTool } from "./tools/memory-delete";
|
|
31
31
|
import { registerMemoryGetTool } from "./tools/memory-get";
|
|
32
|
+
import { registerMemoryRateTool } from "./tools/memory-rate";
|
|
32
33
|
import { registerMemorySearchTool } from "./tools/memory-search";
|
|
33
34
|
import { registerMyAgentInfoTool } from "./tools/my-agent-info";
|
|
34
35
|
import { registerPollTaskTool } from "./tools/poll-task";
|
|
@@ -241,6 +242,7 @@ export function createServer() {
|
|
|
241
242
|
registerMemorySearchTool(server);
|
|
242
243
|
registerMemoryGetTool(server);
|
|
243
244
|
registerMemoryDeleteTool(server);
|
|
245
|
+
registerMemoryRateTool(server);
|
|
244
246
|
registerInjectLearningTool(server);
|
|
245
247
|
}
|
|
246
248
|
|
package/src/slack/handlers.ts
CHANGED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 1.5 (cloud-personalization): per-agent harness_provider column +
|
|
3
|
+
* worker registration push + PATCH /api/agents/:id/harness-provider.
|
|
4
|
+
*
|
|
5
|
+
* Coverage:
|
|
6
|
+
* - Migration applies cleanly (the test bootstrap runs `initDb` which
|
|
7
|
+
* applies all migrations forward-only; existence of the column is
|
|
8
|
+
* verified via PRAGMA below).
|
|
9
|
+
* - Worker registration with `harness_provider` writes the column.
|
|
10
|
+
* - Re-registration updates the column when the value changes.
|
|
11
|
+
* - `PATCH /api/agents/:id/harness-provider` updates the column.
|
|
12
|
+
* - Invalid provider names rejected with 400.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
16
|
+
import { unlink } from "node:fs/promises";
|
|
17
|
+
import { createServer as createHttpServer, type Server } from "node:http";
|
|
18
|
+
import {
|
|
19
|
+
closeDb,
|
|
20
|
+
createAgent,
|
|
21
|
+
getAgentById,
|
|
22
|
+
getAgentHarnessProviders,
|
|
23
|
+
getDb,
|
|
24
|
+
getSwarmConfigs,
|
|
25
|
+
initDb,
|
|
26
|
+
setAgentHarnessProvider,
|
|
27
|
+
} from "../be/db";
|
|
28
|
+
import { handleAgentRegister, handleAgentsRest } from "../http/agents";
|
|
29
|
+
|
|
30
|
+
const TEST_DB_PATH = "./test-agents-harness-provider.sqlite";
|
|
31
|
+
const TEST_PORT = 13059;
|
|
32
|
+
|
|
33
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
34
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
35
|
+
try {
|
|
36
|
+
await unlink(path + suffix);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeTestServer(): Server {
|
|
44
|
+
return createHttpServer(async (req, res) => {
|
|
45
|
+
const url = new URL(req.url ?? "/", `http://localhost:${TEST_PORT}`);
|
|
46
|
+
const pathSegments = url.pathname.split("/").filter(Boolean);
|
|
47
|
+
const queryParams = url.searchParams;
|
|
48
|
+
const myAgentId = (req.headers["x-agent-id"] as string | undefined) ?? undefined;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
if (await handleAgentRegister(req, res, pathSegments, myAgentId)) return;
|
|
52
|
+
if (await handleAgentsRest(req, res, pathSegments, queryParams, myAgentId)) return;
|
|
53
|
+
} catch (err) {
|
|
54
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
55
|
+
res.end(JSON.stringify({ error: (err as Error).message }));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
59
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let server: Server;
|
|
64
|
+
const baseUrl = `http://localhost:${TEST_PORT}`;
|
|
65
|
+
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
68
|
+
initDb(TEST_DB_PATH);
|
|
69
|
+
server = makeTestServer();
|
|
70
|
+
await new Promise<void>((resolve) => {
|
|
71
|
+
server.listen(TEST_PORT, () => resolve());
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterAll(async () => {
|
|
76
|
+
await new Promise<void>((resolve) => {
|
|
77
|
+
server.close(() => resolve());
|
|
78
|
+
});
|
|
79
|
+
closeDb();
|
|
80
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
// Each test starts on an empty agents table.
|
|
85
|
+
getDb().prepare("DELETE FROM agents").run();
|
|
86
|
+
getDb().prepare("DELETE FROM swarm_config").run();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ─── Migration: column exists ────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
describe("migration 054_agent_harness_provider", () => {
|
|
92
|
+
test("`harness_provider` column exists on the `agents` table", () => {
|
|
93
|
+
const cols = getDb()
|
|
94
|
+
.prepare<{ name: string }, []>(`PRAGMA table_info(agents)`)
|
|
95
|
+
.all()
|
|
96
|
+
.map((r) => r.name);
|
|
97
|
+
expect(cols).toContain("harness_provider");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("existing agent rows default to NULL `harness_provider`", () => {
|
|
101
|
+
const a = createAgent({
|
|
102
|
+
name: "legacy-agent",
|
|
103
|
+
isLead: false,
|
|
104
|
+
status: "idle",
|
|
105
|
+
capabilities: [],
|
|
106
|
+
});
|
|
107
|
+
expect(a.harnessProvider).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ─── DB helpers ──────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
describe("DB helpers", () => {
|
|
114
|
+
test("setAgentHarnessProvider writes and returns the updated row", () => {
|
|
115
|
+
const a = createAgent({ name: "a1", isLead: false, status: "idle", capabilities: [] });
|
|
116
|
+
expect(a.harnessProvider).toBeNull();
|
|
117
|
+
|
|
118
|
+
const updated = setAgentHarnessProvider(a.id, "codex");
|
|
119
|
+
expect(updated?.harnessProvider).toBe("codex");
|
|
120
|
+
|
|
121
|
+
const fetched = getAgentById(a.id);
|
|
122
|
+
expect(fetched?.harnessProvider).toBe("codex");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("setAgentHarnessProvider can clear the column with null", () => {
|
|
126
|
+
const a = createAgent({
|
|
127
|
+
name: "a-clear",
|
|
128
|
+
isLead: false,
|
|
129
|
+
status: "idle",
|
|
130
|
+
capabilities: [],
|
|
131
|
+
harnessProvider: "claude",
|
|
132
|
+
});
|
|
133
|
+
expect(a.harnessProvider).toBe("claude");
|
|
134
|
+
|
|
135
|
+
const updated = setAgentHarnessProvider(a.id, null);
|
|
136
|
+
expect(updated?.harnessProvider).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("setAgentHarnessProvider returns null when agent not found", () => {
|
|
140
|
+
const result = setAgentHarnessProvider("nonexistent-id", "claude");
|
|
141
|
+
expect(result).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("getAgentHarnessProviders aggregates by provider, excluding NULL", () => {
|
|
145
|
+
createAgent({
|
|
146
|
+
name: "x1",
|
|
147
|
+
isLead: false,
|
|
148
|
+
status: "idle",
|
|
149
|
+
capabilities: [],
|
|
150
|
+
harnessProvider: "claude",
|
|
151
|
+
});
|
|
152
|
+
createAgent({
|
|
153
|
+
name: "x2",
|
|
154
|
+
isLead: false,
|
|
155
|
+
status: "idle",
|
|
156
|
+
capabilities: [],
|
|
157
|
+
harnessProvider: "claude",
|
|
158
|
+
});
|
|
159
|
+
createAgent({
|
|
160
|
+
name: "x3",
|
|
161
|
+
isLead: false,
|
|
162
|
+
status: "idle",
|
|
163
|
+
capabilities: [],
|
|
164
|
+
harnessProvider: "codex",
|
|
165
|
+
});
|
|
166
|
+
createAgent({ name: "x4", isLead: false, status: "idle", capabilities: [] }); // NULL — excluded
|
|
167
|
+
|
|
168
|
+
const counts = getAgentHarnessProviders();
|
|
169
|
+
expect(counts).toEqual([
|
|
170
|
+
{ provider: "claude", count: 2 },
|
|
171
|
+
{ provider: "codex", count: 1 },
|
|
172
|
+
]);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ─── Worker registration: HTTP path ──────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
describe("POST /api/agents — worker registration pushes harness_provider", () => {
|
|
179
|
+
test("first-time register persists harness_provider", async () => {
|
|
180
|
+
const agentId = "agent-register-1";
|
|
181
|
+
const res = await fetch(`${baseUrl}/api/agents`, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: { "Content-Type": "application/json", "X-Agent-ID": agentId },
|
|
184
|
+
body: JSON.stringify({
|
|
185
|
+
name: "worker-fresh",
|
|
186
|
+
isLead: false,
|
|
187
|
+
harness_provider: "claude",
|
|
188
|
+
}),
|
|
189
|
+
});
|
|
190
|
+
expect(res.status).toBe(201);
|
|
191
|
+
|
|
192
|
+
const row = getAgentById(agentId);
|
|
193
|
+
expect(row?.harnessProvider).toBe("claude");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("re-register with a different harness_provider updates the column", async () => {
|
|
197
|
+
const agentId = "agent-register-2";
|
|
198
|
+
// First register with claude.
|
|
199
|
+
await fetch(`${baseUrl}/api/agents`, {
|
|
200
|
+
method: "POST",
|
|
201
|
+
headers: { "Content-Type": "application/json", "X-Agent-ID": agentId },
|
|
202
|
+
body: JSON.stringify({ name: "worker-rotating", isLead: false, harness_provider: "claude" }),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Re-register with codex.
|
|
206
|
+
const res = await fetch(`${baseUrl}/api/agents`, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: { "Content-Type": "application/json", "X-Agent-ID": agentId },
|
|
209
|
+
body: JSON.stringify({ name: "worker-rotating", isLead: false, harness_provider: "codex" }),
|
|
210
|
+
});
|
|
211
|
+
expect(res.status).toBe(200);
|
|
212
|
+
|
|
213
|
+
const row = getAgentById(agentId);
|
|
214
|
+
expect(row?.harnessProvider).toBe("codex");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("registration WITHOUT harness_provider leaves an existing column value untouched", async () => {
|
|
218
|
+
const agentId = "agent-register-3";
|
|
219
|
+
// First register with claude.
|
|
220
|
+
await fetch(`${baseUrl}/api/agents`, {
|
|
221
|
+
method: "POST",
|
|
222
|
+
headers: { "Content-Type": "application/json", "X-Agent-ID": agentId },
|
|
223
|
+
body: JSON.stringify({ name: "worker-quiet", isLead: false, harness_provider: "claude" }),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Re-register without harness_provider (older worker).
|
|
227
|
+
const res = await fetch(`${baseUrl}/api/agents`, {
|
|
228
|
+
method: "POST",
|
|
229
|
+
headers: { "Content-Type": "application/json", "X-Agent-ID": agentId },
|
|
230
|
+
body: JSON.stringify({ name: "worker-quiet", isLead: false }),
|
|
231
|
+
});
|
|
232
|
+
expect(res.status).toBe(200);
|
|
233
|
+
|
|
234
|
+
// Existing value preserved (so PATCH overrides aren't clobbered by
|
|
235
|
+
// older workers re-registering without the field).
|
|
236
|
+
const row = getAgentById(agentId);
|
|
237
|
+
expect(row?.harnessProvider).toBe("claude");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("rejects an unknown provider name with 400", async () => {
|
|
241
|
+
const res = await fetch(`${baseUrl}/api/agents`, {
|
|
242
|
+
method: "POST",
|
|
243
|
+
headers: { "Content-Type": "application/json", "X-Agent-ID": "agent-bad" },
|
|
244
|
+
body: JSON.stringify({
|
|
245
|
+
name: "worker-bad-provider",
|
|
246
|
+
isLead: false,
|
|
247
|
+
harness_provider: "rogue-llm",
|
|
248
|
+
}),
|
|
249
|
+
});
|
|
250
|
+
expect(res.status).toBe(400);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ─── PATCH /api/agents/:id/harness-provider ─────────────────────────────────
|
|
255
|
+
|
|
256
|
+
describe("PATCH /api/agents/:id/harness-provider", () => {
|
|
257
|
+
test("updates the column on a known agent", async () => {
|
|
258
|
+
const a = createAgent({
|
|
259
|
+
name: "patch-target-1",
|
|
260
|
+
isLead: false,
|
|
261
|
+
status: "idle",
|
|
262
|
+
capabilities: [],
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const res = await fetch(`${baseUrl}/api/agents/${a.id}/harness-provider`, {
|
|
266
|
+
method: "PATCH",
|
|
267
|
+
headers: { "Content-Type": "application/json" },
|
|
268
|
+
body: JSON.stringify({ harness_provider: "codex" }),
|
|
269
|
+
});
|
|
270
|
+
expect(res.status).toBe(200);
|
|
271
|
+
|
|
272
|
+
const row = getAgentById(a.id);
|
|
273
|
+
expect(row?.harnessProvider).toBe("codex");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("rejects unknown provider names with 400", async () => {
|
|
277
|
+
const a = createAgent({
|
|
278
|
+
name: "patch-target-2",
|
|
279
|
+
isLead: false,
|
|
280
|
+
status: "idle",
|
|
281
|
+
capabilities: [],
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const res = await fetch(`${baseUrl}/api/agents/${a.id}/harness-provider`, {
|
|
285
|
+
method: "PATCH",
|
|
286
|
+
headers: { "Content-Type": "application/json" },
|
|
287
|
+
body: JSON.stringify({ harness_provider: "rogue" }),
|
|
288
|
+
});
|
|
289
|
+
expect(res.status).toBe(400);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("returns 404 when agent does not exist", async () => {
|
|
293
|
+
const res = await fetch(`${baseUrl}/api/agents/nonexistent-agent-id/harness-provider`, {
|
|
294
|
+
method: "PATCH",
|
|
295
|
+
headers: { "Content-Type": "application/json" },
|
|
296
|
+
body: JSON.stringify({ harness_provider: "claude" }),
|
|
297
|
+
});
|
|
298
|
+
expect(res.status).toBe(404);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("PATCH also upserts swarm_config (scope=agent) so the worker reconciles", async () => {
|
|
302
|
+
const a = createAgent({
|
|
303
|
+
name: "patch-target-3",
|
|
304
|
+
isLead: false,
|
|
305
|
+
status: "idle",
|
|
306
|
+
capabilities: [],
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const res = await fetch(`${baseUrl}/api/agents/${a.id}/harness-provider`, {
|
|
310
|
+
method: "PATCH",
|
|
311
|
+
headers: { "Content-Type": "application/json" },
|
|
312
|
+
body: JSON.stringify({ harness_provider: "codex" }),
|
|
313
|
+
});
|
|
314
|
+
expect(res.status).toBe(200);
|
|
315
|
+
|
|
316
|
+
const rows = getSwarmConfigs({ scope: "agent", scopeId: a.id });
|
|
317
|
+
const harnessRow = rows.find((r) => r.key === "HARNESS_PROVIDER");
|
|
318
|
+
expect(harnessRow?.value).toBe("codex");
|
|
319
|
+
|
|
320
|
+
// Subsequent PATCH (different value) updates the row in place.
|
|
321
|
+
const res2 = await fetch(`${baseUrl}/api/agents/${a.id}/harness-provider`, {
|
|
322
|
+
method: "PATCH",
|
|
323
|
+
headers: { "Content-Type": "application/json" },
|
|
324
|
+
body: JSON.stringify({ harness_provider: "claude" }),
|
|
325
|
+
});
|
|
326
|
+
expect(res2.status).toBe(200);
|
|
327
|
+
|
|
328
|
+
const rows2 = getSwarmConfigs({ scope: "agent", scopeId: a.id });
|
|
329
|
+
const harnessRow2 = rows2.find((r) => r.key === "HARNESS_PROVIDER");
|
|
330
|
+
expect(harnessRow2?.value).toBe("claude");
|
|
331
|
+
expect(rows2.filter((r) => r.key === "HARNESS_PROVIDER")).toHaveLength(1);
|
|
332
|
+
});
|
|
333
|
+
});
|