Merge conflict resolution progress bar and log viewer (#1620)
* auto-claude: subtask-1-3 - Thread progress callback through MergePipeline and ConflictResolver Add progress_callback parameter to MergePipeline.merge_file() and ConflictResolver.resolve_conflicts(). MergePipeline emits per-file progress at the start of merge within the resolving stage (50-75%). ConflictResolver emits per-conflict resolution progress with details about current file, conflict count, and conflicts resolved so far. Co-Authored-By: Claude Opus 4.5 <[email protected]> * auto-claude: subtask-1-4 - Wire progress emission into CLI merge entry point. Add _create_merge_progress_callback() helper that returns emit_progress only when stdout is piped (subprocess mode from Electron), avoiding JSON pollution in interactive CLI sessions. Wire the callback into _try_smart_merge_inner() with progress emissions at key pipeline stages: ANALYZING, DETECTING_CONFLICTS, RESOLVING, COMPLETE, and ERROR. Co-Authored-By: Claude Opus 4.5 <[email protected]> * Fix PR review issues: conflict counts, progress calculations, and cross-task leakage - Fix conflicts_found on COMPLETE/ERROR stages to use original conflict count - Fix off-by-one in progress percentage calculations (50-75% range) - Add JSON validation for MergeProgress before IPC transmission - Add taskId filtering to prevent cross-task progress event leakage - Limit log entries to 500 to prevent unbounded memory growth - Fix race condition: wait for terminal progress event before hiding overlay - Remove unused imports (ruff fixes) - Remove orphaned unreachable code in workspace.py - Fix test mocks to use optional config_dir argument Co-Authored-By: Claude Opus 4.5 <[email protected]> --------- Co-authored-by: Claude Opus 4.5 <[email protected]>
This commit is contained in:
committed by
StillKnotKnown
co-authored by
Claude Opus 4.5
parent
fe9f93ca38
commit
93e16bfb78
@@ -582,12 +582,15 @@ def _try_smart_merge_inner(
|
||||
|
||||
if progress_callback is not None:
|
||||
stats = resolution_result.get("stats", {})
|
||||
original_conflict_count = len(
|
||||
git_conflicts.get("conflicting_files", [])
|
||||
)
|
||||
progress_callback(
|
||||
MergeProgressStage.COMPLETE,
|
||||
100,
|
||||
"Merge complete",
|
||||
{
|
||||
"conflicts_found": stats.get("conflicts_resolved", 0),
|
||||
"conflicts_found": original_conflict_count,
|
||||
"conflicts_resolved": stats.get("conflicts_resolved", 0),
|
||||
},
|
||||
)
|
||||
@@ -606,14 +609,21 @@ def _try_smart_merge_inner(
|
||||
)
|
||||
|
||||
if progress_callback is not None:
|
||||
original_conflict_count = len(
|
||||
git_conflicts.get("conflicting_files", [])
|
||||
)
|
||||
remaining_count = len(
|
||||
resolution_result.get("remaining_conflicts", [])
|
||||
)
|
||||
progress_callback(
|
||||
MergeProgressStage.ERROR,
|
||||
0,
|
||||
"Some conflicts could not be resolved",
|
||||
{
|
||||
"conflicts_found": len(
|
||||
resolution_result.get("remaining_conflicts", [])
|
||||
),
|
||||
"conflicts_found": original_conflict_count,
|
||||
"conflicts_resolved": original_conflict_count
|
||||
- remaining_count,
|
||||
"conflicts_remaining": remaining_count,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -87,7 +87,8 @@ class ConflictResolver:
|
||||
for idx, conflict in enumerate(conflicts):
|
||||
if progress_callback:
|
||||
# Emit per-conflict progress within the resolving stage (50-75%)
|
||||
conflict_percent = 50 + int((idx / max(total_conflicts, 1)) * 25)
|
||||
# Calculate progress after processing (idx + 1) to reach 75% on last conflict
|
||||
conflict_percent = 50 + int(((idx + 1) / max(total_conflicts, 1)) * 25)
|
||||
progress_callback(
|
||||
stage=MergeProgressStage.RESOLVING,
|
||||
percent=conflict_percent,
|
||||
|
||||
@@ -356,7 +356,8 @@ class MergeOrchestrator:
|
||||
# --- RESOLVING stage (50-75%) ---
|
||||
total_files = len(modifications)
|
||||
for idx, (file_path, snapshot) in enumerate(modifications):
|
||||
file_percent = 50 + int((idx / max(total_files, 1)) * 25)
|
||||
# Calculate progress after processing (idx + 1) to reach 75% on last file
|
||||
file_percent = 50 + int(((idx + 1) / max(total_files, 1)) * 25)
|
||||
_emit(
|
||||
MergeProgressStage.RESOLVING,
|
||||
file_percent,
|
||||
|
||||
@@ -2146,7 +2146,14 @@ export function registerWorktreeHandlers(
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && parsed.type === 'progress') {
|
||||
// Validate parsed object has expected MergeProgress structure before forwarding
|
||||
if (
|
||||
parsed &&
|
||||
parsed.type === 'progress' &&
|
||||
typeof parsed.stage === 'string' &&
|
||||
typeof parsed.percent === 'number' &&
|
||||
typeof parsed.message === 'string'
|
||||
) {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.TASK_MERGE_PROGRESS, taskId, parsed);
|
||||
|
||||
@@ -136,20 +136,33 @@ export function WorkspaceStatus({
|
||||
}, [isMerging]);
|
||||
|
||||
// Minimum display time: keep overlay visible for at least 500ms after merge ends
|
||||
// Also wait for terminal progress event (complete/error) to avoid hiding before final message
|
||||
useEffect(() => {
|
||||
if (!isMerging && showOverlay && mergeStartTimeRef.current !== null) {
|
||||
const elapsed = Date.now() - mergeStartTimeRef.current;
|
||||
const MIN_DISPLAY_MS = 500;
|
||||
const remaining = Math.max(0, MIN_DISPLAY_MS - elapsed);
|
||||
// Check if we received a terminal progress event (complete or error)
|
||||
const hasTerminalEvent = mergeProgress?.stage === 'complete' || mergeProgress?.stage === 'error';
|
||||
|
||||
if (remaining > 0) {
|
||||
// Only hide if we have a terminal event OR if a fallback timeout expires
|
||||
if (hasTerminalEvent) {
|
||||
const elapsed = Date.now() - mergeStartTimeRef.current;
|
||||
const MIN_DISPLAY_MS = 500;
|
||||
const remaining = Math.max(0, MIN_DISPLAY_MS - elapsed);
|
||||
|
||||
if (remaining > 0) {
|
||||
minDisplayTimerRef.current = setTimeout(() => {
|
||||
setShowOverlay(false);
|
||||
mergeStartTimeRef.current = null;
|
||||
}, remaining);
|
||||
} else {
|
||||
setShowOverlay(false);
|
||||
mergeStartTimeRef.current = null;
|
||||
}
|
||||
} else {
|
||||
// Fallback: hide after 2s if no terminal event received (defensive)
|
||||
minDisplayTimerRef.current = setTimeout(() => {
|
||||
setShowOverlay(false);
|
||||
mergeStartTimeRef.current = null;
|
||||
}, remaining);
|
||||
} else {
|
||||
setShowOverlay(false);
|
||||
mergeStartTimeRef.current = null;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +172,7 @@ export function WorkspaceStatus({
|
||||
minDisplayTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isMerging, showOverlay]);
|
||||
}, [isMerging, showOverlay, mergeProgress?.stage]);
|
||||
|
||||
// Subscribe to merge progress IPC events
|
||||
useEffect(() => {
|
||||
@@ -174,24 +187,32 @@ export function WorkspaceStatus({
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = window.electronAPI.onMergeProgress((_taskId: string, progress: MergeProgress) => {
|
||||
const cleanup = window.electronAPI.onMergeProgress((eventTaskId: string, progress: MergeProgress) => {
|
||||
// Filter by task ID to prevent cross-task event leakage
|
||||
if (eventTaskId !== taskId) return;
|
||||
|
||||
setMergeProgress(progress);
|
||||
setLogEntries(prev => [
|
||||
...prev,
|
||||
{
|
||||
setLogEntries(prev => {
|
||||
const newEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: stageToLogType(progress.stage),
|
||||
message: progress.message,
|
||||
details: progress.details?.current_file,
|
||||
};
|
||||
// Limit log entries to prevent unbounded growth during long merges
|
||||
const updated = [...prev, newEntry];
|
||||
if (updated.length > MAX_LOG_ENTRIES) {
|
||||
return updated.slice(-MAX_LOG_ENTRIES);
|
||||
}
|
||||
]);
|
||||
return updated;
|
||||
});
|
||||
});
|
||||
|
||||
// Store cleanup ref so we can call it on unmount even if isMerging changes
|
||||
ipcCleanupRef.current = cleanup;
|
||||
|
||||
return cleanup;
|
||||
}, [isMerging]);
|
||||
}, [isMerging, taskId]);
|
||||
|
||||
// Ensure IPC listener cleanup on unmount during active merge
|
||||
useEffect(() => {
|
||||
|
||||
+9
-6
@@ -70,7 +70,7 @@ class TestEnvVarTokenResolution:
|
||||
def test_no_token_returns_none(self, monkeypatch):
|
||||
"""Returns None when no auth token is configured."""
|
||||
# Mock keychain to return None (env vars already cleared by fixture)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: None)
|
||||
token = get_auth_token()
|
||||
assert token is None
|
||||
|
||||
@@ -374,7 +374,7 @@ class TestRequireAuthToken:
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
os.environ.pop(var, None)
|
||||
# Mock keychain to return None (tests that need a token will set env var)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: None)
|
||||
yield
|
||||
# Cleanup after test
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
@@ -514,7 +514,8 @@ class TestTokenSourceDetection:
|
||||
monkeypatch.setattr("subprocess.run", Mock(return_value=mock_result))
|
||||
|
||||
source = get_auth_token_source()
|
||||
assert source == "macOS Keychain"
|
||||
# Source can be "macOS Keychain" or "macOS Keychain (profile)" depending on profile settings
|
||||
assert source is not None and source.startswith("macOS Keychain")
|
||||
|
||||
def test_source_windows_credential_files(self, monkeypatch, tmp_path):
|
||||
"""Identifies Windows Credential Files as source."""
|
||||
@@ -530,7 +531,8 @@ class TestTokenSourceDetection:
|
||||
)
|
||||
|
||||
source = get_auth_token_source()
|
||||
assert source == "Windows Credential Files"
|
||||
# Source can have "(profile)" suffix depending on profile settings
|
||||
assert source is not None and source.startswith("Windows Credential Files")
|
||||
|
||||
def test_source_linux_secret_service(self, monkeypatch):
|
||||
"""Identifies Linux Secret Service as source."""
|
||||
@@ -556,12 +558,13 @@ class TestTokenSourceDetection:
|
||||
monkeypatch.setattr("core.auth.secretstorage", mock_ss)
|
||||
|
||||
source = get_auth_token_source()
|
||||
assert source == "Linux Secret Service"
|
||||
# Source can have "(profile)" suffix depending on profile settings
|
||||
assert source is not None and source.startswith("Linux Secret Service")
|
||||
|
||||
def test_source_none_when_not_found(self, monkeypatch):
|
||||
"""Returns None when no token source is found."""
|
||||
# Mock keychain to return None (env vars already cleared by fixture)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: None)
|
||||
source = get_auth_token_source()
|
||||
assert source is None
|
||||
|
||||
|
||||
+45
-102
@@ -34,105 +34,38 @@ class TestClientTokenValidation:
|
||||
assert create_client is not None
|
||||
assert callable(create_client)
|
||||
|
||||
def test_module_has_all_attribute(self):
|
||||
"""Test that client module has __all__ attribute."""
|
||||
import client
|
||||
|
||||
assert hasattr(client, "__all__")
|
||||
assert isinstance(client.__all__, list)
|
||||
|
||||
def test_all_exports_exist(self):
|
||||
"""Test that all exports in __all__ actually exist."""
|
||||
import client
|
||||
|
||||
for name in client.__all__:
|
||||
assert hasattr(client, name), f"{name} in __all__ but not exported"
|
||||
|
||||
def test_expected_exports_in_all(self):
|
||||
"""Test that expected exports are in __all__."""
|
||||
import client
|
||||
|
||||
expected = {"create_client"}
|
||||
|
||||
assert set(client.__all__) >= expected
|
||||
|
||||
|
||||
class TestClientModuleLazyImports:
|
||||
"""Tests for client module lazy import mechanism."""
|
||||
|
||||
def test_getattr_lazy_import(self):
|
||||
"""Test that __getattr__ provides lazy imports."""
|
||||
from client import __getattr__
|
||||
|
||||
# Should be able to get attributes through lazy import
|
||||
# This tests the facade pattern without actually importing the heavy core.client
|
||||
assert callable(__getattr__)
|
||||
|
||||
def test_create_client_direct_function(self):
|
||||
"""Test that create_client is a direct function, not lazy."""
|
||||
from client import create_client
|
||||
|
||||
# create_client should be directly defined, not lazily imported
|
||||
# It should be a function that re-exports from core.client
|
||||
assert callable(create_client)
|
||||
assert hasattr(create_client, "__module__")
|
||||
assert "client" in create_client.__module__
|
||||
|
||||
|
||||
class TestClientModuleFacade:
|
||||
"""Tests for client module as a facade to core.client."""
|
||||
|
||||
@patch("client.create_client")
|
||||
def test_create_client_reexports_from_core(self, mock_create_client):
|
||||
"""Test that create_client re-exports from core.client."""
|
||||
from client import create_client as client_create_client
|
||||
|
||||
# The function should be callable
|
||||
assert callable(client_create_client)
|
||||
|
||||
def test_create_client_signature(self):
|
||||
"""Test that create_client has expected signature."""
|
||||
from client import create_client
|
||||
import inspect
|
||||
|
||||
# create_client uses *args, **kwargs to forward to core.client.create_client
|
||||
sig = inspect.signature(create_client)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
# Should accept *args and **kwargs
|
||||
assert "args" in params
|
||||
assert "kwargs" in params
|
||||
|
||||
@patch("client.create_client")
|
||||
def test_create_client_with_args(self, mock_create_client):
|
||||
"""Test that create_client accepts expected arguments."""
|
||||
from client import create_client
|
||||
|
||||
# Mock the actual core.client.create_client
|
||||
mock_instance = MagicMock()
|
||||
mock_create_client.return_value = mock_instance
|
||||
|
||||
# Call with basic args
|
||||
result = create_client(
|
||||
project_dir=Path("/test/project"),
|
||||
spec_dir=Path("/test/spec"),
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "enc:test123456789012")
|
||||
# Mock keychain to ensure encrypted token is the only source
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: None)
|
||||
# Mock decrypt_token to raise ValueError (simulates decryption failure)
|
||||
# This ensures the encrypted token flows through to validate_token_not_encrypted
|
||||
monkeypatch.setattr(
|
||||
"core.auth.decrypt_token",
|
||||
lambda t: (_ for _ in ()).throw(ValueError("Decryption not supported")),
|
||||
)
|
||||
|
||||
# Verify it was called (though mocked)
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestClientModuleImports:
|
||||
"""Tests for client module import structure."""
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "enc:test123456789012")
|
||||
# Mock keychain to ensure encrypted token is the only source
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: None)
|
||||
# Mock decrypt_token to raise ValueError (simulates decryption failure)
|
||||
monkeypatch.setattr(
|
||||
"core.auth.decrypt_token",
|
||||
lambda t: (_ for _ in ()).throw(ValueError("Decryption not supported")),
|
||||
)
|
||||
|
||||
def test_no_circular_imports(self):
|
||||
"""Test that importing client doesn't cause circular imports."""
|
||||
import sys
|
||||
|
||||
# Remove from cache if present
|
||||
if "client" in sys.modules:
|
||||
del sys.modules["client"]
|
||||
def test_create_client_accepts_valid_plaintext_token(self, tmp_path, monkeypatch):
|
||||
"""Verify create_client() accepts valid plaintext tokens and creates SDK client."""
|
||||
valid_token = "sk-ant-oat01-valid-plaintext-token"
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", valid_token)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: None)
|
||||
|
||||
# Should import without issues
|
||||
import client
|
||||
@@ -143,10 +76,11 @@ class TestClientModuleImports:
|
||||
"""Test that client module can be imported before core.client."""
|
||||
import sys
|
||||
|
||||
# Remove both from cache
|
||||
for mod in ["client", "core.client"]:
|
||||
if mod in sys.modules:
|
||||
del sys.modules[mod]
|
||||
def test_create_simple_client_accepts_valid_plaintext_token(self, monkeypatch):
|
||||
"""Verify create_simple_client() accepts valid plaintext tokens and creates SDK client."""
|
||||
valid_token = "sk-ant-oat01-valid-plaintext-token"
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", valid_token)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: None)
|
||||
|
||||
# Import client first (should trigger lazy import of core.client)
|
||||
import client
|
||||
@@ -156,7 +90,13 @@ class TestClientModuleImports:
|
||||
# Now import core.client
|
||||
from core import client as core_client
|
||||
|
||||
assert core_client is not None
|
||||
def test_create_client_validates_token_before_sdk_init(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Verify create_client() validates token format before SDK initialization."""
|
||||
valid_token = "sk-ant-oat01-valid-token"
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", valid_token)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: None)
|
||||
|
||||
# Mock validate_token_not_encrypted to verify it's called
|
||||
with patch(
|
||||
@@ -170,8 +110,11 @@ class TestClientModuleImports:
|
||||
assert core_client is not client_facade
|
||||
|
||||
|
||||
class TestClientModulePatterns:
|
||||
"""Tests for client module design patterns."""
|
||||
def test_create_simple_client_validates_token_before_sdk_init(self, monkeypatch):
|
||||
"""Verify create_simple_client() validates token format before SDK initialization."""
|
||||
valid_token = "sk-ant-oat01-valid-token"
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", valid_token)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: None)
|
||||
|
||||
# Mock validate_token_not_encrypted to verify it's called
|
||||
with patch(
|
||||
@@ -256,7 +199,7 @@ class TestAPIProfileAuthentication:
|
||||
# Don't set ANTHROPIC_BASE_URL - this should trigger OAuth mode
|
||||
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: None)
|
||||
|
||||
# Mock the SDK client
|
||||
mock_sdk_client = MagicMock()
|
||||
@@ -313,7 +256,7 @@ class TestAPIProfileAuthentication:
|
||||
# Set empty ANTHROPIC_BASE_URL - should be treated as "not set"
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", "")
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: None)
|
||||
|
||||
# Mock require_auth_token to verify it's called (OAuth mode)
|
||||
with patch("core.auth.require_auth_token", return_value=oauth_token):
|
||||
@@ -357,7 +300,7 @@ class TestAPIProfileAuthentication:
|
||||
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
||||
|
||||
# Mock keychain to return None
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: None)
|
||||
|
||||
from core.client import create_client
|
||||
|
||||
@@ -440,7 +383,7 @@ class TestAPIProfileAuthenticationIntegration:
|
||||
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token)
|
||||
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", encrypted_oauth_token)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: None)
|
||||
|
||||
from core.client import create_client
|
||||
|
||||
@@ -464,7 +407,7 @@ class TestAPIProfileAuthenticationEdgeCases:
|
||||
# Set whitespace-only ANTHROPIC_BASE_URL - should be trimmed to empty string
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", " ")
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: None)
|
||||
|
||||
# Mock the SDK client
|
||||
mock_sdk_client = MagicMock()
|
||||
@@ -569,7 +512,7 @@ class TestSimpleClientAPIProfileAuthentication:
|
||||
|
||||
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: None)
|
||||
|
||||
mock_sdk_client = MagicMock()
|
||||
with patch("core.simple_client.ClaudeSDKClient", return_value=mock_sdk_client):
|
||||
@@ -625,7 +568,7 @@ class TestSimpleClientAPIProfileAuthentication:
|
||||
# Set whitespace-only ANTHROPIC_BASE_URL - should be trimmed to empty string
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", " ")
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: None)
|
||||
|
||||
mock_sdk_client = MagicMock()
|
||||
with patch("core.simple_client.ClaudeSDKClient", return_value=mock_sdk_client):
|
||||
|
||||
Reference in New Issue
Block a user