Skip to content
Merged
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
129 changes: 120 additions & 9 deletions src/crates/interfaces/acp/src/client/session_options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ pub struct AcpSessionOptions {
pub struct AcpSessionModelOption {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_name: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
Expand Down Expand Up @@ -243,13 +245,41 @@ pub(super) fn model_config_id(config_options: &[SessionConfigOption]) -> Option<
}

fn model_option_from_model_info(model: &ModelInfo) -> AcpSessionModelOption {
let id = model.model_id.to_string();
AcpSessionModelOption {
id: model.model_id.to_string(),
provider_name: provider_name_from_model_identity(&id, model.description.as_deref()),
id,
name: model.name.clone(),
description: model.description.clone(),
}
}

fn provider_name_from_model_identity(id: &str, description: Option<&str>) -> Option<String> {
description
.and_then(provider_prefix)
.or_else(|| provider_prefix(id))
}

fn normalized_provider_name(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}

fn provider_prefix(value: &str) -> Option<String> {
let (provider, model) = value.trim().split_once('/')?;
let provider = provider.trim();
let model = model.trim();
if provider.is_empty() || model.is_empty() {
None
} else {
Some(provider.to_string())
}
}

fn model_config_option(config_options: &[SessionConfigOption]) -> Option<&SessionConfigOption> {
config_options
.iter()
Expand All @@ -274,19 +304,32 @@ fn select_model_values(
let models = match &select.options {
SessionConfigSelectOptions::Ungrouped(options) => options
.iter()
.map(|option| AcpSessionModelOption {
id: option.value.to_string(),
name: option.name.clone(),
description: option.description.clone(),
.map(|option| {
let id = option.value.to_string();
AcpSessionModelOption {
provider_name: provider_name_from_model_identity(
&id,
option.description.as_deref(),
),
id,
name: option.name.clone(),
description: option.description.clone(),
}
})
.collect(),
SessionConfigSelectOptions::Grouped(groups) => groups
.iter()
.flat_map(|group| {
group.options.iter().map(|option| AcpSessionModelOption {
id: option.value.to_string(),
name: option.name.clone(),
description: option.description.clone(),
group.options.iter().map(|option| {
let id = option.value.to_string();
AcpSessionModelOption {
provider_name: normalized_provider_name(&group.name).or_else(|| {
provider_name_from_model_identity(&id, option.description.as_deref())
}),
id,
name: option.name.clone(),
description: option.description.clone(),
}
})
})
.collect(),
Expand All @@ -313,6 +356,21 @@ mod tests {
assert!(options.model_config_id.is_none());
}

#[test]
fn extracts_provider_name_from_native_model_identity() {
let state = SessionModelState::new(
"openai/gpt-5.4",
vec![ModelInfo::new("openai/gpt-5.4", "GPT 5.4")],
);

let options = session_options_from_state(Some(&state), &[], None);

assert_eq!(
options.available_models[0].provider_name.as_deref(),
Some("openai")
);
}

#[test]
fn converts_model_config_option_fallback() {
let config = SessionConfigOption::select(
Expand All @@ -334,6 +392,59 @@ mod tests {
assert_eq!(options.available_models[1].id, "smart");
}

#[test]
fn extracts_provider_name_from_model_config_description() {
let config = SessionConfigOption::select(
"model",
"Model",
"openai/gpt-5.4",
vec![
agent_client_protocol::schema::SessionConfigSelectOption::new(
"openai/gpt-5.4",
"GPT 5.4",
)
.description("openai/gpt-5.4"),
],
)
.category(SessionConfigOptionCategory::Model);

let options = session_options_from_state(None, &[config], None);

assert_eq!(
options.available_models[0].provider_name.as_deref(),
Some("openai")
);
}

#[test]
fn preserves_model_config_group_name_as_provider_name() {
let config = SessionConfigOption::select(
"model",
"Model",
"openai/gpt-5.4",
vec![
agent_client_protocol::schema::SessionConfigSelectGroup::new(
"openai",
"OpenAI",
vec![
agent_client_protocol::schema::SessionConfigSelectOption::new(
"openai/gpt-5.4",
"GPT 5.4",
),
],
),
],
)
.category(SessionConfigOptionCategory::Model);

let options = session_options_from_state(None, &[config], None);

assert_eq!(
options.available_models[0].provider_name.as_deref(),
Some("OpenAI")
);
}

#[test]
fn includes_context_usage() {
let state = SessionModelState::new("gpt-5.4", vec![ModelInfo::new("gpt-5.4", "GPT 5.4")]);
Expand Down
4 changes: 4 additions & 0 deletions src/web-ui/src/flow_chat/components/ModelSelector.scss
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,11 @@
}

&__option-provider {
font-size: var(--flowchat-font-size-2xs);
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

&__option-meta {
Expand Down
9 changes: 6 additions & 3 deletions src/web-ui/src/flow_chat/components/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { Switch, Tooltip } from '@/component-library';
import { FlowChatStore } from '../store/FlowChatStore';
import { getModelMaxTokens } from '../services/flow-chat-manager/SessionModule';
import { acpClientIdFromAgentType } from '../utils/acpSession';
import { buildAcpFastModeValue, resolveAcpFastModeState } from '../utils/acpSessionConfig';
import { buildAcpFastModeValue, getAcpModelProviderName, resolveAcpFastModeState } from '../utils/acpSessionConfig';
import { sessionProjectWorkspacePath } from '../utils/sessionWorkspace';
import {
buildContextUsageTooltip,
Expand Down Expand Up @@ -390,7 +390,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
id: model.id,
configName: model.name,
modelName: model.name,
providerName: acpClientId ? `${acpClientId} ACP` : 'ACP',
providerName: getAcpModelProviderName(model) ?? (acpClientId ? `${acpClientId} ACP` : 'ACP'),
provider: 'acp',
}));
}, [acpClientId, acpOptions, isAcpSession]);
Expand Down Expand Up @@ -780,7 +780,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
const isSelected = currentAcpModelId === model.id;

return (
<Tooltip key={model.id} content={model.id} placement="right">
<Tooltip key={model.id} content={buildModelMetaText(model)} placement="right">
<button
type="button"
role="menuitemradio"
Expand All @@ -796,6 +796,9 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
<span className="bitfun-model-selector__option-name">
{model.modelName}
</span>
<span className="bitfun-model-selector__option-provider">
{model.providerName}
</span>
</div>
{isSelected && (
<Check size={14} className="bitfun-model-selector__option-check" />
Expand Down
37 changes: 36 additions & 1 deletion src/web-ui/src/flow_chat/utils/acpSessionConfig.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest';

import type { AcpSessionConfigOption } from '@/infrastructure/api/service-api/ACPClientAPI';
import { buildAcpFastModeValue, resolveAcpFastModeState } from './acpSessionConfig';
import {
buildAcpFastModeValue,
getAcpModelProviderName,
resolveAcpFastModeState,
} from './acpSessionConfig';

describe('ACP Fast mode config', () => {
it('resolves and toggles the select fallback exposed by Codex ACP', () => {
Expand Down Expand Up @@ -46,3 +50,34 @@ describe('ACP Fast mode config', () => {
expect(buildAcpFastModeValue(malformed, true)).toBeNull();
});
});

describe('ACP model provider display', () => {
it('uses the explicit provider name when the ACP bridge includes one', () => {
expect(getAcpModelProviderName({
id: 'openai/gpt-5.4',
name: 'gpt-5.4',
providerName: 'OpenAI',
})).toBe('OpenAI');
});

it('falls back to provider-qualified description or id', () => {
expect(getAcpModelProviderName({
id: 'openai/gpt-5.4',
name: 'gpt-5.4',
})).toBe('openai');

expect(getAcpModelProviderName({
id: 'gpt-5.4',
name: 'gpt-5.4',
description: 'azure-openai/gpt-5.4',
})).toBe('azure-openai');
});

it('ignores unqualified descriptions', () => {
expect(getAcpModelProviderName({
id: 'gpt-5.4',
name: 'gpt-5.4',
description: 'Fast reasoning model',
})).toBeUndefined();
});
});
24 changes: 24 additions & 0 deletions src/web-ui/src/flow_chat/utils/acpSessionConfig.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
AcpSessionConfigOption,
AcpSessionConfigValue,
AcpSessionModelOption,
} from '@/infrastructure/api/service-api/ACPClientAPI';

const FAST_MODE_CONFIG_ID = 'fast-mode';
Expand Down Expand Up @@ -49,3 +50,26 @@ export function buildAcpFastModeValue(
? { type: 'select', value }
: null;
}

export function getAcpModelProviderName(model: AcpSessionModelOption): string | undefined {
return normalizedProviderName(model.providerName)
?? providerNameFromProviderQualifiedValue(model.description)
?? providerNameFromProviderQualifiedValue(model.id);
}

function normalizedProviderName(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}

function providerNameFromProviderQualifiedValue(value: string | undefined): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;

const slashIndex = trimmed.indexOf('/');
if (slashIndex <= 0 || slashIndex === trimmed.length - 1) return undefined;

const provider = trimmed.slice(0, slashIndex).trim();
const modelId = trimmed.slice(slashIndex + 1).trim();
return provider && modelId ? provider : undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export interface SetAcpSessionConfigOptionRequest {
export interface AcpSessionModelOption {
id: string;
name: string;
providerName?: string;
description?: string;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const installClientCliMock = vi.hoisted(() => vi.fn());
const predownloadClientAdapterMock = vi.hoisted(() => vi.fn());
const listSavedConnectionsMock = vi.hoisted(() => vi.fn());
const notifyErrorMock = vi.hoisted(() => vi.fn());
const notifyInfoMock = vi.hoisted(() => vi.fn());
const notifySuccessMock = vi.hoisted(() => vi.fn());
const translate = (_key: string, options?: Record<string, unknown> & { defaultValue?: string }) => (
options?.defaultValue ?? _key
Expand Down Expand Up @@ -122,6 +123,7 @@ vi.mock('@/features/ssh-remote/sshApi', () => ({
vi.mock('@/shared/notification-system', () => ({
useNotification: () => ({
error: notifyErrorMock,
info: notifyInfoMock,
success: notifySuccessMock,
}),
}));
Expand Down Expand Up @@ -374,6 +376,46 @@ describe('AcpAgentsConfig', () => {
expect(container.textContent).not.toContain('registry.configInvalid');
});

it('labels self-managed missing CLIs as config-only before adding', async () => {
probeClientRequirementsMock.mockResolvedValue([
{
id: 'opencode',
tool: { name: 'opencode', installed: true },
runnable: true,
notes: [],
},
{
id: 'omp',
tool: { name: 'omp', installed: false },
runnable: false,
notes: ['omp is not available on PATH'],
},
]);

await act(async () => {
root.render(<AcpAgentsConfig />);
});

await act(async () => {
await Promise.resolve();
await Promise.resolve();
});

const addConfigButtons = Array.from(container.querySelectorAll('button'))
.filter(button => button.textContent?.includes('actions.addConfig'));
expect(addConfigButtons.length).toBeGreaterThan(0);

await act(async () => {
addConfigButtons[0].click();
await Promise.resolve();
await Promise.resolve();
});

expect(installClientCliMock).not.toHaveBeenCalled();
expect(saveJsonConfigMock).toHaveBeenCalledWith(expect.stringContaining('"omp"'));
expect(notifySuccessMock).toHaveBeenCalledWith('notifications.configAddedManualCliRequired');
});

it('does not downgrade enabled agents on transient probe timeouts during refresh', async () => {
probeClientRequirementsMock
.mockResolvedValueOnce([
Expand Down
Loading