Fix agent skills autosave hydration\n\nCo-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
8460fee380
commit
bb46423969
3 changed files with 118 additions and 14 deletions
58
ui/src/lib/agent-skills-state.test.ts
Normal file
58
ui/src/lib/agent-skills-state.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { applyAgentSkillSnapshot } from "./agent-skills-state";
|
||||||
|
|
||||||
|
describe("applyAgentSkillSnapshot", () => {
|
||||||
|
it("hydrates the initial snapshot without arming autosave", () => {
|
||||||
|
const result = applyAgentSkillSnapshot(
|
||||||
|
{
|
||||||
|
draft: [],
|
||||||
|
lastSaved: [],
|
||||||
|
hasHydratedSnapshot: false,
|
||||||
|
},
|
||||||
|
["paperclip", "para-memory-files"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
draft: ["paperclip", "para-memory-files"],
|
||||||
|
lastSaved: ["paperclip", "para-memory-files"],
|
||||||
|
hasHydratedSnapshot: true,
|
||||||
|
shouldSkipAutosave: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps unsaved local edits when a fresh snapshot arrives", () => {
|
||||||
|
const result = applyAgentSkillSnapshot(
|
||||||
|
{
|
||||||
|
draft: ["paperclip", "custom-skill"],
|
||||||
|
lastSaved: ["paperclip"],
|
||||||
|
hasHydratedSnapshot: true,
|
||||||
|
},
|
||||||
|
["paperclip"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
draft: ["paperclip", "custom-skill"],
|
||||||
|
lastSaved: ["paperclip"],
|
||||||
|
hasHydratedSnapshot: true,
|
||||||
|
shouldSkipAutosave: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adopts server state after a successful save and skips the follow-up autosave pass", () => {
|
||||||
|
const result = applyAgentSkillSnapshot(
|
||||||
|
{
|
||||||
|
draft: ["paperclip", "custom-skill"],
|
||||||
|
lastSaved: ["paperclip", "custom-skill"],
|
||||||
|
hasHydratedSnapshot: true,
|
||||||
|
},
|
||||||
|
["paperclip", "custom-skill"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
draft: ["paperclip", "custom-skill"],
|
||||||
|
lastSaved: ["paperclip", "custom-skill"],
|
||||||
|
hasHydratedSnapshot: true,
|
||||||
|
shouldSkipAutosave: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
29
ui/src/lib/agent-skills-state.ts
Normal file
29
ui/src/lib/agent-skills-state.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
export interface AgentSkillDraftState {
|
||||||
|
draft: string[];
|
||||||
|
lastSaved: string[];
|
||||||
|
hasHydratedSnapshot: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentSkillSnapshotApplyResult extends AgentSkillDraftState {
|
||||||
|
shouldSkipAutosave: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function arraysEqual(a: string[], b: string[]): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return a.every((value, index) => value === b[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyAgentSkillSnapshot(
|
||||||
|
state: AgentSkillDraftState,
|
||||||
|
desiredSkills: string[],
|
||||||
|
): AgentSkillSnapshotApplyResult {
|
||||||
|
const shouldReplaceDraft = !state.hasHydratedSnapshot || arraysEqual(state.draft, state.lastSaved);
|
||||||
|
|
||||||
|
return {
|
||||||
|
draft: shouldReplaceDraft ? desiredSkills : state.draft,
|
||||||
|
lastSaved: desiredSkills,
|
||||||
|
hasHydratedSnapshot: true,
|
||||||
|
shouldSkipAutosave: shouldReplaceDraft,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -74,6 +74,7 @@ import {
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils";
|
import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils";
|
||||||
import { agentRouteRef } from "../lib/utils";
|
import { agentRouteRef } from "../lib/utils";
|
||||||
|
import { applyAgentSkillSnapshot, arraysEqual } from "../lib/agent-skills-state";
|
||||||
|
|
||||||
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
||||||
succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" },
|
succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" },
|
||||||
|
|
@ -137,12 +138,6 @@ const sourceLabels: Record<string, string> = {
|
||||||
const LIVE_SCROLL_BOTTOM_TOLERANCE_PX = 32;
|
const LIVE_SCROLL_BOTTOM_TOLERANCE_PX = 32;
|
||||||
type ScrollContainer = Window | HTMLElement;
|
type ScrollContainer = Window | HTMLElement;
|
||||||
|
|
||||||
function arraysEqual(a: string[], b: string[]): boolean {
|
|
||||||
if (a === b) return true;
|
|
||||||
if (a.length !== b.length) return false;
|
|
||||||
return a.every((value, index) => value === b[index]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWindowContainer(container: ScrollContainer): container is Window {
|
function isWindowContainer(container: ScrollContainer): container is Window {
|
||||||
return container === window;
|
return container === window;
|
||||||
}
|
}
|
||||||
|
|
@ -1250,6 +1245,8 @@ function AgentSkillsTab({
|
||||||
const [skillDraft, setSkillDraft] = useState<string[]>([]);
|
const [skillDraft, setSkillDraft] = useState<string[]>([]);
|
||||||
const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]);
|
const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]);
|
||||||
const lastSavedSkillsRef = useRef<string[]>([]);
|
const lastSavedSkillsRef = useRef<string[]>([]);
|
||||||
|
const hasHydratedSkillSnapshotRef = useRef(false);
|
||||||
|
const skipNextSkillAutosaveRef = useRef(true);
|
||||||
|
|
||||||
const { data: skillSnapshot, isLoading } = useQuery({
|
const { data: skillSnapshot, isLoading } = useQuery({
|
||||||
queryKey: queryKeys.agents.skills(agent.id),
|
queryKey: queryKeys.agents.skills(agent.id),
|
||||||
|
|
@ -1277,16 +1274,36 @@ function AgentSkillsTab({
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!skillSnapshot) return;
|
setSkillDraft([]);
|
||||||
setSkillDraft((current) =>
|
setLastSavedSkills([]);
|
||||||
arraysEqual(current, lastSavedSkillsRef.current) ? skillSnapshot.desiredSkills : current,
|
lastSavedSkillsRef.current = [];
|
||||||
);
|
hasHydratedSkillSnapshotRef.current = false;
|
||||||
lastSavedSkillsRef.current = skillSnapshot.desiredSkills;
|
skipNextSkillAutosaveRef.current = true;
|
||||||
setLastSavedSkills(skillSnapshot.desiredSkills);
|
}, [agent.id]);
|
||||||
}, [skillSnapshot]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!skillSnapshot) return;
|
if (!skillSnapshot) return;
|
||||||
|
const nextState = applyAgentSkillSnapshot(
|
||||||
|
{
|
||||||
|
draft: skillDraft,
|
||||||
|
lastSaved: lastSavedSkillsRef.current,
|
||||||
|
hasHydratedSnapshot: hasHydratedSkillSnapshotRef.current,
|
||||||
|
},
|
||||||
|
skillSnapshot.desiredSkills,
|
||||||
|
);
|
||||||
|
skipNextSkillAutosaveRef.current = nextState.shouldSkipAutosave;
|
||||||
|
hasHydratedSkillSnapshotRef.current = nextState.hasHydratedSnapshot;
|
||||||
|
setSkillDraft(nextState.draft);
|
||||||
|
lastSavedSkillsRef.current = nextState.lastSaved;
|
||||||
|
setLastSavedSkills(nextState.lastSaved);
|
||||||
|
}, [skillDraft, skillSnapshot]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!skillSnapshot) return;
|
||||||
|
if (skipNextSkillAutosaveRef.current) {
|
||||||
|
skipNextSkillAutosaveRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (syncSkills.isPending) return;
|
if (syncSkills.isPending) return;
|
||||||
if (arraysEqual(skillDraft, lastSavedSkillsRef.current)) return;
|
if (arraysEqual(skillDraft, lastSavedSkillsRef.current)) return;
|
||||||
|
|
||||||
|
|
@ -1409,7 +1426,7 @@ function AgentSkillsTab({
|
||||||
const renderSkillRow = (skill: SkillRow) => {
|
const renderSkillRow = (skill: SkillRow) => {
|
||||||
const adapterEntry = skill.adapterEntry ?? adapterEntryByName.get(skill.slug);
|
const adapterEntry = skill.adapterEntry ?? adapterEntryByName.get(skill.slug);
|
||||||
const required = Boolean(adapterEntry?.required);
|
const required = Boolean(adapterEntry?.required);
|
||||||
const checked = required || Boolean(adapterEntry?.desired) || skillDraft.includes(skill.slug);
|
const checked = required || skillDraft.includes(skill.slug);
|
||||||
const disabled = required || skillSnapshot?.mode === "unsupported";
|
const disabled = required || skillSnapshot?.mode === "unsupported";
|
||||||
const checkbox = (
|
const checkbox = (
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue