From 3c1561eb1ff206bbfceb8dd8b15aa6a6ef8e7877 Mon Sep 17 00:00:00 2001 From: zhengda Date: Thu, 3 Sep 2026 15:34:36 +0800 Subject: [PATCH 1/2] Relocate package-owned configs and skills --- .github/workflows/ci.yml | 129 +++++++++++++---- .github/workflows/release.yml | 56 ++++++++ .gitignore | 6 +- SPEC.md | 3 +- fsq_agent/config/SPEC.md | 17 ++- fsq_agent/config/_loader.py | 45 +++--- .../config/config.android.yaml | 1 - .../config/config.example.yaml | 12 +- .../config/config.macos.yaml | 1 - .../config/config.web.yaml | 3 +- .../config/config.windows.yaml | 3 +- .../resources}/skills/android-harness.md | 6 +- .../resources}/skills/automation-basics.md | 0 .../resources}/skills/macos-harness.md | 0 .../resources}/skills/web-harness.md | 0 .../resources}/skills/windows-harness.md | 4 +- pyproject.toml | 12 -- scripts/distribute-frontend-build.mjs | 16 +-- tests/test_config.py | 68 ++++----- tests/test_distribution_contract.py | 134 ++++++++++++++---- tests/test_skills.py | 17 ++- 21 files changed, 365 insertions(+), 168 deletions(-) rename config.android.yaml => fsq_agent/config/config.android.yaml (96%) rename config.example.yaml => fsq_agent/config/config.example.yaml (72%) rename config.macos.yaml => fsq_agent/config/config.macos.yaml (96%) rename config.web.yaml => fsq_agent/config/config.web.yaml (92%) rename config.windows.yaml => fsq_agent/config/config.windows.yaml (92%) rename {knowledge => fsq_agent/resources}/skills/android-harness.md (87%) rename {knowledge => fsq_agent/resources}/skills/automation-basics.md (100%) rename {knowledge => fsq_agent/resources}/skills/macos-harness.md (100%) rename {knowledge => fsq_agent/resources}/skills/web-harness.md (100%) rename {knowledge => fsq_agent/resources}/skills/windows-harness.md (94%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54eb16e..6e01de2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: enable-cache: true cache-dependency-glob: pyproject.toml - - name: Verify clean checkout has no generated package resources + - name: Verify clean checkout package resource ownership shell: python run: | import subprocess @@ -54,12 +54,39 @@ jobs: generated = ( Path("fsq_agent/adapters/control_plane/static"), - Path("fsq_agent/resources"), ) present = [str(path) for path in generated if path.exists()] if present: raise SystemExit(f"Generated package resources are unexpectedly tracked: {present}") + package_resources = ( + Path("fsq_agent/config/config.android.yaml"), + Path("fsq_agent/config/config.web.yaml"), + Path("fsq_agent/config/config.windows.yaml"), + Path("fsq_agent/config/config.macos.yaml"), + Path("fsq_agent/config/config.example.yaml"), + Path("fsq_agent/resources/skills/android-harness.md"), + Path("fsq_agent/resources/skills/web-harness.md"), + Path("fsq_agent/resources/skills/windows-harness.md"), + Path("fsq_agent/resources/skills/macos-harness.md"), + Path("fsq_agent/resources/skills/automation-basics.md"), + ) + missing = [str(path) for path in package_resources if not path.is_file()] + if missing: + raise SystemExit(f"Tracked package resources are missing: {missing}") + + retired = ( + Path("config.android.yaml"), + Path("config.web.yaml"), + Path("config.windows.yaml"), + Path("config.macos.yaml"), + Path("config.example.yaml"), + Path("knowledge/skills"), + ) + present = [str(path) for path in retired if path.exists()] + if present: + raise SystemExit(f"Retired root resources are still present: {present}") + - name: Install quality dependencies run: uv sync --extra dev --reinstall-package fsq-agent @@ -102,19 +129,46 @@ jobs: enable-cache: true cache-dependency-glob: pyproject.toml - - name: Verify clean checkout has no generated package resources + - name: Verify clean checkout package resource ownership shell: python run: | from pathlib import Path generated = ( Path("fsq_agent/adapters/control_plane/static"), - Path("fsq_agent/resources"), ) present = [str(path) for path in generated if path.exists()] if present: raise SystemExit(f"Generated package resources are unexpectedly tracked: {present}") + package_resources = ( + Path("fsq_agent/config/config.android.yaml"), + Path("fsq_agent/config/config.web.yaml"), + Path("fsq_agent/config/config.windows.yaml"), + Path("fsq_agent/config/config.macos.yaml"), + Path("fsq_agent/config/config.example.yaml"), + Path("fsq_agent/resources/skills/android-harness.md"), + Path("fsq_agent/resources/skills/web-harness.md"), + Path("fsq_agent/resources/skills/windows-harness.md"), + Path("fsq_agent/resources/skills/macos-harness.md"), + Path("fsq_agent/resources/skills/automation-basics.md"), + ) + missing = [str(path) for path in package_resources if not path.is_file()] + if missing: + raise SystemExit(f"Tracked package resources are missing: {missing}") + + retired = ( + Path("config.android.yaml"), + Path("config.web.yaml"), + Path("config.windows.yaml"), + Path("config.macos.yaml"), + Path("config.example.yaml"), + Path("knowledge/skills"), + ) + present = [str(path) for path in retired if path.exists()] + if present: + raise SystemExit(f"Retired root resources are still present: {present}") + - name: Install test dependencies run: uv sync --all-extras --reinstall-package fsq-agent @@ -231,15 +285,16 @@ jobs: required_files = ( "fsq_agent/adapters/control_plane/static/control-plane/index.html", "fsq_agent/adapters/control_plane/static/entry-assets.json", - "fsq_agent/resources/config.android.yaml", - "fsq_agent/resources/config.web.yaml", - "fsq_agent/resources/config.windows.yaml", - "fsq_agent/resources/config.macos.yaml", - "fsq_agent/resources/knowledge/skills/android-harness.md", - "fsq_agent/resources/knowledge/skills/web-harness.md", - "fsq_agent/resources/knowledge/skills/windows-harness.md", - "fsq_agent/resources/knowledge/skills/macos-harness.md", - "fsq_agent/resources/knowledge/skills/automation-basics.md", + "fsq_agent/config/config.android.yaml", + "fsq_agent/config/config.web.yaml", + "fsq_agent/config/config.windows.yaml", + "fsq_agent/config/config.macos.yaml", + "fsq_agent/config/config.example.yaml", + "fsq_agent/resources/skills/android-harness.md", + "fsq_agent/resources/skills/web-harness.md", + "fsq_agent/resources/skills/windows-harness.md", + "fsq_agent/resources/skills/macos-harness.md", + "fsq_agent/resources/skills/automation-basics.md", "fsq_agent/agent/templates/agent_instructions.j2", "fsq_agent/agent/templates/task_input.j2", ) @@ -253,29 +308,55 @@ jobs: with ZipFile(rebuilt_wheels[0]) as archive: rebuilt_names = set(archive.namelist()) + runtime_prefixes = ( + "fsq_agent/config/config.", + "fsq_agent/resources/skills/", + *required_prefixes, + ) required_runtime_names = { - name for name in names if name.startswith(("fsq_agent/resources/", *required_prefixes)) + name for name in names if name.startswith(runtime_prefixes) } rebuilt_runtime_names = { - name for name in rebuilt_names if name.startswith(("fsq_agent/resources/", *required_prefixes)) + name for name in rebuilt_names if name.startswith(runtime_prefixes) } if rebuilt_runtime_names != required_runtime_names: raise SystemExit("Wheel rebuilt from sdist has different runtime package resources") + retired_wheel_resources = { + name + for name in names + if name.startswith("fsq_agent/resources/knowledge/") + or name + in { + "fsq_agent/resources/config.android.yaml", + "fsq_agent/resources/config.web.yaml", + "fsq_agent/resources/config.windows.yaml", + "fsq_agent/resources/config.macos.yaml", + } + } + if retired_wheel_resources: + raise SystemExit(f"Wheel contains retired package resource: {sorted(retired_wheel_resources)}") + with tarfile.open(sdists[0], "r:gz") as archive: sdist_names = {name.split("/", 1)[-1] for name in archive.getnames()} - for path in ( - "config.android.yaml", - "config.web.yaml", - "config.windows.yaml", - "config.macos.yaml", - "knowledge/skills/automation-basics.md", - ): - if path not in sdist_names: - raise SystemExit(f"Sdist is missing build input {path}") for path in required_files: if path not in sdist_names: raise SystemExit(f"Sdist is missing {path}") + retired_sdist_resources = { + name + for name in sdist_names + if name.startswith("knowledge/skills/") + or name + in { + "config.android.yaml", + "config.web.yaml", + "config.windows.yaml", + "config.macos.yaml", + "config.example.yaml", + } + } + if retired_sdist_resources: + raise SystemExit(f"Sdist contains retired root resource: {sorted(retired_sdist_resources)}") for prefix in required_prefixes: for suffix in (".js", ".css"): if not any(name.startswith(prefix) and name.endswith(suffix) for name in sdist_names): diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d3b0de2..ddb7812 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,7 +69,23 @@ jobs: "$RUNNER_TEMP/fsq-release-smoke/bin/fsq" --help "$RUNNER_TEMP/fsq-release-smoke/bin/fsq-agent" --help "$RUNNER_TEMP/fsq-release-smoke/bin/python" - <<'PY' + from pathlib import Path + from tempfile import TemporaryDirectory + from fsq_agent.adapters.control_plane import ControlPlaneServer, ControlPlaneServerOptions + from fsq_agent.config import PLATFORM_CONFIG_PATHS, load_platform_settings + + with TemporaryDirectory() as temp_dir: + temp_root = Path(temp_dir) + for platform, preset_path in PLATFORM_CONFIG_PATHS.items(): + assert preset_path.is_file() + settings = load_platform_settings( + platform, + workspace=temp_root / platform, + user_config_root=temp_root / "user", + ) + skills = settings.agent_context.knowledge.skills + assert all(item.path is not None and (skills.dir / item.path).is_file() for item in skills.items) server = ControlPlaneServer(ControlPlaneServerOptions(open_browser=False)) status, body, content_type = server.static_response("/") @@ -114,6 +130,9 @@ jobs: run: | $venv = Join-Path $env:RUNNER_TEMP "fsq-install-smoke" python -m venv $venv + if ($LASTEXITCODE -ne 0) { + throw "Virtual environment creation failed with exit code $LASTEXITCODE." + } if ($IsWindows) { $python = Join-Path $venv "Scripts/python.exe" $fsq = Join-Path $venv "Scripts/fsq.exe" @@ -128,10 +147,47 @@ jobs: throw "Expected exactly one wheel in dist/, found $($wheels.Count)." } & $python -m pip install --upgrade pip + if ($LASTEXITCODE -ne 0) { + throw "pip upgrade failed with exit code $LASTEXITCODE." + } & $python -m pip install $wheels[0].FullName + if ($LASTEXITCODE -ne 0) { + throw "Wheel installation failed with exit code $LASTEXITCODE." + } & $python -c "import fsq_agent" + if ($LASTEXITCODE -ne 0) { + throw "Installed package import failed with exit code $LASTEXITCODE." + } & $fsq --help + if ($LASTEXITCODE -ne 0) { + throw "fsq console-script smoke failed with exit code $LASTEXITCODE." + } & $fsqAgent --help + if ($LASTEXITCODE -ne 0) { + throw "fsq-agent console-script smoke failed with exit code $LASTEXITCODE." + } + $resourceSmoke = @' + from pathlib import Path + from tempfile import TemporaryDirectory + + from fsq_agent.config import PLATFORM_CONFIG_PATHS, load_platform_settings + + with TemporaryDirectory() as temp_dir: + temp_root = Path(temp_dir) + for platform, preset_path in PLATFORM_CONFIG_PATHS.items(): + assert preset_path.is_file() + settings = load_platform_settings( + platform, + workspace=temp_root / platform, + user_config_root=temp_root / "user", + ) + skills = settings.agent_context.knowledge.skills + assert all(item.path is not None and (skills.dir / item.path).is_file() for item in skills.items) + '@ + $resourceSmoke | & $python - + if ($LASTEXITCODE -ne 0) { + throw "Installed package resource smoke failed with exit code $LASTEXITCODE." + } publish: name: Publish to PyPI diff --git a/.gitignore b/.gitignore index 073d775..925cbd6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,13 +19,13 @@ screenshots/ dist/ *.egg-info/ knowledge/* -!knowledge/skills/ -!knowledge/skills/** node_modules/ .frontend-dist/ fsq_agent/adapters/control_plane/playground/static/ fsq_agent/adapters/control_plane/static/ -fsq_agent/resources/ +fsq_agent/resources/* +!fsq_agent/resources/skills/ +!fsq_agent/resources/skills/** # Private/local planning material. Do not commit. diff --git a/SPEC.md b/SPEC.md index 7e22e90..2007a28 100644 --- a/SPEC.md +++ b/SPEC.md @@ -48,7 +48,7 @@ Existing-Case testing parses the Case through FSQ rather than treating YAML as u Except while creating an unregistered Workspace, the exact CLI current directory is a registered workspace root using the canonical `.fsq/config/config..yaml`, `.fsq/runs//`, `cases//`, and `knowledge//` layout. CLI does not create or accept `.fsq-agent-workspace` markers, search parents, or auto-initialize. For a new name, `fsq init` treats the current directory as the selected directory: an empty directory becomes the Workspace root, while a non-empty directory receives a new `/` child. For an existing registered name, initialization uses its stored root independently of the process current directory. All other CLI commands require the exact registered root, and platform operations require the selected platform. Control Plane uses the same Application and Config-owned root-selection and registry rules while retaining explicit browser workspace selection independent of its startup directory. -Default local LLM runs use GitHub Copilot provider authentication with Copilot model `gpt-5.5` and tracing enabled. Provider selection and credentials are managed by the Provider configuration surface rather than workspace initialization. Repository-owned platform YAML presets are committed as `config.android.yaml`, `config.web.yaml`, `config.windows.yaml`, and `config.macos.yaml`; `config.example.yaml` is reference-only. Workspace platform configuration owns local target identity and private runtime-secret values. A Web target always names a browser channel and may omit its executable path so Application can discover exactly one compatible host executable before Driver readiness or workspace mutation. +Default local LLM runs use GitHub Copilot provider authentication with Copilot model `gpt-5.5` and tracing enabled. Provider selection and credentials are managed by the Provider configuration surface rather than workspace initialization. Repository-owned platform YAML presets are package-owned files under `fsq_agent/config/`; the sibling `config.example.yaml` is reference-only. Reusable preset skills are tracked package resources under `fsq_agent/resources/skills/`. Source checkouts and installed distributions resolve the same package-owned preset and skill files. Workspace platform configuration owns local target identity and private runtime-secret values. A Web target always names a browser channel and may omit its executable path so Application can discover exactly one compatible host executable before Driver readiness or workspace mutation. The local workspace setup entry is `fsq init --platform android|web|windows|macos` with the selected platform's target options and optional `--name`. It creates an unregistered Workspace from the current selected directory or initializes and updates exactly one platform at the stored root of an existing registered name. It does not configure Providers or create legacy workspace markers. @@ -161,6 +161,7 @@ Loader diagnostics such as missing optional skills or missing optional knowledge - New frontend application modules use Vite, React, and TypeScript/TSX unless their confirmed module SPEC records a concrete exception. - `ts-ebml` is an exact npm dependency consumed through an ES module import. Third-party browser bundles and Vite-generated assets are not tracked in Git. - Vite-generated Control Plane assets live under `fsq_agent/adapters/control_plane/static`. Its HTML entry point, JavaScript, CSS, entry-asset manifest, and referenced generated assets are included in both wheel and source distribution. Release builds run the npm build before Python distribution construction. An installed distribution is self-contained and does not require Node.js or network access to serve the frontend at runtime. +- The npm build generates and distributes frontend assets only; it does not generate, copy, delete, or mutate tracked Python platform presets or reusable skill resources. - Frontend development may use the Vite development server with API and streaming requests proxied to the Control Plane Python server. Production and installed-wheel usage serve the generated entry and its APIs from one Python process. ## Architecture Diagram diff --git a/fsq_agent/config/SPEC.md b/fsq_agent/config/SPEC.md index b54f705..1dfe92e 100644 --- a/fsq_agent/config/SPEC.md +++ b/fsq_agent/config/SPEC.md @@ -15,10 +15,11 @@ Current `__init__.py` exports via `__all__`: - `Settings`: Runtime aggregate combining preset policy, workspace target and resolved paths, private workspace runtime-secret values, reusable repository skills, and the latest Provider snapshot. Entry layers may place a transient Android serial on a run-specific settings copy. - `UserProviderConfig`: Validated presentation/runtime snapshot for the single active `azure_openai` or `github_copilot` provider, or the explicit unconfigured state. The persisted user document is version 3 and also carries the root-based workspace registry; Provider APIs preserve registry entries on every write. - `WorkspaceConfig`, `WorkspaceInitResult`, platform workspace target models, and `WorkspaceRegistryEntry`: Re-exported shared boundary models used by trusted entry surfaces. -- `PLATFORM_CONFIG_PATHS`: Mapping from supported platform ids (`android`, `web`, `windows`, `macos`) to committed repository preset paths (`config.android.yaml`, `config.web.yaml`, `config.windows.yaml`, `config.macos.yaml`). +- `PLATFORM_CONFIG_PATHS`: Mapping from supported platform ids (`android`, `web`, `windows`, `macos`) to the package-owned committed preset paths under `fsq_agent/config/` (`config.android.yaml`, `config.web.yaml`, `config.windows.yaml`, `config.macos.yaml`). Source checkouts and installed distributions resolve the same files. - `resolve_platform_config_path(platform: str) -> Path`: Validates a platform id and returns the corresponding committed platform preset path. Unsupported platform ids or missing preset files raise `ConfigurationError` with the platform and expected path. -- `load_workspace_platform_settings(workspace: str | Path, platform: str, user_config_root: str | Path | None = None) -> Settings`: Validates the explicit workspace root and exact `.fsq/config/config..yaml`, loads the matching repository preset, resolves repository-owned resources, overlays that platform's target/paths/private secrets, overlays the latest Provider, and validates final paths without initializing workspace identity. -- `load_settings(path: str | Path | None = None, workspace: str | Path | None = None, user_config_root: str | Path | None = None) -> Settings`: Lower-level loader for tests and internal callers. It loads YAML from the provided path or developer default search locations, overlays the latest user-provider snapshot, and resolves runtime paths without parsing `.env` files. Developer default discovery may use `config.yaml` or `config.yml`; it must not use `config.example.yaml`. +- `load_platform_settings(platform: str, workspace: str | Path | None = None, user_config_root: str | Path | None = None) -> Settings`: Validates a platform id, loads its package-owned preset with the package skill-resource binding, applies an optional workspace root and the latest Provider snapshot, and rejects a preset whose configured harness platform does not match the request. It does not load a workspace platform target or private runtime secrets; workspace workflows use `load_workspace_platform_settings`. +- `load_workspace_platform_settings(workspace: str | Path, platform: str, user_config_root: str | Path | None = None) -> Settings`: Validates the explicit workspace root and exact `.fsq/config/config..yaml`, loads the matching package-owned repository preset, binds its reusable skills to `fsq_agent/resources/skills/`, overlays that platform's target/paths/private secrets, overlays the latest Provider, and validates final paths without initializing workspace identity. +- `load_settings(path: str | Path | None = None, workspace: str | Path | None = None, user_config_root: str | Path | None = None) -> Settings`: Lower-level loader for tests and internal callers. It loads YAML from the provided path or developer default search locations, overlays the latest user-provider snapshot, and resolves runtime paths without parsing `.env` files. A package-owned platform preset receives the package skill-resource binding; caller-supplied configuration retains the existing config-relative and knowledge-relative path semantics. Developer default discovery may use `config.yaml` or `config.yml`; it must not use `config.example.yaml`. - `load_user_provider_config(user_config_root: str | Path | None = None) -> UserProviderConfig`: Loads or upgrades the versioned user document, validates Provider metadata plus credentials, preserves the workspace registry, and returns the explicit unconfigured or complete Provider snapshot. The default root is `Path.home() / ".fsq"`; tests pass a temporary root. - `list_workspace_registry(user_config_root: str | Path | None = None) -> list[WorkspaceRegistryEntry]`: Returns root-based registry entries in persisted order without loading target or secret values. - `inspect_registered_workspace(name: str, user_config_root: str | Path | None = None) -> WorkspaceStatus`: Resolves one case-insensitive registry name and independently inspects exact supported platform files, returning canonical safe availability records without target or env values. @@ -34,7 +35,7 @@ Current `__init__.py` exports via `__all__`: - `validate_runtime_settings(settings: Settings) -> None`: Validates user-provider readiness, Azure OpenAI base URL shape when selected, resolved model name, LLM harness/driver/platform tool settings, AgentTool policy, platform CommonTool policy, and local path constraints before a default LLM run starts. - `validate_strict_core_settings(settings: Settings, requires_ai_assertion: bool = False) -> None`: Validates strict-core harness/driver settings not provided by a case file. It does not require provider credentials unless the caller knows the strict run contains an authored `assertWithAI` step or otherwise requires a provider-backed AI assertion evaluator. Runtime-secret text references are validated by entry/core code after the case is parsed because referenced names come from case commands, not from static settings alone. -Repository-owned platform YAML presets contain stable, shareable runtime shape: active platform, backend selection, OpenAI Agents SDK turn limit, execution post-action delay defaults, lifecycle policy, harness policy, and reusable skill definitions. They do not define workspace, cases/output, project knowledge, target, or runtime-secret values. `config.example.yaml` is a reference sample only. +The Config package owns `config.android.yaml`, `config.web.yaml`, `config.windows.yaml`, and `config.macos.yaml` beside its Python implementation. These presets contain stable, shareable runtime shape: active platform, backend selection, OpenAI Agents SDK turn limit, execution post-action delay defaults, lifecycle policy, harness policy, and reusable skill definitions. They do not define workspace, cases/output, project knowledge, target, runtime-secret values, or the physical package skill-resource directory. Config binds their reusable skill definitions to the tracked `fsq_agent/resources/skills/` package directory in source checkouts and installed distributions. The sibling `config.example.yaml` is a reference sample only. Process environment and `.env` files do not contribute FSQ platform, application-target, runtime-secret, or Provider configuration and are not compatibility fallbacks; the sole exception is the macOS operator-local Appium server URL, read from the `FSQ_MACOS_APPIUM_SERVER_URL` process environment variable when set. Config does not automatically parse repository or config-directory `.env` files. @@ -48,7 +49,7 @@ Shared configuration rules: - The explicitly selected configured workspace platform selects exactly one committed preset and matching `harness.` settings block for dynamic and strict execution. - Repository presets own stable platform/backend, turn-limit, timeout, snapshot, browser policy, lifecycle, delay, and reusable skill settings. Workspace config owns target identity, private runtime secrets, and workspace paths. -- `agent_context.knowledge.root_dir` resolves to workspace `knowledge//`. Preset-configured reusable skill paths remain repository-relative and do not move under that workspace root. Optional pre-plan page knowledge uses the selected platform knowledge root. +- `agent_context.knowledge.root_dir` resolves to workspace `knowledge//`. Preset-configured reusable skills resolve from the fixed `fsq_agent/resources/skills/` package directory and do not move under that workspace root. Caller-supplied configuration retains config-relative and knowledge-relative skill path resolution. Optional pre-plan page knowledge uses the selected platform knowledge root. - Validation rejects unsupported platform/backend combinations, mismatched workspace target variants, unsafe roots, and invalid local target files before external actions. - Workspace names are trimmed bounded single-directory names; empty names, dot segments, path separators, control characters, host-invalid forms, and applicable Windows reserved device names are invalid. Registry names are case-insensitively unique, while normalized config paths use host path-case semantics. - Both Control Plane child-directory creation and CLI explicit-root initialization produce the same canonical registered workspace identity and `.fsq/config/config..yaml` layout. Explicit-root initialization may preserve unrelated pre-existing project content but never adopts pre-existing `.fsq` state or legacy markers. @@ -107,12 +108,14 @@ macOS configuration: - `_workspace.py`: Strict workspace YAML loading, new-root selection, revisions, target validation, separate creation/add/update transactions, registry truth checks, and rollback. - `_settings.py`: `Settings` aggregate model. - `_paths.py`: Workspace config/root validation, containment, and side-effect-free runtime path resolution helpers. +- `config.android.yaml`, `config.web.yaml`, `config.windows.yaml`, and `config.macos.yaml`: Package-owned repository platform presets used identically from source checkouts and installed distributions. +- `config.example.yaml`: Package-owned reference sample excluded from default runtime preset discovery. - `SPEC.md`: Module design. ## Python Architecture - Architecture level: 2 Simple Package. -- Public API: runtime/workspace models, explicit workspace-platform loading, Provider load/save/activation/refresh, root-based workspace registry/inspection/child-root initialization/explicit-root initialization/create/platform-add/platform-update operations, path resolution, and validation functions exported from `__init__.py`. +- Public API: runtime/workspace models, package-platform preset loading, explicit workspace-platform loading, Provider load/save/activation/refresh, root-based workspace registry/inspection/child-root initialization/explicit-root initialization/create/platform-add/platform-update operations, path resolution, and validation functions exported from `__init__.py`. - Internal modules: `_loader.py`, `_user_provider.py`, `_workspace.py`, `_settings.py`, and `_paths.py` are private implementation files. - Domain boundaries: config owns Provider/registry/workspace filesystem persistence, configuration composition, revisions, atomic writes, rollback, path containment, and validation. It does not own provider clients, authentication protocols, HTTP transport, file-browser projection, or task orchestration. - Boundary models: shared workspace/target/registry/init-result models come from `models`; `Settings` and `UserProviderConfig` are config runtime boundaries; project exceptions come from `models`. @@ -140,7 +143,7 @@ Invalid or missing configuration raises `ConfigurationError` from `models`. YAML - Direct CLI `run` validates and composes an explicitly selected platform from the exact current-directory workspace root through `load_workspace_platform_settings` without registry lookup or ancestor discovery. Registry-backed CLI and browser entry points resolve a required case-insensitive registry name plus explicit configured platform through `load_registered_workspace`, then validate and compose settings from that registered workspace root without initializing identity or config files. - Config-owned initialization is the single non-browser composition path for create/add/idempotent/update behavior. It preserves immutable workspace name/root/platform identity, compares the complete target and env mapping, and permits a differing existing platform replacement only through the same expected-revision update operation used by Control Plane. - `cases.dir` resolves to `/cases/`; `output.root_dir` and `output.runs_dir` resolve to `/.fsq/runs/`; `agent_context.knowledge.root_dir` resolves to `/knowledge/`. -- Reusable skills remain preset-owned repository resources. Optional project/page knowledge resolves under workspace `knowledge//` and is loaded only when non-blank content exists. +- Reusable skills remain preset-owned tracked package resources under `fsq_agent/resources/skills/`. Optional project/page knowledge resolves under workspace `knowledge//` and is loaded only when non-blank content exists. - `openai_agents.prompt` owns prompt template customization and scalar prompt variables. `prompt.agent_template_path` and `prompt.task_template_path` may point to files resolved relative to the configuration file directory; when template paths are omitted, package default templates are used. Static prompt text, headings, loops, and task formatting live in templates. `prompt.variables` provides operator-controlled scalar model data injected into templates. `prompt.custom_instructions` and `prompt.custom_instructions_path` are not supported configuration keys; project-specific guidance belongs in `knowledge/project.md`, and reusable execution guidance belongs in configured skills. - `harness.platform` selects the platform harness used by goal-driven task execution and strict-core execution. Supported platforms are `android`, `web`, `windows`, and `macos`. - `harness.android.backend` selects the Android backend. The supported backend is `uiautomator2`. diff --git a/fsq_agent/config/_loader.py b/fsq_agent/config/_loader.py index c1dc065..12284f3 100644 --- a/fsq_agent/config/_loader.py +++ b/fsq_agent/config/_loader.py @@ -37,21 +37,24 @@ _MACOS_APPIUM_SERVER_URL_ENV = "FSQ_MACOS_APPIUM_SERVER_URL" -def _runtime_resource_root() -> Path: - source_root = Path(__file__).resolve().parents[2] - if (source_root / "pyproject.toml").is_file(): - return source_root - package_root = Path(__file__).resolve().parents[1] / "resources" - if all((package_root / filename).is_file() for filename in _PLATFORM_CONFIG_FILENAMES.values()): - return package_root - checkout_root = Path.cwd().resolve() - if (checkout_root / "pyproject.toml").is_file(): - return checkout_root - return package_root - - -PLATFORM_CONFIG_PATHS = {platform: _runtime_resource_root() / filename for platform, filename in _PLATFORM_CONFIG_FILENAMES.items()} -_DEFAULT_PLATFORM_CONFIG_PATHS = dict(PLATFORM_CONFIG_PATHS) +def _package_config_root() -> Path: + return Path(__file__).resolve().parent + + +def _package_skill_root() -> Path: + return (Path(__file__).resolve().parents[1] / "resources" / "skills").resolve() + + +def _is_package_platform_config(path: Path) -> bool: + resolved_path = path.expanduser().resolve() + return any(resolved_path == preset_path.expanduser().resolve() for preset_path in PLATFORM_CONFIG_PATHS.values()) + + +def _bind_package_skill_resources(settings: Settings) -> None: + settings.agent_context.knowledge.skills.dir = _package_skill_root() + + +PLATFORM_CONFIG_PATHS = {platform: _package_config_root() / filename for platform, filename in _PLATFORM_CONFIG_FILENAMES.items()} SUPPORTED_LLM_PROVIDERS = ("github_copilot", "azure_openai") @@ -90,6 +93,8 @@ def load_settings( settings = refresh_provider_settings(settings, user_config_root) base_dir = config_path.parent if config_path is not None else Path.cwd() resolve_runtime_paths(settings, base_dir) + if config_path is not None and _is_package_platform_config(config_path): + _bind_package_skill_resources(settings) return settings @@ -101,8 +106,6 @@ def resolve_platform_config_path(platform: str) -> Path: "Unsupported harness platform.", context={"platform": platform, "supported": sorted(PLATFORM_CONFIG_PATHS)}, ) - if not config_path.is_file() and config_path == _DEFAULT_PLATFORM_CONFIG_PATHS[platform_id]: - config_path = _runtime_resource_root() / _PLATFORM_CONFIG_FILENAMES[platform_id] if not config_path.is_file(): raise ConfigurationError( "Platform configuration file is missing.", @@ -124,13 +127,6 @@ def load_platform_settings( "Platform configuration does not match requested platform.", context={"platform": platform_id, "configured_platform": settings.harness.platform}, ) - knowledge = settings.agent_context.knowledge - try: - skills_relative = knowledge.skills.dir.relative_to(knowledge.root_dir) - except ValueError: - pass - else: - knowledge.skills.dir = (preset_path.parent / skills_relative).resolve() return settings @@ -149,6 +145,7 @@ def load_workspace_platform_settings( settings = Settings.model_validate(preset_data) except ValidationError as exc: raise ConfigurationError("Invalid platform preset.", context={"errors": exc.errors()}) from exc + _bind_package_skill_resources(settings) settings.workspace = WorkspaceSettings(root_dir=workspace_root, config_path=workspace_config_path) settings.harness.platform = platform_id diff --git a/config.android.yaml b/fsq_agent/config/config.android.yaml similarity index 96% rename from config.android.yaml rename to fsq_agent/config/config.android.yaml index 7ce5b44..2cfc5c3 100644 --- a/config.android.yaml +++ b/fsq_agent/config/config.android.yaml @@ -15,7 +15,6 @@ execution: agent_context: knowledge: skills: - dir: knowledge/skills items: - name: automation-basics description: Semantic action and evidence guidance for local runs. diff --git a/config.example.yaml b/fsq_agent/config/config.example.yaml similarity index 72% rename from config.example.yaml rename to fsq_agent/config/config.example.yaml index 1552c08..c08027b 100644 --- a/config.example.yaml +++ b/fsq_agent/config/config.example.yaml @@ -1,9 +1,9 @@ # Reference-only configuration example. # -# Public CLI platform runs use config.android.yaml, config.web.yaml, -# config.windows.yaml, or config.macos.yaml. This file is not a runtime preset -# and is ignored by default config discovery. Copy pieces from here into a -# platform preset or local config.yaml when experimenting manually. +# Public CLI platform runs use the sibling config.android.yaml, +# config.web.yaml, config.windows.yaml, or config.macos.yaml presets. This +# file is not a runtime preset and is ignored by default config discovery. +# Copy pieces from here into a local config.yaml when experimenting manually. openai_agents: max_turns: 100 @@ -37,9 +37,9 @@ runtime_secrets: agent_context: knowledge: - root_dir: ./knowledge/project_android_v1 + root_dir: ../../knowledge/project_android_v1 skills: - dir: ../skills + dir: ../../fsq_agent/resources/skills items: - name: automation-basics description: Semantic action and evidence guidance for local runs. diff --git a/config.macos.yaml b/fsq_agent/config/config.macos.yaml similarity index 96% rename from config.macos.yaml rename to fsq_agent/config/config.macos.yaml index bedeb8b..b3f674e 100644 --- a/config.macos.yaml +++ b/fsq_agent/config/config.macos.yaml @@ -19,7 +19,6 @@ execution: agent_context: knowledge: skills: - dir: knowledge/skills items: - name: automation-basics description: Semantic action and evidence guidance for local runs. diff --git a/config.web.yaml b/fsq_agent/config/config.web.yaml similarity index 92% rename from config.web.yaml rename to fsq_agent/config/config.web.yaml index 2a1621b..4fc6394 100644 --- a/config.web.yaml +++ b/fsq_agent/config/config.web.yaml @@ -18,7 +18,6 @@ execution: agent_context: knowledge: skills: - dir: knowledge/skills items: - name: automation-basics description: Semantic action and evidence guidance for local runs. @@ -29,4 +28,4 @@ agent_context: description: Web harness action selection and recovery guidance. kind: markdown path: web-harness.md - required: true + required: true \ No newline at end of file diff --git a/config.windows.yaml b/fsq_agent/config/config.windows.yaml similarity index 92% rename from config.windows.yaml rename to fsq_agent/config/config.windows.yaml index a1cfec2..b7314e7 100644 --- a/config.windows.yaml +++ b/fsq_agent/config/config.windows.yaml @@ -16,7 +16,6 @@ execution: agent_context: knowledge: skills: - dir: knowledge/skills items: - name: automation-basics description: Semantic action and evidence guidance for local runs. @@ -27,4 +26,4 @@ agent_context: description: Windows harness action selection and recovery guidance. kind: markdown path: windows-harness.md - required: true + required: true \ No newline at end of file diff --git a/knowledge/skills/android-harness.md b/fsq_agent/resources/skills/android-harness.md similarity index 87% rename from knowledge/skills/android-harness.md rename to fsq_agent/resources/skills/android-harness.md index 08fdfb5..3eda03b 100644 --- a/knowledge/skills/android-harness.md +++ b/fsq_agent/resources/skills/android-harness.md @@ -11,10 +11,10 @@ Use when `harness.platform` is Android. This skill contains Android-specific sta ## Observation and Locator Rules -- Use `ui_tree` as the Android structural observation for locating current elements and resolving target ambiguity. +- Use `ui_snapshot` as the Android structural observation for locating current elements and resolving target ambiguity. - Prefer locator fields confirmed in current output in this order: `resourceId`, `accessibilityId`, exact visible `text`, then `className` or `xpath` when simpler fields are absent or ambiguous. - Use coordinate taps only when current platform evidence or the user explicitly supplies the point and no reliable locator is available; prefer locator-based actions for normal UI elements. -- Re-evaluate stale or missing targets with a fresh `ui_tree` before retrying the same semantic action. +- Re-evaluate stale or missing targets with a fresh `ui_snapshot` before retrying the same semantic action. - Do not invent abstract Android targets such as an outside blank area. If a menu or dialog must be dismissed and no concrete target is exposed, use the requested semantic key action such as `Back`, then verify the UI state. - For Android key actions, use the requested semantic key string only. Do not mix key names with backend-native key codes. @@ -23,7 +23,7 @@ Use when `harness.platform` is Android. This skill contains Android-specific sta - Use `assert_state` for deterministic element state or text checks, such as verifying that `com.microsoft.emmx:id/url_bar` contains or equals a required URL or keyword. - Use `assert_visible` or `assert_not_visible` for required presence or absence of visible UI elements. - Use `assert_with_ai` for visual/page-content assertions or when the only deterministic option is a brittle complex locator, such as a long XPath through repeated generic controls or reused switch ids. -- Use `ui_tree` to inspect, locate, or collect evidence before an assertion. Before teardown, collect the final required verification with an assertion tool when an assertion-capable locator or text condition is available. +- Use `ui_snapshot` to inspect, locate, or collect evidence before an assertion. Before teardown, collect the final required verification with an assertion tool when an assertion-capable locator or text condition is available. ## Argument Rules diff --git a/knowledge/skills/automation-basics.md b/fsq_agent/resources/skills/automation-basics.md similarity index 100% rename from knowledge/skills/automation-basics.md rename to fsq_agent/resources/skills/automation-basics.md diff --git a/knowledge/skills/macos-harness.md b/fsq_agent/resources/skills/macos-harness.md similarity index 100% rename from knowledge/skills/macos-harness.md rename to fsq_agent/resources/skills/macos-harness.md diff --git a/knowledge/skills/web-harness.md b/fsq_agent/resources/skills/web-harness.md similarity index 100% rename from knowledge/skills/web-harness.md rename to fsq_agent/resources/skills/web-harness.md diff --git a/knowledge/skills/windows-harness.md b/fsq_agent/resources/skills/windows-harness.md similarity index 94% rename from knowledge/skills/windows-harness.md rename to fsq_agent/resources/skills/windows-harness.md index 4fd7a7f..081699f 100644 --- a/knowledge/skills/windows-harness.md +++ b/fsq_agent/resources/skills/windows-harness.md @@ -20,8 +20,8 @@ Use when `harness.platform` is Windows. This skill contains Windows-specific sta ## Verification and Assertion Rules -- Use `assert_visible` or `assert_not_visible` for required presence or absence of a control. -- Use `assert_with_ai` when the assertion requires visual judgment, window interpretation, or a deterministic control check would require a brittle complex locator. +- Use `assert_visible` for required presence of a control. +- Use `assert_with_ai` for required absence, visual judgment, window interpretation, or when a deterministic presence check would require a brittle complex locator. - Use `ui_snapshot` to inspect, locate, or collect context before an assertion. ## Argument Rules diff --git a/pyproject.toml b/pyproject.toml index 676da2c..a4c25cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,6 @@ fsq-agent = "fsq_agent.adapters.cli:main" packages = ["fsq_agent"] artifacts = [ "fsq_agent/adapters/control_plane/static/**", - "fsq_agent/resources/**", ] [tool.hatch.build.targets.sdist] @@ -86,19 +85,8 @@ include = [ "/examples", "/LICENSE", "/pyproject.toml", - "/config.android.yaml", - "/config.web.yaml", - "/config.windows.yaml", - "/config.macos.yaml", - "/knowledge/skills", ] artifacts = [ - "config.android.yaml", - "config.web.yaml", - "config.windows.yaml", - "config.macos.yaml", - "knowledge/skills/**", - "fsq_agent/resources/**", "fsq_agent/adapters/control_plane/static/**", ] diff --git a/scripts/distribute-frontend-build.mjs b/scripts/distribute-frontend-build.mjs index b05310b..fbf3360 100644 --- a/scripts/distribute-frontend-build.mjs +++ b/scripts/distribute-frontend-build.mjs @@ -1,5 +1,5 @@ -import { copyFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { basename, dirname, resolve } from 'node:path'; +import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; const root = resolve(import.meta.dirname, '..'); const staging = resolve(root, '.frontend-dist'); @@ -38,15 +38,3 @@ for (const { name, target } of entries) { } await writeFile(resolve(target, 'entry-assets.json'), `${JSON.stringify({ entry: name, files: [...files].sort() }, null, 2)}\n`); } - -const resources = resolve(root, 'fsq_agent/resources'); -await rm(resources, { recursive: true, force: true }); -await mkdir(resolve(resources, 'knowledge/skills'), { recursive: true }); -for (const platform of ['android', 'web', 'windows', 'macos']) { - const name = `config.${platform}.yaml`; - await copyFile(resolve(root, name), resolve(resources, name)); -} -const skillNames = (await readdir(resolve(root, 'knowledge/skills'))).filter((name) => name.endsWith('.md')).sort(); -for (const name of skillNames) { - await copyFile(resolve(root, 'knowledge/skills', basename(name)), resolve(resources, 'knowledge/skills', basename(name))); -} diff --git a/tests/test_config.py b/tests/test_config.py index 395f137..0cf36c5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest +import yaml from fsq_agent.config import ( PLATFORM_CONFIG_PATHS, @@ -47,34 +48,16 @@ def _windows_executable(tmp_path: Path, name: str = "app.exe") -> Path: return app_path -def test_runtime_resource_root_ignores_incomplete_editable_package_resources(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - checkout = tmp_path / "checkout" - checkout.mkdir() - (checkout / "pyproject.toml").write_text("[project]\nname = 'example'\n", encoding="utf-8") - editable_package = tmp_path / "site-packages" / "fsq_agent" - config_module = editable_package / "config" / "_loader.py" - config_module.parent.mkdir(parents=True) - (editable_package / "resources").mkdir() - monkeypatch.setattr(_loader, "__file__", str(config_module)) - monkeypatch.chdir(checkout) +def test_platform_config_paths_are_package_owned() -> None: + config_root = Path(_loader.__file__).resolve().parent - assert _loader._runtime_resource_root() == checkout - - -def test_resolve_platform_config_path_recovers_a_stale_editable_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - checkout = tmp_path / "checkout" - checkout.mkdir() - (checkout / "pyproject.toml").write_text("[project]\nname = 'example'\n", encoding="utf-8") - preset = checkout / "config.macos.yaml" - preset.write_text("harness:\n platform: macos\n", encoding="utf-8") - stale = tmp_path / "build-cache" / "fsq_agent" / "resources" / "config.macos.yaml" - stale_module = stale.parents[1] / "config" / "_loader.py" - monkeypatch.setattr(_loader, "__file__", str(stale_module)) - monkeypatch.setitem(_loader.PLATFORM_CONFIG_PATHS, "macos", stale) - monkeypatch.setitem(_loader._DEFAULT_PLATFORM_CONFIG_PATHS, "macos", stale) - monkeypatch.chdir(checkout) - - assert _loader.resolve_platform_config_path("macos") == preset + assert { + "android": config_root / "config.android.yaml", + "web": config_root / "config.web.yaml", + "windows": config_root / "config.windows.yaml", + "macos": config_root / "config.macos.yaml", + } == PLATFORM_CONFIG_PATHS + assert all(path.is_file() for path in PLATFORM_CONFIG_PATHS.values()) def test_load_workspace_platform_settings_composes_workspace_without_creating_content(tmp_path: Path) -> None: @@ -134,7 +117,7 @@ def test_load_workspace_platform_settings_uses_committed_preset_outside_reposito assert settings.harness.android.backend == "uiautomator2" assert settings.harness.android.app_id == "com.example.registered" assert settings.openai_agents.max_turns == 100 - assert settings.agent_context.knowledge.skills.dir == Path(__file__).parents[1] / "knowledge" / "skills" + assert settings.agent_context.knowledge.skills.dir == Path(_loader.__file__).resolve().parents[1] / "resources" / "skills" def _macos_workspace(tmp_path: Path, name: str) -> Path: @@ -357,8 +340,8 @@ def test_load_settings_rejects_invalid_case_lifecycle_hooks(tmp_path: Path, hook load_settings(config_path) -def test_config_example_is_reference_only_and_shows_case_lifecycle() -> None: - example_path = Path(__file__).parents[1] / "config.example.yaml" +def test_config_example_is_reference_only_and_shows_case_lifecycle(tmp_path: Path) -> None: + example_path = Path(_loader.__file__).resolve().parent / "config.example.yaml" assert example_path.exists() content = example_path.read_text(encoding="utf-8") @@ -366,23 +349,32 @@ def test_config_example_is_reference_only_and_shows_case_lifecycle() -> None: assert "onCaseStart:" in content assert "onCaseComplete:" in content assert "reference" in content.casefold() + settings = load_settings(example_path, user_config_root=tmp_path / "user") + repository_root = Path(_loader.__file__).resolve().parents[2] + assert settings.agent_context.knowledge.root_dir == repository_root / "knowledge" / "project_android_v1" + assert settings.agent_context.knowledge.skills.dir == repository_root / "fsq_agent" / "resources" / "skills" @pytest.mark.parametrize( - ("config_name", "expected_max_turns"), + ("platform", "expected_max_turns"), [ - ("config.android.yaml", 100), - ("config.web.yaml", 50), - ("config.windows.yaml", 100), - ("config.macos.yaml", 50), + ("android", 100), + ("web", 50), + ("windows", 100), + ("macos", 50), ], ) -def test_committed_platform_presets_define_max_turns(config_name: str, expected_max_turns: int, tmp_path: Path) -> None: - config_path = Path(__file__).parents[1] / config_name +def test_committed_platform_presets_define_max_turns_and_bind_package_skills(platform: str, expected_max_turns: int, tmp_path: Path) -> None: + config_path = PLATFORM_CONFIG_PATHS[platform] settings = load_settings(config_path, workspace=tmp_path / config_path.stem) assert settings.openai_agents.max_turns == expected_max_turns + skills = settings.agent_context.knowledge.skills + assert skills.dir == Path(_loader.__file__).resolve().parents[1] / "resources" / "skills" + assert all(item.path is not None and (skills.dir / item.path).is_file() for item in skills.items) + preset = yaml.safe_load(config_path.read_text(encoding="utf-8")) + assert "dir" not in preset["agent_context"]["knowledge"]["skills"] def test_load_settings_ignores_config_example_by_default(tmp_path: Path) -> None: @@ -411,7 +403,7 @@ def test_load_platform_settings_loads_committed_platform_preset(tmp_path: Path) assert settings.harness.web.base_url is None assert settings.openai_agents.max_turns == 50 skills = settings.agent_context.knowledge.skills - assert skills.dir == Path(__file__).parents[1] / "knowledge" / "skills" + assert skills.dir == Path(_loader.__file__).resolve().parents[1] / "resources" / "skills" assert all(item.path is not None and (skills.dir / item.path).is_file() for item in skills.items) diff --git a/tests/test_distribution_contract.py b/tests/test_distribution_contract.py index 9cf6146..fefb54d 100644 --- a/tests/test_distribution_contract.py +++ b/tests/test_distribution_contract.py @@ -15,6 +15,34 @@ EXACT_REQUIREMENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*(?:\[[A-Za-z0-9._,-]+\])?==[^;\s]+(?:\s*;.*)?$") +def _load_workflow(name: str) -> tuple[str, dict[str, Any]]: + raw = (ROOT / ".github" / "workflows" / name).read_text(encoding="utf-8") + workflow = yaml.safe_load(raw) + assert isinstance(workflow, dict) + assert isinstance(workflow.get("jobs"), dict) + for job_name, job in workflow["jobs"].items(): + assert isinstance(job, dict), job_name + assert isinstance(job.get("steps"), list), job_name + assert all(isinstance(step, dict) for step in job["steps"]), job_name + return raw, workflow + + +def _workflow_step(workflow: dict[str, Any], job_name: str, step_name: str) -> dict[str, Any]: + matches = [step for step in workflow["jobs"][job_name]["steps"] if step.get("name") == step_name] + assert len(matches) == 1, (job_name, step_name) + return matches[0] + + +def _delimited_python(script: str, opener: str, terminator: str) -> str: + lines = script.splitlines() + starts = [index for index, line in enumerate(lines) if opener in line] + assert len(starts) == 1, opener + start = starts[0] + end = next((index for index in range(start + 1, len(lines)) if lines[index] == terminator), None) + assert end is not None, terminator + return "\n".join(lines[start + 1 : end]) + "\n" + + def test_python_dependencies_are_lock_free_public_and_exactly_versioned() -> None: pyproject_path = ROOT / "pyproject.toml" pyproject_text = pyproject_path.read_text(encoding="utf-8") @@ -81,9 +109,7 @@ def test_sdist_includes_public_release_documentation_and_example() -> None: def test_release_workflow_is_manual_safe_and_uses_oidc_trusted_publishing() -> None: - workflow_path = ROOT / ".github" / "workflows" / "release.yml" - raw = workflow_path.read_text(encoding="utf-8") - workflow: dict[str, Any] = yaml.safe_load(raw) + raw, workflow = _load_workflow("release.yml") trigger = workflow[True] # PyYAML 1.1 parses the unquoted GitHub Actions `on` key as true. dispatch = trigger["workflow_dispatch"] assert set(trigger) == {"workflow_dispatch"} @@ -114,6 +140,7 @@ def test_release_workflow_is_manual_safe_and_uses_oidc_trusted_publishing() -> N "fsq-agent", ): assert command in install_smoke_commands + assert "load_platform_settings" in install_smoke_commands verify = workflow["jobs"]["verify"] assert any(step.get("uses", "").startswith("actions/upload-artifact@") for step in verify["steps"]) commands = "\n".join(str(step.get("run", "")) for step in verify["steps"]) @@ -129,6 +156,7 @@ def test_release_workflow_is_manual_safe_and_uses_oidc_trusted_publishing() -> N "tests/test_distribution_contract.py", '"$RUNNER_TEMP/fsq-release-smoke/bin/fsq" --help', '"$RUNNER_TEMP/fsq-release-smoke/bin/fsq-agent" --help', + "load_platform_settings", ): assert command in commands assert "uv sync --extra dev --reinstall-package fsq-agent" in commands @@ -141,59 +169,113 @@ def test_release_workflow_is_manual_safe_and_uses_oidc_trusted_publishing() -> N assert "password:" not in raw +def test_workflow_embedded_python_is_syntactically_valid() -> None: + _, ci = _load_workflow("ci.yml") + for job_name, step_name in ( + ("quality", "Verify clean checkout package resource ownership"), + ("tests", "Verify clean checkout package resource ownership"), + ("package", "Verify distribution contracts"), + ): + step = _workflow_step(ci, job_name, step_name) + assert step["shell"] == "python" + compile(step["run"], f"ci.yml:{job_name}:{step_name}", "exec") + + _, release = _load_workflow("release.yml") + verify_script = _workflow_step(release, "verify", "Install wheel in a clean environment")["run"] + compile(_delimited_python(verify_script, "<<'PY'", "PY"), "release.yml:verify:installed-wheel", "exec") + matrix_script = _workflow_step(release, "install-smoke", "Install wheel and verify console scripts")["run"] + compile(_delimited_python(matrix_script, "$resourceSmoke = @'", "'@"), "release.yml:install-smoke:resources", "exec") + + +def test_release_install_smoke_checks_every_native_command_exit() -> None: + _, release = _load_workflow("release.yml") + script = _workflow_step(release, "install-smoke", "Install wheel and verify console scripts")["run"] + lines = script.splitlines() + + for command in ( + "python -m venv $venv", + "& $python -m pip install --upgrade pip", + "& $python -m pip install $wheels[0].FullName", + '& $python -c "import fsq_agent"', + "& $fsq --help", + "& $fsqAgent --help", + "$resourceSmoke | & $python -", + ): + command_index = lines.index(command) + assert lines[command_index + 1] == "if ($LASTEXITCODE -ne 0) {" + assert lines[command_index + 2].strip().startswith('throw "') + assert lines[command_index + 3] == "}" + + def test_distribution_includes_only_control_plane_frontend_assets() -> None: project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) expected = "fsq_agent/adapters/control_plane/static/**" retired = "fsq_agent/adapters/control_plane/playground/static/**" wheel = project["tool"]["hatch"]["build"]["targets"]["wheel"] - assert set(wheel["artifacts"]) == {expected, "fsq_agent/resources/**"} + assert set(wheel["artifacts"]) == {expected} assert retired not in wheel["artifacts"] assert "force-include" not in wheel sdist = project["tool"]["hatch"]["build"]["targets"]["sdist"] - assert expected in sdist["artifacts"] + assert set(sdist["artifacts"]) == {expected} assert retired not in sdist["artifacts"] assert "force-include" not in sdist -def test_sdist_maps_runtime_resources_to_package_paths() -> None: +def test_frontend_distribution_script_does_not_mutate_python_resources() -> None: + script = (ROOT / "scripts" / "distribute-frontend-build.mjs").read_text(encoding="utf-8") + + assert "fsq_agent/resources" not in script + assert "knowledge/skills" not in script + + +def test_sdist_uses_tracked_package_runtime_resources() -> None: project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) sdist = project["tool"]["hatch"]["build"]["targets"]["sdist"] - assert set(sdist["artifacts"]) == { - "config.android.yaml", - "config.web.yaml", - "config.windows.yaml", - "config.macos.yaml", - "knowledge/skills/**", - "fsq_agent/resources/**", - "fsq_agent/adapters/control_plane/static/**", - } + includes = set(sdist["include"]) + + assert "/fsq_agent" in includes + assert not {"/config.android.yaml", "/config.web.yaml", "/config.windows.yaml", "/config.macos.yaml", "/knowledge/skills"} & includes + for path in ( + ROOT / "fsq_agent/config/config.android.yaml", + ROOT / "fsq_agent/config/config.web.yaml", + ROOT / "fsq_agent/config/config.windows.yaml", + ROOT / "fsq_agent/config/config.macos.yaml", + ROOT / "fsq_agent/config/config.example.yaml", + ROOT / "fsq_agent/resources/skills/android-harness.md", + ROOT / "fsq_agent/resources/skills/web-harness.md", + ROOT / "fsq_agent/resources/skills/windows-harness.md", + ROOT / "fsq_agent/resources/skills/macos-harness.md", + ROOT / "fsq_agent/resources/skills/automation-basics.md", + ): + assert path.is_file() def test_ci_verifies_all_runtime_package_resources() -> None: workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") for resource in ( - "fsq_agent/resources/config.android.yaml", - "fsq_agent/resources/config.web.yaml", - "fsq_agent/resources/config.windows.yaml", - "fsq_agent/resources/config.macos.yaml", - "fsq_agent/resources/knowledge/skills/android-harness.md", - "fsq_agent/resources/knowledge/skills/web-harness.md", - "fsq_agent/resources/knowledge/skills/windows-harness.md", - "fsq_agent/resources/knowledge/skills/macos-harness.md", - "fsq_agent/resources/knowledge/skills/automation-basics.md", + "fsq_agent/config/config.android.yaml", + "fsq_agent/config/config.web.yaml", + "fsq_agent/config/config.windows.yaml", + "fsq_agent/config/config.macos.yaml", + "fsq_agent/config/config.example.yaml", + "fsq_agent/resources/skills/android-harness.md", + "fsq_agent/resources/skills/web-harness.md", + "fsq_agent/resources/skills/windows-harness.md", + "fsq_agent/resources/skills/macos-harness.md", + "fsq_agent/resources/skills/automation-basics.md", "fsq_agent/agent/templates/agent_instructions.j2", "fsq_agent/agent/templates/task_input.j2", ): assert resource in workflow for contract in ( - "Verify clean checkout has no generated package resources", + "Verify clean checkout package resource ownership", "uv.lock must not be tracked", "uv build --wheel dist/*.tar.gz --out-dir rebuilt-dist", "Wheel rebuilt from sdist has different runtime package resources", - "Sdist is missing build input", + "Sdist contains retired root resource", ): assert contract in workflow assert "uv sync --extra dev --reinstall-package fsq-agent" in workflow diff --git a/tests/test_skills.py b/tests/test_skills.py index c98130d..6dcdd55 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -40,7 +40,7 @@ def test_skill_loader_fails_missing_required_skill(tmp_path: Path) -> None: def test_repository_android_harness_skill_documents_tool_usage_recovery() -> None: - skill_path = Path(__file__).resolve().parents[1] / "knowledge" / "skills" / "android-harness.md" + skill_path = Path(__file__).resolve().parents[1] / "fsq_agent" / "resources" / "skills" / "android-harness.md" bundles = SkillLoader(skill_path.parent).load([SkillConfig(name="android-harness", path=Path("android-harness.md"), required=True)]) @@ -53,6 +53,8 @@ def test_repository_android_harness_skill_documents_tool_usage_recovery() -> Non assert "Start each Android case with `launch_app`" in bundles[0].instructions assert "End each Android case with `kill_app`" in bundles[0].instructions assert "Use coordinate taps only when current platform evidence" in bundles[0].instructions + assert "ui_snapshot" in bundles[0].instructions + assert "ui_tree" not in bundles[0].instructions assert "textType" in bundles[0].instructions assert "runtimeSecret" in bundles[0].instructions assert '"key": "Back"' in bundles[0].instructions @@ -69,7 +71,7 @@ def test_repository_android_harness_skill_documents_tool_usage_recovery() -> Non def test_repository_web_harness_skill_documents_snapshot_first_guidance() -> None: - skill_path = Path(__file__).resolve().parents[1] / "knowledge" / "skills" / "web-harness.md" + skill_path = Path(__file__).resolve().parents[1] / "fsq_agent" / "resources" / "skills" / "web-harness.md" bundles = SkillLoader(skill_path.parent).load([SkillConfig(name="web-harness", path=Path("web-harness.md"), required=True)]) @@ -85,3 +87,14 @@ def test_repository_web_harness_skill_documents_snapshot_first_guidance() -> Non assert "JavaScript evaluation" not in bundles[0].instructions assert "active tool schema already defines callable names and arguments" in bundles[0].instructions assert "ui_tree" not in bundles[0].instructions + + +def test_repository_windows_harness_skill_uses_exposed_assertions() -> None: + skill_path = Path(__file__).resolve().parents[1] / "fsq_agent" / "resources" / "skills" / "windows-harness.md" + + bundles = SkillLoader(skill_path.parent).load([SkillConfig(name="windows-harness", path=skill_path.name, required=True)]) + + assert "ui_snapshot" in bundles[0].instructions + assert "assert_visible" in bundles[0].instructions + assert "assert_with_ai" in bundles[0].instructions + assert "assert_not_visible" not in bundles[0].instructions From 61aab6b7672cc44ea552fd53229d4897ac7bb5d5 Mon Sep 17 00:00:00 2001 From: zhengda Date: Thu, 3 Sep 2026 16:05:39 +0800 Subject: [PATCH 2/2] Remove retired path verification in ci --- .github/workflows/ci.yml | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e01de2..ad89b08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,18 +75,6 @@ jobs: if missing: raise SystemExit(f"Tracked package resources are missing: {missing}") - retired = ( - Path("config.android.yaml"), - Path("config.web.yaml"), - Path("config.windows.yaml"), - Path("config.macos.yaml"), - Path("config.example.yaml"), - Path("knowledge/skills"), - ) - present = [str(path) for path in retired if path.exists()] - if present: - raise SystemExit(f"Retired root resources are still present: {present}") - - name: Install quality dependencies run: uv sync --extra dev --reinstall-package fsq-agent @@ -157,18 +145,6 @@ jobs: if missing: raise SystemExit(f"Tracked package resources are missing: {missing}") - retired = ( - Path("config.android.yaml"), - Path("config.web.yaml"), - Path("config.windows.yaml"), - Path("config.macos.yaml"), - Path("config.example.yaml"), - Path("knowledge/skills"), - ) - present = [str(path) for path in retired if path.exists()] - if present: - raise SystemExit(f"Retired root resources are still present: {present}") - - name: Install test dependencies run: uv sync --all-extras --reinstall-package fsq-agent