From a549544cc26e582e7a7c60f3edb9b1e492b51b12 Mon Sep 17 00:00:00 2001 From: Jgit Date: Wed, 22 Jul 2026 22:41:54 +0800 Subject: [PATCH] fix(ci): stabilize cross-platform test behavior --- src/haagent/models/model_resolution.py | 4 +- src/haagent/multi_agent/team_store.py | 6 +- src/haagent/runtime/execution/command.py | 6 +- src/haagent/tools/file_tools.py | 63 +++++++++++++++++++ src/haagent/tools/shell_paths.py | 6 ++ .../integration/app/test_assistant_service.py | 2 + tests/integration/tools/test_tool_router.py | 7 ++- .../multi_agent/test_team_store_messages.py | 16 ++++- tests/unit/runtime/test_command.py | 10 +++ tests/unit/scheduling/test_models.py | 2 +- tests/unit/tools/test_shell_paths.py | 7 +++ 11 files changed, 121 insertions(+), 8 deletions(-) diff --git a/src/haagent/models/model_resolution.py b/src/haagent/models/model_resolution.py index 0fd79ad..e2cd194 100644 --- a/src/haagent/models/model_resolution.py +++ b/src/haagent/models/model_resolution.py @@ -8,8 +8,8 @@ from collections.abc import Mapping +from haagent.models.config import connections as model_connections from haagent.models.config.connections import ( - DEFAULT_CREDENTIAL_STORE, ProviderProfileError, ProvidersConfigSnapshot, credential_record, @@ -54,7 +54,7 @@ def resolve_model( resolved = resolve_api_key( credential_record(connection), environ=environ, - credential_store=credential_store or DEFAULT_CREDENTIAL_STORE, + credential_store=credential_store or model_connections.DEFAULT_CREDENTIAL_STORE, config_dir=snapshot.path.parent, ) except CredentialError as error: diff --git a/src/haagent/multi_agent/team_store.py b/src/haagent/multi_agent/team_store.py index f3ff46d..1903547 100644 --- a/src/haagent/multi_agent/team_store.py +++ b/src/haagent/multi_agent/team_store.py @@ -10,6 +10,7 @@ import os from dataclasses import asdict, dataclass, field from datetime import UTC, datetime +from itertools import count from pathlib import Path from typing import Any, Literal @@ -50,6 +51,7 @@ class TeamRecord: class TeamStore: def __init__(self, root: Path) -> None: self.root = root + self._message_sequence = count() def ensure_team( self, @@ -179,7 +181,9 @@ def write_worker_message(self, team_id: str, agent_id: str, message: WorkerMessa payload = message.to_dict() inbox = self._team_dir(team_id) / "agents" / _safe_id(agent_id) / "messages" inbox.mkdir(parents=True, exist_ok=True) - path = inbox / f"{payload['created_at']}-{payload['message_id']}.json" + # Windows 上连续创建消息可能得到相同时间戳,序号保留实际写入顺序。 + sequence = next(self._message_sequence) + path = inbox / f"{payload['created_at']}-{sequence:020d}-{payload['message_id']}.json" return _atomic_write_json(path, payload) def read_worker_messages(self, team_id: str, agent_id: str) -> list[WorkerMessage]: diff --git a/src/haagent/runtime/execution/command.py b/src/haagent/runtime/execution/command.py index c386eec..132dd98 100644 --- a/src/haagent/runtime/execution/command.py +++ b/src/haagent/runtime/execution/command.py @@ -373,7 +373,11 @@ def redact_secret_like_text(text: str) -> tuple[str, bool]: redacted = KEY_VALUE_PATTERN.sub(lambda match: f"{match.group(1)}={REDACTED_SECRET}", text) redacted = SECRET_TOKEN_PATTERN.sub(REDACTED_TOKEN, redacted) for value in _secret_environment_values(): - redacted = redacted.replace(value, REDACTED_SECRET) + redacted = re.sub( + rf"(? None: skipped = Path(error.filename) if error.filename else root @@ -1079,11 +1081,72 @@ def onerror(error: OSError) -> None: continue path = current_path / filename relative = path.relative_to(root) + if _is_gitignored(relative, gitignore_rules): + continue if include is None or relative.match(include): paths.append(path) return paths +def _gitignore_rules(root: Path) -> list[tuple[Path, str, bool, bool]]: + """读取 Python 后备搜索所需的最小 .gitignore 规则集。""" + rules: list[tuple[Path, str, bool, bool]] = [] + for current, directories, files in os.walk(root, topdown=True): + directories[:] = sorted( + directory + for directory in directories + if directory not in GREP_NOISE_DIRECTORIES and not directory.startswith(".") + ) + if ".gitignore" not in files: + continue + ignore_file = Path(current) / ".gitignore" + try: + lines = ignore_file.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + continue + base = ignore_file.parent.relative_to(root) + for raw_pattern in lines: + pattern = raw_pattern.strip() + if not pattern or pattern.startswith("#"): + continue + negated = pattern.startswith("!") + if negated: + pattern = pattern[1:] + directory_only = pattern.endswith("/") + pattern = pattern.rstrip("/") + if pattern: + rules.append((base, pattern, negated, directory_only)) + return rules + + +def _is_gitignored(path: Path, rules: list[tuple[Path, str, bool, bool]]) -> bool: + ignored = False + for base, pattern, negated, directory_only in rules: + try: + relative = path.relative_to(base) + except ValueError: + continue + if _gitignore_rule_matches(relative, pattern, directory_only): + ignored = not negated + return ignored + + +def _gitignore_rule_matches(path: Path, pattern: str, directory_only: bool) -> bool: + anchored = pattern.startswith("/") + pattern = pattern.lstrip("/") + candidates = [path] + if directory_only: + candidates = [Path(*path.parts[:index]) for index in range(1, len(path.parts))] + for candidate in candidates: + relative = candidate.as_posix() + if anchored or "/" in pattern: + if fnmatchcase(relative, pattern): + return True + elif any(fnmatchcase(part, pattern) for part in candidate.parts): + return True + return False + + def _git_search_paths(root: Path, include: str | None, deadline: float) -> list[Path] | None: git = shutil.which("git") if git is None or root.name in GREP_NOISE_DIRECTORIES: diff --git a/src/haagent/tools/shell_paths.py b/src/haagent/tools/shell_paths.py index cb743d5..67af1aa 100644 --- a/src/haagent/tools/shell_paths.py +++ b/src/haagent/tools/shell_paths.py @@ -146,6 +146,7 @@ def _resolve_path_token( expanded = _expand_environment(value, environ) if expanded is None: return None + expanded = _normalize_path_separators(expanded) wildcard = min((expanded.find(mark) for mark in "*?[" if mark in expanded), default=-1) if wildcard >= 0: prefix = expanded[:wildcard].rstrip("/\\") @@ -156,6 +157,11 @@ def _resolve_path_token( return candidate.resolve() +def _normalize_path_separators(value: str) -> str: + """将 PowerShell 分隔符转换为当前宿主文件系统可识别的形式。""" + return value if os.name == "nt" else value.replace("\\", "/") + + def _expand_environment(value: str, environ: Mapping[str, str]) -> str | None: unresolved = False diff --git a/tests/integration/app/test_assistant_service.py b/tests/integration/app/test_assistant_service.py index c5b47e9..16e8faf 100644 --- a/tests/integration/app/test_assistant_service.py +++ b/tests/integration/app/test_assistant_service.py @@ -24,6 +24,7 @@ from haagent.models.types import ModelGatewayMetadata, ModelResponse from haagent.models.model_settings import ModelSettings from haagent.models.config import connections as model_connections +from haagent.models import model_resolution from haagent.models.config.config_store import ModelConfigStore from haagent.models.config.connections import ProviderConnectionRecord from haagent.models.model_ref import ModelRef @@ -581,6 +582,7 @@ def test_service_selects_models_from_named_connection_without_rewriting_key( _set_home(monkeypatch, home) store = FakeCredentialStore() monkeypatch.setattr(model_connections, "DEFAULT_CREDENTIAL_STORE", store) + monkeypatch.setattr(model_resolution, "DEFAULT_CREDENTIAL_STORE", FakeCredentialStore({}), raising=False) service = _service(tmp_path) connection = service.models.configure_connection( diff --git a/tests/integration/tools/test_tool_router.py b/tests/integration/tools/test_tool_router.py index 538bfcd..3bc4378 100644 --- a/tests/integration/tools/test_tool_router.py +++ b/tests/integration/tools/test_tool_router.py @@ -1371,13 +1371,18 @@ def test_grep_finds_matching_text_and_writes_trace(tmp_path: Path) -> None: assert json.loads(trace_lines[0])["tool_name"] == "grep" -def test_grep_default_respects_ignore_files_and_noise_directories(tmp_path: Path) -> None: +def test_grep_python_fallback_respects_ignore_files_and_noise_directories( + tmp_path: Path, + monkeypatch, +) -> None: (tmp_path / ".gitignore").write_text("ignored/\n", encoding="utf-8") (tmp_path / "ignored").mkdir() (tmp_path / "ignored" / "ignored.txt").write_text("needle\n", encoding="utf-8") (tmp_path / ".tmp").mkdir() (tmp_path / ".tmp" / "blocked.txt").write_text("needle\n", encoding="utf-8") (tmp_path / "kept.txt").write_text("needle\n", encoding="utf-8") + original_which = file_tools.shutil.which + monkeypatch.setattr(file_tools.shutil, "which", lambda name: None if name == "rg" else original_which(name)) result = file_tools.grep({"pattern": "needle"}, tmp_path) diff --git a/tests/unit/multi_agent/test_team_store_messages.py b/tests/unit/multi_agent/test_team_store_messages.py index 7162708..d130352 100644 --- a/tests/unit/multi_agent/test_team_store_messages.py +++ b/tests/unit/multi_agent/test_team_store_messages.py @@ -17,12 +17,24 @@ def test_team_store_writes_and_reads_worker_messages(tmp_path: Path) -> None: store.write_worker_message( "team-1", "worker-1", - WorkerMessage(sender="coordinator", recipient="worker-1", content="first"), + WorkerMessage( + sender="coordinator", + recipient="worker-1", + content="first", + message_id="z-first", + created_at=100.0, + ), ) store.write_worker_message( "team-1", "worker-1", - WorkerMessage(sender="coordinator", recipient="worker-1", content="second"), + WorkerMessage( + sender="coordinator", + recipient="worker-1", + content="second", + message_id="a-second", + created_at=100.0, + ), ) messages = store.read_worker_messages("team-1", "worker-1") diff --git a/tests/unit/runtime/test_command.py b/tests/unit/runtime/test_command.py index 4994705..e225f58 100644 --- a/tests/unit/runtime/test_command.py +++ b/tests/unit/runtime/test_command.py @@ -20,6 +20,7 @@ build_shell_command_argv, build_python_utf8_environment, describe_shell_contract, + redact_secret_like_text, resolve_shell_command, resolve_shell_contract, run_command, @@ -27,6 +28,15 @@ ) +def test_secret_environment_value_does_not_redact_substrings_of_regular_output(monkeypatch) -> None: + monkeypatch.setenv("HAAGENT_TEST_SECRET_TOKEN", "root") + + redacted, changed = redact_secret_like_text("C:/tmp/workspace_root0") + + assert redacted == "C:/tmp/workspace_root0" + assert changed is False + + def test_shell_contract_selects_powershell_on_windows_and_drives_argv() -> None: def fake_which(name: str) -> str | None: return {"pwsh": "C:/Program Files/PowerShell/7/pwsh.exe"}.get(name) diff --git a/tests/unit/scheduling/test_models.py b/tests/unit/scheduling/test_models.py index 6c51677..d5eb45a 100644 --- a/tests/unit/scheduling/test_models.py +++ b/tests/unit/scheduling/test_models.py @@ -25,7 +25,7 @@ def _valid_definition(**overrides: object) -> ScheduleDefinition: "id": "sch_test", "name": "daily report", "prompt": "Summarize the workspace", - "workspace_root": Path("E:/workspace/project"), + "workspace_root": Path.cwd(), "destination_kind": "new_session", "destination_session_path": None, "connection_id": "conn_local", diff --git a/tests/unit/tools/test_shell_paths.py b/tests/unit/tools/test_shell_paths.py index 93d71de..ca420de 100644 --- a/tests/unit/tools/test_shell_paths.py +++ b/tests/unit/tools/test_shell_paths.py @@ -8,9 +8,16 @@ from pathlib import Path +import haagent.tools.shell_paths as shell_paths from haagent.tools.shell_paths import collect_shell_paths +def test_normalize_path_separators_converts_powershell_separators_on_posix(monkeypatch) -> None: + monkeypatch.setattr(shell_paths.os, "name", "posix") + + assert shell_paths._normalize_path_separators(r"home\.config\settings.json") == "home/.config/settings.json" + + def test_collect_shell_paths_expands_powershell_environment_path( tmp_path: Path, monkeypatch,