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:
Dotta 2026-05-06 06:06:47 -05:00 committed by GitHub
parent 454edfe81e
commit 11ffd6f2c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 949 additions and 211 deletions

View 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);
});
});

View file

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