mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 03: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
|
|
@ -85,10 +85,11 @@ Worker:
|
||||||
- database namespace via `ctx.db`
|
- database namespace via `ctx.db`
|
||||||
- scoped JSON API routes declared with `apiRoutes`
|
- scoped JSON API routes declared with `apiRoutes`
|
||||||
- entities
|
- entities
|
||||||
- projects and project workspaces
|
- projects, project workspaces, and plugin-managed projects
|
||||||
- companies
|
- companies
|
||||||
- issues, comments, namespaced `plugin:<pluginKey>` origins, blocker relations, checkout assertions, assignment wakeups, and orchestration summaries
|
- 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
|
- goals
|
||||||
- data/actions
|
- data/actions
|
||||||
- streams
|
- 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
|
body, actor context, and company id. Do not use plugin routes to claim core
|
||||||
paths; they always remain under `/api/plugins/:pluginId/api/*`.
|
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:
|
UI:
|
||||||
|
|
||||||
- `usePluginData`
|
- `usePluginData`
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { AdapterModel } from "@paperclipai/adapter-utils";
|
||||||
|
|
||||||
export const type = "acpx_local";
|
export const type = "acpx_local";
|
||||||
export const label = "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_PERMISSION_MODE = "approve-all";
|
||||||
export const DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS = "deny";
|
export const DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS = "deny";
|
||||||
export const DEFAULT_ACPX_LOCAL_TIMEOUT_SEC = 0;
|
export const DEFAULT_ACPX_LOCAL_TIMEOUT_SEC = 0;
|
||||||
|
export const DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS = 0;
|
||||||
|
|
||||||
export const acpxAgentOptions = [
|
export const acpxAgentOptions = [
|
||||||
{ id: "claude", label: "Claude via ACPX" },
|
{ id: "claude", label: "Claude via ACPX" },
|
||||||
|
|
@ -13,6 +16,8 @@ export const acpxAgentOptions = [
|
||||||
{ id: "custom", label: "Custom ACP command" },
|
{ id: "custom", label: "Custom ACP command" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const models: AdapterModel[] = [];
|
||||||
|
|
||||||
export const agentConfigurationDoc = `# acpx_local agent configuration
|
export const agentConfigurationDoc = `# acpx_local agent configuration
|
||||||
|
|
||||||
Adapter: acpx_local
|
Adapter: acpx_local
|
||||||
|
|
@ -30,7 +35,7 @@ Don't use when:
|
||||||
Core fields:
|
Core fields:
|
||||||
- agent (string, optional): claude, codex, or custom. Defaults to claude.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- instructionsFilePath (string, optional): absolute path to a markdown instructions file used by Paperclip prompt construction.
|
||||||
- promptTemplate (string, optional): run prompt template.
|
- promptTemplate (string, optional): run prompt template.
|
||||||
- bootstrapPromptTemplate (string, optional): first-run bootstrap 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.
|
- 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.
|
- env (object, optional): KEY=VALUE environment variables or secret bindings.
|
||||||
|
|
||||||
Dependency decision:
|
Dependency decision:
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import type { AdapterConfigSchema } from "@paperclipai/adapter-utils";
|
import type { AdapterConfigSchema } from "@paperclipai/adapter-utils";
|
||||||
import {
|
import {
|
||||||
DEFAULT_ACPX_LOCAL_AGENT,
|
DEFAULT_ACPX_LOCAL_AGENT,
|
||||||
DEFAULT_ACPX_LOCAL_MODE,
|
|
||||||
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
|
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
|
||||||
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
|
|
||||||
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
|
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
|
||||||
|
DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
|
||||||
acpxAgentOptions,
|
acpxAgentOptions,
|
||||||
} from "../index.js";
|
} from "../index.js";
|
||||||
|
|
||||||
|
|
@ -26,27 +25,6 @@ export function getConfigSchema(): AdapterConfigSchema {
|
||||||
type: "text",
|
type: "text",
|
||||||
hint: "Required for custom agents; optional override for built-in Claude or Codex ACP commands.",
|
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",
|
key: "nonInteractivePermissions",
|
||||||
label: "Non-interactive permissions",
|
label: "Non-interactive permissions",
|
||||||
|
|
@ -56,6 +34,7 @@ export function getConfigSchema(): AdapterConfigSchema {
|
||||||
{ value: "deny", label: "Deny" },
|
{ value: "deny", label: "Deny" },
|
||||||
{ value: "fail", label: "Fail" },
|
{ 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",
|
key: "cwd",
|
||||||
|
|
@ -70,20 +49,12 @@ export function getConfigSchema(): AdapterConfigSchema {
|
||||||
hint: "Optional ACPX session state directory. Defaults to Paperclip-managed company/agent scoped storage.",
|
hint: "Optional ACPX session state directory. Defaults to Paperclip-managed company/agent scoped storage.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "instructionsFilePath",
|
key: "fastMode",
|
||||||
label: "Instructions file",
|
label: "Codex fast mode",
|
||||||
type: "text",
|
type: "toggle",
|
||||||
hint: "Optional absolute path to markdown instructions injected into the run prompt.",
|
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",
|
key: "timeoutSec",
|
||||||
|
|
@ -91,6 +62,13 @@ export function getConfigSchema(): AdapterConfigSchema {
|
||||||
type: "number",
|
type: "number",
|
||||||
default: DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
|
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",
|
key: "env",
|
||||||
label: "Environment JSON",
|
label: "Environment JSON",
|
||||||
|
|
|
||||||
|
|
@ -45,10 +45,10 @@ import {
|
||||||
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
|
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
|
||||||
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
|
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
|
||||||
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
|
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
|
||||||
|
DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
|
||||||
} from "../index.js";
|
} from "../index.js";
|
||||||
|
|
||||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
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 WRAPPER_CLEANUP_RETENTION_MS = 15 * 60 * 1000;
|
||||||
const PAPERCLIP_MANAGED_CODEX_SKILLS_MANIFEST = ".paperclip-managed-skills.json";
|
const PAPERCLIP_MANAGED_CODEX_SKILLS_MANIFEST = ".paperclip-managed-skills.json";
|
||||||
|
|
||||||
|
|
@ -59,6 +59,7 @@ interface RuntimeCacheEntry {
|
||||||
handle: AcpRuntimeHandle;
|
handle: AcpRuntimeHandle;
|
||||||
fingerprint: string;
|
fingerprint: string;
|
||||||
lastUsedAt: number;
|
lastUsedAt: number;
|
||||||
|
cleanupTimer?: NodeJS.Timeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExecuteDeps {
|
interface ExecuteDeps {
|
||||||
|
|
@ -79,6 +80,9 @@ interface AcpxPreparedRuntime {
|
||||||
stateDir: string;
|
stateDir: string;
|
||||||
permissionMode: "approve-all" | "approve-reads" | "deny-all";
|
permissionMode: "approve-all" | "approve-reads" | "deny-all";
|
||||||
nonInteractivePermissions: "deny" | "fail";
|
nonInteractivePermissions: "deny" | "fail";
|
||||||
|
requestedModel: string;
|
||||||
|
requestedThinkingEffort: string;
|
||||||
|
fastMode: boolean;
|
||||||
timeoutSec: number;
|
timeoutSec: number;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
fingerprint: string;
|
fingerprint: string;
|
||||||
|
|
@ -504,6 +508,15 @@ function normalizeNonInteractivePermissions(config: Record<string, unknown>): "d
|
||||||
: "deny";
|
: "deny";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRequestedThinkingEffort(config: Record<string, unknown>): string {
|
||||||
|
return (
|
||||||
|
asString(config.modelReasoningEffort, "") ||
|
||||||
|
asString(config.reasoningEffort, "") ||
|
||||||
|
asString(config.thinkingEffort, "") ||
|
||||||
|
asString(config.effort, "")
|
||||||
|
).trim();
|
||||||
|
}
|
||||||
|
|
||||||
function isCompatibleSession(
|
function isCompatibleSession(
|
||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
runtime: Pick<AcpxPreparedRuntime, "fingerprint" | "sessionKey" | "cwd" | "mode" | "acpxAgent" | "remoteExecutionIdentity">,
|
runtime: Pick<AcpxPreparedRuntime, "fingerprint" | "sessionKey" | "cwd" | "mode" | "acpxAgent" | "remoteExecutionIdentity">,
|
||||||
|
|
@ -534,6 +547,9 @@ function buildSessionParams(input: {
|
||||||
mode: prepared.mode,
|
mode: prepared.mode,
|
||||||
stateDir: prepared.stateDir,
|
stateDir: prepared.stateDir,
|
||||||
configFingerprint: prepared.fingerprint,
|
configFingerprint: prepared.fingerprint,
|
||||||
|
...(prepared.requestedModel ? { model: prepared.requestedModel } : {}),
|
||||||
|
...(prepared.requestedThinkingEffort ? { thinkingEffort: prepared.requestedThinkingEffort } : {}),
|
||||||
|
...(prepared.fastMode ? { fastMode: true } : {}),
|
||||||
skills: prepared.skillsIdentity,
|
skills: prepared.skillsIdentity,
|
||||||
...(prepared.workspaceId ? { workspaceId: prepared.workspaceId } : {}),
|
...(prepared.workspaceId ? { workspaceId: prepared.workspaceId } : {}),
|
||||||
...(prepared.workspaceRepoUrl ? { repoUrl: prepared.workspaceRepoUrl } : {}),
|
...(prepared.workspaceRepoUrl ? { repoUrl: prepared.workspaceRepoUrl } : {}),
|
||||||
|
|
@ -644,6 +660,9 @@ async function buildRuntime(input: {
|
||||||
const mode = normalizeMode(config);
|
const mode = normalizeMode(config);
|
||||||
const permissionMode = normalizePermissionMode(config);
|
const permissionMode = normalizePermissionMode(config);
|
||||||
const nonInteractivePermissions = normalizeNonInteractivePermissions(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 timeoutSec = asNumber(config.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC);
|
||||||
const stateDir = path.resolve(asString(config.stateDir, "") || defaultStateDir(agent.companyId, agent.id));
|
const stateDir = path.resolve(asString(config.stateDir, "") || defaultStateDir(agent.companyId, agent.id));
|
||||||
await fs.mkdir(stateDir, { recursive: true });
|
await fs.mkdir(stateDir, { recursive: true });
|
||||||
|
|
@ -741,6 +760,9 @@ async function buildRuntime(input: {
|
||||||
mode,
|
mode,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
nonInteractivePermissions,
|
nonInteractivePermissions,
|
||||||
|
requestedModel,
|
||||||
|
requestedThinkingEffort,
|
||||||
|
fastMode,
|
||||||
remoteExecutionIdentity,
|
remoteExecutionIdentity,
|
||||||
skillsIdentity,
|
skillsIdentity,
|
||||||
skillPromptInstructions,
|
skillPromptInstructions,
|
||||||
|
|
@ -766,13 +788,16 @@ async function buildRuntime(input: {
|
||||||
stateDir,
|
stateDir,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
nonInteractivePermissions,
|
nonInteractivePermissions,
|
||||||
|
requestedModel,
|
||||||
|
requestedThinkingEffort,
|
||||||
|
fastMode,
|
||||||
timeoutSec,
|
timeoutSec,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
agentCommand,
|
agentCommand,
|
||||||
agentRegistry,
|
agentRegistry,
|
||||||
remoteExecutionIdentity,
|
remoteExecutionIdentity,
|
||||||
skillPromptInstructions,
|
skillPromptInstructions,
|
||||||
skillsIdentity: {
|
skillsIdentity: {
|
||||||
...skillsIdentity,
|
...skillsIdentity,
|
||||||
commandNotes: skillCommandNotes,
|
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<{
|
async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean): Promise<{
|
||||||
prompt: string;
|
prompt: string;
|
||||||
promptMetrics: Record<string, number>;
|
promptMetrics: Record<string, number>;
|
||||||
|
|
@ -951,20 +1021,77 @@ async function cleanupIdleHandles(input: {
|
||||||
now: number;
|
now: number;
|
||||||
idleMs: number;
|
idleMs: number;
|
||||||
}) {
|
}) {
|
||||||
|
if (input.idleMs <= 0) return;
|
||||||
|
|
||||||
const stale: Array<[string, RuntimeCacheEntry]> = [];
|
const stale: Array<[string, RuntimeCacheEntry]> = [];
|
||||||
for (const entry of input.handles.entries()) {
|
for (const entry of input.handles.entries()) {
|
||||||
if (input.now - entry[1].lastUsedAt >= input.idleMs) stale.push(entry);
|
if (input.now - entry[1].lastUsedAt >= input.idleMs) stale.push(entry);
|
||||||
}
|
}
|
||||||
for (const [key, entry] of stale) {
|
for (const [key, entry] of stale) {
|
||||||
input.handles.delete(key);
|
await closeWarmHandle({
|
||||||
await entry.runtime.close({
|
handles: input.handles,
|
||||||
handle: entry.handle,
|
key,
|
||||||
|
entry,
|
||||||
reason: "paperclip idle cleanup",
|
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(
|
function warmHandleMatches(
|
||||||
entry: RuntimeCacheEntry | undefined,
|
entry: RuntimeCacheEntry | undefined,
|
||||||
runtime: AcpRuntime,
|
runtime: AcpRuntime,
|
||||||
|
|
@ -980,7 +1107,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||||
|
|
||||||
return async function executeAcpxLocal(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
return async function executeAcpxLocal(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||||
const prepared = await buildRuntime({ ctx });
|
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 });
|
await cleanupIdleHandles({ handles: warmHandles, now: now(), idleMs: warmIdleMs });
|
||||||
|
|
||||||
const previousParams = parseObject(ctx.runtime.sessionParams);
|
const previousParams = parseObject(ctx.runtime.sessionParams);
|
||||||
|
|
@ -996,6 +1123,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||||
timeoutMs: prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined,
|
timeoutMs: prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined,
|
||||||
};
|
};
|
||||||
const runtime = cached?.runtime ?? createRuntime(runtimeOptions);
|
const runtime = cached?.runtime ?? createRuntime(runtimeOptions);
|
||||||
|
if (cached) clearWarmHandleTimer(cached);
|
||||||
if (!canResume && asString(previousParams.runtimeSessionName, "")) {
|
if (!canResume && asString(previousParams.runtimeSessionName, "")) {
|
||||||
await ctx.onLog(
|
await ctx.onLog(
|
||||||
"stdout",
|
"stdout",
|
||||||
|
|
@ -1044,7 +1172,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||||
errorMessage: message,
|
errorMessage: message,
|
||||||
...classified,
|
...classified,
|
||||||
provider: "acpx",
|
provider: "acpx",
|
||||||
model: null,
|
model: prepared.requestedModel || null,
|
||||||
clearSession,
|
clearSession,
|
||||||
resultJson: { phase: "ensure_session" },
|
resultJson: { phase: "ensure_session" },
|
||||||
summary: message,
|
summary: message,
|
||||||
|
|
@ -1059,12 +1187,52 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||||
errorMessage: "ACPX did not return a runtime session handle.",
|
errorMessage: "ACPX did not return a runtime session handle.",
|
||||||
errorCode: "acpx_runtime_error",
|
errorCode: "acpx_runtime_error",
|
||||||
provider: "acpx",
|
provider: "acpx",
|
||||||
model: null,
|
model: prepared.requestedModel || null,
|
||||||
resultJson: { phase: "ensure_session" },
|
resultJson: { phase: "ensure_session" },
|
||||||
summary: "ACPX did not return a runtime session handle.",
|
summary: "ACPX did not return a runtime session handle.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const sessionHandle = 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 { prompt, promptMetrics, commandNotes } = await buildPrompt(ctx, resumedSession);
|
||||||
const runPrompt = joinPromptSections([prepared.skillPromptInstructions, prompt]);
|
const runPrompt = joinPromptSections([prepared.skillPromptInstructions, prompt]);
|
||||||
await emitAcpxLog(ctx, {
|
await emitAcpxLog(ctx, {
|
||||||
|
|
@ -1076,6 +1244,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||||
runtimeSessionName: sessionHandle.runtimeSessionName,
|
runtimeSessionName: sessionHandle.runtimeSessionName,
|
||||||
mode: prepared.mode,
|
mode: prepared.mode,
|
||||||
permissionMode: prepared.permissionMode,
|
permissionMode: prepared.permissionMode,
|
||||||
|
model: prepared.requestedModel || null,
|
||||||
|
thinkingEffort: prepared.requestedThinkingEffort || null,
|
||||||
|
fastMode: prepared.fastMode,
|
||||||
});
|
});
|
||||||
if (ctx.onMeta) {
|
if (ctx.onMeta) {
|
||||||
await ctx.onMeta({
|
await ctx.onMeta({
|
||||||
|
|
@ -1085,6 +1256,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||||
commandNotes: [
|
commandNotes: [
|
||||||
`ACPX runtime embedded in Paperclip with ${prepared.mode} session mode.`,
|
`ACPX runtime embedded in Paperclip with ${prepared.mode} session mode.`,
|
||||||
`Effective ACPX permission mode: ${prepared.permissionMode}.`,
|
`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)
|
...(Array.isArray(prepared.skillsIdentity.commandNotes)
|
||||||
? prepared.skillsIdentity.commandNotes.filter((note): note is string => typeof note === "string")
|
? 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;
|
const terminal = await turn.result;
|
||||||
if (timeout) clearTimeout(timeout);
|
if (timeout) clearTimeout(timeout);
|
||||||
if (terminal.status === "failed" || terminal.status === "cancelled" || timedOut) {
|
if (terminal.status === "failed" || terminal.status === "cancelled" || timedOut) {
|
||||||
if (warmHandleMatches(warmHandles.get(prepared.sessionKey), runtime, sessionHandle)) {
|
const existing = warmHandles.get(prepared.sessionKey);
|
||||||
warmHandles.delete(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({
|
} else if (prepared.mode === "persistent" && warmIdleMs > 0) {
|
||||||
handle: sessionHandle,
|
|
||||||
reason: timedOut ? "paperclip timeout cleanup" : `paperclip turn ${terminal.status}`,
|
|
||||||
discardPersistentState: terminal.status === "cancelled" || timedOut,
|
|
||||||
}).catch(() => {});
|
|
||||||
} else if (prepared.mode === "persistent") {
|
|
||||||
const existing = warmHandles.get(prepared.sessionKey);
|
const existing = warmHandles.get(prepared.sessionKey);
|
||||||
if (existing && !warmHandleMatches(existing, runtime, sessionHandle)) {
|
if (existing && !warmHandleMatches(existing, runtime, sessionHandle)) {
|
||||||
await runtime.close({
|
await runtime.close({
|
||||||
|
|
@ -1147,13 +1329,37 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||||
discardPersistentState: false,
|
discardPersistentState: false,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
warmHandles.set(prepared.sessionKey, {
|
const entry: RuntimeCacheEntry = {
|
||||||
runtime,
|
runtime,
|
||||||
handle: sessionHandle,
|
handle: sessionHandle,
|
||||||
fingerprint: prepared.fingerprint,
|
fingerprint: prepared.fingerprint,
|
||||||
lastUsedAt: now(),
|
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
|
const errorMessage = timedOut
|
||||||
|
|
@ -1176,7 +1382,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||||
sessionParams: buildSessionParams({ prepared, handle: sessionHandle }),
|
sessionParams: buildSessionParams({ prepared, handle: sessionHandle }),
|
||||||
sessionDisplayId: sessionHandle.agentSessionId ?? sessionHandle.backendSessionId ?? sessionHandle.runtimeSessionName,
|
sessionDisplayId: sessionHandle.agentSessionId ?? sessionHandle.backendSessionId ?? sessionHandle.runtimeSessionName,
|
||||||
provider: "acpx",
|
provider: "acpx",
|
||||||
model: null,
|
model: prepared.requestedModel || null,
|
||||||
billingType: "unknown",
|
billingType: "unknown",
|
||||||
costUsd: null,
|
costUsd: null,
|
||||||
resultJson: {
|
resultJson: {
|
||||||
|
|
@ -1184,6 +1390,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||||
stopReason: terminalStopReason,
|
stopReason: terminalStopReason,
|
||||||
permissionMode: prepared.permissionMode,
|
permissionMode: prepared.permissionMode,
|
||||||
mode: prepared.mode,
|
mode: prepared.mode,
|
||||||
|
requestedModel: prepared.requestedModel || null,
|
||||||
|
requestedThinkingEffort: prepared.requestedThinkingEffort || null,
|
||||||
|
fastMode: prepared.fastMode,
|
||||||
},
|
},
|
||||||
summary: textParts.join("").trim() || terminalStopReason || terminal.status,
|
summary: textParts.join("").trim() || terminalStopReason || terminal.status,
|
||||||
clearSession,
|
clearSession,
|
||||||
|
|
@ -1199,7 +1408,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||||
reason: timedOut ? "paperclip timeout cleanup" : "paperclip error cleanup",
|
reason: timedOut ? "paperclip timeout cleanup" : "paperclip error cleanup",
|
||||||
discardPersistentState: timedOut,
|
discardPersistentState: timedOut,
|
||||||
}).catch(() => {});
|
}).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);
|
warmHandles.delete(prepared.sessionKey);
|
||||||
}
|
}
|
||||||
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
|
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
|
||||||
|
|
@ -1211,7 +1422,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||||
errorCode: timedOut ? "acpx_timeout" : classified.errorCode,
|
errorCode: timedOut ? "acpx_timeout" : classified.errorCode,
|
||||||
errorMeta: classified.errorMeta,
|
errorMeta: classified.errorMeta,
|
||||||
provider: "acpx",
|
provider: "acpx",
|
||||||
model: null,
|
model: prepared.requestedModel || null,
|
||||||
clearSession: clearSession || timedOut,
|
clearSession: clearSession || timedOut,
|
||||||
resultJson: { phase: "turn" },
|
resultJson: { phase: "turn" },
|
||||||
summary: message,
|
summary: message,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
|
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
|
||||||
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
|
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
|
||||||
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
|
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
|
||||||
|
DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
|
||||||
} from "../index.js";
|
} from "../index.js";
|
||||||
|
|
||||||
function parseCommaArgs(value: string): string[] {
|
function parseCommaArgs(value: string): string[] {
|
||||||
|
|
@ -80,13 +81,15 @@ function readNumber(value: unknown, fallback: number): number {
|
||||||
|
|
||||||
export function buildAcpxLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
export function buildAcpxLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||||
const schemaValues = v.adapterSchemaValues ?? {};
|
const schemaValues = v.adapterSchemaValues ?? {};
|
||||||
|
const agent = String(schemaValues.agent || DEFAULT_ACPX_LOCAL_AGENT);
|
||||||
const ac: Record<string, unknown> = {
|
const ac: Record<string, unknown> = {
|
||||||
agent: schemaValues.agent || DEFAULT_ACPX_LOCAL_AGENT,
|
agent,
|
||||||
mode: schemaValues.mode || DEFAULT_ACPX_LOCAL_MODE,
|
mode: schemaValues.mode || DEFAULT_ACPX_LOCAL_MODE,
|
||||||
permissionMode: schemaValues.permissionMode || DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
|
permissionMode: schemaValues.permissionMode || DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
|
||||||
nonInteractivePermissions:
|
nonInteractivePermissions:
|
||||||
schemaValues.nonInteractivePermissions || DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
|
schemaValues.nonInteractivePermissions || DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
|
||||||
timeoutSec: readNumber(schemaValues.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC),
|
timeoutSec: readNumber(schemaValues.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC),
|
||||||
|
warmHandleIdleMs: readNumber(schemaValues.warmHandleIdleMs, DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS),
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const key of [
|
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.instructionsFilePath && v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||||
if (!ac.promptTemplate && v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
if (!ac.promptTemplate && v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||||
if (!ac.bootstrapPromptTemplate && v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
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 env = parseEnvBindings(v.envBindings);
|
||||||
const legacy = parseEnvVars(v.envVars);
|
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 fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
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 () => {
|
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 root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-reuse-"));
|
||||||
const other = path.join(root, "other");
|
const other = path.join(root, "other");
|
||||||
|
|
@ -228,8 +325,15 @@ describe("acpx_local execute", () => {
|
||||||
return runtime;
|
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, {
|
const second = await execute(buildContext(root, {
|
||||||
runtime: {
|
runtime: {
|
||||||
sessionId: first.sessionId ?? null,
|
sessionId: first.sessionId ?? null,
|
||||||
|
|
@ -237,6 +341,7 @@ describe("acpx_local execute", () => {
|
||||||
sessionDisplayId: first.sessionDisplayId ?? null,
|
sessionDisplayId: first.sessionDisplayId ?? null,
|
||||||
taskKey: "PAP-1",
|
taskKey: "PAP-1",
|
||||||
},
|
},
|
||||||
|
config: warmConfig,
|
||||||
}));
|
}));
|
||||||
const third = await execute(buildContext(root, {
|
const third = await execute(buildContext(root, {
|
||||||
runtime: {
|
runtime: {
|
||||||
|
|
@ -250,6 +355,7 @@ describe("acpx_local execute", () => {
|
||||||
cwd: other,
|
cwd: other,
|
||||||
stateDir: path.join(root, "state"),
|
stateDir: path.join(root, "state"),
|
||||||
promptTemplate: "Do the assigned work.",
|
promptTemplate: "Do the assigned work.",
|
||||||
|
warmHandleIdleMs: 60_000,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -279,8 +385,26 @@ describe("acpx_local execute", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const [first, second] = await Promise.all([
|
const [first, second] = await Promise.all([
|
||||||
execute(buildContext(root, { runId: "run-1" })),
|
execute(buildContext(root, {
|
||||||
execute(buildContext(root, { runId: "run-2" })),
|
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);
|
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 () => {
|
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-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-resume-"));
|
||||||
try {
|
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 cursorFallbackModels } from "@paperclipai/adapter-cursor-local";
|
||||||
import { models as opencodeFallbackModels } from "@paperclipai/adapter-opencode-local";
|
import { models as opencodeFallbackModels } from "@paperclipai/adapter-opencode-local";
|
||||||
import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server";
|
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 { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
|
||||||
import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js";
|
import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js";
|
||||||
|
|
||||||
|
|
@ -30,6 +30,13 @@ describe("adapter model listing", () => {
|
||||||
expect(models).toEqual([]);
|
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 () => {
|
it("returns codex fallback models when no OpenAI key is available", async () => {
|
||||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||||
const models = await listAdapterModels("codex_local");
|
const models = await listAdapterModels("codex_local");
|
||||||
|
|
|
||||||
|
|
@ -248,11 +248,33 @@ describe("adapter routes", () => {
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
key: "permissionMode",
|
key: "fastMode",
|
||||||
default: "approve-all",
|
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 () => {
|
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 { getAdapterSessionManagement } from "@paperclipai/adapter-utils";
|
||||||
import {
|
import {
|
||||||
execute as acpxExecute,
|
execute as acpxExecute,
|
||||||
|
|
@ -8,7 +13,10 @@ import {
|
||||||
listAcpxSkills,
|
listAcpxSkills,
|
||||||
syncAcpxSkills,
|
syncAcpxSkills,
|
||||||
} from "@paperclipai/adapter-acpx-local/server";
|
} 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 {
|
import {
|
||||||
execute as claudeExecute,
|
execute as claudeExecute,
|
||||||
listClaudeSkills,
|
listClaudeSkills,
|
||||||
|
|
@ -182,6 +190,38 @@ function normalizeHermesConfig<T extends { config?: unknown; agent?: unknown }>(
|
||||||
return ctx;
|
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 = {
|
const claudeLocalAdapter: ServerAdapterModule = {
|
||||||
type: "claude_local",
|
type: "claude_local",
|
||||||
execute: claudeExecute,
|
execute: claudeExecute,
|
||||||
|
|
@ -211,6 +251,11 @@ const acpxLocalAdapter: ServerAdapterModule = {
|
||||||
syncSkills: syncAcpxSkills,
|
syncSkills: syncAcpxSkills,
|
||||||
sessionCodec: acpxSessionCodec,
|
sessionCodec: acpxSessionCodec,
|
||||||
sessionManagement: getAdapterSessionManagement("acpx_local") ?? undefined,
|
sessionManagement: getAdapterSessionManagement("acpx_local") ?? undefined,
|
||||||
|
models: dedupeAdapterModels([
|
||||||
|
...prefixAdapterModelLabels(claudeModels, "Claude"),
|
||||||
|
...prefixAdapterModelLabels(codexModels, "Codex"),
|
||||||
|
]),
|
||||||
|
listModels: listAcpxModels,
|
||||||
supportsLocalAgentJwt: true,
|
supportsLocalAgentJwt: true,
|
||||||
supportsInstructionsBundle: true,
|
supportsInstructionsBundle: true,
|
||||||
instructionsPathKey: "instructionsFilePath",
|
instructionsPathKey: "instructionsFilePath",
|
||||||
|
|
|
||||||
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
|
// Component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -369,111 +401,113 @@ export function SchemaConfigFields({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{schema.fields.map((field) => {
|
{schema.fields
|
||||||
switch (field.type) {
|
.filter((field) => fieldMatchesVisibleWhen(field, readValue, schema))
|
||||||
case "select": {
|
.map((field) => {
|
||||||
const currentVal = String(readValue(field) ?? "");
|
switch (field.type) {
|
||||||
return (
|
case "select": {
|
||||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
const currentVal = String(readValue(field) ?? "");
|
||||||
<SelectField
|
return (
|
||||||
value={currentVal}
|
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||||
options={field.options ?? []}
|
<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)}
|
onChange={(v) => writeValue(field, v)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
case "toggle":
|
case "number":
|
||||||
return (
|
return (
|
||||||
<ToggleField
|
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||||
key={field.key}
|
<DraftNumberInput
|
||||||
label={field.label}
|
value={Number(readValue(field) ?? 0)}
|
||||||
hint={field.hint}
|
onCommit={(v) => writeValue(field, v)}
|
||||||
checked={readValue(field) === true}
|
immediate
|
||||||
onChange={(v) => writeValue(field, v)}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
);
|
</Field>
|
||||||
|
);
|
||||||
|
|
||||||
case "number":
|
case "textarea":
|
||||||
return (
|
return (
|
||||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||||
<DraftNumberInput
|
<DraftTextarea
|
||||||
value={Number(readValue(field) ?? 0)}
|
value={String(readValue(field) ?? "")}
|
||||||
onCommit={(v) => writeValue(field, v)}
|
onCommit={(v) => writeValue(field, v || undefined)}
|
||||||
immediate
|
immediate
|
||||||
className={inputClass}
|
/>
|
||||||
/>
|
</Field>
|
||||||
</Field>
|
);
|
||||||
);
|
|
||||||
|
|
||||||
case "textarea":
|
case "combobox": {
|
||||||
return (
|
const currentVal = String(readValue(field) ?? "");
|
||||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
// Dynamic options: if meta.providerModels exists, compute options
|
||||||
<DraftTextarea
|
// based on the current provider value
|
||||||
value={String(readValue(field) ?? "")}
|
let comboboxOptions = field.options ?? [];
|
||||||
onCommit={(v) => writeValue(field, v || undefined)}
|
if (field.meta?.providerModels) {
|
||||||
immediate
|
const providerVal = String(readValue(schema.fields.find((f) => f.key === "provider")!) ?? "auto");
|
||||||
/>
|
const modelsByProvider = field.meta.providerModels as Record<string, string[]>;
|
||||||
</Field>
|
if (providerVal === "auto") {
|
||||||
);
|
// Auto: show all models from all providers, grouped by provider
|
||||||
|
const providerLabel = schema.fields.find((f) => f.key === "provider");
|
||||||
case "combobox": {
|
const providerOptions = providerLabel?.options ?? [];
|
||||||
const currentVal = String(readValue(field) ?? "");
|
comboboxOptions = Object.entries(modelsByProvider).flatMap(([prov, models]) =>
|
||||||
// Dynamic options: if meta.providerModels exists, compute options
|
models.map((m) => ({
|
||||||
// based on the current provider value
|
label: m,
|
||||||
let comboboxOptions = field.options ?? [];
|
value: m,
|
||||||
if (field.meta?.providerModels) {
|
group: providerOptions.find((p) => p.value === prov)?.label ?? prov,
|
||||||
const providerVal = String(readValue(schema.fields.find((f) => f.key === "provider")!) ?? "auto");
|
})),
|
||||||
const modelsByProvider = field.meta.providerModels as Record<string, string[]>;
|
);
|
||||||
if (providerVal === "auto") {
|
} else {
|
||||||
// Auto: show all models from all providers, grouped by provider
|
const providerModels = modelsByProvider[providerVal] ?? [];
|
||||||
const providerLabel = schema.fields.find((f) => f.key === "provider");
|
const providerLabel = schema.fields.find((f) => f.key === "provider");
|
||||||
const providerOptions = providerLabel?.options ?? [];
|
const provName = providerLabel?.options?.find((p) => p.value === providerVal)?.label ?? providerVal;
|
||||||
comboboxOptions = Object.entries(modelsByProvider).flatMap(([prov, models]) =>
|
comboboxOptions = providerModels.map((m) => ({
|
||||||
models.map((m) => ({
|
|
||||||
label: m,
|
label: m,
|
||||||
value: m,
|
value: m,
|
||||||
group: providerOptions.find((p) => p.value === prov)?.label ?? prov,
|
group: provName,
|
||||||
})),
|
}));
|
||||||
);
|
}
|
||||||
} 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,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
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":
|
case "text":
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||||
<DraftInput
|
<DraftInput
|
||||||
value={String(readValue(field) ?? "")}
|
value={String(readValue(field) ?? "")}
|
||||||
onCommit={(v) => writeValue(field, v || undefined)}
|
onCommit={(v) => writeValue(field, v || undefined)}
|
||||||
immediate
|
immediate
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ import { getAdapterDisplay, getAdapterLabel } from "../adapters/adapter-display-
|
||||||
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
|
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
|
||||||
import { buildAgentUpdatePatch, type AgentConfigOverlay } from "../lib/agent-config-patch";
|
import { buildAgentUpdatePatch, type AgentConfigOverlay } from "../lib/agent-config-patch";
|
||||||
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
|
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
|
||||||
|
import { filterAcpxModelsByAgent } from "../lib/acpx-model-filter";
|
||||||
|
|
||||||
/* ---- Create mode values ---- */
|
/* ---- Create mode values ---- */
|
||||||
|
|
||||||
|
|
@ -360,9 +361,21 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
});
|
});
|
||||||
const [refreshModelsError, setRefreshModelsError] = useState<string | null>(null);
|
const [refreshModelsError, setRefreshModelsError] = useState<string | null>(null);
|
||||||
const [refreshingModels, setRefreshingModels] = useState(false);
|
const [refreshingModels, setRefreshingModels] = useState(false);
|
||||||
const models = fetchedModels ?? externalModels ?? [];
|
const rawModels = fetchedModels ?? externalModels ?? [];
|
||||||
const adapterCommandField =
|
const adapterCommandField =
|
||||||
adapterType === "hermes_local" ? "hermesCommand" : "command";
|
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 {
|
const {
|
||||||
data: detectedModelData,
|
data: detectedModelData,
|
||||||
refetch: refetchDetectedModel,
|
refetch: refetchDetectedModel,
|
||||||
|
|
@ -527,19 +540,23 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
const thinkingEffortKey =
|
const thinkingEffortKey =
|
||||||
adapterType === "codex_local"
|
adapterType === "codex_local"
|
||||||
? "modelReasoningEffort"
|
? "modelReasoningEffort"
|
||||||
: adapterType === "cursor"
|
: adapterType === "acpx_local" && acpxAgent === "codex"
|
||||||
? "mode"
|
? "modelReasoningEffort"
|
||||||
: adapterType === "opencode_local"
|
: adapterType === "cursor"
|
||||||
? "variant"
|
? "mode"
|
||||||
: "effort";
|
: adapterType === "opencode_local"
|
||||||
|
? "variant"
|
||||||
|
: "effort";
|
||||||
const thinkingEffortOptions =
|
const thinkingEffortOptions =
|
||||||
adapterType === "codex_local"
|
adapterType === "codex_local"
|
||||||
? codexThinkingEffortOptions
|
? codexThinkingEffortOptions
|
||||||
: adapterType === "cursor"
|
: adapterType === "acpx_local" && acpxAgent === "codex"
|
||||||
? cursorModeOptions
|
? codexThinkingEffortOptions
|
||||||
: adapterType === "opencode_local"
|
: adapterType === "cursor"
|
||||||
? openCodeThinkingEffortOptions
|
? cursorModeOptions
|
||||||
: claudeThinkingEffortOptions;
|
: adapterType === "opencode_local"
|
||||||
|
? openCodeThinkingEffortOptions
|
||||||
|
: claudeThinkingEffortOptions;
|
||||||
const currentThinkingEffort = isCreate
|
const currentThinkingEffort = isCreate
|
||||||
? val!.thinkingEffort
|
? val!.thinkingEffort
|
||||||
: adapterType === "codex_local"
|
: adapterType === "codex_local"
|
||||||
|
|
@ -548,11 +565,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
"modelReasoningEffort",
|
"modelReasoningEffort",
|
||||||
String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""),
|
String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""),
|
||||||
)
|
)
|
||||||
: adapterType === "cursor"
|
: adapterType === "acpx_local" && acpxAgent === "codex"
|
||||||
? eff("adapterConfig", "mode", String(config.mode ?? ""))
|
? eff(
|
||||||
: adapterType === "opencode_local"
|
"adapterConfig",
|
||||||
? eff("adapterConfig", "variant", String(config.variant ?? ""))
|
"modelReasoningEffort",
|
||||||
: eff("adapterConfig", "effort", String(config.effort ?? ""));
|
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 showThinkingEffort = adapterType !== "gemini_local";
|
||||||
const codexSearchEnabled = adapterType === "codex_local"
|
const codexSearchEnabled = adapterType === "codex_local"
|
||||||
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
|
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
|
||||||
|
|
@ -982,7 +1005,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
const result = await refetchDetectedModel();
|
const result = await refetchDetectedModel();
|
||||||
return result.data?.model ?? null;
|
return result.data?.model ?? null;
|
||||||
}}
|
}}
|
||||||
onRefreshModels={adapterType === "codex_local" ? handleRefreshModels : undefined}
|
onRefreshModels={
|
||||||
|
adapterType === "codex_local" || adapterType === "acpx_local"
|
||||||
|
? handleRefreshModels
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
refreshingModels={refreshingModels}
|
refreshingModels={refreshingModels}
|
||||||
detectModelLabel="Detect model"
|
detectModelLabel="Detect model"
|
||||||
emptyDetectHint="No model detected. Select or enter one manually."
|
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 [];
|
||||||
|
}
|
||||||
|
|
@ -43,27 +43,6 @@ const acpxLocalConfigSchema: AdapterConfigSchema = {
|
||||||
type: "text",
|
type: "text",
|
||||||
hint: "Required for custom agents; optional override for built-in Claude or Codex ACP commands.",
|
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",
|
key: "nonInteractivePermissions",
|
||||||
label: "Non-interactive permissions",
|
label: "Non-interactive permissions",
|
||||||
|
|
@ -73,6 +52,7 @@ const acpxLocalConfigSchema: AdapterConfigSchema = {
|
||||||
{ value: "deny", label: "Deny" },
|
{ value: "deny", label: "Deny" },
|
||||||
{ value: "fail", label: "Fail" },
|
{ 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",
|
key: "cwd",
|
||||||
|
|
@ -87,14 +67,21 @@ const acpxLocalConfigSchema: AdapterConfigSchema = {
|
||||||
hint: "Optional ACPX session state directory. Defaults to Paperclip-managed company/agent scoped storage.",
|
hint: "Optional ACPX session state directory. Defaults to Paperclip-managed company/agent scoped storage.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "instructionsFilePath",
|
key: "fastMode",
|
||||||
label: "Instructions file",
|
label: "Codex fast mode",
|
||||||
type: "text",
|
type: "toggle",
|
||||||
hint: "Optional absolute path to markdown instructions injected into the run prompt.",
|
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: "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",
|
key: "env",
|
||||||
label: "Environment JSON",
|
label: "Environment JSON",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue