Files
OpenHands/scripts/check-sdk-version-sync.mjs
T
665a258b80 feat(acp): inline live model picker for ACP conversations (#769) (#832)
* feat(acp): inline live model picker for ACP conversations (#769)

Converge ACP model selection onto the native LLM-profile inline picker UX
with live mid-conversation switching, replacing the display-only popover.

- Bump @openhands/typescript-client 1.23.3 -> 1.24.0 (adds switchAcpModel).
- AgentServerConversationService.switchAcpModel(conversationId, model): POST
  /switch_acp_model via ConversationClient, with switchProfile's local-only guard.
- useSwitchAcpModel hook: live switch for a running ACP session; for the
  home/no-session case, persist the choice as the agent-settings default
  (agent_settings_diff { acp_model }) so the next conversation inherits it.
- ChatInputModel popover becomes a picker over the provider's available_models
  (check on the effective model), local backend only; cloud / custom-provider /
  native surfaces keep the display + Settings link.
- New i18n key MODEL$AVAILABLE_MODELS.
- Tests for the hook (live vs settings-default branches) and the picker.

Local backend only (matches native switching); custom/unknown providers and any
app_server route remain out of scope per #769.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(acp): open the model picker on click (don't self-close via click-outside)

The inline picker's trigger button sits outside the popover element, so the
document click-outside handler (useClickOutsideElement) treated the opening
click as an "outside" click and closed the popover in the same interaction —
clicking the chip appeared to do nothing. (A programmatic el.click() worked by
fluke: the popover isn't rendered yet when that click bubbles, so the ref is
null and the close is skipped.)

Pass the trigger button as the hook's ignoreOutsideClickRef so a click on the
chip toggles the popover instead of being treated as an outside click.

Validated end-to-end against a local agent-server 1.24.0: the picker opens and
lists the provider's available_models, and selecting one writes the default via
PATCH /settings (home case), with the chip updating to the new model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(acp): drop disableToast in useSwitchAcpModel so switch errors surface

useSwitchLlmProfile sets meta.disableToast because it's wrapped by
useSwitchLlmProfileAndLog, which re-surfaces errors via its own onError.
useSwitchAcpModel is called directly (no such wrapper / no onError), so
disableToast was silently swallowing failed switches and settings writes
(e.g. a 409 before the first message, network errors, the cloud guard).

Remove it and let the global mutation error toast report failures — simpler
and gives the user feedback when a switch doesn't take.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(acp): share chat input model picker state

* chore: address PR review feedback (#832)

- Add unit test for useChatInputModelState pinning its branching contract,
  incl. the active-ACP getAcpProvider lookup (was home-only in old component).
- Document why the overflow model submenu uses overflow-y-auto (scroll long
  model lists) rather than overflow-visible — no floating children to clip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: address PR review feedback (#832)

- Wrap the 'Available models' section label in a presentational <li> so it
  is a valid child of the ContextMenu <ul> (was a bare <div>).
- Drop unnecessary 'as never' casts in use-switch-acp-model tests now that
  the real return types (Promise<void>, Promise<boolean>) are honored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(acp): bump agent-server pin to 1.24.0 for /switch_acp_model

The inline ACP model picker POSTs to /api/conversations/{id}/switch_acp_model,
which is new in openhands-agent-server 1.24.0. The PR description already
lists agent-server:1.24.0 as a dependency, but config/defaults.json was
left at 1.23.1, so local dev (npm run dev) and Docker installs would 404
on every model switch attempt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): always land on /settings/agent from /settings

The fallback order in ``getFirstAvailablePath`` put ``/settings/llm``
first whenever ``hide_llm_settings`` was off, so clicking Settings sent
the user to the LLM page. For ACP users that page is disabled and
``redirectIfAcpActive`` only catches them when the *personal* settings
already say ``agent_kind === "acp"`` — being in an ACP conversation
with non-ACP personal settings (the common case during the inline
picker flow) bypassed the guard and dumped them on /settings/llm.

Make ``/settings/agent`` the unconditional first fallback. It is
always available (no feature flag hides it), houses the agent-kind
picker, and the left nav still gets OpenHands users to LLM in one
click — so one extra click for non-ACP users buys a much simpler
routing surface and kills the ACP misroute.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings/agent): clear command when switching to Custom preset

Selecting "Custom" in the agent preset dropdown reset ``acpModel`` and
flipped ``isCustomAcpModel`` but left ``commandText`` untouched. On the
next render, ``detectPreset(commandText, ACP_PROVIDERS)`` still matched
the previous provider's ``default_command`` and snapped the dropdown
back off "Custom" — the toggle never stayed on Custom.

Clear ``commandText`` in the Custom branch so ``detectPreset`` falls
through to ``ACP_CUSTOM_PRESET_KEY`` on the next render and the dropdown
stays where the user put it. Empty command also matches the intended
"user supplies their own" semantics of the preset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): mark Verification page as disabledByAcp

The Verification page writes ``confirmation_mode`` and
``security_analyzer`` into ``conversation_settings_diff``. The ACP
agent loop never reads either: ``openhands/sdk/agent/acp_agent.py``
has zero references to ``confirmation_policy`` or
``security_analyzer``, and the only runtime readers
(``openhands/sdk/agent/agent.py:844,855``) live on the native
``Agent`` class — not on ``ACPAgent``. The backend accepts the values
and stores them on conversation state, but the ACP subprocess never
consults them.

So the page presents real-looking knobs that silently do nothing for
ACP users. Mark it ``disabledByAcp: true`` — same pattern as
``/settings/llm`` and ``/settings/condenser`` — so it greys out in the
nav and the existing route guard at ``src/routes/settings.tsx:47-51``
bounces direct visits to ``/settings/agent``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): bump doc/script SDK version examples to 1.24.0

The docs-version-sync test enforces that every documented agent-server
version example matches ``config/defaults.json:versions.agentServer``.
The previous commit bumped that pin from 1.23.1 to 1.24.0 for the
``/switch_acp_model`` route, but left the example references in
AGENTS.md, ``scripts/dev-safe.mjs``, and ``scripts/check-sdk-version-sync.mjs``
behind — the drift-detector caught it as ``test-and-build`` failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): bump remaining hard-coded 1.23.1 to 1.24.0

``__tests__/scripts/dev-safe.test.ts`` asserts ``buildAgentServerCommand``'s
default ``uvx`` args literally include ``openhands-agent-server==1.23.1`` and
matching ``openhands-{sdk,tools,workspace}==1.23.1``. The CI fix in the prior
commit only updated docs and example references; the central pin bump in
``config/defaults.json`` flowed through to this test's runtime expectation but
the literal expectations were never updated. Bump them.

Also bump the ``MOCK_AGENT_SERVER_VERSION`` placeholder in
``src/mocks/settings-handlers.ts`` for consistency with the central pin —
no test asserts on it, but leaving the mock at 1.23.1 invites future
drift confusion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 16:42:17 +02:00

448 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* Check SDK Version Sync
*
* Verifies that the released automation package (openhands-automation on PyPI)
* uses the SDK version expected for that automation release for all agent SDK libraries:
* - openhands-sdk
* - openhands-tools
* - openhands-workspace
* - openhands-agent-server
*
* This script checks the RELEASED PyPI version of openhands-automation (as specified
* by versions.automation in config/defaults.json), not the main branch.
* versions.automationSdk records the SDK dependency version for that
* released automation package and may intentionally lag versions.agentServer.
*
* This script is run in CI to catch version drift between projects.
*
* Usage:
* node scripts/check-sdk-version-sync.mjs
* EXPECTED_SDK_VERSION=1.24.0 node scripts/check-sdk-version-sync.mjs
* node scripts/check-sdk-version-sync.mjs --check-pypi
*
* Environment variables:
* EXPECTED_SDK_VERSION - Override the expected version (instead of reading from config/defaults.json)
* AUTOMATION_PACKAGE_NAME - Override the automation package name (default: openhands-automation)
* AUTOMATION_PACKAGE_VERSION - Override the automation package version (instead of reading from config/defaults.json)
*
* Options:
* --check-pypi Also check the latest SDK version on PyPI
* --help Show help
*
* Exit codes:
* 0 - All SDK versions match
* 1 - Version mismatch detected or error occurred
*/
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import process from "node:process";
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(__dirname, "..");
// Parse command line arguments
const args = process.argv.slice(2);
const checkPyPI = args.includes("--check-pypi");
const showHelp = args.includes("--help") || args.includes("-h");
if (showHelp) {
console.log(`
SDK Version Sync Check
Verifies that the released openhands-automation package on PyPI uses the
SDK version expected for that automation release.
The automation version is read from config/defaults.json (versions.automation).
The expected SDK dependency version is read from versions.automationSdk,
falling back to versions.agentServer for older configs.
Usage:
node scripts/check-sdk-version-sync.mjs [options]
Options:
--check-pypi Also check the latest SDK version on PyPI
--help, -h Show this help
Environment variables:
EXPECTED_SDK_VERSION Override the expected SDK version (instead of reading from config/defaults.json)
AUTOMATION_PACKAGE_NAME Override the automation package name (default: openhands-automation)
AUTOMATION_PACKAGE_VERSION Override the automation package version (instead of reading from config/defaults.json)
Triggering from other repos:
The automation repo or SDK repo can trigger this check via GitHub repository_dispatch:
curl -X POST \\
-H "Authorization: token \$GITHUB_TOKEN" \\
-H "Accept: application/vnd.github.v3+json" \\
https://api.github.com/repos/OpenHands/agent-canvas/dispatches \\
-d '{"event_type": "sdk-version-check", "client_payload": {"version": "1.24.0"}}'
`);
process.exit(0);
}
// ANSI color codes for terminal output
const colors = {
reset: "\x1b[0m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
cyan: "\x1b[36m",
dim: "\x1b[2m",
};
// SDK packages that must have matching versions
const SDK_PACKAGES = [
"openhands-sdk",
"openhands-tools",
"openhands-workspace",
"openhands-agent-server",
];
// Configurable automation package (can be overridden via env)
const AUTOMATION_PACKAGE_NAME = process.env.AUTOMATION_PACKAGE_NAME || "openhands-automation";
// Default retry configuration
const RETRY_COUNT = 3;
const RETRY_DELAY_MS = 1000;
/**
* Normalize a version string for comparison.
* Handles variations like "1.22" vs "1.22.0" by ensuring consistent format.
*/
function normalizeVersion(version) {
if (!version) return null;
// Remove any pre-release or build metadata for base comparison
const baseVersion = version.split(/[-+]/)[0];
// Split into parts and pad to 3 parts (major.minor.patch)
const parts = baseVersion.split(".").map((p) => parseInt(p, 10) || 0);
while (parts.length < 3) {
parts.push(0);
}
return parts.slice(0, 3).join(".");
}
/**
* Compare two versions for equality (handles semantic equivalence)
*/
function versionsEqual(v1, v2) {
return normalizeVersion(v1) === normalizeVersion(v2);
}
/**
* Sleep for a given number of milliseconds
*/
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ── Centralized config ──────────────────────────────────────────────────────
let SHARED_DEFAULTS;
try {
SHARED_DEFAULTS = JSON.parse(
readFileSync(join(projectRoot, "config", "defaults.json"), "utf-8"),
);
if (!SHARED_DEFAULTS.versions?.agentServer || !SHARED_DEFAULTS.versions?.automationSdk) {
throw new Error("missing required fields: versions.agentServer, versions.automationSdk");
}
} catch (err) {
console.error(`${colors.red}Failed to load config/defaults.json: ${err.message}${colors.reset}`);
console.error("Ensure the file exists and contains valid JSON with required fields.");
process.exit(1);
}
/**
* Read the default agent-server SDK version from config/defaults.json.
*/
function getDefaultAgentServerVersion() {
return { version: SHARED_DEFAULTS.versions.agentServer, source: "config/defaults.json" };
}
/**
* Read the expected automation SDK dependency version from environment
* or config/defaults.json.
*/
function getExpectedVersion() {
// Allow override via environment variable (useful for CI triggers).
const envVersion = process.env.EXPECTED_SDK_VERSION;
if (envVersion && envVersion.trim()) {
return { version: envVersion.trim(), source: "EXPECTED_SDK_VERSION env var" };
}
return {
version: SHARED_DEFAULTS.versions.automationSdk,
source: "config/defaults.json (versions.automationSdk)",
};
}
/**
* Fetch the latest version of a package from PyPI
*/
async function fetchPyPIVersion(packageName) {
const url = `https://pypi.org/pypi/${packageName}/json`;
try {
const response = await fetch(url);
if (!response.ok) {
return null;
}
const data = await response.json();
return data.info?.version || null;
} catch {
return null;
}
}
/**
* Read the automation version from env var or config/defaults.json
*/
function getAutomationVersion() {
// Allow override via environment variable
const envVersion = process.env.AUTOMATION_PACKAGE_VERSION;
if (envVersion && envVersion.trim()) {
return { version: envVersion.trim(), source: "AUTOMATION_PACKAGE_VERSION env var" };
}
return {
version: SHARED_DEFAULTS.versions.automation,
source: "config/defaults.json (versions.automation)",
};
}
/**
* Fetch package metadata from PyPI and extract dependencies (with retry)
*/
async function fetchPyPIDependencies(packageName, version) {
const url = `https://pypi.org/pypi/${packageName}/${version}/json`;
console.log(`${colors.dim}Fetching ${url}${colors.reset}`);
let lastError;
for (let attempt = 0; attempt < RETRY_COUNT; attempt++) {
try {
const response = await fetch(url);
// 404 is a config issue, don't retry
if (response.status === 404) {
throw new Error(
`Package ${packageName}==${version} not found on PyPI (404). Check the package name and version.`,
);
}
if (!response.ok) {
throw new Error(
`Failed to fetch ${packageName}==${version} from PyPI: ${response.status} ${response.statusText}`,
);
}
const data = await response.json();
return data.info?.requires_dist || [];
} catch (err) {
lastError = err;
// Don't retry on 404 (config issue)
if (err.message.includes("not found on PyPI (404)")) {
throw err;
}
// Retry on other errors (network issues, 5xx, etc.)
if (attempt < RETRY_COUNT - 1) {
const delay = RETRY_DELAY_MS * (attempt + 1);
console.log(
`${colors.yellow}Retry ${attempt + 1}/${RETRY_COUNT - 1} after ${delay}ms...${colors.reset}`,
);
await sleep(delay);
}
}
}
throw lastError;
}
/**
* Parse PyPI requires_dist array and extract SDK package versions
*
* PyPI returns dependencies in PEP 508 format like:
* "openhands-sdk>=1.24.0,<2.0.0"
* "openhands-tools==1.24.0"
* "openhands-workspace (>=1.24.0)"
*/
function parseSdkVersionsFromRequiresDist(requiresDist) {
const versions = {};
for (const pkg of SDK_PACKAGES) {
for (const dep of requiresDist) {
// Check if the dependency starts with our package name
// The package name may be followed by whitespace, operators, or parentheses
if (!dep.toLowerCase().startsWith(pkg.toLowerCase())) {
continue;
}
// Extract the version number - look for patterns like:
// ">=1.24.0", "==1.24.0", "(>=1.24.0)", "~=1.24.0"
// After the package name and before any comma or closing paren
const versionPattern = /[><=~!]+\s*([0-9]+(?:\.[0-9]+)*)/;
const match = dep.match(versionPattern);
if (match) {
versions[pkg] = match[1];
break;
}
}
}
return versions;
}
/**
* Main entry point
*/
async function main() {
console.log("");
console.log(
`${colors.cyan}SDK Version Sync Check${colors.reset}`,
);
console.log("─".repeat(50));
console.log("");
try {
// Get expected version from env var or config/defaults.json
const { version: expectedVersion, source: versionSource } = getExpectedVersion();
console.log(
`Expected automation SDK version: ${colors.green}${expectedVersion}${colors.reset} (from ${versionSource})`,
);
const { version: agentServerVersion } = getDefaultAgentServerVersion();
if (!versionsEqual(agentServerVersion, expectedVersion)) {
console.log(
`${colors.yellow}Note:${colors.reset} DEFAULT_AGENT_SERVER_VERSION is ${agentServerVersion}; automation release dependencies may lag while a compatible automation package is pending.`,
);
}
// Get automation version from env var or config/defaults.json
const { version: automationVersion, source: automationSource } = getAutomationVersion();
console.log(
`Automation package: ${colors.cyan}${AUTOMATION_PACKAGE_NAME}==${automationVersion}${colors.reset} (from ${automationSource})`,
);
// Optionally check PyPI for the latest SDK version
if (checkPyPI) {
console.log("");
console.log("Checking latest SDK versions on PyPI:");
for (const pkg of SDK_PACKAGES) {
const pypiVersion = await fetchPyPIVersion(pkg);
if (pypiVersion) {
const status = versionsEqual(pypiVersion, expectedVersion)
? colors.green
: colors.yellow;
console.log(` ${pkg.padEnd(25)} ${status}${pypiVersion}${colors.reset}`);
} else {
console.log(` ${pkg.padEnd(25)} ${colors.dim}(not found on PyPI)${colors.reset}`);
}
}
}
console.log("");
// Fetch automation package dependencies from PyPI
const requiresDist = await fetchPyPIDependencies(AUTOMATION_PACKAGE_NAME, automationVersion);
const automationVersions = parseSdkVersionsFromRequiresDist(requiresDist);
// Check each SDK package
let hasErrors = false;
let foundAny = false;
const mismatches = [];
console.log(`Checking ${AUTOMATION_PACKAGE_NAME}==${automationVersion} SDK dependencies:`);
console.log("");
for (const pkg of SDK_PACKAGES) {
const actualVersion = automationVersions[pkg];
if (actualVersion) {
foundAny = true;
if (versionsEqual(actualVersion, expectedVersion)) {
console.log(
` ${pkg.padEnd(25)} ${colors.green}✓ ${actualVersion}${colors.reset}`,
);
} else {
hasErrors = true;
console.log(
` ${pkg.padEnd(25)} ${colors.red}✗ ${actualVersion} (expected ${expectedVersion})${colors.reset}`,
);
mismatches.push({
package: pkg,
expected: expectedVersion,
actual: actualVersion,
});
}
} else {
// Package not found - might be a transitive dependency, not an error
console.log(
` ${pkg.padEnd(25)} ${colors.dim}- not a direct dependency${colors.reset}`,
);
}
}
console.log("");
if (!foundAny) {
console.log(
`${colors.yellow}Warning: No SDK packages found in ${AUTOMATION_PACKAGE_NAME}==${automationVersion} dependencies${colors.reset}`,
);
console.log("This might indicate a parsing issue or the package is not yet published.");
console.log("");
process.exit(1);
}
if (hasErrors) {
console.log(
`${colors.red}Version mismatch detected!${colors.reset}`,
);
console.log("");
console.log(`The released ${AUTOMATION_PACKAGE_NAME}==${automationVersion} uses different SDK versions than expected for that automation release.`);
console.log("");
console.log("Mismatched packages:");
for (const m of mismatches) {
console.log(` - ${m.package}: ${m.actual} (expected ${m.expected})`);
}
console.log("");
console.log("To fix, update one of the following:");
console.log(
` 1. Update versions.automationSdk in config/defaults.json to match the automation release`,
);
console.log(
` 2. Release a new version of ${AUTOMATION_PACKAGE_NAME} with SDK dependencies pinned to ${expectedVersion}`,
);
console.log(
` 3. Update versions.automation in config/defaults.json to a newer release`,
);
console.log("");
process.exit(1);
}
console.log(
`${colors.green}All SDK versions are in sync!${colors.reset}`,
);
console.log("");
} catch (error) {
console.error(`${colors.red}Error: ${error.message}${colors.reset}`);
process.exit(1);
}
}
// Export for testing
export {
normalizeVersion,
versionsEqual,
parseSdkVersionsFromRequiresDist,
SDK_PACKAGES,
AUTOMATION_PACKAGE_NAME,
};
main();