feat(acp): show ACP agent name on conversation cards (#658)

The ``acpserver`` conversation tag has been stamped at create time since
the ACP integration landed, but the read path silently dropped it — so
the sidebar had no way to tell a Claude-Code conversation from a Codex
one. Plumb the tag through ``DirectConversationInfo`` →
``AppConversation`` → ``ConversationCard`` / ``CompactConversationRow``
and render a small pill above the LLM-model line. Closes the
"conversation list with agent tags" half of agent-canvas#405.

Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
simonrosenberg
2026-05-20 15:41:27 +00:00
committed by GitHub
co-authored by Debug Agent Claude Opus 4.7
parent ad5c5533a9
commit 2aa262acdc
13 changed files with 364 additions and 2 deletions
@@ -567,6 +567,48 @@ describe("toAppConversation", () => {
expect(result.agent_kind).toBe("acp");
expect(result.llm_model).toBeNull();
});
it("surfaces acp_server from tags.acpserver for ACP conversations", () => {
// The ``acpserver`` conversation tag is stamped at create time
// (``buildStartConversationRequest``) but never previously plumbed
// through on read — the sidebar chip in agent-canvas#405 needs this
// value to resolve the human display name ("Claude Code" / "Codex" /
// "Gemini CLI").
const result = toAppConversation({
...baseInfo,
agent: { kind: "ACPAgent", llm: { model: "acp-managed" } },
tags: { [ACP_SERVER_TAG_KEY]: "claude-code" },
});
expect(result.acp_server).toBe("claude-code");
});
it("leaves acp_server null when an ACP conversation has no tag stamped", () => {
// Older conversations created before the tag was added, or ACP
// conversations created via the raw API, won't have the tag. The
// sidebar should still render a chip ("ACP") — but the resolver gets
// null here and the UI fallback handles the generic label.
const result = toAppConversation({
...baseInfo,
agent: { kind: "ACPAgent", llm: { model: "acp-managed" } },
});
expect(result.agent_kind).toBe("acp");
expect(result.acp_server).toBeNull();
});
it("ignores tags.acpserver on OpenHands conversations to prevent stray-tag bleed", () => {
// The agent-server's pydantic model doesn't enforce that ``acpserver``
// is only stamped on ACP conversations. Defensively gating on
// ``agent.kind === "ACPAgent"`` keeps a misconfigured tag from
// turning the sidebar of an OpenHands conversation into "Claude
// Code". Pairs with the ``llm_model`` null-out for ACP.
const result = toAppConversation({
...baseInfo,
agent: { kind: "Agent", llm: { model: "claude-sonnet-4-6" } },
tags: { [ACP_SERVER_TAG_KEY]: "claude-code" },
});
expect(result.agent_kind).toBe("openhands");
expect(result.acp_server).toBeNull();
});
});
describe("buildRuntimeServicesSystemSuffix", () => {
@@ -497,6 +497,66 @@ describe("AgentServerConversationService", () => {
"/workspace/project/agent-canvas",
);
});
it("extracts the acpserver tag from the wire payload for the sidebar chip", async () => {
// The agent-server stamps ``tags.acpserver`` at conversation create
// time (see ``buildStartConversationRequest``); the read path
// must surface it so the conversation card can render the human
// ACP-agent badge ("Claude Code" / "Codex" / "Gemini CLI").
mockHttpGet.mockResolvedValue({
data: [
{
id: "conv-acp",
created_at: "2024-01-01",
updated_at: "2024-01-01",
agent: { kind: "ACPAgent", llm: { model: "acp-managed" } },
tags: { acpserver: "claude-code" },
},
],
});
const [conversation] =
await AgentServerConversationService.batchGetAppConversations([
"conv-acp",
]);
expect(conversation?.agent_kind).toBe("acp");
expect(conversation?.acp_server).toBe("claude-code");
});
it("drops non-string tag values while preserving the well-typed ones", async () => {
// The wire field is server-validated to ``Record[str, str]`` but a
// misbehaving server (or a future schema drift) shouldn't crash the
// parser — we drop non-string values and keep the rest so the
// sidebar still gets whatever good keys made it through.
mockHttpGet.mockResolvedValue({
data: [
{
id: "conv-malformed-tags",
created_at: "2024-01-01",
updated_at: "2024-01-01",
agent: { kind: "ACPAgent", llm: { model: "acp-managed" } },
tags: {
acpserver: "codex",
numeric: 42,
nested: { inner: "x" },
listy: ["a", "b"],
nully: null,
},
},
],
});
const [conversation] =
await AgentServerConversationService.batchGetAppConversations([
"conv-malformed-tags",
]);
// ``acp_server`` is the surfaced field on AppConversation; tags is
// only on DirectConversationInfo. Asserting both via this read
// path keeps the test honest end-to-end.
expect(conversation?.acp_server).toBe("codex");
});
});
describe("switchProfile", () => {
@@ -38,6 +38,7 @@ vi.mock("react-i18next", async () => {
CONVERSATION$AGO: "ago",
CONVERSATION$UPDATED: "Updated",
COMMON$NO_REPOSITORY: "No repository",
CONVERSATION$ACP_AGENT_GENERIC: "ACP",
};
return translations[key] || key;
},
@@ -595,4 +596,81 @@ 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.
renderWithProviders(
<ConversationCard
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
agentKind="acp"
acpServer="claude-code"
/>,
);
const badge = screen.getByTestId("conversation-card-acp-badge");
expect(badge).toHaveTextContent("Claude Code");
});
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.
renderWithProviders(
<ConversationCard
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
agentKind="acp"
acpServer="custom"
/>,
);
const badge = screen.getByTestId("conversation-card-acp-badge");
expect(badge).toHaveTextContent("ACP");
});
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"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
agentKind="acp"
acpServer={null}
/>,
);
const badge = screen.getByTestId("conversation-card-acp-badge");
expect(badge).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"``.
renderWithProviders(
<ConversationCard
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
agentKind="openhands"
acpServer="claude-code"
/>,
);
expect(
screen.queryByTestId("conversation-card-acp-badge"),
).not.toBeInTheDocument();
});
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { getAcpProviderDisplayName } from "#/constants/acp-providers";
describe("getAcpProviderDisplayName", () => {
it("resolves the three built-in registry keys to their human names", () => {
expect(getAcpProviderDisplayName("claude-code")).toBe("Claude Code");
expect(getAcpProviderDisplayName("codex")).toBe("Codex");
expect(getAcpProviderDisplayName("gemini-cli")).toBe("Gemini CLI");
});
it("returns null for the Custom-command preset so callers can fall back to the generic 'ACP' label", () => {
// The custom preset has no canonical brand name — the registry
// resolver intentionally returns null so the conversation card renders
// ``CONVERSATION$ACP_AGENT_GENERIC`` ("ACP") instead.
expect(getAcpProviderDisplayName("custom")).toBeNull();
});
it("returns null for unknown / forward-compatible keys", () => {
// A future ACP server Canvas's registry doesn't know about yet
// shouldn't crash or render a random fragment of the key — fall back
// to the generic chip.
expect(getAcpProviderDisplayName("future-acp-server")).toBeNull();
});
it("returns null for empty / null / undefined input", () => {
expect(getAcpProviderDisplayName(null)).toBeNull();
expect(getAcpProviderDisplayName(undefined)).toBeNull();
expect(getAcpProviderDisplayName("")).toBeNull();
});
});
+15
View File
@@ -55,6 +55,15 @@ export interface DirectConversationInfo {
workspace?: {
working_dir?: string | null;
} | null;
/**
* Arbitrary string-keyed conversation tags surfaced by the agent-server
* (see ``ConversationInfo.tags``). Canvas only consumes one key today —
* ``ACP_SERVER_TAG_KEY`` ("acpserver") — but the field is typed as a
* generic record so future readers don't need another wire-shape change.
* Keys are constrained to ``^[a-z0-9]+$`` by the agent-server validator;
* values are opaque strings.
*/
tags?: Record<string, string> | null;
}
// Module qualname for the Canvas-UI tool. The agent-server imports this via
@@ -246,6 +255,11 @@ export function toAppConversation(
// keeps its own model. Null at the boundary so no consumer has to
// re-derive the rule. Mirrors OpenHands PR #14401.
const isAcp = info.agent?.kind === "ACPAgent";
// Only surface ``acp_server`` for ACP conversations even if the wire
// payload accidentally carries an ``acpserver`` tag on an OpenHands
// conversation — the chip is identity info for the ACP CLI subprocess,
// and showing it on a non-ACP conversation would be a lie.
const acpServer = isAcp ? (info.tags?.[ACP_SERVER_TAG_KEY] ?? null) : null;
return {
id: info.id,
created_by_user_id: null,
@@ -259,6 +273,7 @@ export function toAppConversation(
trigger: null,
pr_number: [],
agent_kind: isAcp ? "acp" : "openhands",
acp_server: acpServer,
llm_model: isAcp
? null
: (info.agent?.llm?.model ?? DEFAULT_SETTINGS.llm_model),
@@ -128,7 +128,12 @@ function normalizeAgent(value: unknown): DirectConversationInfo["agent"] {
const llm = isRecord(value.llm)
? { model: stringOrNull(value.llm.model) }
: null;
return { llm };
// ``kind`` is the SDK's pydantic discriminator (``"Agent"`` vs ``"ACPAgent"``);
// ``toAppConversation`` reads it to derive ``agent_kind`` and to gate the
// ACP-server chip + ``llm_model`` null-out. Preserving it here makes the
// wire path agree with the unit-test path that builds ``DirectConversationInfo``
// directly (e.g. ``__tests__/api/agent-server-adapter.test.ts``).
return { kind: stringOrNull(value.kind), llm };
}
function normalizeWorkspace(
@@ -138,6 +143,27 @@ function normalizeWorkspace(
return { working_dir: stringOrNull(value.working_dir) };
}
/**
* Accept the agent-server's ``tags: Record[str, str]`` payload defensively:
* the wire shape is guaranteed by the server-side validator (keys
* ``^[a-z0-9]+$``, string values), but a non-conforming response (older
* server, raw API write, future schema drift) must never crash the parser
* — Canvas only consumes ``acpserver`` and falls back to a generic chip
* for anything it doesn't recognize. Drop entries whose value isn't a
* plain string; return ``null`` when the wire field is absent or not an
* object so consumers can use ``info.tags?.[KEY] ?? null`` uniformly.
*/
function normalizeTags(value: unknown): Record<string, string> | null {
if (!isRecord(value)) return null;
const tags: Record<string, string> = {};
for (const [key, entry] of Object.entries(value)) {
if (typeof entry === "string") {
tags[key] = entry;
}
}
return tags;
}
function normalizeAbsolutePath(path: string): string | null {
if (!path.startsWith("/")) return null;
@@ -187,6 +213,7 @@ function requireDirectConversationInfo(item: unknown): DirectConversationInfo {
metrics: normalizeMetrics(item.metrics),
agent: normalizeAgent(item.agent),
workspace: normalizeWorkspace(item.workspace),
tags: normalizeTags(item.tags),
};
}
@@ -131,6 +131,19 @@ export interface AppConversation {
* on the subprocess via ``acp_model``, not on ``llm_model``).
*/
agent_kind?: "openhands" | "acp" | null;
/**
* For ACP conversations, the registry key of the ACP CLI server the
* conversation was launched against (e.g. ``"claude-code"``, ``"codex"``,
* ``"gemini-cli"``). Populated from ``info.tags.acpserver`` — see
* ``ACP_SERVER_TAG_KEY`` in ``agent-server-adapter.ts`` for the wire
* format and the rationale behind the snake_case-incompatible
* ``acpserver`` form. ``null`` for OpenHands conversations and for ACP
* conversations whose tag wasn't stamped (e.g. created via an older
* client or via the raw API). Consumers resolve the display name via
* ``getAcpProviderDisplayName(acp_server)`` and fall back to a generic
* "ACP" chip when the key is unknown or null.
*/
acp_server?: string | null;
llm_model: string | null;
metrics: MetricsSnapshot | null;
created_at: string;
@@ -22,6 +22,8 @@ interface CompactConversationRowProps {
showRepositoryMetadata?: boolean;
llmModel?: string | null;
showLlmProfiles?: boolean;
agentKind?: "openhands" | "acp" | null;
acpServer?: string | null;
}
/**
@@ -43,6 +45,8 @@ export function CompactConversationRow({
showRepositoryMetadata = true,
llmModel = null,
showLlmProfiles = false,
agentKind = null,
acpServer = null,
}: CompactConversationRowProps) {
const disableAnimation = import.meta.env.MODE === "test";
@@ -67,6 +71,8 @@ export function CompactConversationRow({
showRepositoryMetadata={showRepositoryMetadata}
llmModel={llmModel}
showLlmModel={showLlmProfiles}
agentKind={agentKind}
acpServer={acpServer}
/>
</div>
);
@@ -5,6 +5,7 @@ import { I18nKey } from "#/i18n/declaration";
import { RepositorySelection } from "#/api/open-hands.types";
import { ExecutionStatus } from "#/types/agent-server/core/base/common";
import { isExecutionPaused } from "#/utils/status";
import { getAcpProviderDisplayName } from "#/constants/acp-providers";
import { ConversationRepoLink } from "./conversation-repo-link";
import { NoRepository } from "./no-repository";
@@ -18,6 +19,24 @@ interface ConversationCardFooterProps {
showTimestamp?: boolean;
llmModel?: string | null;
showLlmModel?: boolean;
/**
* High-level kind of the conversation's agent. The ACP-agent chip is
* only rendered when this is ``"acp"``. The OpenHands rendering path
* is intentionally untouched — for OpenHands conversations the chip is
* suppressed regardless of any ``acpServer`` value (defensive against
* stray wire tags on non-ACP conversations).
*/
agentKind?: "openhands" | "acp" | null;
/**
* Registry key of the ACP CLI server (``"claude-code"`` / ``"codex"`` /
* ``"gemini-cli"`` / unknown / null). Resolved to a human display name
* via {@link getAcpProviderDisplayName}; unknown / null falls back to
* a generic "ACP" label so a Custom-command preset still produces a
* useful chip. Always shown for ACP conversations — this is identity
* info, not gated by the ``showLlmModel`` preference (which is about
* LLM model strings, an orthogonal concern).
*/
acpServer?: string | null;
}
export function ConversationCardFooter({
@@ -30,11 +49,19 @@ export function ConversationCardFooter({
showTimestamp = true,
llmModel,
showLlmModel = false,
agentKind = null,
acpServer = null,
}: ConversationCardFooterProps) {
const { t } = useTranslation("openhands");
const isPaused = isExecutionPaused(executionStatus);
const acpDisplayName =
agentKind === "acp"
? (getAcpProviderDisplayName(acpServer) ??
t(I18nKey.CONVERSATION$ACP_AGENT_GENERIC))
: null;
return (
<div
className={cn(
@@ -42,6 +69,17 @@ export function ConversationCardFooter({
isPaused && "opacity-60",
)}
>
{acpDisplayName ? (
<div className="pl-[18px]">
<span
data-testid="conversation-card-acp-badge"
className="inline-flex items-center px-1.5 py-0.5 rounded-full bg-[var(--oh-surface-raised)] text-[var(--oh-muted)] text-xs font-medium max-w-full truncate"
title={acpDisplayName}
>
{acpDisplayName}
</span>
</div>
) : null}
{showLlmModel && llmModel ? (
<span
className="min-w-0 max-w-full truncate pl-[18px] text-xs text-[var(--oh-muted)]"
@@ -33,6 +33,8 @@ interface ConversationCardProps {
showRepositoryMetadata?: boolean;
llmModel?: string | null;
showLlmProfiles?: boolean;
agentKind?: "openhands" | "acp" | null;
acpServer?: string | null;
}
export function ConversationCard({
@@ -55,6 +57,8 @@ export function ConversationCard({
showRepositoryMetadata = true,
llmModel = null,
showLlmProfiles = false,
agentKind = null,
acpServer = null,
}: ConversationCardProps) {
const posthog = usePostHog();
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
@@ -128,7 +132,9 @@ export function ConversationCard({
const hasContextMenu = !!(onDelete || onChangeTitle || showOptions);
const shouldRenderFooter =
showRepositoryMetadata || (!!llmModel && showLlmProfiles);
showRepositoryMetadata ||
(!!llmModel && showLlmProfiles) ||
agentKind === "acp";
return (
<div
@@ -210,6 +216,8 @@ export function ConversationCard({
showTimestamp={false}
llmModel={llmModel}
showLlmModel={showLlmProfiles}
agentKind={agentKind}
acpServer={acpServer}
/>
)}
</div>
@@ -399,6 +399,8 @@ export function ConversationPanel({
showRepositoryMetadata={showRepoBranchMetadata}
llmModel={conversation.llm_model}
showLlmProfiles={showLlmProfiles}
agentKind={conversation.agent_kind}
acpServer={conversation.acp_server}
/>
);
}
@@ -440,6 +442,8 @@ export function ConversationPanel({
showRepositoryMetadata={showRepoBranchMetadata}
llmModel={conversation.llm_model}
showLlmProfiles={showLlmProfiles}
agentKind={conversation.agent_kind}
acpServer={conversation.acp_server}
/>
</NavigationLink>
);
+24
View File
@@ -99,6 +99,30 @@ export const ACP_PROVIDERS: ACPProviderConfig[] = [
export const ACP_CUSTOM_PRESET_KEY = "custom";
/**
* Resolve an ACP provider registry key (the value stored under
* ``tags.acpserver`` on a conversation) to a human display name for the
* sidebar chip.
*
* Returns ``null`` for an empty / null key and for keys not in
* {@link ACP_PROVIDERS} — most notably ``"custom"`` (the user-supplied
* command preset has no canonical brand name) and any forward-compatible
* value Canvas's registry doesn't know about yet. Callers should fall
* back to a generic ``"ACP"`` label in that case so the chip still
* communicates "this is an ACP conversation".
*
* Kept separate from {@link buildAcpAgentSettingsDiff}'s lookup so the
* conversation-card render path can resolve display names without
* importing the settings-payload builder.
*/
export function getAcpProviderDisplayName(
key: string | null | undefined,
): string | null {
if (!key) return null;
const found = ACP_PROVIDERS.find((p) => p.key === key);
return found ? found.display_name : null;
}
/**
* Build the ``agent_settings_diff`` payload PATCH /api/settings expects
* for the agent-kind/provider choice the user just made.
+17
View File
@@ -9723,6 +9723,23 @@
"uk": "тому",
"ca": "fa"
},
"CONVERSATION$ACP_AGENT_GENERIC": {
"en": "ACP",
"ja": "ACP",
"zh-CN": "ACP",
"zh-TW": "ACP",
"ko-KR": "ACP",
"de": "ACP",
"no": "ACP",
"it": "ACP",
"pt": "ACP",
"es": "ACP",
"ar": "ACP",
"fr": "ACP",
"tr": "ACP",
"uk": "ACP",
"ca": "ACP"
},
"GITHUB$VSCODE_LINK_DESCRIPTION": {
"en": "and use the VS Code link to upload and download your code",
"ja": "そしてVS Codeリンクを使用してコードをアップロードおよびダウンロードします",