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