diff --git a/apps/desktop/e2e/new-bot-config-on-disk.spec.ts b/apps/desktop/e2e/new-bot-config-on-disk.spec.ts new file mode 100644 index 0000000000..58f63287c9 --- /dev/null +++ b/apps/desktop/e2e/new-bot-config-on-disk.spec.ts @@ -0,0 +1,81 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { expect, test } from './test' + +// A bot created from the New Bot dialog WITHOUT a clone source (the "inherited +// from launch profile" path) must land on disk with a usable `model:` block in +// its own config.yaml and its introduction turn must actually run — a profile +// whose config.yaml lacks the block is dead on arrival ("No LLM provider +// configured" on the first message). Verified against storage truth (the +// created profile's config.yaml under HERMES_HOME), not the toast. + +let fixture: MockBackendFixture | null = null + +type Page = MockBackendFixture['page'] + +async function openBots(page: Page): Promise { + const tab = page + .getByRole('button', { name: 'Bots', exact: true }) + .or(page.getByRole('tab', { name: 'Bots', exact: true })) + .first() + + await tab.click() + await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible() +} + +test.beforeAll(async () => { + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) +}) + +// eslint-disable-next-line no-empty-pattern +test.afterEach(async ({}, info) => { + if (!fixture) { + return + } + + await info.attach('native-window', { body: await fixture.page.screenshot(), contentType: 'image/png' }) + const cfg = join(fixture.sandbox.hermesHome, 'profiles', 'fresh-scout', 'config.yaml') + await info.attach('created-config', { + body: existsSync(cfg) ? readFileSync(cfg) : Buffer.from(''), + contentType: 'text/plain' + }) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('a fresh (non-clone) bot gets a runnable model block on disk', async () => { + test.setTimeout(300_000) + const page = fixture!.page + + await openBots(page) + await page.getByRole('button', { name: 'New bot or group chat' }).click() + await page.getByRole('menuitem', { name: 'New Bot' }).click() + + const dialog = page.getByRole('dialog', { name: 'New Bot' }) + await dialog.getByPlaceholder('inbox-triage').fill('fresh-scout') + await dialog.getByPlaceholder('Inbox Triage').fill('Fresh Scout') + await dialog.getByRole('button', { name: 'Advanced' }).click() + + // "Fresh profile" = no clone source; the model must be inherited, not cloned. + await dialog.getByRole('combobox').filter({ hasText: /default|Fresh profile/ }).first().click() + await page.getByRole('option', { name: 'Fresh profile (bundled skills)' }).click() + await dialog.getByRole('button', { name: 'Create Bot' }).click() + await expect(dialog).toBeHidden({ timeout: 60_000 }) + + // Storage truth: the created profile's own config.yaml carries the model block. + const cfgPath = join(fixture!.sandbox.hermesHome, 'profiles', 'fresh-scout', 'config.yaml') + await expect.poll(() => existsSync(cfgPath), { timeout: 30_000 }).toBe(true) + const cfg = readFileSync(cfgPath, 'utf8') + expect(cfg).toMatch(/^model:/m) + expect(cfg).toMatch(/provider:\s*mock/) + expect(cfg).toMatch(/default:\s*mock-model/) + // The inherited model rides a custom `providers:` gateway; its definition must travel with it + // or the first turn dies with "Unknown provider 'mock'". + expect(cfg).toMatch(/^providers:\n\s+mock:/m) +}) diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 50fefe33c4..f99b0db359 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -588,6 +588,22 @@ def _read_config_model(profile_dir: Path) -> tuple: return None, None +def launch_model_seed(source_cfg: dict) -> dict: + """The config a fresh profile needs to run the launch profile's model: its ``model`` block plus, + when that block points at a custom ``providers:`` gateway (self-hosted / local endpoint), that + provider's definition — ``model.provider: my-gateway`` alone is "Unknown provider" on the first + turn. ``{}`` when the launch profile has no model.""" + model_cfg = source_cfg.get("model") + if not model_cfg: + return {} + seed = {"model": model_cfg} + providers = source_cfg.get("providers") + name = model_cfg.get("provider") if isinstance(model_cfg, dict) else None + if isinstance(providers, dict) and name in providers: + seed["providers"] = {name: providers[name]} + return seed + + def _seed_model_config(profile_dir: Path) -> None: """Copy (not link) the active profile's model block into a fresh profile so it is usable; profiles stay independent islands afterwards.""" @@ -599,9 +615,9 @@ def _seed_model_config(profile_dir: Path) -> None: from hermes_constants import get_hermes_home from hermes_cli.config import read_user_config_raw source = get_hermes_home() / "config.yaml" - model_cfg = read_user_config_raw(source).get("model") if source.is_file() else None - if model_cfg: - config_path.write_text(yaml.safe_dump({"model": model_cfg}, sort_keys=False), encoding="utf-8") + seed = launch_model_seed(read_user_config_raw(source)) if source.is_file() else {} + if seed: + config_path.write_text(yaml.safe_dump(seed, sort_keys=False), encoding="utf-8") def _check_gateway_running(profile_dir: Path) -> bool: diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index d8761fb284..48642b7853 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -163,6 +163,25 @@ class TestCreateProfile: assert cfg["model"]["default"] == "some/model" + def test_fresh_profile_inherits_its_custom_provider_gateway(self, profile_env): + """The inherited model may point at a custom `providers:` gateway (self-hosted / local + endpoint). Copying `model` alone left the new bot with `model.provider: my-gateway` and + "Unknown provider 'my-gateway'" on its first turn (#101885 / #94071 class); the provider + definition must travel with the model it backs, and nothing else from `providers:` does. + """ + default_home = profile_env / ".hermes" + (default_home / "config.yaml").write_text( + "model:\n provider: my-gateway\n default: my-finetune\n" + "providers:\n my-gateway:\n api: https://llm.internal.example.com/v1\n key_env: GW_KEY\n" + " unrelated:\n api: https://other.example.com/v1\n" + ) + + profile_dir = create_profile("coder", no_alias=True) + + cfg = yaml.safe_load((profile_dir / "config.yaml").read_text()) + assert cfg["model"] == {"provider": "my-gateway", "default": "my-finetune"} + assert cfg["providers"] == {"my-gateway": {"api": "https://llm.internal.example.com/v1", "key_env": "GW_KEY"}} + def test_fresh_profile_model_is_copied_not_linked(self, profile_env): """Profiles stay independent islands. diff --git a/tui_gateway/methods_profiles.py b/tui_gateway/methods_profiles.py index dc76213d10..5260b59163 100644 --- a/tui_gateway/methods_profiles.py +++ b/tui_gateway/methods_profiles.py @@ -314,10 +314,19 @@ def _inherit_launch_model(path) -> bool: dst_model = (read_user_config_raw() or {}).get("model") or {} if dst_model.get("provider") and dst_model.get("default"): return False - model_cfg = (load_config_readonly() or {}).get("model") or {} + launch_cfg = load_config_readonly() or {} + model_cfg = launch_cfg.get("model") or {} if not (model_cfg.get("provider") and model_cfg.get("default")): return False _pin_profile_model(path, str(model_cfg["provider"]), str(model_cfg["default"])) + # A custom `providers:` gateway travels with the model it backs (same seed as the CLI path). + custom = _lazy("hermes_cli.profiles", "launch_model_seed")(launch_cfg).get("providers") + if custom: + from hermes_cli.config import load_config, save_config + with _hermes_home_scope(path): + cfg = load_config() + cfg["providers"] = {**(cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}), **custom} + save_config(cfg) return True