Fix nested agent config history diffs

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-22 19:30:26 +00:00
1 parent 53bf68638d
commit 6aa50ea1fc
3 files changed
+56 -10

No files matched your search

+31 -1
View File
@@ -4,7 +4,7 @@ import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentConfigRevision } from "@paperclipai/shared";
import { AgentConfigHistory } from "./AgentConfigHistory";
import { AgentConfigHistory, revisionDiff } from "./AgentConfigHistory";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -44,4 +44,34 @@ describe("AgentConfigHistory", () => {
flushSync(() => confirmButton?.click());
expect(onRestore).toHaveBeenCalledWith("revision-1");
});
it("expands legacy whole-object changes into field-level rows", () => {
const legacyRevision = {
...revision,
changedKeys: ["adapterConfig"],
beforeConfig: { adapterConfig: { effort: "high", graceSec: 15, headers: { version: "1" } } },
afterConfig: { adapterConfig: { effort: "high", graceSec: 42, headers: { version: "2" } } },
} satisfies AgentConfigRevision;
expect(revisionDiff(legacyRevision)).toEqual([
{ key: "adapterConfig.graceSec", before: 15, after: 42 },
{ key: "adapterConfig.headers.version", before: "1", after: "2" },
]);
});
it("uses the same human-readable labels as the review changes popover", () => {
const labelRevision = {
...revision,
changedKeys: ["adapterConfig.graceSec"],
beforeConfig: { adapterConfig: { graceSec: 15 } },
afterConfig: { adapterConfig: { graceSec: 42 } },
} satisfies AgentConfigRevision;
flushSync(() => root.render(<AgentConfigHistory revisions={[labelRevision]} onRestore={vi.fn()} restoring={false} />));
const viewDiffButton = container.querySelector<HTMLButtonElement>('button[aria-label="View revision diff"]');
flushSync(() => viewDiffButton?.click());
expect(document.body.textContent).toContain("Grace Sec");
expect(document.body.textContent).toContain("Grace Sec1542");
});
});
+23 -7
View File
@@ -15,7 +15,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { formatDate } from "../lib/utils";
import { formatAgentConfigValue } from "../lib/agent-config-changeset";
import { formatAgentConfigValue, labelForAgentConfigKey } from "../lib/agent-config-changeset";
function valueAt(snapshot: Record<string, unknown>, key: string): unknown {
if (Object.prototype.hasOwnProperty.call(snapshot, key)) return snapshot[key];
@@ -29,11 +29,27 @@ function valueAt(snapshot: Record<string, unknown>, key: string): unknown {
}
export function revisionDiff(revision: AgentConfigRevision) {
return revision.changedKeys.map((key) => ({
key,
before: valueAt(revision.beforeConfig, key),
after: valueAt(revision.afterConfig, key),
}));
return revision.changedKeys.flatMap((key) => expandRevisionDiff(key, valueAt(revision.beforeConfig, key), valueAt(revision.afterConfig, key)));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function expandRevisionDiff(key: string, before: unknown, after: unknown): Array<{ key: string; before: unknown; after: unknown }> {
if (!isRecord(before) && !isRecord(after)) return [{ key, before, after }];
if (before !== undefined && !isRecord(before)) return [{ key, before, after }];
if (after !== undefined && !isRecord(after)) return [{ key, before, after }];
const beforeRecord = isRecord(before) ? before : {};
const afterRecord = isRecord(after) ? after : {};
const fields = [...new Set([...Object.keys(beforeRecord), ...Object.keys(afterRecord)])].sort();
return fields.flatMap((field) => {
const fieldBefore = beforeRecord[field];
const fieldAfter = afterRecord[field];
if (JSON.stringify(fieldBefore) === JSON.stringify(fieldAfter)) return [];
return expandRevisionDiff(key + "." + field, fieldBefore, fieldAfter);
});
}
function RevisionDiffRows({ revision }: { revision: AgentConfigRevision }) {
@@ -41,7 +57,7 @@ function RevisionDiffRows({ revision }: { revision: AgentConfigRevision }) {
<div className="divide-y divide-border">
{revisionDiff(revision).map((entry) => (
<div key={entry.key} className="space-y-2 py-3">
<div className="font-mono text-xs text-muted-foreground">{entry.key}</div>
<div className="text-xs font-medium text-muted-foreground">{labelForAgentConfigKey(entry.key)}</div>
<div className="grid gap-2 text-xs sm:grid-cols-2">
<div className="min-w-0 rounded-md bg-muted/40 p-2 font-mono break-words">{formatAgentConfigValue(entry.before)}</div>
<div className="min-w-0 rounded-md bg-muted/40 p-2 font-mono break-words">{formatAgentConfigValue(entry.after)}</div>
+2 -2
View File
@@ -11,7 +11,7 @@ export type AgentConfigChange = {
type OverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "runtime";
function labelForKey(key: string): string {
export function labelForAgentConfigKey(key: string): string {
return key.replace(/^.*\./, "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/^./, (value) => value.toUpperCase());
}
@@ -41,7 +41,7 @@ export function buildAgentConfigChanges(agent: Agent, overlay: AgentConfigOverla
: group === "runtime"
? field
: `adapterConfig.${field}`;
changes.push({ key, label: labelForKey(key), section: sectionForKey(key), before: originalValue(agent, group, field), after });
changes.push({ key, label: labelForAgentConfigKey(key), section: sectionForKey(key), before: originalValue(agent, group, field), after });
}
};
addGroup("identity");