Fix agent config revision key tracking

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-22 18:44:18 +00:00
1 parent 87e9447623
commit 6d59a03dc7
5 files changed
+110 -14

No files matched your search

@@ -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",
]);
});
});
+40 -2
View File
@@ -201,11 +201,49 @@ function normalizeRuntimeConfigForNewAgent(runtimeConfig: unknown): Record<strin
return normalizedRuntimeConfig;
}
function diffConfigSnapshot(
function diffRecordFields(
before: unknown,
after: unknown,
prefix: string,
): string[] {
const beforeRecord = isPlainRecord(before) ? before : {};
const afterRecord = isPlainRecord(after) ? after : {};
return [...new Set([...Object.keys(beforeRecord), ...Object.keys(afterRecord)])]
.sort()
.filter((field) => !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<typeof agents.$inferInsert> {
+8 -4
View File
@@ -18,10 +18,14 @@ import { formatDate } from "../lib/utils";
import { formatAgentConfigValue } from "../lib/agent-config-changeset";
function valueAt(snapshot: Record<string, unknown>, key: string): unknown {
return key.split(".").reduce<unknown>((current, segment) => {
if (!current || typeof current !== "object") return undefined;
return (current as Record<string, unknown>)[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<string, unknown>, tail);
}
export function revisionDiff(revision: AgentConfigRevision) {
+1 -1
View File
@@ -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"],
]);
});
+28 -7
View File
@@ -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<string, unknown>).modelProfiles as Record<string, unknown> | 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 };