diff --git a/server/src/__tests__/agent-config-revision-diff.test.ts b/server/src/__tests__/agent-config-revision-diff.test.ts new file mode 100644 index 00000000000..32995fbd448 --- /dev/null +++ b/server/src/__tests__/agent-config-revision-diff.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { diffConfigSnapshot } from "../services/agents.js"; + +describe("agent config revision diff", () => { + it("records editor-level paths instead of broad config objects", () => { + const before = { + name: "Coder", + role: "engineer", + title: null, + icon: null, + reportsTo: null, + capabilities: null, + adapterType: "codex_local", + adapterConfig: { model: "gpt-5", env: { DEBUG: "0" } }, + runtimeConfig: { heartbeat: { intervalSec: 300 }, modelProfiles: { cheap: { enabled: false } } }, + defaultEnvironmentId: null, + budgetMonthlyCents: 1000, + metadata: null, + }; + const after = { + ...before, + adapterConfig: { model: "gpt-5.5", env: { DEBUG: "1" } }, + runtimeConfig: { heartbeat: { intervalSec: 600 }, modelProfiles: { cheap: { enabled: true } } }, + }; + + expect(diffConfigSnapshot(before, after)).toEqual([ + "adapterConfig.env", + "adapterConfig.model", + "runtimeConfig.heartbeat.intervalSec", + "runtimeConfig.modelProfiles.cheap", + ]); + }); +}); diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 00ded758323..2b1d3e1ed65 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -201,11 +201,49 @@ function normalizeRuntimeConfigForNewAgent(runtimeConfig: unknown): Record !jsonEqual(beforeRecord[field], afterRecord[field])) + .map((field) => `${prefix}.${field}`); +} + +export function diffConfigSnapshot( before: AgentConfigSnapshot, after: AgentConfigSnapshot, ): string[] { - return CONFIG_REVISION_FIELDS.filter((field) => !jsonEqual(before[field], after[field])); + const changedKeys: string[] = []; + for (const field of CONFIG_REVISION_FIELDS) { + if (jsonEqual(before[field], after[field])) continue; + if (field === "adapterConfig") { + changedKeys.push(...diffRecordFields(before.adapterConfig, after.adapterConfig, field)); + continue; + } + if (field === "runtimeConfig") { + const beforeRuntime = isPlainRecord(before.runtimeConfig) ? before.runtimeConfig : {}; + const afterRuntime = isPlainRecord(after.runtimeConfig) ? after.runtimeConfig : {}; + const runtimeFields = [...new Set([...Object.keys(beforeRuntime), ...Object.keys(afterRuntime)])].sort(); + for (const runtimeField of runtimeFields) { + if (jsonEqual(beforeRuntime[runtimeField], afterRuntime[runtimeField])) continue; + if (runtimeField === "heartbeat") { + changedKeys.push(...diffRecordFields(beforeRuntime.heartbeat, afterRuntime.heartbeat, "runtimeConfig.heartbeat")); + } else if (runtimeField === "modelProfiles") { + changedKeys.push(...diffRecordFields(beforeRuntime.modelProfiles, afterRuntime.modelProfiles, "runtimeConfig.modelProfiles")); + } else { + changedKeys.push(`runtimeConfig.${runtimeField}`); + } + } + continue; + } + changedKeys.push(field); + } + return changedKeys; } function configPatchFromSnapshot(snapshot: unknown): Partial { diff --git a/ui/src/components/AgentConfigHistory.tsx b/ui/src/components/AgentConfigHistory.tsx index 43851c34472..16b74644274 100644 --- a/ui/src/components/AgentConfigHistory.tsx +++ b/ui/src/components/AgentConfigHistory.tsx @@ -18,10 +18,14 @@ import { formatDate } from "../lib/utils"; import { formatAgentConfigValue } from "../lib/agent-config-changeset"; function valueAt(snapshot: Record, key: string): unknown { - return key.split(".").reduce((current, segment) => { - if (!current || typeof current !== "object") return undefined; - return (current as Record)[segment]; - }, snapshot); + if (Object.prototype.hasOwnProperty.call(snapshot, key)) return snapshot[key]; + const separator = key.indexOf("."); + if (separator === -1) return undefined; + const head = key.slice(0, separator); + const tail = key.slice(separator + 1); + const next = snapshot[head]; + if (!next || typeof next !== "object" || Array.isArray(next)) return undefined; + return valueAt(next as Record, tail); } export function revisionDiff(revision: AgentConfigRevision) { diff --git a/ui/src/lib/agent-config-changeset.test.ts b/ui/src/lib/agent-config-changeset.test.ts index 1b57e2c0ee6..1c5dea27603 100644 --- a/ui/src/lib/agent-config-changeset.test.ts +++ b/ui/src/lib/agent-config-changeset.test.ts @@ -10,7 +10,7 @@ describe("agent config changeset", () => { expect(changes.map((change) => [change.key, change.before, change.after, change.section])).toEqual([ ["adapterConfig.model", "gpt-5", "gpt-5.5", "Runtime"], ["adapterConfig.cwd", "/old", "/new", "Danger & Legacy"], - ["heartbeat.intervalSec", 300, 600, "Schedule & Runs"], + ["runtimeConfig.heartbeat.intervalSec", 300, 600, "Schedule & Runs"], ]); }); diff --git a/ui/src/lib/agent-config-changeset.ts b/ui/src/lib/agent-config-changeset.ts index 9180db1136f..e5cd3968a44 100644 --- a/ui/src/lib/agent-config-changeset.ts +++ b/ui/src/lib/agent-config-changeset.ts @@ -16,8 +16,8 @@ function labelForKey(key: string): string { } function sectionForKey(key: string): AgentConfigChange["section"] { - if (key === "identity.defaultEnvironmentId" || key === "adapterConfig.env") return "Environment"; - if (key.startsWith("heartbeat.")) return "Schedule & Runs"; + if (key === "defaultEnvironmentId" || key === "adapterConfig.env") return "Environment"; + if (key.startsWith("runtimeConfig.heartbeat.")) return "Schedule & Runs"; if (/cwd|dangerouslySkipPermissions|dangerouslyBypassSandbox|dangerouslyBypassApprovalsAndSandbox/.test(key)) return "Danger & Legacy"; return "Runtime"; } @@ -34,7 +34,13 @@ export function buildAgentConfigChanges(agent: Agent, overlay: AgentConfigOverla const changes: AgentConfigChange[] = []; const addGroup = (group: OverlayGroup) => { for (const [field, after] of Object.entries(overlay[group])) { - const key = `${group}.${field}`; + const key = group === "identity" + ? field + : group === "heartbeat" + ? `runtimeConfig.heartbeat.${field}` + : group === "runtime" + ? field + : `adapterConfig.${field}`; changes.push({ key, label: labelForKey(key), section: sectionForKey(key), before: originalValue(agent, group, field), after }); } }; @@ -45,16 +51,31 @@ export function buildAgentConfigChanges(agent: Agent, overlay: AgentConfigOverla addGroup("runtime"); if (overlay.modelProfiles?.cheap !== undefined) { const profiles = ((agent.runtimeConfig ?? {}) as Record).modelProfiles as Record | undefined; - changes.push({ key: "modelProfiles.cheap", label: "Cost saver model", section: "Runtime", before: profiles?.cheap, after: overlay.modelProfiles.cheap }); + changes.push({ key: "runtimeConfig.modelProfiles.cheap", label: "Cost saver model", section: "Runtime", before: profiles?.cheap, after: overlay.modelProfiles.cheap }); } return changes; } export function revertAgentConfigChange(overlay: AgentConfigOverlay, key: string): AgentConfigOverlay { if (key === "adapterType") return { ...overlay, adapterType: undefined }; - if (key === "modelProfiles.cheap") return { ...overlay, modelProfiles: undefined }; - const [group, field] = key.split(".", 2) as [OverlayGroup, string]; - if (!field || !(group in overlay)) return overlay; + if (key === "runtimeConfig.modelProfiles.cheap") return { ...overlay, modelProfiles: undefined }; + let group: OverlayGroup; + let field: string; + if (key.startsWith("adapterConfig.")) { + group = "adapterConfig"; + field = key.slice("adapterConfig.".length); + } else if (key.startsWith("runtimeConfig.heartbeat.")) { + group = "heartbeat"; + field = key.slice("runtimeConfig.heartbeat.".length); + } else if (key in overlay.identity) { + group = "identity"; + field = key; + } else if (key in overlay.runtime) { + group = "runtime"; + field = key; + } else { + return overlay; + } const nextGroup = { ...overlay[group] }; delete nextGroup[field]; return { ...overlay, [group]: nextGroup };