fix: deduplicate adapter-agnostic config keys (#9058)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - When you swap an agent's adapter (e.g. from one LLM provider to another), the server merges the incoming PATCH body with stored config — keys listed in \`ADAPTER_AGNOSTIC_KEYS\` are preserved regardless of which adapter is active > - That constant was defined independently in two places: \`server/src/agents.ts\` (used by the adapter-swap route) and \`ui/src/lib/agent-config-patch.ts\` (used by the UI patch builder) > - PR #8975 fixed the bug where \`paperclipSkillSync.desiredSkills\` was dropped on adapter swap by adding it to the server-side constant, but the UI-side copy was not updated in the same PR — creating ongoing drift risk > - This pull request hoists \`ADAPTER_AGNOSTIC_KEYS\` into \`packages/shared\` so both consumers import the same constant > - The benefit is a single source of truth: any future key addition is made in one place and both the server route and the UI patch builder pick it up automatically, with a drift guard to catch any accidental re-duplication ## Linked Issues or Issue Description Refs #8975 — follow-up deduplication: #8975 fixed the runtime bug but left the constant duplicated across server and UI. This PR closes that gap. ## What Changed - Added \`ADAPTER_AGNOSTIC_KEYS\` constant and \`AdapterAgnosticKey\` type to \`packages/shared/src/adapter-agnostic-keys.ts\` - Updated \`server/src/agents.ts\` to import the shared constant, removing the local copy - Updated \`ui/src/lib/agent-config-patch.ts\` to import the shared constant, removing the local copy - Added \`packages/shared/src/adapter-agnostic-keys.test.ts\`: drift guard asserting the expected key set and both consumer import sites ## Verification \`\`\`bash pnpm exec vitest run packages/shared/src/adapter-agnostic-keys.test.ts ui/src/lib/agent-config-patch.test.ts server/src/__tests__/agent-instructions-routes.test.ts pnpm --filter @paperclipai/shared typecheck pnpm --filter @paperclipai/server typecheck pnpm --filter @paperclipai/ui typecheck \`\`\` All 15 tests pass across the three files; all three packages typecheck clean. ## Risks Low risk — behavior-preserving refactor. The key set is unchanged; only the import source changes. The drift guard will fail loudly if someone accidentally re-introduces a local copy or modifies one without updating the other. > For core feature work, check [\`ROADMAP.md\`](ROADMAP.md) first and discuss it in \`#dev\` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See \`CONTRIBUTING.md\`. ## Model Used - Provider: Anthropic - Model: Claude Sonnet 4.6 (\`claude-sonnet-4-6\`) - Context: standard context window, tool use enabled - Reasoning: standard mode (no extended thinking) ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with \`Fixes: #\` / \`Closes #\` / \`Refs #\` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub \`#NNN\` / \`github.com/paperclipai/paperclip\` URLs) - [ ] My branch name describes the change (e.g. \`docs/...\`, \`fix/...\`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
ad961227f5
commit
8a058f9d79
|
|
@ -0,0 +1,41 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ADAPTER_AGNOSTIC_KEYS } from "./constants.js";
|
||||
|
||||
const EXPECTED_ADAPTER_AGNOSTIC_KEYS = [
|
||||
"env",
|
||||
"promptTemplate",
|
||||
"instructionsFilePath",
|
||||
"cwd",
|
||||
"timeoutSec",
|
||||
"graceSec",
|
||||
"bootstrapPromptTemplate",
|
||||
"paperclipSkillSync",
|
||||
] as const;
|
||||
|
||||
function readRepoFile(pathFromRoot: string) {
|
||||
return readFileSync(
|
||||
fileURLToPath(new URL(`../../../${pathFromRoot}`, import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
describe("adapter-agnostic config keys", () => {
|
||||
it("keeps the preserved adapter config keys explicit", () => {
|
||||
expect(ADAPTER_AGNOSTIC_KEYS).toEqual(EXPECTED_ADAPTER_AGNOSTIC_KEYS);
|
||||
});
|
||||
|
||||
it("is imported by the server and UI instead of being re-declared", () => {
|
||||
const serverSource = readRepoFile("server/src/routes/agents.ts");
|
||||
const uiSource = readRepoFile("ui/src/lib/agent-config-patch.ts");
|
||||
|
||||
expect(serverSource).toContain("ADAPTER_AGNOSTIC_KEYS");
|
||||
expect(serverSource).toContain("from \"@paperclipai/shared\"");
|
||||
expect(serverSource).not.toMatch(/const\s+ADAPTER_AGNOSTIC_KEYS\s*=/);
|
||||
|
||||
expect(uiSource).toContain("ADAPTER_AGNOSTIC_KEYS");
|
||||
expect(uiSource).toContain("from \"@paperclipai/shared\"");
|
||||
expect(uiSource).not.toMatch(/const\s+ADAPTER_AGNOSTIC_KEYS\s*=/);
|
||||
});
|
||||
});
|
||||
|
|
@ -78,6 +78,20 @@ export const AGENT_ROLE_LABELS: Record<AgentRole, string> = {
|
|||
export const AGENT_DEFAULT_MAX_CONCURRENT_RUNS = 20;
|
||||
export const WORKSPACE_BRANCH_ROUTINE_VARIABLE = "workspaceBranch";
|
||||
|
||||
// Config keys owned by Paperclip/company state rather than one concrete adapter.
|
||||
// `paperclipSkillSync` is persisted in adapterConfig but must survive adapter swaps.
|
||||
export const ADAPTER_AGNOSTIC_KEYS = [
|
||||
"env",
|
||||
"promptTemplate",
|
||||
"instructionsFilePath",
|
||||
"cwd",
|
||||
"timeoutSec",
|
||||
"graceSec",
|
||||
"bootstrapPromptTemplate",
|
||||
"paperclipSkillSync",
|
||||
] as const;
|
||||
export type AdapterAgnosticKey = (typeof ADAPTER_AGNOSTIC_KEYS)[number];
|
||||
|
||||
export const MODEL_PROFILE_KEYS = ["cheap"] as const;
|
||||
export type ModelProfileKey = (typeof MODEL_PROFILE_KEYS)[number];
|
||||
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ export {
|
|||
AGENT_ROLE_LABELS,
|
||||
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
|
||||
ADAPTER_AGNOSTIC_KEYS,
|
||||
MODEL_PROFILE_KEYS,
|
||||
AGENT_ICON_NAMES,
|
||||
PROJECT_ICON_NAMES,
|
||||
|
|
@ -265,6 +266,7 @@ export {
|
|||
type AgentStatus,
|
||||
type AgentAdapterType,
|
||||
type AgentRole,
|
||||
type AdapterAgnosticKey,
|
||||
type ModelProfileKey,
|
||||
type AgentIconName,
|
||||
type ProjectIconName,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
|||
import {
|
||||
agentSkillSyncSchema,
|
||||
agentMineInboxQuerySchema,
|
||||
ADAPTER_AGNOSTIC_KEYS,
|
||||
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
createAgentKeySchema,
|
||||
createAgentHireSchema,
|
||||
|
|
@ -170,6 +171,7 @@ export function agentRoutes(
|
|||
"instructionsFilePath",
|
||||
"agentsMdPath",
|
||||
] as const;
|
||||
const KNOWN_INSTRUCTIONS_BUNDLE_KEY_SET: ReadonlySet<string> = new Set(KNOWN_INSTRUCTIONS_BUNDLE_KEYS);
|
||||
|
||||
const router = Router();
|
||||
const svc = agentService(db);
|
||||
|
|
@ -2871,15 +2873,8 @@ export function agentRoutes(
|
|||
// Preserve adapter-agnostic keys (env, cwd, etc.) from the existing config
|
||||
// when the adapter type changes. Without this, a PATCH that includes
|
||||
// adapterConfig but omits these keys would silently drop them.
|
||||
// `paperclipSkillSync` holds the agent's desired-skill selection, which is
|
||||
// a company-level (adapter-agnostic) choice even though it is persisted
|
||||
// inside the per-adapter config; switching adapters must not wipe it.
|
||||
const ADAPTER_AGNOSTIC_KEYS = [
|
||||
"env", "cwd", "timeoutSec", "graceSec",
|
||||
"promptTemplate", "bootstrapPromptTemplate",
|
||||
"paperclipSkillSync",
|
||||
] as const;
|
||||
for (const key of ADAPTER_AGNOSTIC_KEYS) {
|
||||
if (KNOWN_INSTRUCTIONS_BUNDLE_KEY_SET.has(key)) continue;
|
||||
if (rawEffectiveAdapterConfig[key] === undefined && existingAdapterConfig[key] !== undefined) {
|
||||
rawEffectiveAdapterConfig = { ...rawEffectiveAdapterConfig, [key]: existingAdapterConfig[key] };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Agent } from "@paperclipai/shared";
|
||||
import { ADAPTER_AGNOSTIC_KEYS, type Agent } from "@paperclipai/shared";
|
||||
|
||||
export interface AgentModelProfileOverlay {
|
||||
enabled?: boolean;
|
||||
|
|
@ -19,20 +19,6 @@ export interface AgentConfigOverlay {
|
|||
modelProfiles?: { cheap?: AgentModelProfileOverlay };
|
||||
}
|
||||
|
||||
const ADAPTER_AGNOSTIC_KEYS = [
|
||||
"env",
|
||||
"promptTemplate",
|
||||
"instructionsFilePath",
|
||||
"cwd",
|
||||
"timeoutSec",
|
||||
"graceSec",
|
||||
"bootstrapPromptTemplate",
|
||||
// Desired-skill selection is a company-level, adapter-agnostic choice even
|
||||
// though it is persisted inside the per-adapter config; keep it when the
|
||||
// adapter type changes so switching adapters does not wipe the agent's skills.
|
||||
"paperclipSkillSync",
|
||||
] as const;
|
||||
|
||||
function omitUndefinedEntries(value: Record<string, unknown>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(([, entryValue]) => entryValue !== undefined),
|
||||
|
|
|
|||
Loading…
Reference in New Issue