Files
PaperClipAI/ui/src/components/task-chat/composer-run-settings.ts
T
DottaandPaperclip 29c8fb0b66 fix(composer): match effort options to adapter execution (#14576)
## Thinking Path

> - Paperclip is an open source control plane for AI agents.
> - The issue composer lets operators choose a model and effort for a
task message.
> - The picker must show only effort levels that the selected adapter
can execute.
> - The Codex Runner fix in #14568 exposed similar gaps in other
adapters.
> - Grok hid a supported control. Claude used one range for all models.
Kimi ACP showed a control that it ignored.
> - This pull request aligns the picker with each adapter and adds
matching stories.
> - Operators can see and save supported effort settings without false
controls.

## Linked Issues or Issue Description

**What happened?**

The composer hid Grok effort. It showed an incomplete effort range for
some Claude models and a slider for Claude Haiku. It showed Kimi effort
on the default ACP engine even though that engine drops the value.

**Expected behavior**

The composer should show only effort levels supported by the selected
model and execution engine. Grok effort should reach its adapter as
`reasoningEffort`.

**Steps to reproduce**

1. Select a Grok agent and `grok-4.7`. Observe that the picker has no
effort slider.
2. Select Claude Sonnet 5 or Haiku 4.5. Observe the generic low to high
slider.
3. Select a Kimi agent on ACP. Observe the slider even though ACP
ignores effort.

Related fix: #14568.

## What Changed

- Use Claude and Grok adapter capability helpers in the production
picker and Storybook preview.
- Map Grok composer effort to `reasoningEffort` in per-message
overrides.
- Show Kimi effort only when its agent uses the CLI engine, including
when it runs its default model.
- Show Grok effort when it runs its default model.
- Add design and production stories for Claude, Grok, and Kimi ACP and
CLI cases.
- Document the picker rules and add focused tests.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/components/task-chat/composer-run-settings.test.ts`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm check:token-gates`
- `pnpm -r typecheck`, `pnpm test:run`, and `pnpm build` passed on the
first commit.
- The focused test, UI typecheck, token check, and Storybook build
passed again after the review fixes.
- Browser smoke: production stories show effort for default Grok and CLI
Kimi, and hide it for ACP Kimi and Claude Haiku.

## Risks

- Existing Kimi ACP agents lose a slider that could not change
execution. Their stored effort override remains unchanged until the next
edit.
- Claude and Grok ranges follow their adapter catalogs. A provider can
change model support before the catalog is updated.

## Model Used

OpenAI Codex based on GPT-6. The exact runtime model ID and context
window are not exposed in this session. Reasoning, tool use, and code
execution were used.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-29 10:34:29 -05:00

103 lines
5.5 KiB
TypeScript

import { codexLocalReasoningEffortsForModel, isCodexLocalFastModeSupported, isCodexLocalKnownModel } from "@paperclipai/adapter-codex-local";
import { claudeLocalReasoningEffortsForModel } from "@paperclipai/adapter-claude-local";
import { DEFAULT_GROK_LOCAL_MODEL, grokLocalReasoningEffortsForModel } from "@paperclipai/adapter-grok-local";
import { DEFAULT_KIMI_LOCAL_MODEL, modelSupportsEffort, KIMI_SUPPORTED_EFFORTS } from "@paperclipai/adapter-kimi-local";
import { aiConnectionBindingSchema, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared";
export interface ComposerRunSettings {
model: string | null;
effort: string | null;
fast: boolean;
}
export const DEFAULT_COMPOSER_RUN_SETTINGS: ComposerRunSettings = { model: null, effort: null, fast: false };
export const EFFORT_LABELS: Record<string, string> = {
off: "Off", minimal: "Minimal", low: "Low", medium: "Medium", high: "High",
xhigh: "Extra High", max: "Max", ultra: "Ultra",
};
const MODEL_ADAPTERS = new Set([
"claude_local", "codex_local", "opencode_local", "pi_local", "kimi_local",
"gemini_local", "cursor", "cursor_cloud", "grok_local", "hermes_local", "paperclip_runner",
]);
export function supportsComposerModel(agent: Agent | undefined): boolean {
return Boolean(agent && MODEL_ADAPTERS.has(agent.adapterType));
}
export function composerCatalogProvider(agent: Agent | undefined): string | undefined {
if (!agent) return undefined;
if (agent.adapterType === "paperclip_runner") return String(agent.adapterConfig.provider ?? "codex");
if (agent.adapterType !== "opencode_local") return undefined;
const binding = aiConnectionBindingSchema.safeParse(agent.runtimeConfig?.aiConnection).data;
const configuredModel = agent.adapterConfig.model;
return binding?.provider === "openrouter" || typeof configuredModel === "string" && configuredModel.startsWith("openrouter/")
? "openrouter" : undefined;
}
export function composerEfforts(agent: Agent | undefined, model: string, catalogIds: readonly string[]): readonly string[] {
if (!agent) return [];
const effectiveModel = model.trim() || (agent.adapterType === "grok_local" ? DEFAULT_GROK_LOCAL_MODEL
: agent.adapterType === "kimi_local" ? DEFAULT_KIMI_LOCAL_MODEL : "");
if (!effectiveModel) return [];
if (agent.adapterType === "codex_local" || (agent.adapterType === "paperclip_runner" && composerCatalogProvider(agent) === "codex")) {
return isCodexLocalKnownModel(effectiveModel) ? codexLocalReasoningEffortsForModel(effectiveModel) : [];
}
if (!catalogIds.includes(effectiveModel)) return [];
if (agent.adapterType === "claude_local") return claudeLocalReasoningEffortsForModel(effectiveModel);
if (agent.adapterType === "grok_local") return grokLocalReasoningEffortsForModel(effectiveModel);
if (agent.adapterType === "pi_local") return ["off", "minimal", "low", "medium", "high", "xhigh"];
if (agent.adapterType === "kimi_local" && typeof agent.adapterConfig.engine === "string"
&& agent.adapterConfig.engine.trim().toLowerCase() === "cli" && modelSupportsEffort(effectiveModel)) return KIMI_SUPPORTED_EFFORTS;
return [];
}
export function composerFastAvailable(agent: Agent | undefined, model: string): boolean {
return Boolean(agent && agent.adapterType === "codex_local" && isCodexLocalKnownModel(model) && isCodexLocalFastModeSupported(model));
}
export function readComposerRunSettings(overrides: IssueAssigneeAdapterOverrides | null | undefined, adapterType: string | undefined): ComposerRunSettings {
const config = overrides?.adapterConfig ?? {};
const effortKey = composerEffortKey(adapterType);
const effortValue = effortKey && (config[effortKey]
?? (adapterType === "codex_local" || adapterType === "paperclip_runner" ? config.reasoningEffort ?? config.effort : undefined));
return {
model: typeof config.model === "string" ? config.model : null,
effort: typeof effortValue === "string" ? effortValue : null,
fast: adapterType === "codex_local" && config.fastMode === true,
};
}
function composerEffortKey(adapterType: string | undefined): string | null {
if (adapterType === "codex_local" || adapterType === "paperclip_runner") return "modelReasoningEffort";
if (adapterType === "claude_local" || adapterType === "kimi_local") return "effort";
if (adapterType === "grok_local") return "reasoningEffort";
if (adapterType === "pi_local") return "thinking";
if (adapterType === "opencode_local") return "variant";
return null;
}
export function mergeComposerRunSettings(
previous: IssueAssigneeAdapterOverrides | null | undefined,
adapterType: string | undefined,
settings: ComposerRunSettings,
reassigned = false,
): IssueAssigneeAdapterOverrides | null {
const config = { ...(reassigned ? {} : previous?.adapterConfig) };
delete config.model;
delete config.modelReasoningEffort;
delete config.reasoningEffort;
delete config.effort;
delete config.thinking;
delete config.variant;
delete config.fastMode;
if (settings.model) config.model = settings.model;
const effortKey = composerEffortKey(adapterType);
if (settings.effort && effortKey) config[effortKey] = settings.effort;
if (settings.fast && adapterType === "codex_local") config.fastMode = true;
const useProjectWorkspace = reassigned ? undefined : previous?.useProjectWorkspace;
return Object.keys(config).length || useProjectWorkspace !== undefined
? { ...(Object.keys(config).length ? { adapterConfig: config } : {}), ...(useProjectWorkspace !== undefined ? { useProjectWorkspace } : {}) }
: null;
}