Skip to content

Commit 141846f

Browse files
david-siqi-liuIsaac
andauthored
[AIGTWY-4710] Keep managed models across config transitions (#709)
Keeps a coding agent's managed model settings in sync with the managed config across workspace transitions (AIGTWY-4710). Switching between managed configs reconciles each agent's model settings to the current config, and configuring a workspace with no managed config clears ug's managed model settings so an unmanaged workspace never enforces a stale list inherited from a previous managed workspace. Lifecycle exercised, per agent (functional in-process tests plus end-to-end integration journeys): - no managed config: no picker (Claude) / no catalog (Codex). - static config A: the picker/catalog lists exactly A's models. - static config B: reconciles to B; models A listed that B omits are pruned. - model discovery via a Model Provider Service: the picker/catalog is cleared, since the `Databricks-Model-Provider-Service` header routes and there is no static list. - no managed config again: the picker/catalog is cleared, not inherited. Changes: - Claude clears the managed picker (`availableModels`, `enforceAvailableModels`, `modelPicker`) whenever ug is not writing a static list this run (an unmanaged workspace or a provider switch). It always clears the picker in its own `ucode-settings.json`. In the OS `managed-settings.json` the picker is shared with the administrator, so ug treats it as one unit and reverts it to its pre-ucode baseline only when the live picker is unchanged since ug last wrote it (a three-way merge of baseline, ug's last write, and the live file). That way ug removes only what it introduced and never an administrator's own picker, a picker they added after ug cleared its own, or one they later edited (the enforce flag included). - Codex clears its generated catalog and `model_catalog_json` pointer on an unmanaged configure or a provider switch, so an unmanaged workspace uses its own discovery. A managed static config writes them; the launch-time `model` pin follows Codex's existing clear-when-unmanaged behavior. - Prettifies the Claude `/model` picker labels: the enforced allow-list still shows one row per model (it can include multiple versions per family and non-Claude models), but each label is now title-cased with a dotted version and known acronyms upper-cased (`system.ai.claude-haiku-4-5` shows as `Claude Haiku 4.5`, `system.ai.glm-5-3` as `GLM 5.3`) instead of the raw id. The routable id still shows on the right of each row. - The `UCODE_MANAGED_CONFIG_STUB` test hook accepts an explicit JSON `null` to reproduce a workspace with no managed config, distinct from an unset or unreadable stub (which still falls through to the real HTTP read). - Fixes a pre-existing bug where a managed config's per-family default models were dropped. `managed_resolve._manifest_models` read a stale `models` key instead of the normalized `default_models_by_model_family`, so `managed_claude_family_models` returned nothing and no `ANTHROPIC_DEFAULT_<family>_MODEL` was written (only the overall default and the picker survived, on separate paths). Unit fixtures that hand-built the stale shape masked it; the fix corrects them and adds a regression test that builds through `normalize_managed_config` so the shape can't drift again. - Reconciles the per-family default env keys the same three-way way as the picker so a workspace switch does not carry stale defaults over. Each `ANTHROPIC_DEFAULT_<family>_MODEL` resolves as config, then an administrator-authored value (one that differs from ug's last write, preserved verbatim), then discovery; a value ug itself wrote is re-derived from the new workspace rather than preserved, and any family default whose model is not in the enforced `availableModels` list is dropped (e.g. a discovered `fable` default when the admin's list omits fable). - Adds functional lifecycle tests (both agents) covering the full sequence above, including the per-family default env keys landing (opus and sonnet carry ug's `[1m]` context tag), per-agent `managed_fixture` integration journeys running it through `ug configure` against the managed e2e workspace, and coverage-matrix updates. The integration MPS states point at real provider services on that workspace: `main.default.ci_e2e_anthropic_mps` (Claude) and `main.default.ci_e2e_openai_mps` (Codex). What Claude Code writes for models, by managed model source: - static (`model_services`): sets `availableModels`, `enforceAvailableModels`, and `modelPicker` in `~/.claude/ucode-settings.json` and the OS `managed-settings.json`. The managed default is applied at launch as `env.ANTHROPIC_MODEL`. - model discovery via MPS (`model_provider_service`): writes no model list and clears ug's static picker; the `Databricks-Model-Provider-Service` header routes and Claude shows its own model names. A Bedrock-backed provider pins `ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL` from the manifest's family slots; an Anthropic provider pins nothing. - model discovery via `unity_catalog_location`: not consumed on main (schema only; no fields written). What Codex writes for models, by managed model source: - static (`model_services`): writes the catalog file `~/.ucode/codex-model-catalog.json` and points `ucode.config.toml`'s `model_catalog_json` at it. The managed default is pinned as `model` in `ucode.config.toml`. - model discovery via MPS (`model_provider_service`): writes no catalog and clears any static catalog; the `Databricks-Model-Provider-Service` header routes and Codex discovers models at launch. - model discovery via `unity_catalog_location`: not consumed on main (schema only; no fields written). Known limitations (out of scope here): - Clearing on an unmanaged configure also resets in the rare case where the same workspace's managed config genuinely disappeared, since ug does not distinguish that from an intentional switch to an unmanaged workspace. - On a non-interactive configure (no sudo, `managed_writes_allowed()` False), ug cannot rewrite the root-owned OS `managed-settings.json`, so a stale picker there is not cleared in that path; the private `ucode-settings.json` still is. The interactive flow clears both. - If ug's managed-file snapshots (its pre-ucode baseline or last write) are unreadable, ug leaves the managed-file picker in place rather than risk clobbering an admin's entry. This pull request and its description were written by Isaac. Co-authored-by: Isaac <no-reply@databricks.com>
1 parent 08f6684 commit 141846f

14 files changed

Lines changed: 948 additions & 46 deletions

src/ucode/agents/claude.py

Lines changed: 90 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,12 @@
4646
from ucode.launcher import exec_or_spawn
4747
from ucode.managed_files import (
4848
OS,
49+
ManagedFileSnapshots,
4950
ManagedFileWriteUnavailable,
5051
current_os,
5152
managed_file_conflicts,
5253
managed_file_is_verified,
54+
managed_file_snapshots,
5355
managed_file_status,
5456
managed_writes_allowed,
5557
mark_managed_file_verified,
@@ -198,7 +200,6 @@ def _otel_trace_env(workspace: str) -> dict[str, str]:
198200
# settings file on every launch so stale values never linger.
199201
CLAUDE_REMOVED_ENV_KEYS = ("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS",)
200202
CLAUDE_MANAGED_PICKER_KEYS = ("availableModels", "enforceAvailableModels", "modelPicker")
201-
CLAUDE_PRUNED_PICKER_KEYS = ("availableModels", "enforceAvailableModels")
202203
ANTHROPIC_CUSTOM_HEADERS_ENV_KEY = "ANTHROPIC_CUSTOM_HEADERS"
203204
CLAUDE_MANAGED_CUSTOM_HEADER_NAMES = frozenset(
204205
{
@@ -495,9 +496,27 @@ def render_overlay(
495496
return overlay, keys
496497

497498

499+
_MODEL_LABEL_ACRONYMS = frozenset({"glm", "gpt"})
500+
501+
498502
def _picker_label(model: str) -> str:
499-
"""A short picker label for a model id — the raw id minus the ``system.ai.`` prefix."""
500-
return model.removeprefix("system.ai.")
503+
"""A human-friendly picker label for a model id (e.g. ``system.ai.claude-haiku-4-5`` ->
504+
``Claude Haiku 4.5``): keep the vendor and name words title-cased, uppercase known acronyms,
505+
and join a run of numeric segments into a dotted version."""
506+
stem = model.removeprefix("system.ai.")
507+
parts: list[str] = []
508+
version: list[str] = []
509+
for token in stem.split("-"):
510+
if token.isdigit():
511+
version.append(token)
512+
continue
513+
if version:
514+
parts.append(".".join(version))
515+
version = []
516+
parts.append(token.upper() if token in _MODEL_LABEL_ACRONYMS else token.title())
517+
if version:
518+
parts.append(".".join(version))
519+
return " ".join(parts) if parts else stem
501520

502521

503522
def _maybe_add_1m_suffix(model: str) -> str:
@@ -522,24 +541,29 @@ def _enforce_model_default_hierarchy(
522541
coding_agent_config_defaults: dict[str, str],
523542
settings_file_existing_defaults: dict[str, str],
524543
ucode_defaults: dict[str, str],
544+
ucode_last_written_defaults: dict[str, str],
545+
enforced_models: list[str] | None,
525546
) -> str | None:
526-
"""Apply managed-file model precedence for one Claude family."""
527-
coding_agent_config_default_model = coding_agent_config_defaults.get(family)
528-
settings_file_existing_default_model = settings_file_existing_defaults.get(family)
529-
ucode_default_model = ucode_defaults.get(family)
530-
531-
if coding_agent_config_default_model is not None:
532-
selected_default_model = coding_agent_config_default_model
533-
elif settings_file_existing_default_model is not None:
534-
return settings_file_existing_default_model
535-
else:
536-
selected_default_model = ucode_default_model
547+
"""Resolve one Claude family's managed-file default model.
537548
538-
if selected_default_model is None:
549+
An existing managed-file default ucode did not write itself (it differs from ucode's last write)
550+
is an administrator's, so it is preserved verbatim. Otherwise the value is ucode's own or unset,
551+
so ucode re-derives it from the coding-agent config, then discovery, resetting a value carried
552+
over from a previous workspace, and drops the result when an enforced model list excludes it.
553+
"""
554+
selected = coding_agent_config_defaults.get(family)
555+
if selected is None:
556+
existing = settings_file_existing_defaults.get(family)
557+
if existing is not None and existing != ucode_last_written_defaults.get(family):
558+
return existing
559+
selected = ucode_defaults.get(family)
560+
if selected is None:
539561
return None
540562
if family in ("opus", "sonnet"):
541-
return _maybe_add_1m_suffix(selected_default_model)
542-
return selected_default_model
563+
selected = _maybe_add_1m_suffix(selected)
564+
if enforced_models is not None and selected.split("[", 1)[0] not in enforced_models:
565+
return None
566+
return selected
543567

544568

545569
def add_claude_mcp_server(
@@ -757,7 +781,12 @@ def write_tool_config(
757781

758782
# V2 installs routing hooks in a transient per-launch settings file. Persistent settings must
759783
# contain no ucode routing hooks; surgically strip legacy ones while preserving user hooks.
760-
def _compose(base: dict, *, enforce_model_default_hierarchy: bool) -> dict:
784+
def _compose(
785+
base: dict,
786+
*,
787+
enforce_model_default_hierarchy: bool,
788+
managed_settings_snapshots: ManagedFileSnapshots | None,
789+
) -> dict:
761790
base_env = base.get("env")
762791
existing_custom_headers = (
763792
base_env.get(ANTHROPIC_CUSTOM_HEADERS_ENV_KEY) if isinstance(base_env, dict) else None
@@ -778,12 +807,26 @@ def _compose(base: dict, *, enforce_model_default_hierarchy: bool) -> dict:
778807
managed_overlay.get("claude_models") or state.get("claude_models") or {}
779808
)
780809

810+
enforced_models = overlay_for_merge.get("availableModels")
811+
last_applied_env = {}
812+
if (
813+
managed_settings_snapshots is not None
814+
and managed_settings_snapshots.last_applied_by_ug
815+
):
816+
last_applied_env = managed_settings_snapshots.last_applied_by_ug.get("env") or {}
817+
ucode_last_written_defaults = {
818+
family: last_applied_env[key]
819+
for family, key in CLAUDE_DEFAULT_MODEL_ENV_KEYS.items()
820+
if isinstance(last_applied_env.get(key), str)
821+
}
781822
for family, key in CLAUDE_DEFAULT_MODEL_ENV_KEYS.items():
782823
selected_default_model = _enforce_model_default_hierarchy(
783824
family,
784825
coding_agent_config_defaults=configured_defaults,
785826
settings_file_existing_defaults=settings_file_existing_defaults,
786827
ucode_defaults=ucode_defaults,
828+
ucode_last_written_defaults=ucode_last_written_defaults,
829+
enforced_models=enforced_models,
787830
)
788831
if selected_default_model is None:
789832
target_env.pop(key, None)
@@ -816,19 +859,43 @@ def _compose(base: dict, *, enforce_model_default_hierarchy: bool) -> dict:
816859
# longer writes.
817860
for key in CLAUDE_REMOVED_ENV_KEYS:
818861
merged_env.pop(key, None)
862+
if not any(key in overlay_for_merge for key in CLAUDE_MANAGED_PICKER_KEYS):
863+
if managed_settings_snapshots is None:
864+
for key in CLAUDE_MANAGED_PICKER_KEYS:
865+
merged.pop(key, None)
866+
elif managed_settings_snapshots.last_applied_by_ug is not None:
867+
last_applied = managed_settings_snapshots.last_applied_by_ug
868+
live_picker = [merged.get(key) for key in CLAUDE_MANAGED_PICKER_KEYS]
869+
ucode_picker = [last_applied.get(key) for key in CLAUDE_MANAGED_PICKER_KEYS]
870+
if live_picker == ucode_picker:
871+
baseline = managed_settings_snapshots.original_before_ug or {}
872+
for key in CLAUDE_MANAGED_PICKER_KEYS:
873+
if key in baseline:
874+
merged[key] = baseline[key]
875+
else:
876+
merged.pop(key, None)
819877
if "otelHeadersHelper" not in overlay_for_merge:
820878
merged.pop("otelHeadersHelper", None)
821879
sync_smart_routing_hooks(merged, state, enabled=False)
822880
return merged
823881

882+
managed_snapshots = managed_file_snapshots("claude", _parse_managed_settings)
824883
write_json_file(
825884
CLAUDE_SETTINGS_PATH,
826-
_compose(read_json_safe(CLAUDE_SETTINGS_PATH), enforce_model_default_hierarchy=False),
885+
_compose(
886+
read_json_safe(CLAUDE_SETTINGS_PATH),
887+
enforce_model_default_hierarchy=False,
888+
managed_settings_snapshots=None,
889+
),
827890
)
828891

829892
_reconcile_managed_settings(
830893
state,
831-
lambda base: _compose(base, enforce_model_default_hierarchy=provider is None),
894+
lambda base: _compose(
895+
base,
896+
enforce_model_default_hierarchy=provider is None,
897+
managed_settings_snapshots=managed_snapshots,
898+
),
832899
managed_file_keys,
833900
relayed,
834901
)
@@ -919,8 +986,9 @@ def _reconcile_managed_settings(
919986
configuration mirrors ucode's settings there. The same compose operation that produced the
920987
private file is applied to the existing managed file, preserving unrelated IT-authored keys.
921988
922-
`ug configure` updates gateway-owned fields in this file, but does not generate or modify
923-
the `modelPicker` object; an existing picker is retained by the merge.
989+
`ug configure` updates gateway-owned fields in this file. It writes the picker
990+
(`availableModels`/`modelPicker`) for a static managed list and removes the picker keys it
991+
previously wrote when it no longer manages one, leaving an administrator's own picker untouched.
924992
925993
Relayed launches are skipped: they depend on a per-session loopback refresh proxy that only runs
926994
during `ucode claude`, so a bare `claude` could not reach the gateway anyway.

src/ucode/managed_config.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,13 @@ def get_model_recommendation(workspace: str, token: str) -> tuple[dict | None, s
511511
}, None
512512

513513

514+
class _StubUnset:
515+
"""Sentinel type: the stub env var is unset or unreadable, so fall through to the real read."""
516+
517+
518+
_STUB_UNSET = _StubUnset()
519+
520+
514521
def get_managed_config(workspace: str, token: str) -> FetchedManagedConfig:
515522
"""Fetch and normalize the workspace's managed config.
516523
@@ -529,11 +536,14 @@ def get_managed_config(workspace: str, token: str) -> FetchedManagedConfig:
529536
v0 stores at most one config per workspace, so the first entry is the workspace's config.
530537
531538
``UCODE_MANAGED_CONFIG_STUB`` short-circuits the HTTP read: when it names a readable JSON file,
532-
that file's single CodingAgentConfig is used verbatim. It exists so this client can be exercised
533-
against the managed-config shape before the server emits it (AIGTWY-4572); unset in normal use.
539+
that file's single CodingAgentConfig is used verbatim, or an explicit ``null`` stands in for a
540+
workspace with no managed config. It exists so this client can be exercised against the
541+
managed-config shape before the server emits it (AIGTWY-4572); unset in normal use.
534542
"""
535543
stub = _stub_config()
536-
if stub is not None:
544+
if not isinstance(stub, _StubUnset):
545+
if stub is None:
546+
return FetchedManagedConfig(None, None)
537547
return _gate_config(stub)
538548
configs, reason = fetch_managed_coding_agent_configs(workspace, token)
539549
if reason is not None:
@@ -548,17 +558,22 @@ def get_managed_config(workspace: str, token: str) -> FetchedManagedConfig:
548558
return _gate_config(configs[0])
549559

550560

551-
def _stub_config() -> dict | None:
552-
"""The stub CodingAgentConfig named by ``UCODE_MANAGED_CONFIG_STUB``, or None when unset/bad."""
561+
def _stub_config() -> dict | None | _StubUnset:
562+
"""The stub named by ``UCODE_MANAGED_CONFIG_STUB``: the CodingAgentConfig dict it holds, ``None``
563+
when it is an explicit JSON ``null`` (a workspace with no managed config), or ``_STUB_UNSET``
564+
when the var is unset, the file cannot be read, or its content is not a config dict (fall
565+
through to the real read)."""
553566
path = os.environ.get("UCODE_MANAGED_CONFIG_STUB")
554567
if not path:
555-
return None
568+
return _STUB_UNSET
556569
try:
557570
raw = json.loads(Path(path).read_text(encoding="utf-8"))
558571
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
559572
print_warning(f"UCODE_MANAGED_CONFIG_STUB could not be read ({exc}); ignoring it.")
573+
return _STUB_UNSET
574+
if raw is None:
560575
return None
561-
return raw if isinstance(raw, dict) else None
576+
return raw if isinstance(raw, dict) else _STUB_UNSET
562577

563578

564579
def _gate_config(raw: dict) -> FetchedManagedConfig:

src/ucode/managed_files.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from collections.abc import Callable, Iterator
1717
from contextlib import contextmanager
1818
from copy import deepcopy
19+
from dataclasses import dataclass
1920
from enum import Enum
2021
from pathlib import Path
2122
from typing import Any, cast
@@ -168,6 +169,45 @@ def _print_managed_write_permission(display: str) -> None:
168169
_managed_write_notice_shown = True
169170

170171

172+
@dataclass
173+
class ManagedFileSnapshots:
174+
"""The pre-ucode baseline and ucode's last write for a managed file, for a three-way merge."""
175+
176+
original_before_ug: dict | None
177+
last_applied_by_ug: dict | None
178+
179+
180+
def managed_file_snapshots(tool: str, parser: ManagedParser) -> ManagedFileSnapshots:
181+
"""Return the parsed baseline and last-applied snapshots of ``tool``'s managed file.
182+
183+
``original_before_ug`` is the pre-ucode baseline; ``last_applied_by_ug`` is what ucode last
184+
wrote. A caller reverting a value ucode owns compares the live value against
185+
``last_applied_by_ug`` (unchanged since ucode wrote it) and restores ``original_before_ug`` (the
186+
value before ucode, if any) as a three-way merge, so it removes only what ucode itself introduced
187+
and never an administrator's own or edited entry. Either field is None when its snapshot is absent
188+
or cannot be read/parsed, so callers fall back to keeping the live value.
189+
"""
190+
191+
def _parse(text: str | None) -> dict | None:
192+
if text is None:
193+
return None
194+
try:
195+
return parser(text)
196+
except Exception: # noqa: BLE001 - an unparseable snapshot just means "unknown".
197+
return None
198+
199+
try:
200+
entry = _manifest_files(_load_manifest()).get(tool)
201+
if not isinstance(entry, dict):
202+
return ManagedFileSnapshots(None, None)
203+
return ManagedFileSnapshots(
204+
_parse(_snapshot_text(entry, "backup_file")),
205+
_parse(_snapshot_text(entry, "last_applied_file")),
206+
)
207+
except RuntimeError:
208+
return ManagedFileSnapshots(None, None)
209+
210+
171211
def managed_file_conflicts(
172212
existing: dict, desired: dict, owned_paths: list[list[str]]
173213
) -> list[str]:

src/ucode/managed_resolve.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,14 +126,16 @@ def managed_unservable_models(managed: dict, tool: str) -> list[str]:
126126

127127
def _manifest_models(managed: dict, tool: str) -> dict | list | None:
128128
"""The manifest's models for ``tool`` in its own vocabulary, or None when it names none."""
129-
manifest_models = _agent_model_config(managed, tool).get("models")
129+
model_config = _agent_model_config(managed, tool)
130130
if tool == "claude":
131+
family_slots = _as_dict(model_config.get("default_models_by_model_family"))
131132
slots: dict[str, str] = {}
132133
for slot, family in _CLAUDE_FAMILY_SLOTS.items():
133-
model = _str(_as_dict(manifest_models).get(slot))
134+
model = _str(family_slots.get(slot))
134135
if model:
135136
slots[family] = model
136137
return slots or None
138+
manifest_models = model_config.get("models")
137139
if isinstance(manifest_models, list):
138140
listed = [model for model in (_str(item) for item in manifest_models) if model]
139141
return listed or None

tests/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ All tests live directly in `integration/`; shared mechanics live in `utils/`.
5252
| `test_ug_configure_claude_rejects_invalid_credentials`, `test_ug_configure_codex_rejects_invalid_credentials` | Configure with a rejected bearer against the real workspace | Authentication failure; no successful saved setup |
5353
| `test_ug_configure_managed_claude`, `test_ug_configure_managed_codex` | Configure against a workspace that publishes a managed CodingAgentConfig | No agent selector; each agent's generated config exposes exactly the admin's static model_services; real gateway prompt on launch |
5454
| `test_ug_configure_managed_codex_catalog_fallback` | Configure from an injected managed response containing a GPT model absent from Codex's bundled catalog | Actionable metadata warning; conservative catalog entry for the unknown model; real Codex prompt on the valid default model |
55+
| `test_managed_fixture_claude_model_lifecycle`, `test_managed_fixture_codex_model_lifecycle` | Configure across no config -> static A -> static B -> MPS -> no config (stub-injected, `null` for no-config; MPS via a real provider service) | Each agent's model files reconcile to each static config (removed models pruned); switching to an MPS and a workspace with no managed config both clear ug's managed model settings so no stale list is enforced |
5556
| `test_ug_installed_wheel_exposes_help_and_version` | Invoke freshly installed console command | Package version matches; public help works |
5657
| `test_ug_status_in_fresh_home_is_unconfigured` | Request status before configure | Unconfigured status |
5758
| `test_ug_auth_without_configuration_explains_how_to_configure` | Request auth before configure | Actionable setup error and nonzero exit |
@@ -60,7 +61,7 @@ All tests live directly in `integration/`; shared mechanics live in `utils/`.
6061

6162
With both agents selected there are **41 live cases** (6 interactive TUI cases),
6263
**3 managed-workspace cases** (marker `managed`, run against a separate workspace that
63-
publishes a CodingAgentConfig), **3 managed-fixture cases** (marker `managed_fixture`, with only
64+
publishes a CodingAgentConfig), **5 managed-fixture cases** (marker `managed_fixture`, with only
6465
the CodingAgentConfig input injected), and **5 installation checks**. Parametrization varies
6566
argument spelling or routing mode, never hides the agent/provider in the test name. Duplicate boot-only cases
6667
are incorporated into the Databricks configuration TUI journeys.

tests/integration/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ test_ug_configure_codex_lifecycle.py # repeat setup, revert, rejected credent
101101
test_ug_configure_managed.py # managed workspace: static model list, no agent selector
102102
test_ug_configure_managed_models.py # injected model lists: pickers and Codex fallback metadata
103103
test_ug_configure_managed_mcp.py # injected managed MCP list
104+
test_ug_configure_managed_lifecycle.py # none -> A -> B -> MPS -> none: reconcile, clear on MPS/no-config
104105
test_installation.py # fresh installed package
105106
utils/ # process/terminal/evidence helpers and Docker files
106107
```
@@ -157,9 +158,14 @@ fails the selected CUJ, rather than skipping it.
157158
There are **42 live cases** (including 6 TUI journeys) and **5 installation
158159
checks** with both agents. A separate **3 managed-workspace cases** (one per agent
159160
plus an idempotent re-configure, marker `managed`) run against a workspace that publishes a CodingAgentConfig; see
160-
"Managed-workspace journeys" below. A further **3 `managed_fixture` cases** inject the admin config
161+
"Managed-workspace journeys" below. A further **5 `managed_fixture` cases** inject the admin config
161162
locally (via `UCODE_MANAGED_CONFIG_STUB`) to cover shapes the live workspace does not publish; each
162163
differs from the published config in what it asserts so it proves the injected config drove configure.
164+
Two of those are per-agent lifecycle journeys (no config -> static A -> static B -> MPS -> no config):
165+
they assert the generated model files reconcile to each static config, are cleared when switching to
166+
a Model Provider Service, and are cleared when the workspace has no managed config (an explicit
167+
`null` stub reproduces the no-config states; the MPS states use real provider services on the managed
168+
workspace, `main.default.ci_e2e_anthropic_mps` and `main.default.ci_e2e_openai_mps`).
163169
See the named coverage and gaps matrix in
164170
[../README.md](../README.md).
165171

0 commit comments

Comments
 (0)