@desplega.ai/agent-swarm 1.77.0 → 1.77.2

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/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.77.0",
5
+ "version": "1.77.2",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
@@ -448,6 +448,73 @@
448
448
  }
449
449
  }
450
450
  },
451
+ "/api/agents/{id}/runtime": {
452
+ "patch": {
453
+ "summary": "Update an agent's runtime harness and default model",
454
+ "description": "Updates `agents.harness_provider` and upserts agent-scoped `swarm_config` rows for HARNESS_PROVIDER and MODEL_OVERRIDE. The settings apply to future provider sessions.",
455
+ "tags": [
456
+ "Agents"
457
+ ],
458
+ "security": [
459
+ {
460
+ "bearerAuth": []
461
+ }
462
+ ],
463
+ "parameters": [
464
+ {
465
+ "schema": {
466
+ "type": "string"
467
+ },
468
+ "required": true,
469
+ "name": "id",
470
+ "in": "path"
471
+ }
472
+ ],
473
+ "requestBody": {
474
+ "content": {
475
+ "application/json": {
476
+ "schema": {
477
+ "type": "object",
478
+ "properties": {
479
+ "harness_provider": {
480
+ "type": "string",
481
+ "enum": [
482
+ "claude",
483
+ "codex",
484
+ "pi",
485
+ "opencode"
486
+ ]
487
+ },
488
+ "model": {
489
+ "type": "string",
490
+ "minLength": 1
491
+ },
492
+ "allow_custom_model": {
493
+ "type": "boolean",
494
+ "default": false
495
+ }
496
+ },
497
+ "required": [
498
+ "harness_provider",
499
+ "model"
500
+ ]
501
+ }
502
+ }
503
+ }
504
+ },
505
+ "responses": {
506
+ "200": {
507
+ "description": "Updated agent row"
508
+ },
509
+ "400": {
510
+ "description": "Validation error"
511
+ },
512
+ "404": {
513
+ "description": "Agent not found"
514
+ }
515
+ }
516
+ }
517
+ },
451
518
  "/api/agents/{id}/name": {
452
519
  "put": {
453
520
  "summary": "Update agent name",
@@ -797,6 +864,59 @@
797
864
  "testedAt"
798
865
  ]
799
866
  },
867
+ "latestModel": {
868
+ "type": [
869
+ "object",
870
+ "null"
871
+ ],
872
+ "properties": {
873
+ "model": {
874
+ "type": "string",
875
+ "minLength": 1
876
+ },
877
+ "source": {
878
+ "type": "string",
879
+ "enum": [
880
+ "task",
881
+ "agent_config",
882
+ "adapter_default",
883
+ "custom"
884
+ ]
885
+ },
886
+ "taskId": {
887
+ "type": [
888
+ "string",
889
+ "null"
890
+ ],
891
+ "default": null
892
+ },
893
+ "harnessProvider": {
894
+ "type": [
895
+ "string",
896
+ "null"
897
+ ],
898
+ "enum": [
899
+ "claude",
900
+ "codex",
901
+ "pi",
902
+ "devin",
903
+ "claude-managed",
904
+ "opencode",
905
+ null
906
+ ],
907
+ "default": null
908
+ },
909
+ "reportedAt": {
910
+ "type": "number"
911
+ }
912
+ },
913
+ "default": null,
914
+ "required": [
915
+ "model",
916
+ "source",
917
+ "reportedAt"
918
+ ]
919
+ },
800
920
  "reportedAt": {
801
921
  "type": "number"
802
922
  },
@@ -813,11 +933,57 @@
813
933
  "ready",
814
934
  "reportedAt"
815
935
  ]
936
+ },
937
+ "latest_model": {
938
+ "type": "object",
939
+ "properties": {
940
+ "model": {
941
+ "type": "string",
942
+ "minLength": 1
943
+ },
944
+ "source": {
945
+ "type": "string",
946
+ "enum": [
947
+ "task",
948
+ "agent_config",
949
+ "adapter_default",
950
+ "custom"
951
+ ]
952
+ },
953
+ "taskId": {
954
+ "type": [
955
+ "string",
956
+ "null"
957
+ ],
958
+ "default": null
959
+ },
960
+ "harnessProvider": {
961
+ "type": [
962
+ "string",
963
+ "null"
964
+ ],
965
+ "enum": [
966
+ "claude",
967
+ "codex",
968
+ "pi",
969
+ "devin",
970
+ "claude-managed",
971
+ "opencode",
972
+ null
973
+ ],
974
+ "default": null
975
+ },
976
+ "reportedAt": {
977
+ "type": "number"
978
+ }
979
+ },
980
+ "required": [
981
+ "model",
982
+ "source",
983
+ "reportedAt"
984
+ ]
816
985
  }
817
- },
818
- "required": [
819
- "ready"
820
- ]
986
+ }
821
987
  }
822
988
  }
823
989
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.77.0",
3
+ "version": "1.77.2",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -25,7 +25,7 @@ import { checkDevinCredentials } from "../providers/devin-adapter";
25
25
  import { checkOpencodeCredentials } from "../providers/opencode-adapter";
26
26
  import { checkPiMonoCredentials } from "../providers/pi-mono-adapter";
27
27
  import type { CredCheckOptions, CredStatus } from "../providers/types";
28
- import type { AgentCredStatus } from "../types";
28
+ import type { AgentCredStatus, AgentLatestModel, ProviderName } from "../types";
29
29
  import { scrubSecrets } from "../utils/secret-scrubber";
30
30
 
31
31
  export type SupportedProvider = "claude" | "claude-managed" | "codex" | "devin" | "opencode" | "pi";
@@ -392,6 +392,7 @@ export async function buildCredStatusReport(
392
392
  satisfiedBy: presence.satisfiedBy ?? null,
393
393
  hint: presence.hint ?? null,
394
394
  liveTest,
395
+ latestModel: null,
395
396
  reportedAt: Date.now(),
396
397
  reportKind: kind,
397
398
  };
@@ -432,3 +433,53 @@ export async function reportCredStatus(
432
433
  console.warn(`[cred-status] POST failed (non-fatal): ${err}`);
433
434
  }
434
435
  }
436
+
437
+ export async function reportLatestModel(
438
+ apiUrl: string,
439
+ apiKey: string,
440
+ agentId: string,
441
+ latestModel: AgentLatestModel,
442
+ ): Promise<void> {
443
+ try {
444
+ await fetch(`${apiUrl}/api/agents/${encodeURIComponent(agentId)}/credential-status`, {
445
+ method: "PUT",
446
+ headers: {
447
+ Authorization: `Bearer ${apiKey}`,
448
+ "X-Agent-ID": agentId,
449
+ "Content-Type": "application/json",
450
+ },
451
+ body: JSON.stringify({
452
+ latest_model: latestModel,
453
+ }),
454
+ });
455
+ } catch (err) {
456
+ console.warn(`[latest-model] POST failed (non-fatal): ${err}`);
457
+ }
458
+ }
459
+
460
+ export function buildLatestModelReport(opts: {
461
+ model: string;
462
+ taskModel?: string;
463
+ configModel?: string;
464
+ taskId?: string;
465
+ harnessProvider: ProviderName;
466
+ }): AgentLatestModel | null {
467
+ const model = opts.model.trim();
468
+ if (!model) return null;
469
+ const taskModel = opts.taskModel?.trim();
470
+ const configModel = opts.configModel?.trim();
471
+ return {
472
+ model,
473
+ source:
474
+ taskModel && model === taskModel
475
+ ? "task"
476
+ : configModel && model === configModel
477
+ ? "agent_config"
478
+ : taskModel || configModel
479
+ ? "custom"
480
+ : "adapter_default",
481
+ taskId: opts.taskId ?? null,
482
+ harnessProvider: opts.harnessProvider,
483
+ reportedAt: Date.now(),
484
+ };
485
+ }
@@ -33,8 +33,10 @@ import { interpolate } from "../workflows/template.ts";
33
33
  import { awaitCredentials, BootMaxWaitExceededError, EX_CONFIG } from "./credential-wait.ts";
34
34
  import {
35
35
  buildCredStatusReport,
36
+ buildLatestModelReport,
36
37
  isCredCheckDisabled,
37
38
  reportCredStatus,
39
+ reportLatestModel,
38
40
  } from "./provider-credentials.ts";
39
41
  // Side-effect import: registers runner trigger/resumption templates
40
42
  import "./templates.ts";
@@ -415,13 +417,19 @@ export function humanizeToolName(name: string): string {
415
417
  export function toolCallToProgress(toolName: string, args: unknown): string | null {
416
418
  if (SKIP_PROGRESS_TOOLS.has(toolName)) return null;
417
419
 
420
+ const a = args as Record<string, unknown>;
421
+ const maybeMcpServer = typeof a?.server === "string" ? a.server : undefined;
422
+ const maybeMcpTool = typeof a?.tool === "string" ? a.tool : undefined;
423
+ const effectiveToolName =
424
+ maybeMcpServer && maybeMcpTool ? `mcp__${maybeMcpServer}__${maybeMcpTool}` : toolName;
425
+ if (SKIP_PROGRESS_TOOLS.has(effectiveToolName)) return null;
426
+
418
427
  // Normalize: pi-mono uses lowercase ("read"), Claude uses PascalCase ("Read")
419
428
  const normalized =
420
- toolName.startsWith("mcp__") || toolName.includes("_")
421
- ? toolName
422
- : toolName.charAt(0).toUpperCase() + toolName.slice(1);
429
+ effectiveToolName.startsWith("mcp__") || effectiveToolName.includes("_")
430
+ ? effectiveToolName
431
+ : effectiveToolName.charAt(0).toUpperCase() + effectiveToolName.slice(1);
423
432
 
424
- const a = args as Record<string, unknown>;
425
433
  const shortPath = (p: unknown) => {
426
434
  if (typeof p !== "string") return "";
427
435
  // Show last 2 path segments for readability
@@ -450,8 +458,8 @@ export function toolCallToProgress(toolName: string, args: unknown): string | nu
450
458
  return `⚙️ Running /${a.skill}`;
451
459
  default: {
452
460
  // MCP tools: mcp__server__tool
453
- if (toolName.startsWith("mcp__")) {
454
- const parts = toolName.split("__");
461
+ if (effectiveToolName.startsWith("mcp__")) {
462
+ const parts = effectiveToolName.split("__");
455
463
  if (parts.length >= 3) {
456
464
  const server = parts[1];
457
465
  const tool = parts.slice(2).join("__");
@@ -465,8 +473,18 @@ export function toolCallToProgress(toolName: string, args: unknown): string | nu
465
473
  // Other MCP servers: "🔌 server: Humanized tool"
466
474
  return `🔌 ${server}: ${humanizeToolName(tool)}`;
467
475
  }
468
- return `🔌 ${toolName}`;
476
+ return `🔌 ${effectiveToolName}`;
477
+ }
478
+
479
+ // Pi-mono exposes tools from the built-in swarm MCP endpoint as bare
480
+ // names ("store-progress", "send-task", ...), not as mcp__ names.
481
+ // Treat those names as agent-swarm tools so activity stays readable.
482
+ if (toolName.includes("-")) {
483
+ const label = SWARM_TOOL_LABELS[toolName];
484
+ if (label === null) return null;
485
+ if (label) return label;
469
486
  }
487
+
470
488
  return `🔧 ${toolName}`;
471
489
  }
472
490
  }
@@ -1736,6 +1754,7 @@ async function spawnProviderProcess(
1736
1754
  iteration: number;
1737
1755
  taskId?: string;
1738
1756
  model?: string;
1757
+ harnessProvider: ProviderName;
1739
1758
  cwd?: string;
1740
1759
  vcsRepo?: string;
1741
1760
  },
@@ -1767,7 +1786,8 @@ async function spawnProviderProcess(
1767
1786
  process.env.AGENT_FS_SHARED_ORG_ID = freshEnv.AGENT_FS_SHARED_ORG_ID as string;
1768
1787
  }
1769
1788
 
1770
- const model = opts.model || (freshEnv.MODEL_OVERRIDE as string) || "";
1789
+ const configModel = (freshEnv.MODEL_OVERRIDE as string | undefined) || "";
1790
+ const model = opts.model || configModel || "";
1771
1791
 
1772
1792
  const config: ProviderSessionConfig = {
1773
1793
  prompt: opts.prompt,
@@ -1787,6 +1807,18 @@ async function spawnProviderProcess(
1787
1807
  };
1788
1808
 
1789
1809
  const session = await adapter.createSession(config);
1810
+ const initialModelReport = buildLatestModelReport({
1811
+ model,
1812
+ taskModel: opts.model,
1813
+ configModel,
1814
+ taskId: realTaskId,
1815
+ harnessProvider: opts.harnessProvider,
1816
+ });
1817
+ if (initialModelReport) {
1818
+ reportLatestModel(opts.apiUrl, opts.apiKey, opts.agentId, initialModelReport).catch((err) =>
1819
+ console.warn(`[runner] Failed to report latest model: ${err}`),
1820
+ );
1821
+ }
1790
1822
 
1791
1823
  let oauthSelection: CredentialSelection | undefined;
1792
1824
  if (adapter.name === "codex" && credentialSelections.length === 0) {
@@ -1944,6 +1976,20 @@ async function spawnProviderProcess(
1944
1976
  break;
1945
1977
  }
1946
1978
  case "result":
1979
+ {
1980
+ const latestModel = buildLatestModelReport({
1981
+ model: event.cost.model,
1982
+ taskModel: opts.model,
1983
+ configModel,
1984
+ taskId: realTaskId,
1985
+ harnessProvider: opts.harnessProvider,
1986
+ });
1987
+ if (latestModel) {
1988
+ reportLatestModel(opts.apiUrl, opts.apiKey, opts.agentId, latestModel).catch((err) =>
1989
+ console.warn(`[runner] Failed to report latest model: ${err}`),
1990
+ );
1991
+ }
1992
+ }
1947
1993
  // Cost save is handled in waitForCompletion().then() to ensure
1948
1994
  // it completes before the process exits (fire-and-forget here
1949
1995
  // races with container shutdown).
@@ -3190,6 +3236,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3190
3236
  iteration,
3191
3237
  taskId: task.id,
3192
3238
  model: (task as { model?: string }).model,
3239
+ harnessProvider: state.harnessProvider,
3193
3240
  cwd: resumeCwd,
3194
3241
  vcsRepo: task.vcsRepo,
3195
3242
  },
@@ -3589,6 +3636,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3589
3636
  iteration,
3590
3637
  taskId: trigger.taskId,
3591
3638
  model: taskModel,
3639
+ harnessProvider: state.harnessProvider,
3592
3640
  cwd: effectiveCwd,
3593
3641
  vcsRepo: taskVcsRepo,
3594
3642
  },
@@ -21,7 +21,12 @@ import {
21
21
  updateAgentStatus,
22
22
  upsertSwarmConfig,
23
23
  } from "../be/db";
24
- import { AgentCredStatusSchema, ProviderNameSchema } from "../types";
24
+ import {
25
+ AgentCredStatusSchema,
26
+ AgentLatestModelSchema,
27
+ type ProviderName,
28
+ ProviderNameSchema,
29
+ } from "../types";
25
30
  import { route } from "./route-def";
26
31
  import { agentWithCapacity, json, jsonError } from "./utils";
27
32
 
@@ -74,6 +79,29 @@ const setAgentHarnessProviderRoute = route({
74
79
  },
75
80
  });
76
81
 
82
+ const LocalHarnessProviderSchema = z.enum(["claude", "codex", "pi", "opencode"]);
83
+
84
+ const updateAgentRuntimeRoute = route({
85
+ method: "patch",
86
+ path: "/api/agents/{id}/runtime",
87
+ pattern: ["api", "agents", null, "runtime"],
88
+ summary: "Update an agent's runtime harness and default model",
89
+ description:
90
+ "Updates `agents.harness_provider` and upserts agent-scoped `swarm_config` rows for HARNESS_PROVIDER and MODEL_OVERRIDE. The settings apply to future provider sessions.",
91
+ tags: ["Agents"],
92
+ params: z.object({ id: z.string() }),
93
+ body: z.object({
94
+ harness_provider: LocalHarnessProviderSchema,
95
+ model: z.string().trim().min(1),
96
+ allow_custom_model: z.boolean().optional().default(false),
97
+ }),
98
+ responses: {
99
+ 200: { description: "Updated agent row" },
100
+ 400: { description: "Validation error" },
101
+ 404: { description: "Agent not found" },
102
+ },
103
+ });
104
+
77
105
  const listAgents = route({
78
106
  method: "get",
79
107
  path: "/api/agents",
@@ -175,7 +203,7 @@ const getAgent = route({
175
203
  // ─── Credential-status (Phase 3 + 4 of the credential safe-loop plan) ───────
176
204
 
177
205
  const credentialStatusBody = z.object({
178
- ready: z.boolean(),
206
+ ready: z.boolean().optional(),
179
207
  /** Env-var names (or absolute file paths) the worker is blocked on. Empty/null when ready. */
180
208
  missing: z.array(z.string()).optional().nullable(),
181
209
  /**
@@ -185,6 +213,11 @@ const credentialStatusBody = z.object({
185
213
  * reads the row instead of running its own check.
186
214
  */
187
215
  cred_status: AgentCredStatusSchema.optional().nullable(),
216
+ /**
217
+ * Worker-reported latest model telemetry. Optional and merge-only: when sent
218
+ * without `cred_status`, the API preserves existing readiness/live-test data.
219
+ */
220
+ latest_model: AgentLatestModelSchema.optional(),
188
221
  });
189
222
 
190
223
  const updateAgentCredentialStatusRoute = route({
@@ -468,6 +501,41 @@ export async function handleAgentsRest(
468
501
  return true;
469
502
  }
470
503
 
504
+ if (updateAgentRuntimeRoute.match(req.method, pathSegments)) {
505
+ const parsed = await updateAgentRuntimeRoute.parse(req, res, pathSegments, queryParams);
506
+ if (!parsed) return true;
507
+ const agent = getDb().transaction(() => {
508
+ const updated = setAgentHarnessProvider(
509
+ parsed.params.id,
510
+ parsed.body.harness_provider as ProviderName,
511
+ );
512
+ if (!updated) return null;
513
+ upsertSwarmConfig({
514
+ scope: "agent",
515
+ scopeId: parsed.params.id,
516
+ key: "HARNESS_PROVIDER",
517
+ value: parsed.body.harness_provider,
518
+ description: "Set via PATCH /api/agents/{id}/runtime",
519
+ });
520
+ upsertSwarmConfig({
521
+ scope: "agent",
522
+ scopeId: parsed.params.id,
523
+ key: "MODEL_OVERRIDE",
524
+ value: parsed.body.model,
525
+ description: parsed.body.allow_custom_model
526
+ ? "Custom model set via PATCH /api/agents/{id}/runtime"
527
+ : "Set via PATCH /api/agents/{id}/runtime",
528
+ });
529
+ return updated;
530
+ })();
531
+ if (!agent) {
532
+ jsonError(res, "Agent not found", 404);
533
+ return true;
534
+ }
535
+ json(res, agentWithCapacity(agent));
536
+ return true;
537
+ }
538
+
471
539
  // Bulk credential-status MUST be matched BEFORE single-agent routes — the
472
540
  // path "api/agents/credential-status" otherwise looks like an agent id.
473
541
  if (listCredentialStatusRoute.match(req.method, pathSegments)) {
@@ -498,11 +566,19 @@ export async function handleAgentsRest(
498
566
  queryParams,
499
567
  );
500
568
  if (!parsed) return true;
501
- const agent = updateAgentCredentialState(
502
- parsed.params.id,
503
- parsed.body.ready,
504
- parsed.body.missing ?? null,
505
- );
569
+ const existing = getAgentById(parsed.params.id);
570
+ if (!existing) {
571
+ jsonError(res, "Agent not found", 404);
572
+ return true;
573
+ }
574
+ const agent =
575
+ parsed.body.ready !== undefined
576
+ ? (updateAgentCredentialState(
577
+ parsed.params.id,
578
+ parsed.body.ready,
579
+ parsed.body.missing ?? null,
580
+ ) ?? existing)
581
+ : existing;
506
582
  if (!agent) {
507
583
  jsonError(res, "Agent not found", 404);
508
584
  return true;
@@ -510,10 +586,36 @@ export async function handleAgentsRest(
510
586
  // Phase 055: persist the richer worker-reported snapshot when sent.
511
587
  // We accept `null` to explicitly clear (e.g. on harness change), and
512
588
  // `undefined` to leave the existing row value untouched.
513
- const finalAgent =
514
- parsed.body.cred_status !== undefined
515
- ? (updateAgentCredStatus(parsed.params.id, parsed.body.cred_status ?? null) ?? agent)
516
- : agent;
589
+ let finalAgent = agent;
590
+ if (parsed.body.cred_status !== undefined) {
591
+ const nextStatus = parsed.body.cred_status
592
+ ? {
593
+ ...parsed.body.cred_status,
594
+ latestModel:
595
+ parsed.body.latest_model ??
596
+ parsed.body.cred_status.latestModel ??
597
+ agent.credStatus?.latestModel ??
598
+ null,
599
+ }
600
+ : null;
601
+ finalAgent = updateAgentCredStatus(parsed.params.id, nextStatus) ?? agent;
602
+ } else if (parsed.body.latest_model) {
603
+ const current = agent.credStatus ?? {
604
+ ready: parsed.body.ready ?? true,
605
+ missing: parsed.body.missing ?? [],
606
+ satisfiedBy: null,
607
+ hint: null,
608
+ liveTest: null,
609
+ latestModel: null,
610
+ reportedAt: parsed.body.latest_model.reportedAt,
611
+ reportKind: "post_task" as const,
612
+ };
613
+ finalAgent =
614
+ updateAgentCredStatus(parsed.params.id, {
615
+ ...current,
616
+ latestModel: parsed.body.latest_model,
617
+ }) ?? agent;
618
+ }
517
619
  json(res, agentWithCapacity(finalAgent));
518
620
  return true;
519
621
  }
@@ -28,7 +28,7 @@ import {
28
28
  import { handleAgentRegister, handleAgentsRest } from "../http/agents";
29
29
 
30
30
  const TEST_DB_PATH = "./test-agents-harness-provider.sqlite";
31
- const TEST_PORT = 13059;
31
+ const TEST_PORT = 13059 + (process.pid % 1000);
32
32
 
33
33
  async function removeDbFiles(path: string): Promise<void> {
34
34
  for (const suffix of ["", "-wal", "-shm"]) {
@@ -42,7 +42,7 @@ async function removeDbFiles(path: string): Promise<void> {
42
42
 
43
43
  function makeTestServer(): Server {
44
44
  return createHttpServer(async (req, res) => {
45
- const url = new URL(req.url ?? "/", `http://localhost:${TEST_PORT}`);
45
+ const url = new URL(req.url ?? "/", "http://localhost");
46
46
  const pathSegments = url.pathname.split("/").filter(Boolean);
47
47
  const queryParams = url.searchParams;
48
48
  const myAgentId = (req.headers["x-agent-id"] as string | undefined) ?? undefined;
@@ -331,3 +331,44 @@ describe("PATCH /api/agents/:id/harness-provider", () => {
331
331
  expect(rows2.filter((r) => r.key === "HARNESS_PROVIDER")).toHaveLength(1);
332
332
  });
333
333
  });
334
+
335
+ describe("PATCH /api/agents/:id/runtime", () => {
336
+ test("updates harness_provider and agent-scoped runtime config rows", async () => {
337
+ const a = createAgent({
338
+ name: "runtime-target-1",
339
+ isLead: false,
340
+ status: "idle",
341
+ capabilities: [],
342
+ });
343
+
344
+ const res = await fetch(`${baseUrl}/api/agents/${a.id}/runtime`, {
345
+ method: "PATCH",
346
+ headers: { "Content-Type": "application/json" },
347
+ body: JSON.stringify({ harness_provider: "codex", model: "gpt-5.4" }),
348
+ });
349
+ expect(res.status).toBe(200);
350
+
351
+ const row = getAgentById(a.id);
352
+ expect(row?.harnessProvider).toBe("codex");
353
+
354
+ const rows = getSwarmConfigs({ scope: "agent", scopeId: a.id });
355
+ expect(rows.find((r) => r.key === "HARNESS_PROVIDER")?.value).toBe("codex");
356
+ expect(rows.find((r) => r.key === "MODEL_OVERRIDE")?.value).toBe("gpt-5.4");
357
+ });
358
+
359
+ test("rejects non-local harnesses for runtime editing", async () => {
360
+ const a = createAgent({
361
+ name: "runtime-target-2",
362
+ isLead: false,
363
+ status: "idle",
364
+ capabilities: [],
365
+ });
366
+
367
+ const res = await fetch(`${baseUrl}/api/agents/${a.id}/runtime`, {
368
+ method: "PATCH",
369
+ headers: { "Content-Type": "application/json" },
370
+ body: JSON.stringify({ harness_provider: "devin", model: "devin" }),
371
+ });
372
+ expect(res.status).toBe(400);
373
+ });
374
+ });
@@ -20,7 +20,7 @@ import { getPathSegments, parseQueryParams } from "../http/utils";
20
20
  */
21
21
 
22
22
  const TEST_DB_PATH = "./test-credential-status-api.sqlite";
23
- const TEST_PORT = 13041;
23
+ const TEST_PORT = 13041 + (process.pid % 1000);
24
24
 
25
25
  function createTestServer(): Server {
26
26
  return createHttpServer(async (req, res) => {
@@ -220,4 +220,43 @@ describe("Phase 4 — credential-status HTTP endpoints", () => {
220
220
  });
221
221
  expect(resp.status).toBe(400);
222
222
  });
223
+
224
+ test("PUT /credential-status merges latest_model without clobbering readiness", async () => {
225
+ const snapshot = {
226
+ ready: true,
227
+ missing: [],
228
+ satisfiedBy: "env" as const,
229
+ hint: null,
230
+ liveTest: { ok: true, error: null, latency_ms: 45, testedAt: Date.now() },
231
+ reportedAt: Date.now(),
232
+ reportKind: "boot" as const,
233
+ };
234
+ await fetch(`${baseUrl}/api/agents/${readyAgentId}/credential-status`, {
235
+ method: "PUT",
236
+ headers: { "Content-Type": "application/json" },
237
+ body: JSON.stringify({ ready: true, missing: [], cred_status: snapshot }),
238
+ });
239
+
240
+ const modelReport = {
241
+ model: "gpt-5.4",
242
+ source: "agent_config" as const,
243
+ taskId: "task-123",
244
+ harnessProvider: "codex" as const,
245
+ reportedAt: Date.now(),
246
+ };
247
+ const put = await fetch(`${baseUrl}/api/agents/${readyAgentId}/credential-status`, {
248
+ method: "PUT",
249
+ headers: { "Content-Type": "application/json" },
250
+ body: JSON.stringify({ latest_model: modelReport }),
251
+ });
252
+ expect(put.status).toBe(200);
253
+
254
+ const get = await fetch(`${baseUrl}/api/agents/${readyAgentId}/credential-status`);
255
+ const body = (await get.json()) as {
256
+ credStatus: typeof snapshot & { latestModel?: typeof modelReport };
257
+ };
258
+ expect(body.credStatus.ready).toBe(true);
259
+ expect(body.credStatus.liveTest).toMatchObject({ ok: true, latency_ms: 45 });
260
+ expect(body.credStatus.latestModel).toMatchObject({ model: "gpt-5.4", source: "agent_config" });
261
+ });
223
262
  });
@@ -175,6 +175,50 @@ describe("toolCallToProgress", () => {
175
175
  expect(result).toBe("🔌 context7: Query docs");
176
176
  });
177
177
 
178
+ // --- Provider-normalized MCP variants ---
179
+
180
+ test("bare agent-swarm store-progress is skipped", () => {
181
+ expect(toolCallToProgress("store-progress", {})).toBeNull();
182
+ });
183
+
184
+ test("bare agent-swarm send-task has pretty label", () => {
185
+ const result = toolCallToProgress("send-task", {});
186
+ expect(result).toBe("📤 Delegating task");
187
+ });
188
+
189
+ test("bare agent-swarm db-query has pretty label", () => {
190
+ const result = toolCallToProgress("db-query", {});
191
+ expect(result).toBe("🗃️ Querying database");
192
+ });
193
+
194
+ test("codex MCP args with agent-swarm server use pretty labels", () => {
195
+ const result = toolCallToProgress("send-task", {
196
+ server: "agent-swarm",
197
+ tool: "send-task",
198
+ arguments: { task: "ping" },
199
+ });
200
+ expect(result).toBe("📤 Delegating task");
201
+ });
202
+
203
+ test("codex MCP args with agent-swarm store-progress are skipped", () => {
204
+ expect(
205
+ toolCallToProgress("store-progress", {
206
+ server: "agent-swarm",
207
+ tool: "store-progress",
208
+ arguments: { status: "completed" },
209
+ }),
210
+ ).toBeNull();
211
+ });
212
+
213
+ test("codex MCP args with external server keep server prefix", () => {
214
+ const result = toolCallToProgress("list-issues", {
215
+ server: "linear",
216
+ tool: "list-issues",
217
+ arguments: {},
218
+ });
219
+ expect(result).toBe("🔌 linear: List issues");
220
+ });
221
+
178
222
  // --- Short path helper (tested implicitly) ---
179
223
 
180
224
  test("Read with short path (<=2 segments) keeps full path", () => {
package/src/types.ts CHANGED
@@ -386,12 +386,22 @@ export const AgentCredStatusLiveTestSchema = z.object({
386
386
  });
387
387
  export type AgentCredStatusLiveTest = z.infer<typeof AgentCredStatusLiveTestSchema>;
388
388
 
389
+ export const AgentLatestModelSchema = z.object({
390
+ model: z.string().min(1),
391
+ source: z.enum(["task", "agent_config", "adapter_default", "custom"]),
392
+ taskId: z.string().nullable().default(null),
393
+ harnessProvider: ProviderNameSchema.nullable().default(null),
394
+ reportedAt: z.number(), // unix ms
395
+ });
396
+ export type AgentLatestModel = z.infer<typeof AgentLatestModelSchema>;
397
+
389
398
  export const AgentCredStatusSchema = z.object({
390
399
  ready: z.boolean(),
391
400
  missing: z.array(z.string()).default([]),
392
401
  satisfiedBy: z.enum(["env", "file", "side-effect-pending"]).nullable().default(null),
393
402
  hint: z.string().nullable().default(null),
394
403
  liveTest: AgentCredStatusLiveTestSchema.nullable().default(null),
404
+ latestModel: AgentLatestModelSchema.nullable().default(null),
395
405
  reportedAt: z.number(), // unix ms
396
406
  reportKind: z.enum(["boot", "post_task"]).default("boot"),
397
407
  });