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:
Nicky Leach 2026-07-05 21:47:38 -07:00 committed by GitHub
parent ad961227f5
commit 8a058f9d79
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 61 additions and 23 deletions

View File

@ -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*=/);
});
});

View File

@ -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];

View File

@ -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,

View File

@ -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] };
}

View File

@ -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),