@desplega.ai/agent-swarm 1.77.1 → 1.77.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/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.3",
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.1",
3
+ "version": "1.77.3",
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>",
package/src/be/db.ts CHANGED
@@ -1411,9 +1411,14 @@ export function getAllTasks(filters?: TaskFilters): AgentTask[] {
1411
1411
  params.push(filters.createdAfter);
1412
1412
  }
1413
1413
 
1414
- // Exclude heartbeat tasks by default
1414
+ // Exclude system/heartbeat tasks by default. The flag is still called
1415
+ // `includeHeartbeat` for backward compat with existing API callers, but we
1416
+ // also gate boot-triage + heartbeat-checklist behind it since those are
1417
+ // equally noisy in the dashboard task list.
1415
1418
  if (!filters?.includeHeartbeat) {
1416
- conditions.push("(IFNULL(taskType, '') != 'heartbeat' AND tags NOT LIKE '%\"heartbeat\"%')");
1419
+ conditions.push(
1420
+ "(IFNULL(taskType, '') NOT IN ('heartbeat', 'heartbeat-checklist', 'boot-triage') AND tags NOT LIKE '%\"heartbeat\"%')",
1421
+ );
1417
1422
  }
1418
1423
 
1419
1424
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
@@ -1509,9 +1514,14 @@ export function getTasksCount(filters?: Omit<TaskFilters, "limit" | "readyOnly">
1509
1514
  params.push(filters.createdAfter);
1510
1515
  }
1511
1516
 
1512
- // Exclude heartbeat tasks by default
1517
+ // Exclude system/heartbeat tasks by default. The flag is still called
1518
+ // `includeHeartbeat` for backward compat with existing API callers, but we
1519
+ // also gate boot-triage + heartbeat-checklist behind it since those are
1520
+ // equally noisy in the dashboard task list.
1513
1521
  if (!filters?.includeHeartbeat) {
1514
- conditions.push("(IFNULL(taskType, '') != 'heartbeat' AND tags NOT LIKE '%\"heartbeat\"%')");
1522
+ conditions.push(
1523
+ "(IFNULL(taskType, '') NOT IN ('heartbeat', 'heartbeat-checklist', 'boot-triage') AND tags NOT LIKE '%\"heartbeat\"%')",
1524
+ );
1515
1525
  }
1516
1526
 
1517
1527
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
@@ -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";
@@ -1752,6 +1754,7 @@ async function spawnProviderProcess(
1752
1754
  iteration: number;
1753
1755
  taskId?: string;
1754
1756
  model?: string;
1757
+ harnessProvider: ProviderName;
1755
1758
  cwd?: string;
1756
1759
  vcsRepo?: string;
1757
1760
  },
@@ -1783,7 +1786,8 @@ async function spawnProviderProcess(
1783
1786
  process.env.AGENT_FS_SHARED_ORG_ID = freshEnv.AGENT_FS_SHARED_ORG_ID as string;
1784
1787
  }
1785
1788
 
1786
- const model = opts.model || (freshEnv.MODEL_OVERRIDE as string) || "";
1789
+ const configModel = (freshEnv.MODEL_OVERRIDE as string | undefined) || "";
1790
+ const model = opts.model || configModel || "";
1787
1791
 
1788
1792
  const config: ProviderSessionConfig = {
1789
1793
  prompt: opts.prompt,
@@ -1803,6 +1807,18 @@ async function spawnProviderProcess(
1803
1807
  };
1804
1808
 
1805
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
+ }
1806
1822
 
1807
1823
  let oauthSelection: CredentialSelection | undefined;
1808
1824
  if (adapter.name === "codex" && credentialSelections.length === 0) {
@@ -1960,6 +1976,20 @@ async function spawnProviderProcess(
1960
1976
  break;
1961
1977
  }
1962
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
+ }
1963
1993
  // Cost save is handled in waitForCompletion().then() to ensure
1964
1994
  // it completes before the process exits (fire-and-forget here
1965
1995
  // races with container shutdown).
@@ -3206,6 +3236,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3206
3236
  iteration,
3207
3237
  taskId: task.id,
3208
3238
  model: (task as { model?: string }).model,
3239
+ harnessProvider: state.harnessProvider,
3209
3240
  cwd: resumeCwd,
3210
3241
  vcsRepo: task.vcsRepo,
3211
3242
  },
@@ -3605,6 +3636,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3605
3636
  iteration,
3606
3637
  taskId: trigger.taskId,
3607
3638
  model: taskModel,
3639
+ harnessProvider: state.harnessProvider,
3608
3640
  cwd: effectiveCwd,
3609
3641
  vcsRepo: taskVcsRepo,
3610
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
  });
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
  });