mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
Merge pull request #3232 from officialasishkumar/fix/clear-empty-agent-env-bindings
fix(ui): persist cleared agent env bindings on save
This commit is contained in:
commit
62d05a7ae2
3 changed files with 185 additions and 55 deletions
108
ui/src/lib/agent-config-patch.test.ts
Normal file
108
ui/src/lib/agent-config-patch.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { buildAgentUpdatePatch, type AgentConfigOverlay } from "./agent-config-patch";
|
||||
|
||||
function makeAgent(): Agent {
|
||||
return {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Agent",
|
||||
role: "engineer",
|
||||
title: "Engineer",
|
||||
icon: null,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
model: "claude-sonnet-4-6",
|
||||
env: {
|
||||
OPENAI_API_KEY: {
|
||||
type: "plain",
|
||||
value: "secret",
|
||||
},
|
||||
},
|
||||
promptTemplate: "Work the issue.",
|
||||
},
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: true,
|
||||
intervalSec: 300,
|
||||
},
|
||||
},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
lastHeartbeatAt: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
urlKey: "agent",
|
||||
permissions: {
|
||||
canCreateAgents: false,
|
||||
},
|
||||
metadata: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeOverlay(patch?: Partial<AgentConfigOverlay>): AgentConfigOverlay {
|
||||
return {
|
||||
identity: {},
|
||||
adapterConfig: {},
|
||||
heartbeat: {},
|
||||
runtime: {},
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildAgentUpdatePatch", () => {
|
||||
it("replaces adapter config and drops env when the last env binding is cleared", () => {
|
||||
const patch = buildAgentUpdatePatch(
|
||||
makeAgent(),
|
||||
makeOverlay({
|
||||
adapterConfig: {
|
||||
env: undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(patch).toEqual({
|
||||
adapterConfig: {
|
||||
model: "claude-sonnet-4-6",
|
||||
promptTemplate: "Work the issue.",
|
||||
},
|
||||
replaceAdapterConfig: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves adapter-agnostic keys when changing adapter types", () => {
|
||||
const patch = buildAgentUpdatePatch(
|
||||
makeAgent(),
|
||||
makeOverlay({
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {
|
||||
model: "gpt-5.4",
|
||||
dangerouslyBypassApprovalsAndSandbox: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(patch).toEqual({
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {
|
||||
env: {
|
||||
OPENAI_API_KEY: {
|
||||
type: "plain",
|
||||
value: "secret",
|
||||
},
|
||||
},
|
||||
promptTemplate: "Work the issue.",
|
||||
model: "gpt-5.4",
|
||||
dangerouslyBypassApprovalsAndSandbox: true,
|
||||
},
|
||||
replaceAdapterConfig: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
70
ui/src/lib/agent-config-patch.ts
Normal file
70
ui/src/lib/agent-config-patch.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { Agent } from "@paperclipai/shared";
|
||||
|
||||
export interface AgentConfigOverlay {
|
||||
identity: Record<string, unknown>;
|
||||
adapterType?: string;
|
||||
adapterConfig: Record<string, unknown>;
|
||||
heartbeat: Record<string, unknown>;
|
||||
runtime: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const ADAPTER_AGNOSTIC_KEYS = [
|
||||
"env",
|
||||
"promptTemplate",
|
||||
"instructionsFilePath",
|
||||
"cwd",
|
||||
"timeoutSec",
|
||||
"graceSec",
|
||||
"bootstrapPromptTemplate",
|
||||
] as const;
|
||||
|
||||
function omitUndefinedEntries(value: Record<string, unknown>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(([, entryValue]) => entryValue !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAgentUpdatePatch(agent: Agent, overlay: AgentConfigOverlay) {
|
||||
const patch: Record<string, unknown> = {};
|
||||
|
||||
if (Object.keys(overlay.identity).length > 0) {
|
||||
Object.assign(patch, overlay.identity);
|
||||
}
|
||||
|
||||
if (overlay.adapterType !== undefined) {
|
||||
patch.adapterType = overlay.adapterType;
|
||||
}
|
||||
|
||||
if (overlay.adapterType !== undefined || Object.keys(overlay.adapterConfig).length > 0) {
|
||||
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
const nextAdapterConfig =
|
||||
overlay.adapterType !== undefined
|
||||
? {
|
||||
...Object.fromEntries(
|
||||
ADAPTER_AGNOSTIC_KEYS
|
||||
.filter((key) => existing[key] !== undefined)
|
||||
.map((key) => [key, existing[key]]),
|
||||
),
|
||||
...overlay.adapterConfig,
|
||||
}
|
||||
: {
|
||||
...existing,
|
||||
...overlay.adapterConfig,
|
||||
};
|
||||
|
||||
patch.adapterConfig = omitUndefinedEntries(nextAdapterConfig);
|
||||
patch.replaceAdapterConfig = true;
|
||||
}
|
||||
|
||||
if (Object.keys(overlay.heartbeat).length > 0) {
|
||||
const existingRc = (agent.runtimeConfig ?? {}) as Record<string, unknown>;
|
||||
const existingHb = (existingRc.heartbeat ?? {}) as Record<string, unknown>;
|
||||
patch.runtimeConfig = { ...existingRc, heartbeat: { ...existingHb, ...overlay.heartbeat } };
|
||||
}
|
||||
|
||||
if (Object.keys(overlay.runtime).length > 0) {
|
||||
Object.assign(patch, overlay.runtime);
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue