fix(vscode): persist compatible Claude modes and route Copilot CAPI (#2986)

## Description

Fixes #2492, #2028, and #2827.

Claude daemon workers consume project settings rather than reliably
inheriting wrapper environment state, while the Claude VS Code webview
cannot render deferred-tool response blocks. Separately, recent Copilot
Chat versions use the whole CAPI override for generation; the legacy
proxy override alone only sends model discovery through Headroom.

This PR carries both integrations through to the actual consumers
instead of only changing their launch-time surface configuration.

## Type of Change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
- [ ] Build / CI

## Changes Made

- Persist the resolved Claude ENABLE_TOOL_SEARCH value into project
settings for daemon workers and restore it transactionally after wrap
exits.
- Use compatibility-safe Foundry and Claude VS Code defaults while
preserving explicit user choices.
- Configure both Copilot overrideProxyUrl and overrideCapiUrl in the
reversible managed VS Code settings block.
- Route Copilot unprefixed POST /chat/completions and HTTP /responses
requests through the real compression handlers.
- Keep /responses out of the Codex WebSocket aliases because Copilot and
Codex use different WebSocket wire protocols.
- Extend wrap E2E assertions for both the Claude webview mode and
Copilot CAPI routing.

## Testing

- [x] 127 combined Claude, Copilot, route-integration, and MCP
dependency-contract tests pass.
- [x] Ruff check passes on all changed Python files.
- [x] Ruff format check passes.
- [x] Python compilation and git diff --check pass.

## Runtime Safety

Standalone Claude CLI defaults remain unchanged. Explicit Claude
tool-search values retain precedence, and project settings are restored
through the existing cleanup path. Copilot model/session helper
endpoints continue through generic passthrough, while only validated
HTTP generation paths receive explicit compression routes. Existing
Codex WebSocket behavior is unchanged.

## Review Readiness

- [x] Current main and MCP v1 compatibility retained
- [x] Worker-facing Claude persistence covered
- [x] Reversible Copilot and Claude settings behavior covered
- [x] Copilot generation routes covered at registration and proxy
integration layers
- [x] Ready for review
This commit is contained in:
JD Davis 2026-08-13 15:06:41 -05:00 committed by GitHub
parent eafdf11a2c
commit 1aa701adaa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 162 additions and 13 deletions

View file

@ -16,7 +16,8 @@ from headroom.cli import init as init_cli
from headroom.providers.claude import install as claude_install
def test_ensure_claude_hooks_sets_enable_tool_search(tmp_path: Path) -> None:
def test_ensure_claude_hooks_sets_enable_tool_search(tmp_path: Path, monkeypatch) -> None:
monkeypatch.delenv("CLAUDE_CODE_USE_FOUNDRY", raising=False)
settings = tmp_path / "settings.json"
init_cli._ensure_claude_hooks(settings, profile="init-user", port=8787)
@ -25,6 +26,16 @@ def test_ensure_claude_hooks_sets_enable_tool_search(tmp_path: Path) -> None:
assert env["ENABLE_TOOL_SEARCH"] == "true"
def test_ensure_claude_hooks_disables_tool_search_for_foundry(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("CLAUDE_CODE_USE_FOUNDRY", "1")
settings = tmp_path / "settings.json"
init_cli._ensure_claude_hooks(settings, profile="init-user", port=8787)
env = json.loads(settings.read_text(encoding="utf-8"))["env"]
assert env["ENABLE_TOOL_SEARCH"] == "false"
def test_ensure_claude_hooks_respects_user_tool_search_value(tmp_path: Path) -> None:
settings = tmp_path / "settings.json"
settings.write_text(

View file

@ -34,6 +34,29 @@ def test_write_preserves_other_env_keys(tmp_path: Path) -> None:
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
def test_tool_search_write_and_restore_reaches_daemon_worker_settings(tmp_path: Path) -> None:
path = _settings(tmp_path)
path.parent.mkdir(parents=True)
path.write_text(
json.dumps({"env": {"ENABLE_TOOL_SEARCH": "true", "KEEP": "1"}}),
encoding="utf-8",
)
previous = wrap_cli._write_claude_wrap_tool_search("false", settings_path=path)
assert previous == "true"
assert json.loads(path.read_text(encoding="utf-8"))["env"] == {
"ENABLE_TOOL_SEARCH": "false",
"KEEP": "1",
}
wrap_cli._restore_claude_wrap_tool_search(previous, settings_path=path)
assert json.loads(path.read_text(encoding="utf-8"))["env"] == {
"ENABLE_TOOL_SEARCH": "true",
"KEEP": "1",
}
def test_write_returns_none_when_key_absent(tmp_path: Path) -> None:
path = _settings(tmp_path)
prev = wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path)

View file

@ -78,6 +78,13 @@ def _invoke_wrap_claude(
monkeypatch.setattr(wrap_mod, "_write_claude_wrap_base_url", fake_write_base_url)
monkeypatch.setattr(wrap_mod, "_restore_claude_wrap_base_url", lambda *_args, **_kwargs: None)
def fake_write_tool_search(value: str, **kwargs: object) -> None:
captured["write_tool_search_value"] = value
captured["write_tool_search_kwargs"] = kwargs
monkeypatch.setattr(wrap_mod, "_write_claude_wrap_tool_search", fake_write_tool_search)
monkeypatch.setattr(wrap_mod, "_restore_claude_wrap_tool_search", lambda *_a, **_k: None)
monkeypatch.setattr(wrap_mod, "_print_telemetry_notice", lambda: None)
def fake_ensure_proxy(*args: object, **kwargs: object) -> tuple[None, int]:
@ -209,6 +216,24 @@ def test_wrap_claude_tool_search_banner_line_still_accurate_when_active(
assert "on-demand tool loading kept on" in output
assert "keeps it on for this session" in output
assert "DISABLED per your setting" not in output
assert _captured["write_tool_search_value"] == "true"
def test_wrap_claude_foundry_persists_disabled_tool_search_for_workers(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch
) -> None:
captured, output = _invoke_wrap_claude(
runner,
monkeypatch,
env={
"CLAUDE_CODE_USE_FOUNDRY": "1",
"ANTHROPIC_FOUNDRY_BASE_URL": "https://tenant.services.ai.azure.com/anthropic",
},
)
assert captured["child_env"]["ENABLE_TOOL_SEARCH"] == "false"
assert captured["write_tool_search_value"] == "false"
assert "on-demand tool loading DISABLED" in output
def test_wrap_claude_vertex_passes_custom_base_url_to_proxy_before_child_redirect(

View file

@ -66,6 +66,7 @@ def test_wrap_vscode_no_configure_prints_transparent_settings(tmp_path: Path) ->
assert result.exit_code == 0, result.output
assert not path.exists()
assert "overrideProxyUrl" in result.output
assert "overrideCapiUrl" in result.output
assert "overrideAuthType" in result.output

View file

@ -25,7 +25,7 @@ def test_wrap_vscode_claude_configures_actual_port(tmp_path: Path) -> None:
assert result.exit_code == 0, result.output
env = json.loads(path.read_text(encoding="utf-8"))["env"]
assert env["ANTHROPIC_BASE_URL"].startswith("http://127.0.0.1:9999/p/")
assert env["ENABLE_TOOL_SEARCH"] == "true"
assert env["ENABLE_TOOL_SEARCH"] == "false"
assert "Reload VS Code" in result.output
assert captured["agent_type"] == "claude"