mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
Fix agent config revision key tracking
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user