Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion __tests__/api/agent-server-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,13 +554,18 @@ describe("toAppConversation", () => {
expect(result.llm_model).toBe("claude-sonnet-4-6");
});

it("marks ACP conversations and nulls llm_model so the chat UI can't mislead", () => {
it("nulls llm_model on ACP conversations from older agent-servers that don't lift current_model_*", () => {
// The SDK's ACPAgent carries a sentinel ``llm`` (``acp-managed``) for
// cost-attribution only; the *real* model lives on the ACP subprocess via
// ``acp_model`` and isn't surfaced on ``agent.llm.model``. Surfacing the
// sentinel as ``llm_model`` would let SwitchProfileButton render an
// affordance to "change the model" on a Claude-Code conversation while
// the running subprocess kept its own — a confusing silent no-op.
//
// For older agent-servers (pre software-agent-sdk PR #3347) neither
// ``current_model_name`` nor ``current_model_id`` is on the response,
// so we fall through to ``null`` — the chip then shows just the
// provider brand ("Claude Code") rather than a misleading model name.
const result = toAppConversation({
...baseInfo,
agent: { kind: "ACPAgent", llm: { model: "acp-managed" } },
Expand All @@ -569,6 +574,47 @@ describe("toAppConversation", () => {
expect(result.llm_model).toBeNull();
});

it("prefers ConversationInfo.current_model_name on ACP conversations", () => {
// When the agent-server lifts ``current_model_name`` (software-agent-sdk
// PR #3347+, resolved via ``available_models.name`` lookup), surface the
// human-readable name as ``llm_model`` so the chip shows
// "Default (recommended)" / "GPT-5.5 (xhigh)" rather than the opaque id.
const result = toAppConversation({
...baseInfo,
agent: { kind: "ACPAgent", llm: { model: "acp-managed" } },
current_model_id: "default",
current_model_name: "Default (recommended)",
});
expect(result.agent_kind).toBe("acp");
expect(result.llm_model).toBe("Default (recommended)");
});

it("falls back to current_model_id when only the raw id is available", () => {
// Intermediate state — some SDK builds set ``current_model_id`` without
// also lifting ``current_model_name``. Better the raw id than ``null``.
const result = toAppConversation({
...baseInfo,
agent: { kind: "ACPAgent", llm: { model: "acp-managed" } },
current_model_id: "claude-opus-4-1",
});
expect(result.llm_model).toBe("claude-opus-4-1");
});

it("treats current_model_* on non-ACP conversations as a no-op", () => {
// Defensive: native conversations should always source ``llm_model`` from
// ``agent.llm.model``. A stray ``current_model_name`` on the wire
// shouldn't be promoted onto an OpenHands conversation (it doesn't
// semantically apply there).
const result = toAppConversation({
...baseInfo,
agent: { kind: "Agent", llm: { model: "claude-sonnet-4-6" } },
current_model_id: "ignored",
current_model_name: "Ignored",
});
expect(result.agent_kind).toBe("openhands");
expect(result.llm_model).toBe("claude-sonnet-4-6");
});

it("surfaces acp_server from tags.acpserver for ACP conversations", () => {
// The ``acpserver`` conversation tag is stamped at create time
// (``buildStartConversationRequest``) but never previously plumbed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -597,12 +597,12 @@ describe("ConversationCard", () => {
});
});

describe("ACP agent badge", () => {
it("renders the resolved display name for a known ACP server", () => {
// ``claude-code`` resolves through the ACP_PROVIDERS registry to the
// human display name "Claude Code". The badge always renders for
// ACP conversations — it's identity info, not gated by the LLM-
// profile preference.
describe("agent chip (brand icon + model text)", () => {
it("renders provider brand text when an ACP conversation has no llm_model", () => {
// When the SDK doesn't report ``current_model_name`` yet (older
// builds), the adapter sets ``llm_model`` to null and we fall back
// to the provider display name. The brand icon still flags this as
// an ACP / Claude Code conversation.
renderWithProviders(
<ConversationCard
title="Conversation 1"
Expand All @@ -613,15 +613,39 @@ describe("ConversationCard", () => {
/>,
);

const badge = screen.getByTestId("conversation-card-acp-badge");
expect(badge).toHaveTextContent("Claude Code");
const chip = screen.getByTestId("conversation-card-agent-chip");
expect(chip).toHaveTextContent("Claude Code");
expect(
chip.querySelector('[data-testid="agent-brand-icon-claude-code"]'),
).toBeInTheDocument();
});

it("prefers llm_model (resolved name) over the provider brand for ACP chips", () => {
// Once the agent-server lifts ``current_model_name`` onto the
// conversation, the chip becomes the actual model the harness is
// running — exactly the model surfacing we wanted for ACP.
renderWithProviders(
<ConversationCard
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
agentKind="acp"
acpServer="claude-code"
llmModel="Default (recommended)"
/>,
);

const chip = screen.getByTestId("conversation-card-agent-chip");
expect(chip).toHaveTextContent("Default (recommended)");
// Tooltip still includes the harness identity so the chip is
// unambiguous on hover even when the visible text is just a model.
expect(chip).toHaveAttribute(
"title",
"Claude Code · Default (recommended)",
);
});

it("falls back to the generic 'ACP' label when the server key is unknown", () => {
// The Custom-command preset uses ``acp_server: "custom"`` (and
// future ACP servers Canvas's registry doesn't know about look the
// same here) — the resolver returns null and the chip shows the
// generic ``CONVERSATION$ACP_AGENT_GENERIC`` translation.
it("falls back to the generic 'ACP' label when the server key is unknown (custom command)", () => {
renderWithProviders(
<ConversationCard
title="Conversation 1"
Expand All @@ -632,14 +656,15 @@ describe("ConversationCard", () => {
/>,
);

const badge = screen.getByTestId("conversation-card-acp-badge");
expect(badge).toHaveTextContent("ACP");
const chip = screen.getByTestId("conversation-card-agent-chip");
expect(chip).toHaveTextContent("ACP");
// Unknown providers get the neutral terminal glyph, not a brand mark.
expect(
chip.querySelector('[data-testid="agent-brand-icon-generic"]'),
).toBeInTheDocument();
});

it("falls back to the generic 'ACP' label when the server key is null", () => {
// ACP conversations missing the ``acpserver`` tag (older clients,
// raw API writes) still get a chip — the goal is "this is an ACP
// conversation" first, exact provider second.
renderWithProviders(
<ConversationCard
title="Conversation 1"
Expand All @@ -650,27 +675,47 @@ describe("ConversationCard", () => {
/>,
);

const badge = screen.getByTestId("conversation-card-acp-badge");
expect(badge).toHaveTextContent("ACP");
const chip = screen.getByTestId("conversation-card-agent-chip");
expect(chip).toHaveTextContent("ACP");
});

it("does not render the badge for OpenHands conversations", () => {
// The OpenHands rendering path must be untouched — even if a stray
// ``acp_server`` value somehow reaches the prop, the chip stays
// hidden because ``agentKind !== "acp"``.
it("does not render the chip for OpenHands conversations by default", () => {
// For native OpenHands conversations the chip is gated behind the
// ``showLlmProfiles`` user preference; without it the chip stays
// hidden even when ``llmModel`` is present (preserves prior UX).
renderWithProviders(
<ConversationCard
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
agentKind="openhands"
acpServer="claude-code"
llmModel="anthropic/claude-sonnet-4-5"
/>,
);

expect(
screen.queryByTestId("conversation-card-acp-badge"),
screen.queryByTestId("conversation-card-agent-chip"),
).not.toBeInTheDocument();
});

it("renders the OpenHands logo + model when showLlmProfiles is on", () => {
renderWithProviders(
<ConversationCard
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
agentKind="openhands"
llmModel="anthropic/claude-sonnet-4-5"
showLlmProfiles
/>,
);

const chip = screen.getByTestId("conversation-card-agent-chip");
expect(chip).toHaveTextContent("anthropic/claude-sonnet-4-5");
expect(
chip.querySelector('[data-testid="agent-brand-icon-openhands"]'),
).toBeInTheDocument();
});
});
});
26 changes: 14 additions & 12 deletions __tests__/scripts/dev-safe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,21 +312,23 @@ describe("formatMissingUvxGuidance", () => {
});

describe("buildAgentServerCommand", () => {
it("uses released PyPI version by default with all packages pinned", () => {
it("uses the in-repo git-ref pin from config/defaults.json by default", () => {
// The repo currently pins to a software-agent-sdk PR commit so the dev
// stack picks up the unreleased ConversationInfo.current_model_name /
// .current_model_id fields used by the conversation chip. Once that PR
// merges and ships in a release, the pin is removed and the default
// reverts to the latest PyPI version (see the "no git ref pin" case
// below).
const cmd = buildAgentServerCommand({});

expect(cmd.command).toBe("uvx");
// Defaults to the released PyPI version with all SDK packages pinned to same version
expect(cmd.args).toEqual([
"--from",
"openhands-agent-server==1.23.0",
"--with",
"openhands-tools==1.23.0",
"--with",
"openhands-workspace==1.23.0",
"agent-server",
]);
expect(cmd.source).toBe("PyPI (1.23.0, default)");
expect(cmd.source).toMatch(/^git \(/);
// Sanity-check that all three SDK packages got pinned to the same ref.
const fromArgs = cmd.args.filter(
(_v, i) => cmd.args[i - 1] === "--from" || cmd.args[i - 1] === "--with",
);
expect(fromArgs).toHaveLength(3);
expect(fromArgs.every((a) => a.startsWith("git+"))).toBe(true);
});

it("uses specific PyPI version when OH_AGENT_SERVER_VERSION is set with all packages pinned", () => {
Expand Down
10 changes: 3 additions & 7 deletions config/defaults.json
Original file line number Diff line number Diff line change
@@ -1,38 +1,34 @@
{
"_comment": "Single source of truth for version pins, ports, paths, and defaults shared across the npm and Docker install paths. Read by scripts/dev-safe.mjs, scripts/dev-with-automation.mjs, docker/entrypoint.sh (via generated defaults.env), and .github/workflows/docker.yml.",

"versions": {
"agentServer": "1.23.0",
"automation": "1.0.0a3",
"automationSdk": "1.22.1"
},

"_agentServerGitRefComment": "Set to pin the dev agent-server to a specific software-agent-sdk commit/branch. Takes precedence over versions.agentServer when present. Current pin: software-agent-sdk PR #3347 + the deadlock fix PR #3349 merged in (commit 6aeb0817b) \u2014 surfaces current_model_name and fixes the stats_callback re-entry that hung every ACP turn.",
"agentServerGitRef": "6aeb0817bbaa6009fa833df8578c9efe36b973a9",
"images": {
"agentServer": "ghcr.io/openhands/agent-server",
"agentCanvas": "ghcr.io/openhands/agent-canvas"
},

"ports": {
"agentServer": 18000,
"automation": 18001,
"proxy": 8000
},

"paths": {
"stateSubdir": "agent-canvas",
"conversations": "agent-canvas/conversations",
"bashEvents": "agent-canvas/bash_events",
"automationDb": "automation/automations.db"
},

"packages": {
"agentServer": "openhands-agent-server",
"automation": "openhands-automation",
"tools": "openhands-tools",
"workspace": "openhands-workspace"
},

"defaults": {
"secretKey": "openhands-dev-secret-key-change-in-prod"
}
}
}
20 changes: 18 additions & 2 deletions scripts/dev-safe.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ const LOCAL_AGENT_SERVER_SUBDIRS = [
];
const DEFAULT_SECRET_KEY = SHARED_DEFAULTS.defaults.secretKey;
const DEFAULT_AGENT_SERVER_VERSION = SHARED_DEFAULTS.versions.agentServer;
// Optional git pin in ``config/defaults.json``: when present, dev installs
// the agent-server from this commit/branch of software-agent-sdk instead of
// the PyPI release. Used to track unreleased SDK PRs; cleared once the SDK
// PR merges and a release ships.
const DEFAULT_AGENT_SERVER_GIT_REF = SHARED_DEFAULTS.agentServerGitRef ?? null;
const FRONTEND_REQUIRED_BINS = ["cross-env", "react-router"];

/**
Expand Down Expand Up @@ -348,8 +353,19 @@ export function validateFrontendDependencies(
*/
export function buildAgentServerCommand(env = process.env) {
const localPath = env.OH_AGENT_SERVER_LOCAL_PATH;
const gitRef = env.OH_AGENT_SERVER_GIT_REF;
const version = env.OH_AGENT_SERVER_VERSION;
// Precedence (highest first):
// 1. OH_AGENT_SERVER_LOCAL_PATH — env: editable local checkout
// 2. OH_AGENT_SERVER_GIT_REF — env: explicit git ref override
// 3. OH_AGENT_SERVER_VERSION — env: explicit PyPI version override
// 4. DEFAULT_AGENT_SERVER_GIT_REF — config: in-repo PR-tracking pin
// 5. DEFAULT_AGENT_SERVER_VERSION — config: released PyPI default
// Env vars beat the in-repo default so devs can always override, but the
// in-repo git-ref pin beats the PyPI fallback so an unreleased SDK PR can
// be the operative agent-server for everyone running the dev stack.
const envGitRef = env.OH_AGENT_SERVER_GIT_REF;
const envVersion = env.OH_AGENT_SERVER_VERSION;
const gitRef = envGitRef ?? (envVersion ? null : DEFAULT_AGENT_SERVER_GIT_REF);
const version = envVersion;

const uvxArgs = [];
let source = "";
Expand Down
26 changes: 25 additions & 1 deletion src/api/agent-server-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,22 @@ export interface DirectConversationInfo {
* values are opaque strings.
*/
tags?: Record<string, string> | null;
/**
* Raw model id the agent-server reports as active for the current session
* (lifted from ``ACPAgent.current_model_id`` for ACP conversations on
* software-agent-sdk PR #3347+). For Claude Code this is sometimes an
* alias like ``"default"``; prefer ``current_model_name`` for display.
*/
current_model_id?: string | null;
/**
* Human-readable model name resolved via the SDK's ``available_models``
* lookup (e.g. ``"Default (recommended)"``, ``"GPT-5.5 (xhigh)"``,
* ``"Opus 4.1"``). Falls back to the raw model id when the alias-resolution
* lookup misses, and is undefined for SDK builds that predate the field —
* callers consuming this should chain through ``current_model_id`` then
* ``null``.
*/
current_model_name?: string | null;
}

// Module qualname for the Canvas-UI tool. The agent-server imports this via
Expand Down Expand Up @@ -274,8 +290,16 @@ export function toAppConversation(
pr_number: [],
agent_kind: isAcp ? "acp" : "openhands",
acp_server: acpServer,
// For ACP conversations the dummy ``agent.llm.model`` would lie (see the
// longer comment above). The agent-server lifts the SDK's resolved
// model — ``ConversationInfo.current_model_name`` (human-readable, via
// ``ModelInfo.name`` lookup against ``availableModels``) and
// ``.current_model_id`` (raw) — onto the response when running software-
// agent-sdk PR #3347 or later. Prefer the name; fall back to the id so
// older agent-server builds still surface *something*, and to ``null``
// for builds that predate both fields. Mirrors OpenHands PR #14511.
llm_model: isAcp
? null
? (info.current_model_name ?? info.current_model_id ?? null)
: (info.agent?.llm?.model ?? DEFAULT_SETTINGS.llm_model),
metrics: info.metrics
? {
Expand Down
Loading
Loading