test+docs: add capability flag tests and documentation

Tests:
- adapter-registry: verify capability flags are exposed, undefined when
  unset, and correctly declared on built-in claude_local adapter
- adapter-routes: verify GET /api/adapters includes capabilities object,
  correct flags for codex_local/process/cursor, and derived supportsSkills

Documentation:
- docs/adapters/creating-an-adapter.md: new "Capability Flags" section
  documenting all flags, their defaults, and usage example for external
  adapter plugins

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-13 01:26:42 +00:00
parent 904e9cb95e
commit 0dfb93e898
3 changed files with 151 additions and 0 deletions
+37
View File
@@ -203,6 +203,43 @@ export const sessionCodec: AdapterSessionCodec = {
};
```
## Capability Flags
Adapters can declare what "local" capabilities they support by setting optional fields on the `ServerAdapterModule`. The server and UI use these flags to decide which features to enable for agents using the adapter (instructions bundle editor, skills sync, JWT auth, etc.).
| Flag | Type | Default | What it controls |
|------|------|---------|------------------|
| `supportsLocalAgentJwt` | `boolean` | `false` | Whether heartbeat generates a local JWT for the agent |
| `supportsInstructionsBundle` | `boolean` | `false` | Managed instructions bundle (AGENTS.md) — server-side resolution + UI editor |
| `instructionsPathKey` | `string` | `"instructionsFilePath"` | The `adapterConfig` key that holds the instructions file path |
| `requiresMaterializedRuntimeSkills` | `boolean` | `false` | Whether runtime skill entries must be written to disk before execution |
These flags are exposed via `GET /api/adapters` in a `capabilities` object, along with a derived `supportsSkills` flag (true when `listSkills` or `syncSkills` is defined).
### Example
```ts
export function createServerAdapter(): ServerAdapterModule {
return {
type: "my_k8s_adapter",
execute: myExecute,
testEnvironment: myTestEnvironment,
listSkills: myListSkills,
syncSkills: mySyncSkills,
// Capability flags
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: true,
};
}
```
With these flags set, the Paperclip UI will automatically show the instructions bundle editor, skills management tab, and working directory field for agents using this adapter — no Paperclip source changes required.
If capability flags are not set, the server falls back to legacy hardcoded lists for built-in adapter types. External adapters that omit the flags will default to `false` for all capabilities.
## Skills Injection
Make Paperclip skills discoverable to your agent runtime without writing to the agent's working directory:
@@ -95,6 +95,51 @@ describe("server adapter registry", () => {
]);
});
it("exposes capability flags from registered adapters", () => {
const adapterWithCaps: ServerAdapterModule = {
type: "external_test",
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
testEnvironment: async () => ({
adapterType: "external_test",
status: "pass" as const,
checks: [],
testedAt: new Date(0).toISOString(),
}),
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "customPathKey",
requiresMaterializedRuntimeSkills: true,
};
registerServerAdapter(adapterWithCaps);
const resolved = findActiveServerAdapter("external_test");
expect(resolved).not.toBeNull();
expect(resolved!.supportsInstructionsBundle).toBe(true);
expect(resolved!.instructionsPathKey).toBe("customPathKey");
expect(resolved!.requiresMaterializedRuntimeSkills).toBe(true);
expect(resolved!.supportsLocalAgentJwt).toBe(true);
});
it("returns undefined for capability flags on adapters that do not set them", () => {
registerServerAdapter(externalAdapter);
const resolved = findActiveServerAdapter("external_test");
expect(resolved).not.toBeNull();
expect(resolved!.supportsInstructionsBundle).toBeUndefined();
expect(resolved!.instructionsPathKey).toBeUndefined();
expect(resolved!.requiresMaterializedRuntimeSkills).toBeUndefined();
});
it("built-in claude_local adapter declares capability flags", () => {
const adapter = findActiveServerAdapter("claude_local");
expect(adapter).not.toBeNull();
expect(adapter!.supportsInstructionsBundle).toBe(true);
expect(adapter!.instructionsPathKey).toBe("instructionsFilePath");
expect(adapter!.requiresMaterializedRuntimeSkills).toBe(false);
expect(adapter!.supportsLocalAgentJwt).toBe(true);
});
it("switches active adapter behavior back to the builtin when an override is paused", async () => {
const builtIn = findServerAdapter("claude_local");
expect(builtIn).not.toBeNull();
@@ -57,6 +57,75 @@ describe("adapter routes", () => {
unregisterServerAdapter("claude_local");
});
it("GET /api/adapters includes capabilities object for each adapter", async () => {
const app = createApp();
const res = await request(app).get("/api/adapters");
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body.length).toBeGreaterThan(0);
// Every adapter should have a capabilities object
for (const adapter of res.body) {
expect(adapter.capabilities).toBeDefined();
expect(typeof adapter.capabilities.supportsInstructionsBundle).toBe("boolean");
expect(typeof adapter.capabilities.supportsSkills).toBe("boolean");
expect(typeof adapter.capabilities.supportsLocalAgentJwt).toBe("boolean");
expect(typeof adapter.capabilities.requiresMaterializedRuntimeSkills).toBe("boolean");
}
});
it("GET /api/adapters returns correct capabilities for built-in adapters", async () => {
const app = createApp();
const res = await request(app).get("/api/adapters");
expect(res.status).toBe(200);
// codex_local has instructions bundle + skills + jwt, no materialized skills
// (claude_local is overridden by beforeEach, so check codex_local instead)
const codexLocal = res.body.find((a: any) => a.type === "codex_local");
expect(codexLocal).toBeDefined();
expect(codexLocal.capabilities).toMatchObject({
supportsInstructionsBundle: true,
supportsSkills: true,
supportsLocalAgentJwt: true,
requiresMaterializedRuntimeSkills: false,
});
// process adapter should have no local capabilities
const processAdapter = res.body.find((a: any) => a.type === "process");
expect(processAdapter).toBeDefined();
expect(processAdapter.capabilities).toMatchObject({
supportsInstructionsBundle: false,
supportsSkills: false,
supportsLocalAgentJwt: false,
requiresMaterializedRuntimeSkills: false,
});
// cursor adapter should require materialized runtime skills
const cursorAdapter = res.body.find((a: any) => a.type === "cursor");
expect(cursorAdapter).toBeDefined();
expect(cursorAdapter.capabilities.requiresMaterializedRuntimeSkills).toBe(true);
expect(cursorAdapter.capabilities.supportsInstructionsBundle).toBe(true);
});
it("GET /api/adapters derives supportsSkills from listSkills/syncSkills presence", async () => {
const app = createApp();
const res = await request(app).get("/api/adapters");
expect(res.status).toBe(200);
// http adapter has no listSkills/syncSkills
const httpAdapter = res.body.find((a: any) => a.type === "http");
expect(httpAdapter).toBeDefined();
expect(httpAdapter.capabilities.supportsSkills).toBe(false);
// codex_local has listSkills/syncSkills
const codexLocal = res.body.find((a: any) => a.type === "codex_local");
expect(codexLocal).toBeDefined();
expect(codexLocal.capabilities.supportsSkills).toBe(true);
});
it("uses the active adapter when resolving config schema for a paused builtin override", async () => {
const app = createApp();