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:
@@ -85,10 +85,11 @@ Worker:
|
||||
- database namespace via `ctx.db`
|
||||
- scoped JSON API routes declared with `apiRoutes`
|
||||
- entities
|
||||
- projects and project workspaces
|
||||
- projects, project workspaces, and plugin-managed projects
|
||||
- companies
|
||||
- issues, comments, namespaced `plugin:<pluginKey>` origins, blocker relations, checkout assertions, assignment wakeups, and orchestration summaries
|
||||
- agents and agent sessions
|
||||
- agents, plugin-managed agents, and agent sessions
|
||||
- plugin-managed routines
|
||||
- goals
|
||||
- data/actions
|
||||
- streams
|
||||
@@ -145,6 +146,161 @@ handler. The worker receives sanitized headers, route params, query, parsed JSON
|
||||
body, actor context, and company id. Do not use plugin routes to claim core
|
||||
paths; they always remain under `/api/plugins/:pluginId/api/*`.
|
||||
|
||||
## Managed Paperclip resources
|
||||
|
||||
Plugins that provide durable Paperclip business objects should declare them in
|
||||
the manifest and let the host create or relink the actual records per company.
|
||||
Do this for plugin-owned agents, plugin-owned projects, and recurring automation.
|
||||
Do not hide long-lived work behind private plugin state when it should be visible
|
||||
to the board, scoped to a company, audited, budgeted, and assigned like normal
|
||||
Paperclip work.
|
||||
|
||||
Use these surfaces:
|
||||
|
||||
- Managed agents: declare top-level `agents[]` and require
|
||||
`agents.managed`. Use this when the plugin provides a named worker the board
|
||||
should see in the org, budget, pause, invoke, and inspect. Managed agents are
|
||||
normal Paperclip agents with plugin ownership metadata, not background plugin
|
||||
workers.
|
||||
- Managed projects: declare top-level `projects[]` and require
|
||||
`projects.managed`. Use this when the plugin needs a stable company-scoped
|
||||
project for its issues, routines, or workspace-oriented UI. Keep plugin work
|
||||
in a project instead of scattering generated issues across unrelated projects.
|
||||
- Managed routines: declare top-level `routines[]` and require
|
||||
`routines.managed`. Use this for scheduled, webhook, or manually triggered
|
||||
jobs that should create visible Paperclip issues. Prefer managed routines over
|
||||
plugin `jobs[]` for recurring business work; plugin jobs are for plugin
|
||||
runtime maintenance that does not need a board-visible task trail.
|
||||
|
||||
Managed resources are resolved by stable plugin keys, not hardcoded database
|
||||
ids. In a worker action or data handler, call `ctx.agents.managed.reconcile()`,
|
||||
`ctx.projects.managed.reconcile()`, and `ctx.routines.managed.reconcile()` for
|
||||
the current `companyId`. `reconcile()` creates the missing resource, relinks a
|
||||
recoverable binding, or returns the existing resource. `reset()` reapplies the
|
||||
manifest defaults when the operator wants to restore the plugin's suggested
|
||||
configuration.
|
||||
|
||||
Declare dependencies between managed resources with refs. A routine can point
|
||||
at a managed agent through `assigneeRef` and at a managed project through
|
||||
`projectRef`. Reconcile the referenced agent and project before reconciling the
|
||||
routine; if a ref is still missing, the routine resolution reports
|
||||
`missing_refs` instead of guessing.
|
||||
|
||||
```ts
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: "example.research-plugin",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Research Plugin",
|
||||
description: "Creates a managed research agent and scheduled research routine.",
|
||||
author: "Example",
|
||||
categories: ["automation"],
|
||||
capabilities: [
|
||||
"agents.managed",
|
||||
"projects.managed",
|
||||
"routines.managed",
|
||||
"instance.settings.register",
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
agents: [
|
||||
{
|
||||
agentKey: "researcher",
|
||||
displayName: "Researcher",
|
||||
role: "research",
|
||||
title: "Research Agent",
|
||||
capabilities: "Runs recurring research briefs for this company.",
|
||||
adapterPreference: ["codex_local", "claude_local", "process"],
|
||||
instructions: {
|
||||
content: "Follow the Paperclip heartbeat and produce concise research briefs.",
|
||||
},
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
projectKey: "research",
|
||||
displayName: "Research",
|
||||
description: "Recurring research work created by the Research Plugin.",
|
||||
status: "in_progress",
|
||||
},
|
||||
],
|
||||
routines: [
|
||||
{
|
||||
routineKey: "weekly-brief",
|
||||
title: "Weekly research brief",
|
||||
description: "Create a short research brief for the board.",
|
||||
assigneeRef: { resourceKind: "agent", resourceKey: "researcher" },
|
||||
projectRef: { resourceKind: "project", resourceKey: "research" },
|
||||
priority: "medium",
|
||||
triggers: [
|
||||
{
|
||||
kind: "schedule",
|
||||
label: "Monday morning",
|
||||
cronExpression: "0 9 * * 1",
|
||||
timezone: "America/Chicago",
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "settingsPage",
|
||||
id: "settings",
|
||||
displayName: "Research",
|
||||
exportName: "SettingsPage",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
```
|
||||
|
||||
In the worker, expose a small setup action or settings-page action that
|
||||
reconciles the resources for the selected company:
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "@paperclipai/plugin-sdk";
|
||||
|
||||
export default definePlugin({
|
||||
setup(ctx) {
|
||||
ctx.actions.register("setup-company", async (params) => {
|
||||
const companyId = String(params.companyId ?? "");
|
||||
if (!companyId) throw new Error("companyId is required");
|
||||
|
||||
const project = await ctx.projects.managed.reconcile("research", companyId);
|
||||
const agent = await ctx.agents.managed.reconcile("researcher", companyId);
|
||||
const routine = await ctx.routines.managed.reconcile("weekly-brief", companyId);
|
||||
|
||||
return { project, agent, routine };
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Authoring rules:
|
||||
|
||||
- Keep keys stable once published. Renaming `agentKey`, `projectKey`, or
|
||||
`routineKey` creates a new managed resource from the host's point of view.
|
||||
- Use managed agents for plugin-provided labor. Use `ctx.agents.invoke()` or
|
||||
`ctx.agents.sessions` only after you have a real agent id, either selected by
|
||||
the operator or resolved from `ctx.agents.managed`.
|
||||
- Use managed routines for recurring or externally triggered work that should
|
||||
produce tasks. Schedule, webhook, and API triggers are visible routine
|
||||
triggers, and each run has the normal Paperclip issue/audit trail.
|
||||
- Use managed projects to keep plugin-generated work organized and to give
|
||||
project-scoped plugin UI a stable home. For filesystem access inside a
|
||||
project, still resolve project workspaces through `ctx.projects`.
|
||||
- Keep defaults conservative. Managed declarations are suggestions owned by the
|
||||
plugin, but the resulting resources are normal Paperclip records that the
|
||||
operator can inspect, pause, and adjust.
|
||||
|
||||
UI:
|
||||
|
||||
- `usePluginData`
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AdapterModel } from "@paperclipai/adapter-utils";
|
||||
|
||||
export const type = "acpx_local";
|
||||
export const label = "ACPX (local)";
|
||||
|
||||
@@ -6,6 +8,7 @@ export const DEFAULT_ACPX_LOCAL_MODE = "persistent";
|
||||
export const DEFAULT_ACPX_LOCAL_PERMISSION_MODE = "approve-all";
|
||||
export const DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS = "deny";
|
||||
export const DEFAULT_ACPX_LOCAL_TIMEOUT_SEC = 0;
|
||||
export const DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS = 0;
|
||||
|
||||
export const acpxAgentOptions = [
|
||||
{ id: "claude", label: "Claude via ACPX" },
|
||||
@@ -13,6 +16,8 @@ export const acpxAgentOptions = [
|
||||
{ id: "custom", label: "Custom ACP command" },
|
||||
] as const;
|
||||
|
||||
export const models: AdapterModel[] = [];
|
||||
|
||||
export const agentConfigurationDoc = `# acpx_local agent configuration
|
||||
|
||||
Adapter: acpx_local
|
||||
@@ -30,7 +35,7 @@ Don't use when:
|
||||
Core fields:
|
||||
- agent (string, optional): claude, codex, or custom. Defaults to claude.
|
||||
- agentCommand (string, optional): custom ACP command when agent=custom, or an override for a built-in ACP agent command.
|
||||
- mode (string, optional): persistent or oneshot. Defaults to persistent.
|
||||
- mode (string, optional): persistent or oneshot. Defaults to persistent. Paperclip keeps session state persistent and may close the live process between runs.
|
||||
- cwd (string, optional): default absolute working directory fallback for the agent process.
|
||||
- permissionMode (string, optional): defaults to approve-all, meaning ACPX permission requests are auto-approved.
|
||||
- nonInteractivePermissions (string, optional): fallback behavior when ACPX cannot ask interactively. Supported values are deny and fail.
|
||||
@@ -38,7 +43,11 @@ Core fields:
|
||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file used by Paperclip prompt construction.
|
||||
- promptTemplate (string, optional): run prompt template.
|
||||
- bootstrapPromptTemplate (string, optional): first-run bootstrap prompt template.
|
||||
- model (string, optional): requested ACP model. Claude and Codex ACP agents both receive this through ACP session config.
|
||||
- effort/modelReasoningEffort (string, optional): requested thinking effort. Claude uses effort; Codex uses modelReasoningEffort/reasoning_effort.
|
||||
- fastMode (boolean, optional): for ACPX Codex, request Codex fast mode through ACP session config.
|
||||
- timeoutSec (number, optional): run timeout in seconds. Defaults to 0, meaning no adapter timeout.
|
||||
- warmHandleIdleMs (number, optional): live ACPX process idle window after a successful persistent run. Defaults to 0, meaning Paperclip shuts the process down after each run while retaining ACPX session state.
|
||||
- env (object, optional): KEY=VALUE environment variables or secret bindings.
|
||||
|
||||
Dependency decision:
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { AdapterConfigSchema } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
DEFAULT_ACPX_LOCAL_AGENT,
|
||||
DEFAULT_ACPX_LOCAL_MODE,
|
||||
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
|
||||
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
|
||||
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
|
||||
DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
|
||||
acpxAgentOptions,
|
||||
} from "../index.js";
|
||||
|
||||
@@ -26,27 +25,6 @@ export function getConfigSchema(): AdapterConfigSchema {
|
||||
type: "text",
|
||||
hint: "Required for custom agents; optional override for built-in Claude or Codex ACP commands.",
|
||||
},
|
||||
{
|
||||
key: "mode",
|
||||
label: "Session mode",
|
||||
type: "select",
|
||||
default: DEFAULT_ACPX_LOCAL_MODE,
|
||||
options: [
|
||||
{ value: "persistent", label: "Persistent" },
|
||||
{ value: "oneshot", label: "One shot" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "permissionMode",
|
||||
label: "Permission mode",
|
||||
type: "select",
|
||||
default: DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
|
||||
options: [
|
||||
{ value: "approve-all", label: "Approve all" },
|
||||
{ value: "default", label: "Approve reads" },
|
||||
],
|
||||
hint: "Defaults to maximum permissions. Approve reads grants read-only requests and asks for approval on writes.",
|
||||
},
|
||||
{
|
||||
key: "nonInteractivePermissions",
|
||||
label: "Non-interactive permissions",
|
||||
@@ -56,6 +34,7 @@ export function getConfigSchema(): AdapterConfigSchema {
|
||||
{ value: "deny", label: "Deny" },
|
||||
{ value: "fail", label: "Fail" },
|
||||
],
|
||||
hint: "Fallback if the ACP agent asks for input outside an interactive session. Paperclip still auto-approves permissions by default.",
|
||||
},
|
||||
{
|
||||
key: "cwd",
|
||||
@@ -70,20 +49,12 @@ export function getConfigSchema(): AdapterConfigSchema {
|
||||
hint: "Optional ACPX session state directory. Defaults to Paperclip-managed company/agent scoped storage.",
|
||||
},
|
||||
{
|
||||
key: "instructionsFilePath",
|
||||
label: "Instructions file",
|
||||
type: "text",
|
||||
hint: "Optional absolute path to markdown instructions injected into the run prompt.",
|
||||
},
|
||||
{
|
||||
key: "promptTemplate",
|
||||
label: "Prompt template",
|
||||
type: "textarea",
|
||||
},
|
||||
{
|
||||
key: "bootstrapPromptTemplate",
|
||||
label: "Bootstrap prompt template",
|
||||
type: "textarea",
|
||||
key: "fastMode",
|
||||
label: "Codex fast mode",
|
||||
type: "toggle",
|
||||
default: false,
|
||||
hint: "Only applies when ACP agent is Codex. Requests Codex Fast mode through ACP session config.",
|
||||
meta: { visibleWhen: { key: "agent", values: ["codex"] } },
|
||||
},
|
||||
{
|
||||
key: "timeoutSec",
|
||||
@@ -91,6 +62,13 @@ export function getConfigSchema(): AdapterConfigSchema {
|
||||
type: "number",
|
||||
default: DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
|
||||
},
|
||||
{
|
||||
key: "warmHandleIdleMs",
|
||||
label: "Warm process idle ms",
|
||||
type: "number",
|
||||
default: DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
|
||||
hint: "Defaults to 0, which closes the ACPX process after each run while retaining persistent session state.",
|
||||
},
|
||||
{
|
||||
key: "env",
|
||||
label: "Environment JSON",
|
||||
|
||||
@@ -45,10 +45,10 @@ import {
|
||||
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
|
||||
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
|
||||
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
|
||||
DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
|
||||
} from "../index.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DEFAULT_WARM_HANDLE_IDLE_MS = 15 * 60 * 1000;
|
||||
const WRAPPER_CLEANUP_RETENTION_MS = 15 * 60 * 1000;
|
||||
const PAPERCLIP_MANAGED_CODEX_SKILLS_MANIFEST = ".paperclip-managed-skills.json";
|
||||
|
||||
@@ -59,6 +59,7 @@ interface RuntimeCacheEntry {
|
||||
handle: AcpRuntimeHandle;
|
||||
fingerprint: string;
|
||||
lastUsedAt: number;
|
||||
cleanupTimer?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
interface ExecuteDeps {
|
||||
@@ -79,6 +80,9 @@ interface AcpxPreparedRuntime {
|
||||
stateDir: string;
|
||||
permissionMode: "approve-all" | "approve-reads" | "deny-all";
|
||||
nonInteractivePermissions: "deny" | "fail";
|
||||
requestedModel: string;
|
||||
requestedThinkingEffort: string;
|
||||
fastMode: boolean;
|
||||
timeoutSec: number;
|
||||
sessionKey: string;
|
||||
fingerprint: string;
|
||||
@@ -504,6 +508,15 @@ function normalizeNonInteractivePermissions(config: Record<string, unknown>): "d
|
||||
: "deny";
|
||||
}
|
||||
|
||||
function normalizeRequestedThinkingEffort(config: Record<string, unknown>): string {
|
||||
return (
|
||||
asString(config.modelReasoningEffort, "") ||
|
||||
asString(config.reasoningEffort, "") ||
|
||||
asString(config.thinkingEffort, "") ||
|
||||
asString(config.effort, "")
|
||||
).trim();
|
||||
}
|
||||
|
||||
function isCompatibleSession(
|
||||
params: Record<string, unknown>,
|
||||
runtime: Pick<AcpxPreparedRuntime, "fingerprint" | "sessionKey" | "cwd" | "mode" | "acpxAgent" | "remoteExecutionIdentity">,
|
||||
@@ -534,6 +547,9 @@ function buildSessionParams(input: {
|
||||
mode: prepared.mode,
|
||||
stateDir: prepared.stateDir,
|
||||
configFingerprint: prepared.fingerprint,
|
||||
...(prepared.requestedModel ? { model: prepared.requestedModel } : {}),
|
||||
...(prepared.requestedThinkingEffort ? { thinkingEffort: prepared.requestedThinkingEffort } : {}),
|
||||
...(prepared.fastMode ? { fastMode: true } : {}),
|
||||
skills: prepared.skillsIdentity,
|
||||
...(prepared.workspaceId ? { workspaceId: prepared.workspaceId } : {}),
|
||||
...(prepared.workspaceRepoUrl ? { repoUrl: prepared.workspaceRepoUrl } : {}),
|
||||
@@ -644,6 +660,9 @@ async function buildRuntime(input: {
|
||||
const mode = normalizeMode(config);
|
||||
const permissionMode = normalizePermissionMode(config);
|
||||
const nonInteractivePermissions = normalizeNonInteractivePermissions(config);
|
||||
const requestedModel = asString(config.model, "").trim();
|
||||
const requestedThinkingEffort = normalizeRequestedThinkingEffort(config);
|
||||
const fastMode = acpxAgent === "codex" && config.fastMode === true;
|
||||
const timeoutSec = asNumber(config.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC);
|
||||
const stateDir = path.resolve(asString(config.stateDir, "") || defaultStateDir(agent.companyId, agent.id));
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
@@ -741,6 +760,9 @@ async function buildRuntime(input: {
|
||||
mode,
|
||||
permissionMode,
|
||||
nonInteractivePermissions,
|
||||
requestedModel,
|
||||
requestedThinkingEffort,
|
||||
fastMode,
|
||||
remoteExecutionIdentity,
|
||||
skillsIdentity,
|
||||
skillPromptInstructions,
|
||||
@@ -766,13 +788,16 @@ async function buildRuntime(input: {
|
||||
stateDir,
|
||||
permissionMode,
|
||||
nonInteractivePermissions,
|
||||
requestedModel,
|
||||
requestedThinkingEffort,
|
||||
fastMode,
|
||||
timeoutSec,
|
||||
sessionKey,
|
||||
fingerprint,
|
||||
agentCommand,
|
||||
agentRegistry,
|
||||
remoteExecutionIdentity,
|
||||
skillPromptInstructions,
|
||||
skillPromptInstructions,
|
||||
skillsIdentity: {
|
||||
...skillsIdentity,
|
||||
commandNotes: skillCommandNotes,
|
||||
@@ -780,6 +805,51 @@ async function buildRuntime(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function sessionConfigOptions(prepared: AcpxPreparedRuntime): Array<{ key: string; value: string }> {
|
||||
const options: Array<{ key: string; value: string }> = [];
|
||||
if (prepared.requestedModel) options.push({ key: "model", value: prepared.requestedModel });
|
||||
if (prepared.requestedThinkingEffort) {
|
||||
options.push({
|
||||
key: prepared.acpxAgent === "codex" ? "reasoning_effort" : "effort",
|
||||
value: prepared.requestedThinkingEffort,
|
||||
});
|
||||
}
|
||||
if (prepared.fastMode) {
|
||||
options.push(
|
||||
{ key: "service_tier", value: "fast" },
|
||||
{ key: "features.fast_mode", value: "true" },
|
||||
);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function applySessionConfigOptions(input: {
|
||||
runtime: AcpRuntime;
|
||||
handle: AcpRuntimeHandle;
|
||||
prepared: AcpxPreparedRuntime;
|
||||
onLog: AdapterExecutionContext["onLog"];
|
||||
}) {
|
||||
const options = sessionConfigOptions(input.prepared);
|
||||
if (options.length === 0) return;
|
||||
if (!input.runtime.setConfigOption) {
|
||||
const message =
|
||||
"ACPX runtime does not expose session config controls; upgrade ACPX or remove configured model, effort, and fast mode overrides.";
|
||||
await input.onLog("stderr", `[paperclip] ${message}\n`);
|
||||
throw new Error(message);
|
||||
}
|
||||
for (const option of options) {
|
||||
await input.runtime.setConfigOption({
|
||||
handle: input.handle,
|
||||
key: option.key,
|
||||
value: option.value,
|
||||
});
|
||||
await input.onLog(
|
||||
"stdout",
|
||||
`[paperclip] Applied ACPX ${input.prepared.acpxAgent} config ${option.key}=${option.value}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean): Promise<{
|
||||
prompt: string;
|
||||
promptMetrics: Record<string, number>;
|
||||
@@ -951,20 +1021,77 @@ async function cleanupIdleHandles(input: {
|
||||
now: number;
|
||||
idleMs: number;
|
||||
}) {
|
||||
if (input.idleMs <= 0) return;
|
||||
|
||||
const stale: Array<[string, RuntimeCacheEntry]> = [];
|
||||
for (const entry of input.handles.entries()) {
|
||||
if (input.now - entry[1].lastUsedAt >= input.idleMs) stale.push(entry);
|
||||
}
|
||||
for (const [key, entry] of stale) {
|
||||
input.handles.delete(key);
|
||||
await entry.runtime.close({
|
||||
handle: entry.handle,
|
||||
await closeWarmHandle({
|
||||
handles: input.handles,
|
||||
key,
|
||||
entry,
|
||||
reason: "paperclip idle cleanup",
|
||||
discardPersistentState: false,
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearWarmHandleTimer(entry: RuntimeCacheEntry) {
|
||||
if (!entry.cleanupTimer) return;
|
||||
clearTimeout(entry.cleanupTimer);
|
||||
entry.cleanupTimer = undefined;
|
||||
}
|
||||
|
||||
async function closeWarmHandle(input: {
|
||||
handles: Map<string, RuntimeCacheEntry>;
|
||||
key: string;
|
||||
entry: RuntimeCacheEntry;
|
||||
reason: string;
|
||||
discardPersistentState?: boolean;
|
||||
}) {
|
||||
if (input.handles.get(input.key) === input.entry) {
|
||||
input.handles.delete(input.key);
|
||||
}
|
||||
clearWarmHandleTimer(input.entry);
|
||||
await input.entry.runtime.close({
|
||||
handle: input.entry.handle,
|
||||
reason: input.reason,
|
||||
discardPersistentState: input.discardPersistentState ?? false,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function scheduleIdleHandleCleanup(input: {
|
||||
handles: Map<string, RuntimeCacheEntry>;
|
||||
key: string;
|
||||
entry: RuntimeCacheEntry;
|
||||
idleMs: number;
|
||||
now: () => number;
|
||||
}) {
|
||||
clearWarmHandleTimer(input.entry);
|
||||
if (input.idleMs <= 0) return;
|
||||
|
||||
const delayMs = Math.max(1, input.entry.lastUsedAt + input.idleMs - input.now());
|
||||
input.entry.cleanupTimer = setTimeout(() => {
|
||||
void (async () => {
|
||||
const current = input.handles.get(input.key);
|
||||
if (current !== input.entry) return;
|
||||
const idleForMs = input.now() - input.entry.lastUsedAt;
|
||||
if (idleForMs < input.idleMs) {
|
||||
scheduleIdleHandleCleanup(input);
|
||||
return;
|
||||
}
|
||||
await closeWarmHandle({
|
||||
handles: input.handles,
|
||||
key: input.key,
|
||||
entry: input.entry,
|
||||
reason: "paperclip idle cleanup",
|
||||
});
|
||||
})();
|
||||
}, delayMs);
|
||||
input.entry.cleanupTimer.unref?.();
|
||||
}
|
||||
|
||||
function warmHandleMatches(
|
||||
entry: RuntimeCacheEntry | undefined,
|
||||
runtime: AcpRuntime,
|
||||
@@ -980,7 +1107,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||
|
||||
return async function executeAcpxLocal(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const prepared = await buildRuntime({ ctx });
|
||||
const warmIdleMs = asNumber(ctx.config.warmHandleIdleMs, DEFAULT_WARM_HANDLE_IDLE_MS);
|
||||
const warmIdleMs = asNumber(ctx.config.warmHandleIdleMs, DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS);
|
||||
await cleanupIdleHandles({ handles: warmHandles, now: now(), idleMs: warmIdleMs });
|
||||
|
||||
const previousParams = parseObject(ctx.runtime.sessionParams);
|
||||
@@ -996,6 +1123,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||
timeoutMs: prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined,
|
||||
};
|
||||
const runtime = cached?.runtime ?? createRuntime(runtimeOptions);
|
||||
if (cached) clearWarmHandleTimer(cached);
|
||||
if (!canResume && asString(previousParams.runtimeSessionName, "")) {
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
@@ -1044,7 +1172,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||
errorMessage: message,
|
||||
...classified,
|
||||
provider: "acpx",
|
||||
model: null,
|
||||
model: prepared.requestedModel || null,
|
||||
clearSession,
|
||||
resultJson: { phase: "ensure_session" },
|
||||
summary: message,
|
||||
@@ -1059,12 +1187,52 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||
errorMessage: "ACPX did not return a runtime session handle.",
|
||||
errorCode: "acpx_runtime_error",
|
||||
provider: "acpx",
|
||||
model: null,
|
||||
model: prepared.requestedModel || null,
|
||||
resultJson: { phase: "ensure_session" },
|
||||
summary: "ACPX did not return a runtime session handle.",
|
||||
};
|
||||
}
|
||||
const sessionHandle = handle;
|
||||
try {
|
||||
await applySessionConfigOptions({
|
||||
runtime,
|
||||
handle: sessionHandle,
|
||||
prepared,
|
||||
onLog: ctx.onLog,
|
||||
});
|
||||
} catch (err) {
|
||||
const classified = classifyError(err);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
|
||||
await runtime.close({
|
||||
handle: sessionHandle,
|
||||
reason: "paperclip config cleanup",
|
||||
discardPersistentState: false,
|
||||
}).catch(() => {});
|
||||
const existing = warmHandles.get(prepared.sessionKey);
|
||||
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
|
||||
clearWarmHandleTimer(existing);
|
||||
warmHandles.delete(prepared.sessionKey);
|
||||
}
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: message,
|
||||
...classified,
|
||||
provider: "acpx",
|
||||
model: prepared.requestedModel || null,
|
||||
clearSession,
|
||||
resultJson: {
|
||||
phase: "configure_session",
|
||||
agent: prepared.acpxAgent,
|
||||
requestedModel: prepared.requestedModel || null,
|
||||
requestedThinkingEffort: prepared.requestedThinkingEffort || null,
|
||||
fastMode: prepared.fastMode,
|
||||
},
|
||||
summary: message,
|
||||
};
|
||||
}
|
||||
const { prompt, promptMetrics, commandNotes } = await buildPrompt(ctx, resumedSession);
|
||||
const runPrompt = joinPromptSections([prepared.skillPromptInstructions, prompt]);
|
||||
await emitAcpxLog(ctx, {
|
||||
@@ -1076,6 +1244,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||
runtimeSessionName: sessionHandle.runtimeSessionName,
|
||||
mode: prepared.mode,
|
||||
permissionMode: prepared.permissionMode,
|
||||
model: prepared.requestedModel || null,
|
||||
thinkingEffort: prepared.requestedThinkingEffort || null,
|
||||
fastMode: prepared.fastMode,
|
||||
});
|
||||
if (ctx.onMeta) {
|
||||
await ctx.onMeta({
|
||||
@@ -1085,6 +1256,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||
commandNotes: [
|
||||
`ACPX runtime embedded in Paperclip with ${prepared.mode} session mode.`,
|
||||
`Effective ACPX permission mode: ${prepared.permissionMode}.`,
|
||||
...(prepared.requestedModel ? [`Requested ACPX model: ${prepared.requestedModel}.`] : []),
|
||||
...(prepared.requestedThinkingEffort ? [`Requested ACPX thinking effort: ${prepared.requestedThinkingEffort}.`] : []),
|
||||
...(prepared.fastMode ? ["Requested ACPX Codex fast mode."] : []),
|
||||
...(Array.isArray(prepared.skillsIdentity.commandNotes)
|
||||
? prepared.skillsIdentity.commandNotes.filter((note): note is string => typeof note === "string")
|
||||
: []),
|
||||
@@ -1130,15 +1304,23 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||
const terminal = await turn.result;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (terminal.status === "failed" || terminal.status === "cancelled" || timedOut) {
|
||||
if (warmHandleMatches(warmHandles.get(prepared.sessionKey), runtime, sessionHandle)) {
|
||||
warmHandles.delete(prepared.sessionKey);
|
||||
const existing = warmHandles.get(prepared.sessionKey);
|
||||
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
|
||||
await closeWarmHandle({
|
||||
handles: warmHandles,
|
||||
key: prepared.sessionKey,
|
||||
entry: existing,
|
||||
reason: timedOut ? "paperclip timeout cleanup" : `paperclip turn ${terminal.status}`,
|
||||
discardPersistentState: terminal.status === "cancelled" || timedOut,
|
||||
});
|
||||
} else {
|
||||
await runtime.close({
|
||||
handle: sessionHandle,
|
||||
reason: timedOut ? "paperclip timeout cleanup" : `paperclip turn ${terminal.status}`,
|
||||
discardPersistentState: terminal.status === "cancelled" || timedOut,
|
||||
}).catch(() => {});
|
||||
}
|
||||
await runtime.close({
|
||||
handle: sessionHandle,
|
||||
reason: timedOut ? "paperclip timeout cleanup" : `paperclip turn ${terminal.status}`,
|
||||
discardPersistentState: terminal.status === "cancelled" || timedOut,
|
||||
}).catch(() => {});
|
||||
} else if (prepared.mode === "persistent") {
|
||||
} else if (prepared.mode === "persistent" && warmIdleMs > 0) {
|
||||
const existing = warmHandles.get(prepared.sessionKey);
|
||||
if (existing && !warmHandleMatches(existing, runtime, sessionHandle)) {
|
||||
await runtime.close({
|
||||
@@ -1147,13 +1329,37 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||
discardPersistentState: false,
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
warmHandles.set(prepared.sessionKey, {
|
||||
const entry: RuntimeCacheEntry = {
|
||||
runtime,
|
||||
handle: sessionHandle,
|
||||
fingerprint: prepared.fingerprint,
|
||||
lastUsedAt: now(),
|
||||
};
|
||||
warmHandles.set(prepared.sessionKey, entry);
|
||||
scheduleIdleHandleCleanup({
|
||||
handles: warmHandles,
|
||||
key: prepared.sessionKey,
|
||||
entry,
|
||||
idleMs: warmIdleMs,
|
||||
now,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const existing = warmHandles.get(prepared.sessionKey);
|
||||
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
|
||||
await closeWarmHandle({
|
||||
handles: warmHandles,
|
||||
key: prepared.sessionKey,
|
||||
entry: existing,
|
||||
reason: "paperclip completed turn cleanup",
|
||||
});
|
||||
} else {
|
||||
await runtime.close({
|
||||
handle: sessionHandle,
|
||||
reason: "paperclip completed turn cleanup",
|
||||
discardPersistentState: false,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = timedOut
|
||||
@@ -1176,7 +1382,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||
sessionParams: buildSessionParams({ prepared, handle: sessionHandle }),
|
||||
sessionDisplayId: sessionHandle.agentSessionId ?? sessionHandle.backendSessionId ?? sessionHandle.runtimeSessionName,
|
||||
provider: "acpx",
|
||||
model: null,
|
||||
model: prepared.requestedModel || null,
|
||||
billingType: "unknown",
|
||||
costUsd: null,
|
||||
resultJson: {
|
||||
@@ -1184,6 +1390,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||
stopReason: terminalStopReason,
|
||||
permissionMode: prepared.permissionMode,
|
||||
mode: prepared.mode,
|
||||
requestedModel: prepared.requestedModel || null,
|
||||
requestedThinkingEffort: prepared.requestedThinkingEffort || null,
|
||||
fastMode: prepared.fastMode,
|
||||
},
|
||||
summary: textParts.join("").trim() || terminalStopReason || terminal.status,
|
||||
clearSession,
|
||||
@@ -1199,7 +1408,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||
reason: timedOut ? "paperclip timeout cleanup" : "paperclip error cleanup",
|
||||
discardPersistentState: timedOut,
|
||||
}).catch(() => {});
|
||||
if (warmHandleMatches(warmHandles.get(prepared.sessionKey), runtime, sessionHandle)) {
|
||||
const existing = warmHandles.get(prepared.sessionKey);
|
||||
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
|
||||
clearWarmHandleTimer(existing);
|
||||
warmHandles.delete(prepared.sessionKey);
|
||||
}
|
||||
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
|
||||
@@ -1211,7 +1422,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||
errorCode: timedOut ? "acpx_timeout" : classified.errorCode,
|
||||
errorMeta: classified.errorMeta,
|
||||
provider: "acpx",
|
||||
model: null,
|
||||
model: prepared.requestedModel || null,
|
||||
clearSession: clearSession || timedOut,
|
||||
resultJson: { phase: "turn" },
|
||||
summary: message,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
|
||||
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
|
||||
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
|
||||
DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
|
||||
} from "../index.js";
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
@@ -80,13 +81,15 @@ function readNumber(value: unknown, fallback: number): number {
|
||||
|
||||
export function buildAcpxLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const schemaValues = v.adapterSchemaValues ?? {};
|
||||
const agent = String(schemaValues.agent || DEFAULT_ACPX_LOCAL_AGENT);
|
||||
const ac: Record<string, unknown> = {
|
||||
agent: schemaValues.agent || DEFAULT_ACPX_LOCAL_AGENT,
|
||||
agent,
|
||||
mode: schemaValues.mode || DEFAULT_ACPX_LOCAL_MODE,
|
||||
permissionMode: schemaValues.permissionMode || DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
|
||||
nonInteractivePermissions:
|
||||
schemaValues.nonInteractivePermissions || DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
|
||||
timeoutSec: readNumber(schemaValues.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC),
|
||||
warmHandleIdleMs: readNumber(schemaValues.warmHandleIdleMs, DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS),
|
||||
};
|
||||
|
||||
for (const key of [
|
||||
@@ -105,6 +108,11 @@ export function buildAcpxLocalConfig(v: CreateConfigValues): Record<string, unkn
|
||||
if (!ac.instructionsFilePath && v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (!ac.promptTemplate && v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (!ac.bootstrapPromptTemplate && v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
if (v.model?.trim()) ac.model = v.model.trim();
|
||||
if (v.thinkingEffort) {
|
||||
ac[agent === "codex" ? "modelReasoningEffort" : "effort"] = v.thinkingEffort;
|
||||
}
|
||||
if (schemaValues.fastMode === true) ac.fastMode = true;
|
||||
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
const legacy = parseEnvVars(v.envVars);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -215,6 +215,103 @@ describe("acpx_local execute", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("closes successful persistent runs by default while retaining session state", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-close-success-"));
|
||||
try {
|
||||
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
|
||||
const execute = createAcpxLocalExecutor({
|
||||
createRuntime: () => runtime,
|
||||
});
|
||||
const result = await execute(buildContext(root));
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.sessionParams).toMatchObject({
|
||||
mode: "persistent",
|
||||
acpSessionId: "acp-1",
|
||||
});
|
||||
expect(runtime.closeInputs).toEqual([
|
||||
expect.objectContaining({
|
||||
reason: "paperclip completed turn cleanup",
|
||||
discardPersistentState: false,
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("applies requested Codex model, reasoning effort, and fast mode before starting the turn", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-codex-config-"));
|
||||
try {
|
||||
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
|
||||
const execute = createAcpxLocalExecutor({
|
||||
createRuntime: () => runtime,
|
||||
});
|
||||
const result = await execute(buildContext(root, {
|
||||
config: {
|
||||
agent: "codex",
|
||||
cwd: root,
|
||||
stateDir: path.join(root, "state"),
|
||||
promptTemplate: "Do the assigned work.",
|
||||
model: "gpt-5.4",
|
||||
modelReasoningEffort: "xhigh",
|
||||
fastMode: true,
|
||||
},
|
||||
}));
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.model).toBe("gpt-5.4");
|
||||
expect(runtime.setConfigInputs).toEqual([
|
||||
expect.objectContaining({ key: "model", value: "gpt-5.4" }),
|
||||
expect.objectContaining({ key: "reasoning_effort", value: "xhigh" }),
|
||||
expect.objectContaining({ key: "service_tier", value: "fast" }),
|
||||
expect.objectContaining({ key: "features.fast_mode", value: "true" }),
|
||||
]);
|
||||
expect(runtime.startInputs).toHaveLength(1);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("logs a clear error when configured session options need unsupported runtime controls", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-missing-config-controls-"));
|
||||
try {
|
||||
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
|
||||
Object.defineProperty(runtime, "setConfigOption", { value: undefined });
|
||||
const logs: LogEntry[] = [];
|
||||
const execute = createAcpxLocalExecutor({
|
||||
createRuntime: () => runtime,
|
||||
});
|
||||
const result = await execute(buildContext(root, {
|
||||
config: {
|
||||
agent: "codex",
|
||||
cwd: root,
|
||||
stateDir: path.join(root, "state"),
|
||||
promptTemplate: "Do the assigned work.",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
onLog: async (stream, chunk) => logs.push({ stream, chunk }),
|
||||
}));
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorMessage).toContain("does not expose session config controls");
|
||||
expect(logs).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
stream: "stderr",
|
||||
chunk: expect.stringContaining("upgrade ACPX or remove configured model"),
|
||||
}),
|
||||
]));
|
||||
expect(runtime.closeInputs).toEqual([
|
||||
expect.objectContaining({
|
||||
reason: "paperclip config cleanup",
|
||||
discardPersistentState: false,
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses a compatible warm session and starts fresh when cwd changes", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-reuse-"));
|
||||
const other = path.join(root, "other");
|
||||
@@ -228,8 +325,15 @@ describe("acpx_local execute", () => {
|
||||
return runtime;
|
||||
},
|
||||
});
|
||||
const warmConfig = {
|
||||
agent: "claude",
|
||||
cwd: root,
|
||||
stateDir: path.join(root, "state"),
|
||||
promptTemplate: "Do the assigned work.",
|
||||
warmHandleIdleMs: 60_000,
|
||||
};
|
||||
|
||||
const first = await execute(buildContext(root));
|
||||
const first = await execute(buildContext(root, { config: warmConfig }));
|
||||
const second = await execute(buildContext(root, {
|
||||
runtime: {
|
||||
sessionId: first.sessionId ?? null,
|
||||
@@ -237,6 +341,7 @@ describe("acpx_local execute", () => {
|
||||
sessionDisplayId: first.sessionDisplayId ?? null,
|
||||
taskKey: "PAP-1",
|
||||
},
|
||||
config: warmConfig,
|
||||
}));
|
||||
const third = await execute(buildContext(root, {
|
||||
runtime: {
|
||||
@@ -250,6 +355,7 @@ describe("acpx_local execute", () => {
|
||||
cwd: other,
|
||||
stateDir: path.join(root, "state"),
|
||||
promptTemplate: "Do the assigned work.",
|
||||
warmHandleIdleMs: 60_000,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -279,8 +385,26 @@ describe("acpx_local execute", () => {
|
||||
});
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
execute(buildContext(root, { runId: "run-1" })),
|
||||
execute(buildContext(root, { runId: "run-2" })),
|
||||
execute(buildContext(root, {
|
||||
runId: "run-1",
|
||||
config: {
|
||||
agent: "claude",
|
||||
cwd: root,
|
||||
stateDir: path.join(root, "state"),
|
||||
promptTemplate: "Do the assigned work.",
|
||||
warmHandleIdleMs: 60_000,
|
||||
},
|
||||
})),
|
||||
execute(buildContext(root, {
|
||||
runId: "run-2",
|
||||
config: {
|
||||
agent: "claude",
|
||||
cwd: root,
|
||||
stateDir: path.join(root, "state"),
|
||||
promptTemplate: "Do the assigned work.",
|
||||
warmHandleIdleMs: 60_000,
|
||||
},
|
||||
})),
|
||||
]);
|
||||
|
||||
expect(first.exitCode).toBe(0);
|
||||
@@ -295,6 +419,47 @@ describe("acpx_local execute", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("cleans configured warm handles after their idle window", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-warm-idle-"));
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let clock = 0;
|
||||
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
|
||||
const warmHandles = new Map();
|
||||
const execute = createAcpxLocalExecutor({
|
||||
warmHandles,
|
||||
now: () => clock,
|
||||
createRuntime: () => runtime,
|
||||
});
|
||||
|
||||
const result = await execute(buildContext(root, {
|
||||
config: {
|
||||
agent: "claude",
|
||||
cwd: root,
|
||||
stateDir: path.join(root, "state"),
|
||||
promptTemplate: "Do the assigned work.",
|
||||
warmHandleIdleMs: 1_000,
|
||||
},
|
||||
}));
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(warmHandles.size).toBe(1);
|
||||
clock = 1_000;
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(warmHandles.size).toBe(0);
|
||||
expect(runtime.closeInputs).toEqual([
|
||||
expect.objectContaining({
|
||||
reason: "paperclip idle cleanup",
|
||||
discardPersistentState: false,
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("retries with a fresh session when ACPX cannot resume the saved backend session", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-resume-"));
|
||||
try {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local"
|
||||
import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local";
|
||||
import { models as opencodeFallbackModels } from "@paperclipai/adapter-opencode-local";
|
||||
import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server";
|
||||
import { listAdapterModels, refreshAdapterModels } from "../adapters/index.js";
|
||||
import { listAdapterModels, listServerAdapters, refreshAdapterModels } from "../adapters/index.js";
|
||||
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
|
||||
import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js";
|
||||
|
||||
@@ -30,6 +30,13 @@ describe("adapter model listing", () => {
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses provider-prefixed ACPX fallback model labels", () => {
|
||||
const adapter = listServerAdapters().find((candidate) => candidate.type === "acpx_local");
|
||||
|
||||
expect(adapter?.models?.some((model) => model.label.startsWith("Claude: "))).toBe(true);
|
||||
expect(adapter?.models?.some((model) => model.label.startsWith("Codex: "))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns codex fallback models when no OpenAI key is available", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
const models = await listAdapterModels("codex_local");
|
||||
|
||||
@@ -248,11 +248,33 @@ describe("adapter routes", () => {
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: "permissionMode",
|
||||
default: "approve-all",
|
||||
key: "fastMode",
|
||||
default: false,
|
||||
meta: { visibleWhen: { key: "agent", values: ["codex"] } },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: "warmHandleIdleMs",
|
||||
default: 0,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const keys = res.body.fields.map((field: { key: string }) => field.key);
|
||||
expect(keys).not.toContain("mode");
|
||||
expect(keys).not.toContain("permissionMode");
|
||||
expect(keys).not.toContain("instructionsFilePath");
|
||||
expect(keys).not.toContain("promptTemplate");
|
||||
expect(keys).not.toContain("bootstrapPromptTemplate");
|
||||
});
|
||||
|
||||
it("GET /api/adapters includes ACPX model availability", async () => {
|
||||
const app = createApp();
|
||||
|
||||
const res = await request(app).get("/api/adapters");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
const acpxLocal = res.body.find((a: any) => a.type === "acpx_local");
|
||||
expect(acpxLocal).toBeDefined();
|
||||
expect(acpxLocal.modelsCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects signed-in users without org access", async () => {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { AdapterModelProfileDefinition, AdapterRuntimeCommandSpec, ServerAdapterModule } from "./types.js";
|
||||
import type {
|
||||
AdapterModel,
|
||||
AdapterModelProfileDefinition,
|
||||
AdapterRuntimeCommandSpec,
|
||||
ServerAdapterModule,
|
||||
} from "./types.js";
|
||||
import { getAdapterSessionManagement } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
execute as acpxExecute,
|
||||
@@ -8,7 +13,10 @@ import {
|
||||
listAcpxSkills,
|
||||
syncAcpxSkills,
|
||||
} from "@paperclipai/adapter-acpx-local/server";
|
||||
import { agentConfigurationDoc as acpxAgentConfigurationDoc } from "@paperclipai/adapter-acpx-local";
|
||||
import {
|
||||
agentConfigurationDoc as acpxAgentConfigurationDoc,
|
||||
models as acpxModels,
|
||||
} from "@paperclipai/adapter-acpx-local";
|
||||
import {
|
||||
execute as claudeExecute,
|
||||
listClaudeSkills,
|
||||
@@ -182,6 +190,38 @@ function normalizeHermesConfig<T extends { config?: unknown; agent?: unknown }>(
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function dedupeAdapterModels(models: AdapterModel[]): AdapterModel[] {
|
||||
const seen = new Set<string>();
|
||||
const result: AdapterModel[] = [];
|
||||
for (const model of models) {
|
||||
const id = model.id.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
result.push({ ...model, id });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function prefixAdapterModelLabels(models: AdapterModel[], provider: "Claude" | "Codex"): AdapterModel[] {
|
||||
const prefix = `${provider}: `;
|
||||
return models.map((model) => ({
|
||||
...model,
|
||||
label: model.label.startsWith(prefix) ? model.label : `${prefix}${model.label}`,
|
||||
}));
|
||||
}
|
||||
|
||||
async function listAcpxModels(): Promise<AdapterModel[]> {
|
||||
const [claude, codex] = await Promise.all([
|
||||
listClaudeModels().catch(() => claudeModels),
|
||||
listCodexModels().catch(() => codexModels),
|
||||
]);
|
||||
return dedupeAdapterModels([
|
||||
...acpxModels,
|
||||
...prefixAdapterModelLabels(claude, "Claude"),
|
||||
...prefixAdapterModelLabels(codex, "Codex"),
|
||||
]);
|
||||
}
|
||||
|
||||
const claudeLocalAdapter: ServerAdapterModule = {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
@@ -211,6 +251,11 @@ const acpxLocalAdapter: ServerAdapterModule = {
|
||||
syncSkills: syncAcpxSkills,
|
||||
sessionCodec: acpxSessionCodec,
|
||||
sessionManagement: getAdapterSessionManagement("acpx_local") ?? undefined,
|
||||
models: dedupeAdapterModels([
|
||||
...prefixAdapterModelLabels(claudeModels, "Claude"),
|
||||
...prefixAdapterModelLabels(codexModels, "Codex"),
|
||||
]),
|
||||
listModels: listAcpxModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
supportsInstructionsBundle: true,
|
||||
instructionsPathKey: "instructionsFilePath",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -43,27 +43,6 @@ const acpxLocalConfigSchema: AdapterConfigSchema = {
|
||||
type: "text",
|
||||
hint: "Required for custom agents; optional override for built-in Claude or Codex ACP commands.",
|
||||
},
|
||||
{
|
||||
key: "mode",
|
||||
label: "Session mode",
|
||||
type: "select",
|
||||
default: "persistent",
|
||||
options: [
|
||||
{ value: "persistent", label: "Persistent" },
|
||||
{ value: "oneshot", label: "One shot" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "permissionMode",
|
||||
label: "Permission mode",
|
||||
type: "select",
|
||||
default: "approve-all",
|
||||
options: [
|
||||
{ value: "approve-all", label: "Approve all" },
|
||||
{ value: "default", label: "ACP default" },
|
||||
],
|
||||
hint: "Defaults to maximum permissions: ACPX permission requests are auto-approved.",
|
||||
},
|
||||
{
|
||||
key: "nonInteractivePermissions",
|
||||
label: "Non-interactive permissions",
|
||||
@@ -73,6 +52,7 @@ const acpxLocalConfigSchema: AdapterConfigSchema = {
|
||||
{ value: "deny", label: "Deny" },
|
||||
{ value: "fail", label: "Fail" },
|
||||
],
|
||||
hint: "Fallback if the ACP agent asks for input outside an interactive session. Paperclip still auto-approves permissions by default.",
|
||||
},
|
||||
{
|
||||
key: "cwd",
|
||||
@@ -87,14 +67,21 @@ const acpxLocalConfigSchema: AdapterConfigSchema = {
|
||||
hint: "Optional ACPX session state directory. Defaults to Paperclip-managed company/agent scoped storage.",
|
||||
},
|
||||
{
|
||||
key: "instructionsFilePath",
|
||||
label: "Instructions file",
|
||||
type: "text",
|
||||
hint: "Optional absolute path to markdown instructions injected into the run prompt.",
|
||||
key: "fastMode",
|
||||
label: "Codex fast mode",
|
||||
type: "toggle",
|
||||
default: false,
|
||||
hint: "Only applies when ACP agent is Codex. Requests Codex Fast mode through ACP session config.",
|
||||
meta: { visibleWhen: { key: "agent", values: ["codex"] } },
|
||||
},
|
||||
{ key: "promptTemplate", label: "Prompt template", type: "textarea" },
|
||||
{ key: "bootstrapPromptTemplate", label: "Bootstrap prompt template", type: "textarea" },
|
||||
{ key: "timeoutSec", label: "Timeout seconds", type: "number", default: 0 },
|
||||
{
|
||||
key: "warmHandleIdleMs",
|
||||
label: "Warm process idle ms",
|
||||
type: "number",
|
||||
default: 0,
|
||||
hint: "Defaults to 0, which closes the ACPX process after each run while retaining persistent session state.",
|
||||
},
|
||||
{
|
||||
key: "env",
|
||||
label: "Environment JSON",
|
||||
|
||||
Reference in New Issue
Block a user