From 8a058f9d79ea06a8db6df51616c21fd27bcbc1d1 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Sun, 5 Jul 2026 21:47:38 -0700 Subject: [PATCH] fix: deduplicate adapter-agnostic config keys (#9058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../shared/src/adapter-agnostic-keys.test.ts | 41 +++++++++++++++++++ packages/shared/src/constants.ts | 14 +++++++ packages/shared/src/index.ts | 2 + server/src/routes/agents.ts | 11 ++--- ui/src/lib/agent-config-patch.ts | 16 +------- 5 files changed, 61 insertions(+), 23 deletions(-) create mode 100644 packages/shared/src/adapter-agnostic-keys.test.ts diff --git a/packages/shared/src/adapter-agnostic-keys.test.ts b/packages/shared/src/adapter-agnostic-keys.test.ts new file mode 100644 index 0000000000..d4977904a1 --- /dev/null +++ b/packages/shared/src/adapter-agnostic-keys.test.ts @@ -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*=/); + }); +}); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 6d124a9531..978aa4a5ed 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -78,6 +78,20 @@ export const AGENT_ROLE_LABELS: Record = { 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]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d561a47fc0..dfef663433 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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, diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 7b0474685e..659491cac5 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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 = 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] }; } diff --git a/ui/src/lib/agent-config-patch.ts b/ui/src/lib/agent-config-patch.ts index 325d8d8833..9817650738 100644 --- a/ui/src/lib/agent-config-patch.ts +++ b/ui/src/lib/agent-config-patch.ts @@ -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) { return Object.fromEntries( Object.entries(value).filter(([, entryValue]) => entryValue !== undefined),