diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..b569087f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,44 @@ +name: 🧪 Tests + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + pytest: + name: 🐍 ${{ matrix.os }} · py${{ matrix.python }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Ubuntu only while this branch is based on a main that predates the + # Windows support in #57: the Windows lanes there exercise platform + # code this branch does not carry, and fail on main's known + # POSIX-only layers (os.killpg and friends). #57 brings the full + # ubuntu + windows matrix; on merge, its version of this file wins. + os: [ubuntu-latest] + python: ["3.10", "3.14"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + - name: 📦 Install with test dependencies + run: python -m pip install -e ".[test]" + + - name: 🧪 Run the suite + # The Windows symlink fixtures skip themselves when the runner lacks + # SeCreateSymbolicLinkPrivilege; those skips are expected and green. + run: python -m pytest -q diff --git a/src/lh_harness/role_prompts.py b/src/lh_harness/role_prompts.py index d3b38847..03c419ef 100644 --- a/src/lh_harness/role_prompts.py +++ b/src/lh_harness/role_prompts.py @@ -480,12 +480,21 @@ def format_audit_findings( def parse_role_manager_next_step(text: str) -> RoleNextStep: for line in str(text or "").splitlines(): - normalized = line.strip().strip("*").replace(" ", "").replace(" ", "").lower() + # `*` covers bold; the backtick covers code spans. The prompt itself + # displays every route inside backticks ("exactly one route: + # `Next: gui`, ..."), and models -- reliably the smaller ones -- copy + # that formatting into their answer, so a parser that accepts + # `**Next: cli**` but not "`Next: cli`" burns a whole round on + # formatting the harness's own instruction taught the model. + normalized = line.strip().strip("*`").replace(" ", "").replace(" ", "").lower() # Models commonly append a short rationale after the required route, # e.g. `Next: done — all constraints passed`. Treat only an explicitly # delimited suffix as commentary so prose such as `Next: done later` # remains invalid. - normalized = re.split(r"(?:—|–|--|//|#|[((])", normalized, maxsplit=1)[0] + # Strip wrappers again after cutting the rationale: in + # "`Next: cli` — reason" the closing backtick sits before the dash and + # survives the first strip. + normalized = re.split(r"(?:—|–|--|//|#|[((])", normalized, maxsplit=1)[0].strip("*`") if normalized in {"下一步:gui任务", "下一步:gui任务", "next:gui"}: return MANAGER_NEXT_GUI if normalized in {"下一步:cli任务", "下一步:cli任务", "next:cli"}: diff --git a/tests/test_role_prompts.py b/tests/test_role_prompts.py index 290980e1..68cf3346 100644 --- a/tests/test_role_prompts.py +++ b/tests/test_role_prompts.py @@ -94,6 +94,21 @@ def test_manager_route_rejects_undelimited_suffix() -> None: assert parse_role_manager_next_step("Next: done later") != MANAGER_NEXT_DONE +@pytest.mark.parametrize( + "line", + [ + # The prompt shows every route inside backticks, and models copy that + # formatting literally; bold was already accepted, so a code span must + # not be the one markdown wrapper that voids an otherwise valid route. + "`Next: cli`", + "**`Next: cli`**", + "`Next: cli` — routed to the CLI executor", + ], +) +def test_manager_route_accepts_code_span_wrapping(line: str) -> None: + assert parse_role_manager_next_step(line) == MANAGER_NEXT_CLI + + @pytest.mark.parametrize("language", ["en", "zh"]) def test_manager_prompt_exposes_total_and_remaining_round_budget(language: str) -> None: prompt = build_role_manager_prompt(