From 11ffd6f2c5cda5625a26e709bb97d9d55d33139c Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 6 May 2026 06:06:47 -0500 Subject: [PATCH] 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 --- doc/plugins/PLUGIN_AUTHORING_GUIDE.md | 160 ++++++++++- packages/adapters/acpx-local/src/index.ts | 11 +- .../acpx-local/src/server/config-schema.ts | 52 ++-- .../adapters/acpx-local/src/server/execute.ts | 255 ++++++++++++++++-- .../acpx-local/src/ui/build-config.ts | 10 +- .../src/__tests__/acpx-local-execute.test.ts | 173 +++++++++++- server/src/__tests__/adapter-models.test.ts | 9 +- server/src/__tests__/adapter-routes.test.ts | 26 +- server/src/adapters/registry.ts | 49 +++- ui/src/adapters/schema-config-fields.test.ts | 47 ++++ ui/src/adapters/schema-config-fields.tsx | 224 ++++++++------- ui/src/components/AgentConfigForm.tsx | 61 +++-- ui/src/lib/acpx-model-filter.test.ts | 26 ++ ui/src/lib/acpx-model-filter.ts | 16 ++ ui/storybook/stories/acpx-local.stories.tsx | 41 +-- 15 files changed, 949 insertions(+), 211 deletions(-) create mode 100644 ui/src/adapters/schema-config-fields.test.ts create mode 100644 ui/src/lib/acpx-model-filter.test.ts create mode 100644 ui/src/lib/acpx-model-filter.ts diff --git a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md index 01edfcff..fc43ae7b 100644 --- a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md +++ b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md @@ -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:` 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` diff --git a/packages/adapters/acpx-local/src/index.ts b/packages/adapters/acpx-local/src/index.ts index 1e4933c0..dcbba953 100644 --- a/packages/adapters/acpx-local/src/index.ts +++ b/packages/adapters/acpx-local/src/index.ts @@ -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: diff --git a/packages/adapters/acpx-local/src/server/config-schema.ts b/packages/adapters/acpx-local/src/server/config-schema.ts index 87100917..ca41aaac 100644 --- a/packages/adapters/acpx-local/src/server/config-schema.ts +++ b/packages/adapters/acpx-local/src/server/config-schema.ts @@ -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", diff --git a/packages/adapters/acpx-local/src/server/execute.ts b/packages/adapters/acpx-local/src/server/execute.ts index dc5d4d0c..7d9080c6 100644 --- a/packages/adapters/acpx-local/src/server/execute.ts +++ b/packages/adapters/acpx-local/src/server/execute.ts @@ -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): "d : "deny"; } +function normalizeRequestedThinkingEffort(config: Record): string { + return ( + asString(config.modelReasoningEffort, "") || + asString(config.reasoningEffort, "") || + asString(config.thinkingEffort, "") || + asString(config.effort, "") + ).trim(); +} + function isCompatibleSession( params: Record, runtime: Pick, @@ -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; @@ -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; + 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; + 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 { 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, diff --git a/packages/adapters/acpx-local/src/ui/build-config.ts b/packages/adapters/acpx-local/src/ui/build-config.ts index 445686dc..729d16c1 100644 --- a/packages/adapters/acpx-local/src/ui/build-config.ts +++ b/packages/adapters/acpx-local/src/ui/build-config.ts @@ -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 { const schemaValues = v.adapterSchemaValues ?? {}; + const agent = String(schemaValues.agent || DEFAULT_ACPX_LOCAL_AGENT); const ac: Record = { - 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 { } }); + 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 { diff --git a/server/src/__tests__/adapter-models.test.ts b/server/src/__tests__/adapter-models.test.ts index 9e0ec359..b920de22 100644 --- a/server/src/__tests__/adapter-models.test.ts +++ b/server/src/__tests__/adapter-models.test.ts @@ -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"); diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index 6fa67915..06c25c7f 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -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 () => { diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index ae356365..baca83f4 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -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( return ctx; } +function dedupeAdapterModels(models: AdapterModel[]): AdapterModel[] { + const seen = new Set(); + 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 { + 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", diff --git a/ui/src/adapters/schema-config-fields.test.ts b/ui/src/adapters/schema-config-fields.test.ts new file mode 100644 index 00000000..e77d675e --- /dev/null +++ b/ui/src/adapters/schema-config-fields.test.ts @@ -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): 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); + }); +}); diff --git a/ui/src/adapters/schema-config-fields.tsx b/ui/src/adapters/schema-config-fields.tsx index 7161f9e0..08a3e524 100644 --- a/ui/src/adapters/schema-config-fields.tsx +++ b/ui/src/adapters/schema-config-fields.tsx @@ -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 ( - - fieldMatchesVisibleWhen(field, readValue, schema)) + .map((field) => { + switch (field.type) { + case "select": { + const currentVal = String(readValue(field) ?? ""); + return ( + + writeValue(field, v)} + /> + + ); + } + + case "toggle": + return ( + writeValue(field, v)} /> - - ); - } + ); - case "toggle": - return ( - writeValue(field, v)} - /> - ); + case "number": + return ( + + writeValue(field, v)} + immediate + className={inputClass} + /> + + ); - case "number": - return ( - - writeValue(field, v)} - immediate - className={inputClass} - /> - - ); + case "textarea": + return ( + + writeValue(field, v || undefined)} + immediate + /> + + ); - case "textarea": - return ( - - writeValue(field, v || undefined)} - immediate - /> - - ); - - 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; - 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; + 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 ( + + writeValue(field, v || undefined)} + placeholder={field.hint} + /> + + ); } - return ( - - writeValue(field, v || undefined)} - placeholder={field.hint} - /> - - ); - } - case "text": - default: - return ( - - writeValue(field, v || undefined)} - immediate - className={inputClass} - /> - - ); - } - })} + case "text": + default: + return ( + + writeValue(field, v || undefined)} + immediate + className={inputClass} + /> + + ); + } + })} ); } diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 9f4804eb..078468a4 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -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(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." diff --git a/ui/src/lib/acpx-model-filter.test.ts b/ui/src/lib/acpx-model-filter.test.ts new file mode 100644 index 00000000..ae4d157e --- /dev/null +++ b/ui/src/lib/acpx-model-filter.test.ts @@ -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([]); + }); +}); diff --git a/ui/src/lib/acpx-model-filter.ts b/ui/src/lib/acpx-model-filter.ts new file mode 100644 index 00000000..ff13f9d3 --- /dev/null +++ b/ui/src/lib/acpx-model-filter.ts @@ -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 []; +} diff --git a/ui/storybook/stories/acpx-local.stories.tsx b/ui/storybook/stories/acpx-local.stories.tsx index 93d30ed2..3bdec0e5 100644 --- a/ui/storybook/stories/acpx-local.stories.tsx +++ b/ui/storybook/stories/acpx-local.stories.tsx @@ -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",