mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
Improve ACPX adapter configuration (#5290)
## Thinking Path > - Paperclip orchestrates AI agents across several adapter implementations. > - ACPX is a local adapter path that can proxy Claude and Codex-style execution. > - Its configuration needed stronger schema defaults, provider-aware model handling, and better UI support. > - Plugin authors also need clear docs for managed resources. > - This pull request improves ACPX adapter configuration and documents plugin-managed resources. > - The benefit is a more predictable adapter setup path without changing unrelated control-plane behavior. ## What Changed - Improved ACPX config schema, execution config handling, UI build config, and route coverage. - Added ACPX model filtering support and tests. - Updated the agent config form and storybook coverage for ACPX model/provider behavior. - Expanded plugin authoring documentation for managed resources. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run server/src/__tests__/acpx-local-execute.test.ts server/src/__tests__/adapter-routes.test.ts ui/src/lib/acpx-model-filter.test.ts` ## Risks - Low-to-medium risk: adapter configuration behavior changes can affect ACPX users, but the change is isolated to ACPX/plugin-doc surfaces and covered by targeted adapter tests. ## Model Used - OpenAI GPT-5 Codex via Paperclip `codex_local` adapter, with shell/git/GitHub CLI tool use. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
454edfe81e
commit
11ffd6f2c5
15 changed files with 949 additions and 211 deletions
47
ui/src/adapters/schema-config-fields.test.ts
Normal file
47
ui/src/adapters/schema-config-fields.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { AdapterConfigSchema, ConfigFieldSchema } from "@paperclipai/adapter-utils";
|
||||
import { fieldMatchesVisibleWhen } from "./schema-config-fields";
|
||||
|
||||
const sourceField: ConfigFieldSchema = {
|
||||
key: "provider",
|
||||
label: "Provider",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Claude", value: "claude" },
|
||||
{ label: "Codex", value: "codex" },
|
||||
],
|
||||
};
|
||||
|
||||
const schema: AdapterConfigSchema = {
|
||||
fields: [sourceField],
|
||||
};
|
||||
|
||||
function targetWithVisibleWhen(visibleWhen: Record<string, unknown>): ConfigFieldSchema {
|
||||
return {
|
||||
key: "model",
|
||||
label: "Model",
|
||||
type: "text",
|
||||
meta: { visibleWhen },
|
||||
};
|
||||
}
|
||||
|
||||
describe("fieldMatchesVisibleWhen", () => {
|
||||
it("treats an empty values array as no match", () => {
|
||||
const field = targetWithVisibleWhen({ key: "provider", values: [] });
|
||||
|
||||
expect(fieldMatchesVisibleWhen(field, () => "claude", schema)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats all non-string values as no match", () => {
|
||||
const field = targetWithVisibleWhen({ key: "provider", values: [null, 42] });
|
||||
|
||||
expect(fieldMatchesVisibleWhen(field, () => "claude", schema)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches non-empty string values", () => {
|
||||
const field = targetWithVisibleWhen({ key: "provider", values: ["claude"] });
|
||||
|
||||
expect(fieldMatchesVisibleWhen(field, () => "claude", schema)).toBe(true);
|
||||
expect(fieldMatchesVisibleWhen(field, () => "codex", schema)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -283,6 +283,38 @@ function getDefaultValue(field: ConfigFieldSchema): unknown {
|
|||
}
|
||||
}
|
||||
|
||||
export function fieldMatchesVisibleWhen(
|
||||
field: ConfigFieldSchema,
|
||||
readValue: (field: ConfigFieldSchema) => unknown,
|
||||
schema: AdapterConfigSchema,
|
||||
): boolean {
|
||||
const visibleWhen = field.meta?.visibleWhen;
|
||||
if (!visibleWhen || typeof visibleWhen !== "object" || Array.isArray(visibleWhen)) return true;
|
||||
|
||||
const condition = visibleWhen as {
|
||||
key?: unknown;
|
||||
value?: unknown;
|
||||
values?: unknown;
|
||||
notValues?: unknown;
|
||||
};
|
||||
if (typeof condition.key !== "string" || condition.key.length === 0) return true;
|
||||
|
||||
const sourceField = schema.fields.find((candidate) => candidate.key === condition.key);
|
||||
if (!sourceField) return true;
|
||||
|
||||
const actual = String(readValue(sourceField) ?? "");
|
||||
if (typeof condition.value === "string") return actual === condition.value;
|
||||
if (Array.isArray(condition.values)) {
|
||||
const values = condition.values.filter((value): value is string => typeof value === "string");
|
||||
return values.length > 0 && values.includes(actual);
|
||||
}
|
||||
if (Array.isArray(condition.notValues)) {
|
||||
const values = condition.notValues.filter((value): value is string => typeof value === "string");
|
||||
return !values.includes(actual);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -369,111 +401,113 @@ export function SchemaConfigFields({
|
|||
|
||||
return (
|
||||
<>
|
||||
{schema.fields.map((field) => {
|
||||
switch (field.type) {
|
||||
case "select": {
|
||||
const currentVal = String(readValue(field) ?? "");
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<SelectField
|
||||
value={currentVal}
|
||||
options={field.options ?? []}
|
||||
{schema.fields
|
||||
.filter((field) => fieldMatchesVisibleWhen(field, readValue, schema))
|
||||
.map((field) => {
|
||||
switch (field.type) {
|
||||
case "select": {
|
||||
const currentVal = String(readValue(field) ?? "");
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<SelectField
|
||||
value={currentVal}
|
||||
options={field.options ?? []}
|
||||
onChange={(v) => writeValue(field, v)}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
case "toggle":
|
||||
return (
|
||||
<ToggleField
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
hint={field.hint}
|
||||
checked={readValue(field) === true}
|
||||
onChange={(v) => writeValue(field, v)}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
case "toggle":
|
||||
return (
|
||||
<ToggleField
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
hint={field.hint}
|
||||
checked={readValue(field) === true}
|
||||
onChange={(v) => writeValue(field, v)}
|
||||
/>
|
||||
);
|
||||
case "number":
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftNumberInput
|
||||
value={Number(readValue(field) ?? 0)}
|
||||
onCommit={(v) => writeValue(field, v)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftNumberInput
|
||||
value={Number(readValue(field) ?? 0)}
|
||||
onCommit={(v) => writeValue(field, v)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
case "textarea":
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftTextarea
|
||||
value={String(readValue(field) ?? "")}
|
||||
onCommit={(v) => writeValue(field, v || undefined)}
|
||||
immediate
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftTextarea
|
||||
value={String(readValue(field) ?? "")}
|
||||
onCommit={(v) => writeValue(field, v || undefined)}
|
||||
immediate
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
||||
case "combobox": {
|
||||
const currentVal = String(readValue(field) ?? "");
|
||||
// Dynamic options: if meta.providerModels exists, compute options
|
||||
// based on the current provider value
|
||||
let comboboxOptions = field.options ?? [];
|
||||
if (field.meta?.providerModels) {
|
||||
const providerVal = String(readValue(schema.fields.find((f) => f.key === "provider")!) ?? "auto");
|
||||
const modelsByProvider = field.meta.providerModels as Record<string, string[]>;
|
||||
if (providerVal === "auto") {
|
||||
// Auto: show all models from all providers, grouped by provider
|
||||
const providerLabel = schema.fields.find((f) => f.key === "provider");
|
||||
const providerOptions = providerLabel?.options ?? [];
|
||||
comboboxOptions = Object.entries(modelsByProvider).flatMap(([prov, models]) =>
|
||||
models.map((m) => ({
|
||||
case "combobox": {
|
||||
const currentVal = String(readValue(field) ?? "");
|
||||
// Dynamic options: if meta.providerModels exists, compute options
|
||||
// based on the current provider value
|
||||
let comboboxOptions = field.options ?? [];
|
||||
if (field.meta?.providerModels) {
|
||||
const providerVal = String(readValue(schema.fields.find((f) => f.key === "provider")!) ?? "auto");
|
||||
const modelsByProvider = field.meta.providerModels as Record<string, string[]>;
|
||||
if (providerVal === "auto") {
|
||||
// Auto: show all models from all providers, grouped by provider
|
||||
const providerLabel = schema.fields.find((f) => f.key === "provider");
|
||||
const providerOptions = providerLabel?.options ?? [];
|
||||
comboboxOptions = Object.entries(modelsByProvider).flatMap(([prov, models]) =>
|
||||
models.map((m) => ({
|
||||
label: m,
|
||||
value: m,
|
||||
group: providerOptions.find((p) => p.value === prov)?.label ?? prov,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
const providerModels = modelsByProvider[providerVal] ?? [];
|
||||
const providerLabel = schema.fields.find((f) => f.key === "provider");
|
||||
const provName = providerLabel?.options?.find((p) => p.value === providerVal)?.label ?? providerVal;
|
||||
comboboxOptions = providerModels.map((m) => ({
|
||||
label: m,
|
||||
value: m,
|
||||
group: providerOptions.find((p) => p.value === prov)?.label ?? prov,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
const providerModels = modelsByProvider[providerVal] ?? [];
|
||||
const providerLabel = schema.fields.find((f) => f.key === "provider");
|
||||
const provName = providerLabel?.options?.find((p) => p.value === providerVal)?.label ?? providerVal;
|
||||
comboboxOptions = providerModels.map((m) => ({
|
||||
label: m,
|
||||
value: m,
|
||||
group: provName,
|
||||
}));
|
||||
group: provName,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<ComboboxField
|
||||
value={currentVal}
|
||||
options={comboboxOptions}
|
||||
onChange={(v) => writeValue(field, v || undefined)}
|
||||
placeholder={field.hint}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<ComboboxField
|
||||
value={currentVal}
|
||||
options={comboboxOptions}
|
||||
onChange={(v) => writeValue(field, v || undefined)}
|
||||
placeholder={field.hint}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
case "text":
|
||||
default:
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftInput
|
||||
value={String(readValue(field) ?? "")}
|
||||
onCommit={(v) => writeValue(field, v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
})}
|
||||
case "text":
|
||||
default:
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftInput
|
||||
value={String(readValue(field) ?? "")}
|
||||
onCommit={(v) => writeValue(field, v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ import { getAdapterDisplay, getAdapterLabel } from "../adapters/adapter-display-
|
|||
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
|
||||
import { buildAgentUpdatePatch, type AgentConfigOverlay } from "../lib/agent-config-patch";
|
||||
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
|
||||
import { filterAcpxModelsByAgent } from "../lib/acpx-model-filter";
|
||||
|
||||
/* ---- Create mode values ---- */
|
||||
|
||||
|
|
@ -360,9 +361,21 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
});
|
||||
const [refreshModelsError, setRefreshModelsError] = useState<string | null>(null);
|
||||
const [refreshingModels, setRefreshingModels] = useState(false);
|
||||
const models = fetchedModels ?? externalModels ?? [];
|
||||
const rawModels = fetchedModels ?? externalModels ?? [];
|
||||
const adapterCommandField =
|
||||
adapterType === "hermes_local" ? "hermesCommand" : "command";
|
||||
const acpxAgent =
|
||||
adapterType === "acpx_local"
|
||||
? isCreate
|
||||
? String(val!.adapterSchemaValues?.agent ?? "claude")
|
||||
: eff("adapterConfig", "agent", String(config.agent ?? "claude"))
|
||||
: "";
|
||||
const models = useMemo(
|
||||
() => adapterType === "acpx_local"
|
||||
? filterAcpxModelsByAgent(rawModels, acpxAgent)
|
||||
: rawModels,
|
||||
[adapterType, rawModels, acpxAgent],
|
||||
);
|
||||
const {
|
||||
data: detectedModelData,
|
||||
refetch: refetchDetectedModel,
|
||||
|
|
@ -527,19 +540,23 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
const thinkingEffortKey =
|
||||
adapterType === "codex_local"
|
||||
? "modelReasoningEffort"
|
||||
: adapterType === "cursor"
|
||||
? "mode"
|
||||
: adapterType === "opencode_local"
|
||||
? "variant"
|
||||
: "effort";
|
||||
: adapterType === "acpx_local" && acpxAgent === "codex"
|
||||
? "modelReasoningEffort"
|
||||
: adapterType === "cursor"
|
||||
? "mode"
|
||||
: adapterType === "opencode_local"
|
||||
? "variant"
|
||||
: "effort";
|
||||
const thinkingEffortOptions =
|
||||
adapterType === "codex_local"
|
||||
? codexThinkingEffortOptions
|
||||
: adapterType === "cursor"
|
||||
? cursorModeOptions
|
||||
: adapterType === "opencode_local"
|
||||
? openCodeThinkingEffortOptions
|
||||
: claudeThinkingEffortOptions;
|
||||
: adapterType === "acpx_local" && acpxAgent === "codex"
|
||||
? codexThinkingEffortOptions
|
||||
: adapterType === "cursor"
|
||||
? cursorModeOptions
|
||||
: adapterType === "opencode_local"
|
||||
? openCodeThinkingEffortOptions
|
||||
: claudeThinkingEffortOptions;
|
||||
const currentThinkingEffort = isCreate
|
||||
? val!.thinkingEffort
|
||||
: adapterType === "codex_local"
|
||||
|
|
@ -548,11 +565,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
"modelReasoningEffort",
|
||||
String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""),
|
||||
)
|
||||
: adapterType === "cursor"
|
||||
? eff("adapterConfig", "mode", String(config.mode ?? ""))
|
||||
: adapterType === "opencode_local"
|
||||
? eff("adapterConfig", "variant", String(config.variant ?? ""))
|
||||
: eff("adapterConfig", "effort", String(config.effort ?? ""));
|
||||
: adapterType === "acpx_local" && acpxAgent === "codex"
|
||||
? eff(
|
||||
"adapterConfig",
|
||||
"modelReasoningEffort",
|
||||
String(config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort ?? ""),
|
||||
)
|
||||
: adapterType === "cursor"
|
||||
? eff("adapterConfig", "mode", String(config.mode ?? ""))
|
||||
: adapterType === "opencode_local"
|
||||
? eff("adapterConfig", "variant", String(config.variant ?? ""))
|
||||
: eff("adapterConfig", "effort", String(config.effort ?? ""));
|
||||
const showThinkingEffort = adapterType !== "gemini_local";
|
||||
const codexSearchEnabled = adapterType === "codex_local"
|
||||
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
|
||||
|
|
@ -982,7 +1005,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
const result = await refetchDetectedModel();
|
||||
return result.data?.model ?? null;
|
||||
}}
|
||||
onRefreshModels={adapterType === "codex_local" ? handleRefreshModels : undefined}
|
||||
onRefreshModels={
|
||||
adapterType === "codex_local" || adapterType === "acpx_local"
|
||||
? handleRefreshModels
|
||||
: undefined
|
||||
}
|
||||
refreshingModels={refreshingModels}
|
||||
detectModelLabel="Detect model"
|
||||
emptyDetectHint="No model detected. Select or enter one manually."
|
||||
|
|
|
|||
26
ui/src/lib/acpx-model-filter.test.ts
Normal file
26
ui/src/lib/acpx-model-filter.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { filterAcpxModelsByAgent } from "./acpx-model-filter";
|
||||
|
||||
const mixedModels = [
|
||||
{ id: "claude-sonnet-4-6", label: "Claude: Claude Sonnet 4.6" },
|
||||
{ id: "gpt-5.3-codex", label: "Codex: gpt-5.3-codex" },
|
||||
{ id: "provider/custom-model", label: "Custom model" },
|
||||
];
|
||||
|
||||
describe("filterAcpxModelsByAgent", () => {
|
||||
it("keeps only Claude models when ACPX Claude is selected", () => {
|
||||
expect(filterAcpxModelsByAgent(mixedModels, "claude").map((model) => model.id)).toEqual([
|
||||
"claude-sonnet-4-6",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps only Codex models when ACPX Codex is selected", () => {
|
||||
expect(filterAcpxModelsByAgent(mixedModels, "codex").map((model) => model.id)).toEqual([
|
||||
"gpt-5.3-codex",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not show built-in provider models for custom ACP commands", () => {
|
||||
expect(filterAcpxModelsByAgent(mixedModels, "custom")).toEqual([]);
|
||||
});
|
||||
});
|
||||
16
ui/src/lib/acpx-model-filter.ts
Normal file
16
ui/src/lib/acpx-model-filter.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { AdapterModel } from "../api/agents";
|
||||
import { models as CLAUDE_LOCAL_MODELS } from "@paperclipai/adapter-claude-local";
|
||||
import { models as CODEX_LOCAL_MODELS } from "@paperclipai/adapter-codex-local";
|
||||
|
||||
const claudeModelIds = new Set(CLAUDE_LOCAL_MODELS.map((model) => model.id));
|
||||
const codexModelIds = new Set(CODEX_LOCAL_MODELS.map((model) => model.id));
|
||||
|
||||
export function filterAcpxModelsByAgent(models: AdapterModel[], acpxAgent: string): AdapterModel[] {
|
||||
if (acpxAgent === "claude") {
|
||||
return models.filter((model) => claudeModelIds.has(model.id) || model.label.startsWith("Claude: "));
|
||||
}
|
||||
if (acpxAgent === "codex") {
|
||||
return models.filter((model) => codexModelIds.has(model.id) || model.label.startsWith("Codex: "));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue