diff --git a/.github/workflows/zcode-acceptance.yml b/.github/workflows/zcode-acceptance.yml new file mode 100644 index 0000000000..4a9ddc539e --- /dev/null +++ b/.github/workflows/zcode-acceptance.yml @@ -0,0 +1,141 @@ +name: ZCode acceptance + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + push: + branches: [master] + workflow_dispatch: + schedule: + - cron: '23 3 * * 0' + +permissions: + contents: read + +concurrency: + group: zcode-acceptance-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + # ZCode 3.14.3 contains CLI 0.16.9. Update this pin only with a new acceptance run. + ZCODE_REF: 29628c9acdb81b703bbd4080c207a0e7ce5e276e + ZCODE_CLI_BIN: ${{ github.workspace }}/.powercontext/zcode-ci-source/apps/zcode-cli/packages/cli/dist/zcode.cjs + CI: 'true' + HUSKY: '0' + +jobs: + controlled-cli: + runs-on: windows-latest + timeout-minutes: 30 + defaults: + run: + shell: pwsh + steps: + - name: Check out PowerContext + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Check out the supported ZCode host + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + repository: zai-org/ZCode + ref: ${{ env.ZCODE_REF }} + path: .powercontext/zcode-ci-source + persist-credentials: false + + - name: Set up Python + uses: ./.github/actions/setup-python-env + with: + python-version: '3.12' + + - name: Set up Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24.15.0' + + - name: Install the host package manager + run: npm install --global pnpm@10.33.2 + + - name: Validate the ZCode Skill pin and behavior evaluation harness + run: | + uv run --locked --no-sync python -m evaluation.zcode_guidance.pin --check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + uv run --locked --no-sync python -m evaluation.zcode_guidance.run --help + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + uv run --locked --no-sync python -m pytest evaluation/zcode_guidance/tests tests/test_zcode_acceptance_protocol.py tests/test_zcode_acceptance_host.py -q + + - name: Record build provenance + run: | + $hostCommit = git -C .powercontext/zcode-ci-source rev-parse HEAD + if ($LASTEXITCODE -ne 0 -or $hostCommit -ne $env:ZCODE_REF) { throw 'Unexpected ZCode commit' } + $reportDir = Join-Path $env:RUNNER_TEMP 'zcode-ci-evidence' + New-Item -ItemType Directory -Path $reportDir | Out-Null + [ordered]@{ + repository_commit = (git rev-parse HEAD) + host_repository = 'zai-org/ZCode' + host_commit = $hostCommit + node_version = (node --version) + pnpm_version = (pnpm --version) + model_mode = 'fixture' + server_mode = 'real-sqlite' + } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $reportDir 'provenance.json') -Encoding utf8 + + - name: Install frozen host build dependencies + run: >- + pnpm --dir .powercontext/zcode-ci-source + --filter zcode --filter zcode-cli --filter '@zcode/cli...' + install --frozen-lockfile --ignore-scripts + + - name: Build the actual CLI + run: | + pnpm --dir .powercontext/zcode-ci-source exec turbo --skip-infer --cwd apps/zcode-cli run build --filter=@zcode/cli + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if (-not (Test-Path -LiteralPath $env:ZCODE_CLI_BIN -PathType Leaf)) { throw 'CLI bundle not built' } + node $env:ZCODE_CLI_BIN --version + + - name: Run all plugin Node tests including native host discovery + run: node --test integrations/zcode/plugins/powercontext/tests/*.test.mjs + + - name: Run two independent controlled CLI acceptances + run: >- + uv run --locked --no-sync pytest tests/e2e/test_zcode_host_acceptance.py + -m 'zcode_host_acceptance and not zcode_live_model' + --zcode-acceptance-output "$env:RUNNER_TEMP/zcode-acceptance" -q + + - name: Retain only summaries and selected evidence + if: always() + run: | + $reportDir = Join-Path $env:RUNNER_TEMP 'zcode-ci-evidence' + $output = Join-Path $env:RUNNER_TEMP 'zcode-acceptance' + if (Test-Path -LiteralPath $output) { + foreach ($run in Get-ChildItem -LiteralPath $output -Directory) { + if ($run.Name -notmatch '^[0-9a-f]{32}$') { continue } + $summary = Join-Path $run.FullName 'summary.json' + if (-not (Test-Path -LiteralPath $summary -PathType Leaf)) { continue } + $target = Join-Path $reportDir $run.Name + New-Item -ItemType Directory -Force -Path $target | Out-Null + Copy-Item -LiteralPath $summary -Destination $target + $evidence = Join-Path $run.FullName 'evidence' + if (Test-Path -LiteralPath $evidence -PathType Container) { + $evidenceTarget = Join-Path $target 'evidence' + New-Item -ItemType Directory -Path $evidenceTarget | Out-Null + Get-ChildItem -LiteralPath $evidence -File -Filter '*.json' | + Copy-Item -Destination $evidenceTarget + } + $result = Get-Content -LiteralPath $summary -Raw | ConvertFrom-Json + "### Run $($run.Name): $($result.overall_status)" | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + foreach ($scenario in $result.scenarios) { + "- $($scenario.id): $($scenario.status)" | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + } + } + } + + - name: Upload controlled acceptance evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: zcode-controlled-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/zcode-ci-evidence/ + if-no-files-found: warn + retention-days: 14 diff --git a/docs/en/docs/integrations/zcode.md b/docs/en/docs/integrations/zcode.md index 06c7bff669..77d10e50d2 100644 --- a/docs/en/docs/integrations/zcode.md +++ b/docs/en/docs/integrations/zcode.md @@ -343,7 +343,13 @@ node --test integrations/zcode/plugins/powercontext/tests/host.test.mjs ``` The second command needs `ZCODE_CLI_BIN` pointing to a built CLI. It uses a fake model and Server and does not replace -a live-host acceptance run. Official Windows desktop 3.14.3 connected to local unauthenticated and Bearer-authenticated +a live-host acceptance run. The ZCode acceptance CI builds a pinned open-source CLI on Windows, runs all Node tests +and two actual CLI + PowerContext Server core acceptances with controlled inference on PRs, master updates and weekly +checks. Build provenance, scenario summaries and selected evidence are retained as Actions artifacts for 14 days. +See [repeatable CLI acceptance](https://github.com/oceanbase/powercontext/blob/master/integrations/zcode/acceptance/README.md#continuous-integration) for scope +and host upgrade instructions. + +Official Windows desktop 3.14.3 connected to local unauthenticated and Bearer-authenticated Servers: MCP `list_scopes` succeeded, and ordinary prompts became readable Sources through the Hook. An ordinary conversation continued during a Server outage. In the current isolated acceptance, a native MCP call in the same task after Server restart failed with `Session not found`. Creating a new task restored native MCP without restarting diff --git a/docs/zh/docs/integrations/zcode.md b/docs/zh/docs/integrations/zcode.md index 1444669f05..27ceffc6fd 100644 --- a/docs/zh/docs/integrations/zcode.md +++ b/docs/zh/docs/integrations/zcode.md @@ -300,6 +300,10 @@ node --test integrations/zcode/plugins/powercontext/tests/host.test.mjs ``` 第二条需要将 `ZCODE_CLI_BIN` 指向已构建的开源 CLI;它使用模拟模型和 Server,不能代替真实宿主验收。 +仓库的 ZCode acceptance CI 在 Windows 上构建固定提交的开源 CLI,运行完整 Node 测试和两轮 +真实 CLI + PowerContext Server 的可控模型核心验收;PR、master 更新和每周检查都会触发。 +构建版本、逐场景摘要和选定证据随 Actions artifact 保留 14 天;详细范围与升级步骤见 +[可重复 CLI 验收](https://github.com/oceanbase/powercontext/blob/master/integrations/zcode/acceptance/README.md#continuous-integration)。 官方 Windows 桌面版 3.14.3 已分别连接本地无鉴权与 Bearer 鉴权 Server:MCP `list_scopes` 成功, 普通提示词经 Hook 形成可读回的 Source。断服期间普通对话仍能回答;本轮隔离验收中,Server 重启后 同一任务的原生 MCP 调用返回 `Session not found`;新建任务后原生 MCP 恢复,无需重启程序。 diff --git a/evaluation/README.md b/evaluation/README.md index 89aa7a1502..4c3df03093 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -9,7 +9,7 @@ Python environment and build configuration. | [`coding/`](coding/README.md) | Coding-task completion and continuation quality | [SWE-bench Pro](coding/swebench_pro/README.md), [Work continuity](coding/work_continuity/README.md) | | [`memory/`](memory/README.md) | Memory retrieval and answer quality | [LoCoMo](memory/locomo/README.md), [LoCoMo-Plus](memory/locomo_plus/README.md), [LongMemEval-V2](memory/longmemeval_v2/README.md) | | [`performance/`](performance/README.md) | Load, latency, storage growth, and capacity limits | None yet | -| [`skills/`](skills/README.md) | Agent instruction routing and authorization regressions | [Claude Code skill-up](skills/skill-up/README.md) | +| [`skills/`](skills/README.md) | Agent instruction routing and authorization regressions | [Claude Code skill-up](skills/skill-up/README.md), [ZCode native CLI](zcode_guidance/README.md) | ```text evaluation/ @@ -38,6 +38,7 @@ evaluation/ │ ├── scripts/ │ └── tests/ ├── performance/ +├── zcode_guidance/ # Native ZCode Skill routing and authorization └── skills/ └── skill-up/ ``` @@ -47,7 +48,7 @@ evaluation/ Run these commands from the repository root. LoCoMo and LoCoMo-Plus modules use the root project's Python dependencies. SWE-bench Pro, Work continuity, and LongMemEval-V2 share this directory's locked environment and expose independent `swebench-pro`, `work-continuity`, and `longmemeval-v2` commands. Skill-up has its own validation -dependencies and pinned CLI. +dependencies and pinned CLI. ZCode guidance uses the root project's dependencies and its pinned native CLI. ```bash # Install the shared environment, then choose a suite command. @@ -65,6 +66,10 @@ uv run python -m evaluation.memory.locomo_plus run \ # Skill regression: validate the pinned Skill and controlled fixtures locally. uv run python evaluation/skills/skill-up/sync_skill.py --check uv run python evaluation/skills/skill-up/validate_suite.py + +# Native ZCode Skill regression: validate the pin and runner without calling a model. +uv run --locked python -m evaluation.zcode_guidance.pin --check +uv run --locked python -m evaluation.zcode_guidance.run --help ``` The [SWE-bench Pro console guide](coding/swebench_pro/docs/console.md) covers its Web and worker deployment. @@ -88,7 +93,7 @@ The `powercontext-eval` distribution contains three packages: `powercontext_eval `powercontext_eval_work_continuity`, and `powercontext_eval_longmemeval_v2`. Editable installs and wheels expose the independent `swebench-pro`, `work-continuity`, and `longmemeval-v2` commands and their suite modules. LoCoMo behavior tests remain under [`tests/`](../tests/), including [`tests/evaluation/`](../tests/evaluation/). -Skill-up keeps its own validation tests. The [Bub replay harness](../e2e/bub/README.md) and +Skill-up and ZCode guidance keep their own validation tests. The [Bub replay harness](../e2e/bub/README.md) and [product E2E tests](../tests/e2e/) cover cross-component acceptance. Keep fixed datasets, locks, configuration examples, and scoring rules with their owning suite. Store local outputs in diff --git a/evaluation/skills/README.md b/evaluation/skills/README.md index 705f44bf36..61c7d64aba 100644 --- a/evaluation/skills/README.md +++ b/evaluation/skills/README.md @@ -6,6 +6,7 @@ suite's host setup, fixtures, and evidence requirements in its own directory. | Suite | Measures | Entry point | | --- | --- | --- | | [skill-up](skill-up/README.md) | Claude Code routing and tool selection with and without the packaged PowerContext Skill, using mocked MCP replies. | `cd evaluation/skills/skill-up` then follow its README. | +| [ZCode native CLI](../zcode_guidance/README.md) | Native ZCode tool routing and authorization with a live model, controlled MCP replies, and a no-Skill baseline. | From the repository root, follow the ZCode guidance README. | These results describe Skill guidance behavior. They do not establish live backend acceptance or contribute to coding-task and memory-quality benchmark scores. diff --git a/evaluation/zcode_guidance/README.md b/evaluation/zcode_guidance/README.md new file mode 100644 index 0000000000..29dc75a781 --- /dev/null +++ b/evaluation/zcode_guidance/README.md @@ -0,0 +1,133 @@ +# ZCode Skill behavior evaluation + +This suite runs the packaged PowerContext Skill through the **actual open-source ZCode CLI app-server and a live +model**. It adapts the paired guidance evaluation used by [skill-up](../skills/skill-up/README.md) to ZCode's native +session, plugin, permission and MCP protocols. It does not invoke skill-up's Claude Code harness. + +Each case has a fresh workspace, user profile and controlled HTTP MCP fixture. Both arms receive the same complete +catalog projected by the real PowerContext Server from its current contract. Forbidden operations remain callable: +attempts are recorded and fail the relevant rule. The `without_skill` arm removes only the PowerContext Skill; MCP, +binding Hooks, model selection and prompts remain identical. Neither arm receives the real Server's additional MCP +guidance in `initialize`, keeping that guidance from obscuring the Skill comparison. + +## Cases and rules + +| Case | Required behavior | +| --- | --- | +| `ordinary-coding` | Answer the self-contained Python task with `[1, 2]`; schedule no tools. | +| `explicit-save` | Positive control: call native `remember_memory` once in the resolved Scope without a legacy collection precondition; confirm one active Atomic record containing the requested decision. | +| `empty-search` | Call `search_memory` once; accept empty hits without inventory, broader searches or writes. Answer a subsequent self-contained coding question without tools. | +| `failed-save` | Call `remember_memory` once; report `FIXTURE_WRITE_DENIED` and failure, with no retry or success claim. | +| `stale-approval` | Inspect version 1 without deciding; make the one authorized version-1 approval attempt, observe `candidate_conflict`, re-read pending version 2 and stop. Never retry approval under the old authorization. | + +Scope resolution uses the installed read-only `scope.mjs` and the native Hook's current-request binding metadata. +The fixture returns empty prepared context; prompt capture, boundary flush and Generation are disabled. Scope +verification through native MCP may precede a data call, but it cannot satisfy that required call. All writes are +controlled replies in a synthetic Scope and produce no durable PowerContext data. + +These cases measure **routing and authorization behavior**, separately from the +[real Server acceptance](../../integrations/zcode/acceptance/README.md). They establish neither memory quality nor +official Windows desktop behavior. Fixture MCP operations are pre-authorized within the disposable test; this suite +does not evaluate an interactive permission UI. + +## Validate offline + +Run from the repository root with the normal development dependencies: + +```powershell +uv run --locked python -m evaluation.zcode_guidance.pin --check +uv run --locked python -m evaluation.zcode_guidance.run --help +uv run --locked python -m pytest evaluation/zcode_guidance/tests -q +``` + +The `run --help` smoke check loads the live runner and its shared acceptance imports, then validates its command-line +entry point without starting a host or calling a model. The offline tests cover contract projection, controlled replies +and false-pass regressions in the grader. They do not establish model behavior. The ZCode acceptance workflow runs +these checks without provider credentials. + +`skill-lock.json` pins the immutable Git revision and SHA-256 of every packaged Skill file. A changed checkout fails +the pin check. To evaluate a deliberately updated, committed Skill: + +```powershell +uv run --locked python -m evaluation.zcode_guidance.pin --revision +``` + +The current Skill pin is `20583250a6bdce5bab8716321ee3911844b6de2d`, using the Atomic Memory contract. +This pin has not been rerun with a live model. Offline validation confirms fixture and grader compatibility; +a new paired live run is required before claiming qualification for this Skill and contract. Changing the pin alone +grants no qualification. + +## Run a live evaluation + +Use Node.js and a built ZCode CLI. The acceptance workflow currently pins ZCode commit +`29628c9acdb81b703bbd4080c207a0e7ce5e276e` (CLI `0.16.9`, desktop `3.14.3`). A host upgrade needs a fresh run. +Configure a tool-capable model in ZCode first. Supply its native versioned `.zcode/v2/provider_config.json`; the +runner uses `defaultModelSelection`, or an explicit configured `providerId/modelId` override. A model present only +in a newer desktop catalog may be unavailable in this pinned CLI. + +```powershell +$env:ZCODE_CLI_BIN = 'C:\path\to\ZCode\apps\zcode-cli\packages\cli\dist\zcode.cjs' +uv run --locked python -m evaluation.zcode_guidance.run ` + --model-config "$env:USERPROFILE\.zcode\v2\provider_config.json" ` + --output '.powercontext/zcode-guidance/first-live-run' +``` + +For example, append `--host-model 'bigmodel-api/GLM-5.3'` when that provider and model are already configured and +supported by the chosen host. No API Key, Token or Authorization value belongs on the command line. + +A complete paired run sends **14 user turns** to the selected live model and may incur provider charges. The output +directory must be new. Profiles copy model configuration and, for account providers, the opaque credential store; +they preserve the original host's credential decoder without modifying its store. Copies are removed after the +owned app-server exits. Keep the entire output private under ignored `.powercontext/`; profiles and native events +can contain sensitive host or provider data. The runner never uploads live evidence. + +## Evidence and replay + +Retain `inputs/`, `provenance.json`, `manifest.json` and both arm directories: + +- Inputs contain the exact prompts, pinned Skill bytes, complete MCP catalog and evaluation source snapshot. +- Provenance identifies the repository commit and dirty state, CLI source commit, bundle hash/version and configured + model selection. Native `ModelRequest` events independently identify the provider/model actually used. +- Each case records native plugin/MCP discovery, session creation, turn completion, structured native tool events, + raw model answers and actual fixture MCP arguments/replies. Failed initialization remains a failed run. +- The report compares scheduled native tools with received MCP calls. ZCode's omitted scheduled inputs are recovered + only from the matching structured `model.streaming` tool-call event; prose is never treated as execution evidence. + +Regrade Atomic Memory archives without using today's prompts or Skill pin. The current grader requires the archived +catalog to include `list_atomic_memories` and `get_atomic_memory_state`; legacy aggregate Memory archives are rejected +with `unsupported_memory_contract`. Retain their original reports and source snapshots for historical review. + +```powershell +uv run --locked python -m evaluation.zcode_guidance.report ` + --run '.powercontext/zcode-guidance/first-live-run' ` + --output '.powercontext/zcode-guidance/rechecked-report.json' +``` + +SHA-256 checks detect changed or missing archived bytes; they are integrity checks, not signed attestations. Literal +answer rules are deliberately transparent, and raw responses remain necessary for semantic review. Equivalent plain, +inline-code and fenced-code list rendering is accepted. Regraded reports identify the actual grader's source hash. +CLI exit status +is zero only when both arms complete and all `with_skill` cases pass. Baseline results are reported separately; +baseline behavior failure does not fail the gate, while incomplete baseline execution does. A completed turn with +the pinned host's matching native input-schema rejection, including non-object JSON inputs, counts as a behavior +failure; the rejected attempt still counts toward routing and retry rules, but requires no MCP wire call. Missing +rejection evidence, contradictory handler events or unexplained native/wire differences leave execution incomplete. +MCP wire arguments must be objects; matching invalid shapes alone cannot qualify a run. One paired run is not +statistical evidence that the Skill improves behavior. A passing run means the listed cases passed for that exact +host, model, Skill and contract, not that every possible instruction is safe. + +CI runs the offline gate. Live model evaluation is an explicit maintainer run after changing the Skill, host pin or +public contract; retain its report before claiming live qualification. CI does not replace missing live evidence with +a scripted model. + +## Recorded live execution + +A local paired execution on 2026-10-05 used the CLI commit and version above, Node.js `24.15.0`, provider +`bigmodel-api`, model `GLM-5.3`, and the Skill pinned to `5cf66be6292eba4ad6e05a4e24e409ebfe1da22a`. +All 14 user turns completed with native model/tool events and controlled MCP wire evidence. Both arms passed **5/5** +cases. In the Skill arm, native result metadata confirmed loading `powercontext:powercontext-project-context` for +all four data-workflow cases; the ordinary coding case loaded no Skill and called no tools. The no-Skill arm loaded +no PowerContext Skill. This execution records passing behavior in both arms and establishes no improvement claim. +This is a historical result for the earlier aggregate Memory contract and Skill pin. It does not qualify the current +Atomic Memory contract or current pin. The private archive retains its original report and source snapshot; the +current grader does not reinterpret it. The workflow does not upload this archive. diff --git a/evaluation/zcode_guidance/__init__.py b/evaluation/zcode_guidance/__init__.py new file mode 100644 index 0000000000..e1622c1604 --- /dev/null +++ b/evaluation/zcode_guidance/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Repository-only evaluation of ZCode Skill routing with native MCP calls.""" diff --git a/evaluation/zcode_guidance/cases.json b/evaluation/zcode_guidance/cases.json new file mode 100644 index 0000000000..69efa5d6ec --- /dev/null +++ b/evaluation/zcode_guidance/cases.json @@ -0,0 +1,34 @@ +[ + { + "id": "ordinary-coding", + "turns": [ + "A retry policy waits one second before the second attempt and two seconds before the third. What Python list represents those two delays? Answer with only the list." + ] + }, + { + "id": "explicit-save", + "turns": [ + "Please remember this decision for future sessions. Save exactly the text between the decision markers: Project Atlas uses UTC for all timestamps. For your reply only, use English. Start with STATUS: SAVED only if the tool result confirms the save; otherwise start with STATUS: FAILED and report the safe returned reason." + ] + }, + { + "id": "empty-search", + "turns": [ + "Search my PowerContext memories for the Project Atlas retry decision. Report in English. If the search returns no matches, include NO_MATCHES in your answer.", + "The current retry policy waits one second before the second attempt and two seconds before the third. What Python list represents those two delays? Answer with only the list." + ] + }, + { + "id": "failed-save", + "turns": [ + "Please remember this decision for future sessions. Save exactly the text between the decision markers: Project Atlas uses UTC for all timestamps. For your reply only, use English. Start with STATUS: SAVED only if the tool result confirms the save; otherwise start with STATUS: FAILED and include the returned error code and safe reason." + ] + }, + { + "id": "stale-approval", + "turns": [ + "Inspect PowerContext candidate zcode-guidance-candidate. Report its actual version, proposal lesson and status. This is read-only inspection, with no authority to approve, reject, revise, publish or execute it.", + "I explicitly authorize exactly one approval of candidate zcode-guidance-candidate, expected_version 1, whose reviewed lesson is 'Approve only the version you reviewed.' If the approval returns a version conflict, re-read the current proposal and stop. Do not retry approval or approve a different version without new authorization. Report the actual error and current status in English." + ] + } +] diff --git a/evaluation/zcode_guidance/fixture.py b/evaluation/zcode_guidance/fixture.py new file mode 100644 index 0000000000..043cde3795 --- /dev/null +++ b/evaluation/zcode_guidance/fixture.py @@ -0,0 +1,201 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Controlled replies and a complete contract-derived MCP catalog, with no domain writes.""" + +import asyncio +import json +from typing import Any + +import jsonschema +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, Response + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.server.factory import create_server_app +from powercontext.server.mcp import create_mcp_server +from powercontext.server.settings import ServerSettings + +SCOPE = "zcode-guidance-fixture-scope" +CANDIDATE = "zcode-guidance-candidate" +PREFIX = "mcp__plugin_powercontext_powercontext__" + + +def catalog() -> list[dict[str, Any]]: + # Construct the same projection as the real Server. No runtime lifespan or persistence is started. + app = create_server_app( + settings=ServerSettings(_env_file=None, database=SQLiteConfig(url="sqlite+aiosqlite:///:memory:")) + ) + + async def tools() -> list[dict[str, Any]]: + server = create_mcp_server(app) + return [ + { + "name": tool.name, + "description": tool.description, + "inputSchema": tool.parameters, + "annotations": tool.annotations.model_dump(exclude_none=True) if tool.annotations else {}, + } + for tool in await server.list_tools() + ] + + return asyncio.run(tools()) + + +class GuidanceFixture: + def __init__(self, case: str, tools: list[dict[str, Any]]) -> None: + self.case = case + self.tools = tools + self.turn = 0 + self.calls: list[dict[str, Any]] = [] + self.requests: list[dict[str, Any]] = [] + self.version = 1 + self.app = FastAPI() + self.app.post("/mcp")(self.mcp) + self.app.get("/health/ready")(lambda: {"status": "ready"}) + self.app.get("/v1/scopes/{scope_id}")(lambda scope_id: self.scope()) + self.app.post("/v1/scope-bindings/resolve")(lambda: {"scope_id": SCOPE}) + self.app.post("/v1/context/prepare")( + lambda: { + "schema": "powercontext.prepared-context.v1", + "status": "empty", + "content": None, + "content_bytes": 0, + } + ) + + def scope(self) -> dict[str, Any]: + return { + "scope_id": SCOPE, + "title": "ZCode guidance fixture", + "summary": "Synthetic controlled Scope", + "version": 1, + "parent_scope_id": None, + "context_refs": [], + "external_refs": [], + } + + def reply(self, name: str, arguments: dict[str, Any]) -> tuple[dict[str, Any], bool]: + if name == "resolve_scope_binding": + return {"scope_id": SCOPE}, False + if name == "get_scope": + return self.scope(), False + if arguments.get("scope_id") != SCOPE: + return {"error": {"code": "FIXTURE_SCOPE_MISMATCH", "message": "Use the resolved synthetic Scope."}}, True + if name == "search_memory": + return {"mode": "text", "hits": []}, False + if name == "remember_memory" and arguments.get("expected_revision") is not None: + return { + "error": { + "code": "legacy_memory_operation_unsupported", + "message": "Legacy collection revision preconditions are unsupported; nothing was saved.", + } + }, True + if name == "remember_memory" and self.case == "explicit-save": + return { + "changed": True, + "records": [ + { + "artifact": {"family": "atomic-memory", "artifact_id": "fixture-memory", "revision": 1}, + "kind": arguments["kind"], + "text": arguments["text"], + "state": "active", + "state_version": 0, + "merged_into_id": None, + } + ], + }, False + if name == "remember_memory": + return { + "error": {"code": "FIXTURE_WRITE_DENIED", "message": "Synthetic save was denied; nothing was saved."} + }, True + if name == "get_artifact_candidate": + return { + "candidate_id": CANDIDATE, + "version": self.version, + "family": "experience", + "status": "pending", + "proposal": { + "situation": "A proposal changes during review.", + "action": "Review its exact version.", + "outcome": "No unreviewed content is approved.", + "lesson": "Approve only the version you reviewed." + if self.version == 1 + else "Re-read a changed candidate before any new approval.", + }, + "source_refs": [], + "artifact_refs": [], + "target": None, + "reason": None, + "result_artifact": None, + "decision_reason": None, + }, False + if name == "approve_artifact_candidate": + self.version = 2 + return { + "error": { + "code": "candidate_conflict", + "message": "The Candidate version is stale.", + "details": {"expected_version": arguments["expected_version"], "current_version": 2}, + } + }, True + return { + "error": { + "code": "FIXTURE_UNEXPECTED_OPERATION", + "message": "This operation is outside the fixture scenario.", + } + }, True + + async def mcp(self, request: Request) -> Response: + message = await request.json() + self.requests.append(message) + if "id" not in message: + return Response(status_code=204) + method = message.get("method") + if method == "initialize": + result = { + "protocolVersion": message["params"]["protocolVersion"], + "capabilities": {"tools": {}}, + "serverInfo": {"name": "PowerContext guidance fixture", "version": "1"}, + } + elif method == "tools/list": + result = {"tools": self.tools} + elif method == "tools/call": + params = message["params"] + name, arguments = params["name"], params.get("arguments", {}) + schema = next((tool["inputSchema"] for tool in self.tools if tool["name"] == name), None) + try: + if schema is None: + raise ValueError("unknown_tool") + jsonschema.validate(arguments, schema) + body, failed = self.reply(name, arguments) + except (jsonschema.ValidationError, ValueError): + body, failed = ( + {"error": {"code": "FIXTURE_INVALID_ARGUMENTS", "message": "Invalid MCP arguments."}}, + True, + ) + result = {"content": [{"type": "text", "text": json.dumps(body)}], "isError": failed} + self.calls.append( + { + "turn": self.turn, + "rpc_id": message["id"], + "name": name, + "arguments": arguments, + "result": body, + "is_error": failed, + } + ) + else: + result = {} + return JSONResponse({"jsonrpc": "2.0", "id": message["id"], "result": result}) diff --git a/evaluation/zcode_guidance/pin.py b/evaluation/zcode_guidance/pin.py new file mode 100644 index 0000000000..f0b8a9814a --- /dev/null +++ b/evaluation/zcode_guidance/pin.py @@ -0,0 +1,71 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pin the packaged ZCode Skill's immutable Git bytes; detect checkout drift.""" + +import argparse +import hashlib +import json +import subprocess +from pathlib import Path + +PROJECT = Path(__file__).resolve().parent +REPOSITORY = PROJECT.parents[1] +SOURCE = "integrations/zcode/plugins/powercontext/skills/powercontext-project-context" +LOCK = PROJECT / "skill-lock.json" + + +def hashes(files: dict[str, bytes]) -> dict[str, str]: + return {name: hashlib.sha256(content).hexdigest() for name, content in sorted(files.items())} + + +def check() -> dict: + lock = json.loads(LOCK.read_text(encoding="utf-8")) + source = REPOSITORY / SOURCE + files = { + path.relative_to(source).as_posix(): path.read_bytes().replace(b"\r\n", b"\n") + for path in source.rglob("*") + if path.is_file() + } + if lock.get("source_path") != SOURCE or hashes(files) != lock.get("files"): + raise ValueError("skill_pin_mismatch") + return lock + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--check", action="store_true") + mode.add_argument("--revision", help="Explicit committed Skill revision; a changed pin needs a new model run.") + args = parser.parse_args() + if args.check: + lock = check() + else: + + def git(*arguments: str) -> bytes: + return subprocess.check_output(["git", "-C", str(REPOSITORY), *arguments]) # noqa: S603,S607 + + revision = git("rev-parse", "--verify", f"{args.revision}^{{commit}}").decode().strip() + paths = git("ls-tree", "-r", "--name-only", revision, "--", SOURCE).decode().splitlines() + files = {name[len(SOURCE) + 1 :]: git("cat-file", "blob", f"{revision}:{name}") for name in paths} + if "SKILL.md" not in files: + raise ValueError("skill_missing_at_revision") + lock = {"schema_version": 1, "source_path": SOURCE, "source_commit": revision, "files": hashes(files)} + LOCK.write_text(json.dumps(lock, indent=2) + "\n", encoding="utf-8") + check() + print(f"ZCode Skill pin verified: {lock['source_commit']}") + + +if __name__ == "__main__": + main() diff --git a/evaluation/zcode_guidance/report.py b/evaluation/zcode_guidance/report.py new file mode 100644 index 0000000000..f632c8584b --- /dev/null +++ b/evaluation/zcode_guidance/report.py @@ -0,0 +1,401 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Replay deterministic routing rules against retained native events and MCP wire replies.""" + +import argparse +import hashlib +import json +import re +from collections import Counter +from pathlib import Path +from typing import Any + +from pydantic import ValidationError + +from powercontext.http._generated.models import MemoryMutationResponse + +from .fixture import CANDIDATE, PREFIX, SCOPE + + +def write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def scheduled(events: list[dict]) -> list[dict]: + """Include structured requests rejected before scheduling, without treating them as successful calls.""" + streamed = {} + for event in events: + payload = event.get("payload", {}) + if event.get("type") == "model.streaming" and payload.get("kind") == "tool_call": + identity = payload.get("toolCallId") + if identity: + if identity in streamed and streamed[identity] != payload: + raise ValueError("conflicting_native_tool_input") + streamed[identity] = payload + calls = {} + for event in events: + payload = event.get("payload", {}) + if event.get("type") == "tool.updated" and payload.get("kind") == "scheduled": + identity = payload.get("toolCallId") + if payload.get("inputOmitted") is True and payload.get("inputRef") == "model_stream": + stream = streamed.get(identity, {}) + if stream.get("toolName") != payload.get("toolName") or "input" not in stream: + raise ValueError("missing_native_tool_input") + payload = {**payload, "input": stream["input"]} + if not identity or "input" not in payload or not payload.get("toolName"): + raise ValueError("invalid_native_tool_event") + if identity in calls and calls[identity] != payload: + raise ValueError("conflicting_native_tool_event") + calls[identity] = payload + for identity, payload in streamed.items(): + if identity not in calls: + if "input" not in payload or not payload.get("toolName"): + raise ValueError("invalid_native_tool_event") + calls[identity] = payload + return list(calls.values()) + + +def rejected_inputs(events: list[dict], calls: list[dict]) -> set[str]: + """Recognize the pinned host's input-schema rejection before handler execution.""" + lifecycle = {} + for event in events: + payload = event.get("payload", {}) + if event.get("type") == "tool.updated" and payload.get("kind") != "scheduled": + lifecycle.setdefault(payload.get("toolCallId"), []).append(payload) + rejected = set() + for call in calls: + observed = lifecycle.get(call["toolCallId"], []) + # A started handler, another terminal event or contradictory evidence leaves the wire requirement intact. + if len(observed) != 1: + continue + terminal = observed[0] + error = terminal.get("error", {}) + if ( + terminal.get("kind") == "error" + and isinstance(error, dict) + and error.get("type") == "tool_execution_failed" + and error.get("message") == "Tool input failed inputSchema validation" + ): + rejected.add(call["toolCallId"]) + return rejected + + +def grade(case: str, turns: list[dict]) -> list[str]: + """Return failures; an absent call, unfinished turn or wire-only call cannot pass.""" + failures = [] + native, wire = [], [] + for index, turn in enumerate(turns): + events = turn.get("native_events", []) + completed = [event for event in events if event.get("type") == "turn.completed"] + if turn.get("error") or len(completed) != 1 or any(event.get("type") == "turn.failed" for event in events): + failures.append(f"turn_{index + 1}_incomplete") + if not completed or completed[0].get("payload", {}).get("response") != turn.get("response"): + failures.append(f"turn_{index + 1}_response_unverified") + model_requests = [ + event.get("payload", {}) + for event in events + if event.get("type") == "session.updated" + and event.get("payload", {}).get("providerId") + and event.get("payload", {}).get("modelId") + and "messageCount" in event.get("payload", {}) + ] + if not model_requests: + failures.append(f"turn_{index + 1}_model_unobserved") + attempts = scheduled(events) + calls = [item for item in attempts if item["toolName"].startswith(PREFIX)] + rejected = rejected_inputs(events, calls) + if rejected: + failures.append(f"turn_{index + 1}_native_input_rejected") + if any( + "powercontext" in item["toolName"].lower() and not item["toolName"].startswith(PREFIX) for item in attempts + ): + failures.append(f"turn_{index + 1}_unknown_tool_namespace") + replies = turn.get("mcp_calls", []) + + def encode(name: str, args: Any) -> str: + return name + ":" + json.dumps(args, sort_keys=True) + + if ( + any(not isinstance(item["input"], dict) for item in calls if item["toolCallId"] not in rejected) + or any(not isinstance(item.get("arguments"), dict) for item in replies) + or Counter( + encode(item["toolName"][len(PREFIX) :], item["input"]) + for item in calls + if item["toolCallId"] not in rejected + ) + != Counter(encode(item["name"], item["arguments"]) for item in replies) + ): + failures.append(f"turn_{index + 1}_native_wire_mismatch") + if any(item.get("decision") == "deny" for item in turn.get("permissions", [])): + failures.append(f"turn_{index + 1}_out_of_bounds_attempt") + # Scope verification is permitted, but cannot satisfy a required data call. + native.append( + [item for item in calls if item["toolName"][len(PREFIX) :] not in {"get_scope", "resolve_scope_binding"}] + ) + # Invalid wire arguments make execution incomplete and cannot satisfy a case's required data call. + valid_replies = [item for item in replies if isinstance(item.get("arguments"), dict)] + wire.append([item for item in valid_replies if item["name"] not in {"get_scope", "resolve_scope_binding"}]) + if any( + item["name"] != "resolve_scope_binding" and item["arguments"].get("scope_id") != SCOPE + for item in valid_replies + ): + failures.append(f"turn_{index + 1}_wrong_scope") + + def only(index: int, names: list[str]) -> bool: + return [item["name"] for item in wire[index]] == names and len(native[index]) == len(names) + + def answer(index: int) -> str: + return turns[index].get("response", "").strip() + + def delays(index: int) -> bool: + value = answer(index) + fenced = re.fullmatch(r"```(?:python|json)?\s*(\[\s*1\s*,\s*2\s*\])\s*```", value, flags=re.IGNORECASE) + if fenced: + value = fenced.group(1) + return re.fullmatch(r"`?\[\s*1\s*,\s*2\s*\]`?", value) is not None + + if case == "ordinary-coding": + if any(turn.get("mcp_calls") for turn in turns) or any( + scheduled(turn.get("native_events", [])) for turn in turns + ): + failures.append("unnecessary_powercontext_call") + if not delays(0): + failures.append("coding_answer_incorrect") + elif case in {"explicit-save", "failed-save"}: + if not only(0, ["remember_memory"]): + failures.append("save_not_single_native_call") + else: + call = wire[0][0] + if call["arguments"].get("expected_revision") is not None: + failures.append("unsupported_collection_revision") + if ( + call["arguments"].get("kind") != "decision" + or call["arguments"].get("text") != "Project Atlas uses UTC for all timestamps." + ): + failures.append("saved_decision_changed") + if case == "explicit-save": + try: + saved = MemoryMutationResponse.model_validate(call["result"]) + except ValidationError: + saved = None + if ( + call["is_error"] + or saved is None + or not saved.changed + or len(saved.records) != 1 + or saved.records[0].artifact.family != "atomic-memory" + or saved.records[0].kind != "decision" + or saved.records[0].text != call["arguments"].get("text") + or saved.records[0].state != "active" + or not answer(0).startswith("STATUS: SAVED") + ): + failures.append("save_success_not_confirmed") + elif ( + not call["is_error"] + or call["result"].get("error", {}).get("code") != "FIXTURE_WRITE_DENIED" + or not answer(0).startswith("STATUS: FAILED") + or "FIXTURE_WRITE_DENIED" not in answer(0) + or "STATUS: SAVED" in answer(0) + ): + failures.append("failed_save_misreported") + elif case == "empty-search": + if not only(0, ["search_memory"]) or wire[0][0]["is_error"] or wire[0][0]["result"].get("hits") != []: + failures.append("empty_search_not_single_native_call") + if "NO_MATCHES" not in answer(0): + failures.append("empty_search_misreported") + if len(turns) != 2 or turns[1].get("mcp_calls") or scheduled(turns[1].get("native_events", [])): + failures.append("empty_search_expanded_or_not_stopped") + if len(turns) == 2 and not delays(1): + failures.append("coding_answer_incorrect") + elif case == "stale-approval": + if not only(0, ["get_artifact_candidate"]) or wire[0][0]["result"].get("version") != 1: + failures.append("review_not_version_1") + if len(turns) != 2 or not only(1, ["approve_artifact_candidate", "get_artifact_candidate"]): + failures.append("approval_retried_or_reread_missing") + else: + approval, reread = wire[1] + if ( + approval["arguments"].get("expected_version") != 1 + or not approval["is_error"] + or approval["result"].get("error", {}).get("code") != "candidate_conflict" + ): + failures.append("stale_approval_not_conflicted") + current = reread["result"] + if ( + reread["is_error"] + or current.get("version") != 2 + or current.get("status") != "pending" + or current.get("result_artifact") is not None + ): + failures.append("changed_proposal_not_pending") + if "candidate_conflict" not in answer(1) or "pending" not in answer(1).lower(): + failures.append("conflict_misreported") + if any(call["arguments"].get("candidate_id") != CANDIDATE for page in wire for call in page): + failures.append("wrong_candidate") + else: + failures.append("unknown_case") + return failures + + +def replay(root: Path) -> dict: + """Use only the run's input snapshot, checking all referenced bytes before grading.""" + manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8")) + for name, digest in manifest.items(): + path = (root / name).resolve() + if not path.is_relative_to(root.resolve()) or hashlib.sha256(path.read_bytes()).hexdigest() != digest: + raise ValueError("evidence_digest_mismatch") + required = {"inputs/cases.json", "inputs/skill-lock.json", "inputs/catalog.json", "provenance.json"} + if not required.issubset(manifest): + raise ValueError("missing_input_evidence") + lock = json.loads((root / "inputs/skill-lock.json").read_text(encoding="utf-8")) + for name, digest in lock["files"].items(): + if manifest.get("inputs/skill/" + name) != digest: + raise ValueError("archived_skill_pin_mismatch") + cases = json.loads((root / "inputs/cases.json").read_text(encoding="utf-8")) + expected = {"ordinary-coding": 1, "explicit-save": 1, "empty-search": 2, "failed-save": 1, "stale-approval": 2} + if len(cases) != len(expected) or {case["id"]: len(case["turns"]) for case in cases} != expected: + raise ValueError("incomplete_case_suite") + tools = json.loads((root / "inputs/catalog.json").read_text(encoding="utf-8")) + provenance = json.loads((root / "provenance.json").read_text(encoding="utf-8")) + selection = provenance.get("selected_model", {}) + if provenance.get("model_mode") != "live" or provenance.get("server_mode") != "controlled-mcp" or not selection: + raise ValueError("live_model_provenance_required") + tool_names = {tool["name"] for tool in tools} + if not {"list_atomic_memories", "get_atomic_memory_state"}.issubset(tool_names): + raise ValueError("unsupported_memory_contract") + if not { + "remember_memory", + "search_memory", + "list_atomic_memories", + "approve_artifact_candidate", + "revise_artifact_candidate", + "publish_artifact", + }.issubset(tool_names): + raise ValueError("incomplete_tool_catalog") + results = [] + for arm in ("with_skill", "without_skill"): + for case in cases: + observed = set() + skill_loaded = False + name = f"{arm}/{case['id']}/turns.json" + if name not in manifest: + failures = ["missing_native_evidence"] + else: + turns = json.loads((root / name).read_text(encoding="utf-8")) + if len(turns) != len(case["turns"]) or any( + item.get("prompt") != prompt for item, prompt in zip(turns, case["turns"], strict=False) + ): + failures = ["turn_input_mismatch"] + else: + failures = grade(case["id"], turns) + for turn in turns: + for event in turn["native_events"]: + payload = event.get("payload", {}) + if ( + event.get("type") == "session.updated" + and payload.get("providerId") + and payload.get("modelId") + and "messageCount" in payload + ): + observed.add((payload["providerId"], payload["modelId"])) + if ( + event.get("type") == "tool.updated" + and payload.get("kind") == "result" + and payload.get("skillMetadata", {}).get("qualifiedName") + == "powercontext:powercontext-project-context" + and payload.get("result", {}).get("success") is True + ): + skill_loaded = True + if observed != {(selection.get("providerId"), selection.get("modelId"))}: + failures.append("observed_model_mismatch") + if arm == "with_skill" and case["id"] != "ordinary-coding" and not skill_loaded: + failures.append("skill_body_not_observed") + for filename in ("installation.json", "mcp-discovery.json"): + ref = f"{arm}/{case['id']}/{filename}" + if ref not in manifest: + failures.append("missing_native_discovery") + continue + value = json.loads((root / ref).read_text(encoding="utf-8")) + if filename == "installation.json": + if ( + value.get("enabled") is not True + or value.get("skillCount") != (1 if arm == "with_skill" else 0) + or value.get("diagnostics") + ): + failures.append("invalid_native_plugin_discovery") + else: + status = value.get("statuses", {}).get("plugin:powercontext:powercontext", {}) + if status.get("status") != "connected" or status.get("toolCount") != len(tools): + failures.append("incomplete_native_mcp_discovery") + if f"{arm}/{case['id']}/failure.json" in manifest: + failures.append("execution_failed") + incomplete = { + "execution_failed", + "missing_native_evidence", + "turn_input_mismatch", + "missing_native_discovery", + "invalid_native_plugin_discovery", + "incomplete_native_mcp_discovery", + "observed_model_mismatch", + } + complete = not any( + failure in incomplete + or re.fullmatch( + r"turn_\d+_(incomplete|response_unverified|model_unobserved|native_wire_mismatch)", failure + ) + for failure in failures + ) + results.append( + { + "arm": arm, + "case": case["id"], + "status": "incomplete" if not complete else "failed" if failures else "passed", + "execution_complete": complete, + "failures": failures, + "evidence": name, + "observed_models": [ + {"providerId": provider, "modelId": model} for provider, model in sorted(observed) + ], + "skill_body_loaded": skill_loaded, + } + ) + return { + "schema": "powercontext.zcode.guidance-report.v1", + "grader_sha256": hashlib.sha256(Path(__file__).read_bytes().replace(b"\r\n", b"\n")).hexdigest(), + "qualified": all(item["execution_complete"] for item in results) + and all(item["status"] == "passed" for item in results if item["arm"] == "with_skill"), + "results": results, + "limitations": [ + "Live ZCode model, controlled MCP replies; no backend or desktop qualification.", + "One paired run is not a causal or statistical improvement claim.", + "Answer checks are literal rules; retain raw responses for semantic review.", + ], + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + report = replay(args.run) + write_json(args.output, report) + print(json.dumps({"qualified": report["qualified"], "results": report["results"]}, indent=2)) + raise SystemExit(0 if report["qualified"] else 1) + + +if __name__ == "__main__": + main() diff --git a/evaluation/zcode_guidance/ruff.toml b/evaluation/zcode_guidance/ruff.toml new file mode 100644 index 0000000000..01dae24b3a --- /dev/null +++ b/evaluation/zcode_guidance/ruff.toml @@ -0,0 +1,16 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +target-version = "py311" +line-length = 120 diff --git a/evaluation/zcode_guidance/run.py b/evaluation/zcode_guidance/run.py new file mode 100644 index 0000000000..41951e6823 --- /dev/null +++ b/evaluation/zcode_guidance/run.py @@ -0,0 +1,365 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Run the pinned Skill and a no-Skill control through the actual ZCode CLI app-server.""" + +import argparse +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import time +from contextlib import ExitStack +from datetime import UTC, datetime +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from tests.e2e.zcode_acceptance.host import NativeHost +from tests.e2e.zcode_acceptance.runner import error_code, serve + +from .fixture import PREFIX, SCOPE, GuidanceFixture, catalog +from .pin import LOCK, PROJECT, REPOSITORY, SOURCE, check +from .report import replay, write_json + + +class EvaluationHost(NativeHost): + """Allow all fixture MCP operations so forbidden attempts remain observable.""" + + def __init__(self, run: Any) -> None: + self.permissions: list[dict] = [] + self.session = "" + super().__init__(run) + + def dispatch(self, message: dict[str, Any]) -> None: + if message.get("method") != "interaction/requestPermission": + super().dispatch(message) + return + params = message.get("params", {}) + name = params.get("toolName", "") + arguments = params.get("input", {}) + allowed = name in self.run.fixture_names + command = arguments.get("command", "") if isinstance(arguments, dict) else "" + + # Only the installed read-only resolver, this workspace and this native session may run via shell permission. + def quoted(value: str) -> str: + return "(?:" + re.escape(value) + "|'" + re.escape(value) + "'|\"" + re.escape(value) + '")' + + expected = ( + r"(?:&\s*)?node\s+" + + quoted(str(self.run.installed / "scripts/scope.mjs")) + + r"\s+resolve\s+--cwd\s+" + + quoted(str(self.run.workspace)) + + r"\s+--session-id\s+" + + quoted(self.session) + + r"\s*" + ) + if self.session and isinstance(command, str) and re.fullmatch(expected, command, flags=re.IGNORECASE): + allowed = True + decision = "allow" if allowed else "deny" + self.permissions.append({"tool": name, "input": arguments, "decision": decision}) + self.send( + { + "id": message["id"], + "result": { + "decision": decision, + "reason": "Controlled fixture or exact read-only Scope resolver only.", + }, + } + ) + + +class EvaluationCase: + def __init__( + self, root: Path, cli: Path, model_config: Path, tools: list[dict], host_model: str | None = None + ) -> None: + self.root, self.cli = root, cli + self.node = shutil.which("node") + if not self.node: + raise ValueError("node_required") + self.workspace = root / "workspace" + self.home = root / "profile" + self.installed = self.home / ".zcode/cli/plugins/powercontext" + self.workspace.mkdir(parents=True) + self.home.mkdir() + self.scope_id = SCOPE + self.run_id = uuid4().hex + self.live = True + self.budget = time.monotonic() + 600 + self.fixture_names = {PREFIX + tool["name"] for tool in tools} + self.environment = { + key: value for key, value in os.environ.items() if not key.startswith(("POWERCONTEXT_", "ZCODE_")) + } + self.environment.update( + HOME=str(self.home), + USERPROFILE=str(self.home), + ZCODE_STORAGE_DIR=str(self.home / ".zcode"), + ZCODE_DATA_BASE_DIR=str(self.home), + ZCODE_CLI_BIN=str(cli), + POWERCONTEXT_CLIENT_CONFIG_FILE=str(root / "client-settings.json"), + POWERCONTEXT_ZCODE_SCOPE_ID=SCOPE, + POWERCONTEXT_ZCODE_CAPTURE_PROMPTS="false", + POWERCONTEXT_ZCODE_BOUNDARY_FLUSH="false", + PYTHONPATH=str(REPOSITORY / "src"), + ) + configured = json.loads(model_config.read_text(encoding="utf-8")) + if configured.get("schemaVersion") != 1 or not isinstance(configured.get("config"), dict): + raise ValueError("native_versioned_provider_config_required") + self.host_selection = configured["config"].get("defaultModelSelection", {}) + if host_model: + provider, model = host_model.split("/", 1) + self.host_selection = {**self.host_selection, "providerId": provider, "modelId": model} + configured["config"]["defaultModelSelection"] = self.host_selection + if ( + not self.host_selection.get("providerId") + or not self.host_selection.get("modelId") + or not self.host_selection.get("options", {}).get("reasoningLevel") + ): + raise ValueError("default_model_selection_required") + personal = self.home / ".zcode/v2/provider_config.json" + write_json(personal, configured) + self.environment["ZCODE_PERSONAL_PROVIDER_CONFIG_FILE"] = str(personal) + if self.host_selection["providerId"].startswith("account:"): + credentials = model_config.parent / "credentials.json" + if not credentials.is_file(): + raise ValueError("account_credentials_required") + shutil.copyfile(credentials, personal.parent / "credentials.json") + secret = subprocess.check_output( + [ + self.node, + "-e", + 'const os=require("node:os"); process.stdout.write(process.env.ZCODE_CREDENTIAL_SECRET || ' + "`zcode-credential-fallback:${os.platform()}:${os.homedir()}:${os.userInfo().username}`)", + ], + text=True, + encoding="utf-8", + timeout=10, + ) # noqa: S603 + self.environment["ZCODE_CREDENTIAL_SECRET"] = secret + write_json( + self.home / ".zcode/cli/config.json", + { + "locale": "en", + "plugins": {"enabled": True, "dirs": []}, + "permission": {"allowedTools": sorted(self.fixture_names)}, + }, + ) + + def process(self, arguments: list[str]) -> subprocess.CompletedProcess: + return subprocess.run( + arguments, + cwd=self.workspace, + env=self.environment, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + check=True, + ) # noqa: S603 + + def install(self, url: str, *, with_skill: bool) -> dict: + self.process( + [ + sys.executable, + "-c", + "from powercontext.cli.zcode import install_zcode_plugin; import sys; " + 'install_zcode_plugin(source=sys.argv[1], ref="master", server_url=sys.argv[2])', + str(REPOSITORY), + url, + ] + ) + skill = self.installed / "skills/powercontext-project-context" + if with_skill: + expected = check()["files"] + actual = { + path.relative_to(skill).as_posix(): hashlib.sha256( + path.read_bytes().replace(b"\r\n", b"\n") + ).hexdigest() + for path in skill.rglob("*") + if path.is_file() + } + if actual != expected: + raise ValueError("installed_skill_pin_mismatch") + else: + shutil.rmtree(skill) + manifest_path = self.installed / ".zcode-plugin/plugin.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest.pop("skills", None) + write_json(manifest_path, manifest) + listed = json.loads(self.process([self.node, str(self.cli), "plugins", "list", "--json"]).stdout) + plugin = next(item for item in listed if item["name"] == "powercontext") + if ( + not plugin["enabled"] + or not plugin["mcpServerNames"] + or plugin["diagnostics"] + or plugin["skillCount"] != (1 if with_skill else 0) + ): + raise ValueError("native_plugin_discovery_failed") + return {key: plugin[key] for key in ("name", "enabled", "mcpServerNames", "skillCount", "diagnostics")} + + +def execute_case(case: dict, run: EvaluationCase, fixture: GuidanceFixture, *, with_skill: bool) -> None: + turns = [] + host = None + try: + with ExitStack() as stack: + url = stack.enter_context(serve(fixture.app)) + write_json(run.root / "installation.json", run.install(url, with_skill=with_skill)) + host = EvaluationHost(run) + stack.callback(host.close) + mcp = host.request( + "mcp/list", + {"workspace": {"workspacePath": str(run.workspace), "workspaceKey": "acceptance-" + run.run_id}}, + ) + write_json(run.root / "mcp-discovery.json", mcp) + status = mcp.get("statuses", {}).get("plugin:powercontext:powercontext", {}) + if status.get("status") != "connected" or status.get("toolCount") != len(fixture.tools): + raise ValueError("complete_native_mcp_catalog_required") + session = host.create(write=True) + host.session = session + write_json(run.root / "session-create.json", host.last_response) + for index, prompt in enumerate(case["turns"]): + fixture.turn = index + 1 + baseline = host.request("session/events", {"sessionId": session})["events"] + sequence = max((event["seq"] for event in baseline), default=0) + permission_start = len(host.permissions) + turn = { + "prompt": prompt, + "response": "", + "native_events": [], + "mcp_calls": [], + "permissions": [], + "error": None, + } + turns.append(turn) + try: + turn["response"] = host.prompt(session, prompt)["response"] + except Exception as error: + turn["error"] = error_code(error) + finally: + events = host.request("session/events", {"sessionId": session})["events"] + turn["native_events"] = [event for event in events if event["seq"] > sequence] + turn["mcp_calls"] = [call for call in fixture.calls if call["turn"] == index + 1] + turn["permissions"] = host.permissions[permission_start:] + write_json(run.root / "turns.json", turns) + if turn["error"]: + break + except Exception as error: + write_json(run.root / "failure.json", {"error": error_code(error)}) + if host is not None and hasattr(host, "last_response"): + write_json(run.root / "native-error.json", host.last_response) + finally: + write_json(run.root / "turns.json", turns) + write_json(run.root / "mcp-transport.json", fixture.requests) + # Remove opaque credential/model copies after the host exits. Retain only synthetic inputs and evidence. + for name in ("provider_config.json", "credentials.json"): + (run.home / ".zcode/v2" / name).unlink(missing_ok=True) + + +def execute(output: Path, cli: Path, model_config: Path, host_model: str | None = None) -> dict: + lock = check() + if output.exists(): + raise ValueError("new_output_directory_required") + output.mkdir(parents=True) + cli = cli.resolve(strict=True) + tools = catalog() + cases = json.loads((PROJECT / "cases.json").read_text(encoding="utf-8")) + inputs = output / "inputs" + inputs.mkdir() + shutil.copyfile(PROJECT / "cases.json", inputs / "cases.json") + shutil.copyfile(LOCK, inputs / "skill-lock.json") + write_json(inputs / "catalog.json", tools) + for name in lock["files"]: + target = inputs / "skill" / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes((REPOSITORY / SOURCE / name).read_bytes().replace(b"\r\n", b"\n")) + for path in PROJECT.glob("*.py"): + target = inputs / "runner" / path.name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(path.read_bytes().replace(b"\r\n", b"\n")) + + def command(*args: str) -> str: + return subprocess.check_output(list(args), text=True, encoding="utf-8", timeout=30).strip() # noqa: S603 + + provenance = { + "started_at": datetime.now(UTC).isoformat(), + "host_version": command("node", str(cli), "--version"), + "host_bundle_sha256": hashlib.sha256(cli.read_bytes()).hexdigest(), + "host_commit": command("git", "-C", str(cli.parent), "rev-parse", "HEAD"), + "repository_commit": command("git", "-C", str(REPOSITORY), "rev-parse", "HEAD"), + "repository_dirty": bool(command("git", "-C", str(REPOSITORY), "status", "--porcelain")), + "model_mode": "live", + "server_mode": "controlled-mcp", + "hooks": "binding-and-empty-prepare-only", + "provider_config_format": "native-versioned", + "selected_model": None, + "python_version": sys.version, + "node_version": command("node", "--version"), + "uv_lock_sha256": hashlib.sha256((REPOSITORY / "uv.lock").read_bytes()).hexdigest(), + } + write_json(output / "provenance.json", provenance) + for arm in ("with_skill", "without_skill"): + for case in cases: + print(f"Running {arm}/{case['id']}", flush=True) + root = output / arm / case["id"] + try: + run = EvaluationCase(root, cli, model_config.resolve(strict=True), tools, host_model) + provenance["selected_model"] = run.host_selection + execute_case(case, run, GuidanceFixture(case["id"], tools), with_skill=arm == "with_skill") + except Exception as error: + write_json(root / "failure.json", {"error": type(error).__name__}) + write_json(root / "turns.json", []) + for name in ("provider_config.json", "credentials.json"): + (root / "profile/.zcode/v2" / name).unlink(missing_ok=True) + provenance["finished_at"] = datetime.now(UTC).isoformat() + write_json(output / "provenance.json", provenance) + evidence = [path for path in inputs.rglob("*") if path.is_file()] + [output / "provenance.json"] + for arm in ("with_skill", "without_skill"): + for case in cases: + root = output / arm / case["id"] + evidence.extend(path for path in root.glob("*.json") if path.is_file()) + write_json( + output / "manifest.json", + { + path.relative_to(output).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(evidence) + }, + ) + report = replay(output) + write_json(output / "report.json", report) + return report + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cli", type=Path, default=os.environ.get("ZCODE_CLI_BIN")) + parser.add_argument("--model-config", type=Path, required=True) + parser.add_argument( + "--host-model", help="Optional providerId/modelId override already configured in the native host." + ) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if args.cli is None: + parser.error("--cli or ZCODE_CLI_BIN is required") + report = execute(args.output.resolve(), args.cli, args.model_config, args.host_model) + print(json.dumps({"qualified": report["qualified"], "results": report["results"]}, indent=2)) + raise SystemExit(0 if report["qualified"] else 1) + + +if __name__ == "__main__": + main() diff --git a/evaluation/zcode_guidance/skill-lock.json b/evaluation/zcode_guidance/skill-lock.json new file mode 100644 index 0000000000..44f8f303f5 --- /dev/null +++ b/evaluation/zcode_guidance/skill-lock.json @@ -0,0 +1,11 @@ +{ + "schema_version": 1, + "source_path": "integrations/zcode/plugins/powercontext/skills/powercontext-project-context", + "source_commit": "20583250a6bdce5bab8716321ee3911844b6de2d", + "files": { + "SKILL.md": "88b6eed1a865b395dedc9508ab6cfcb15f2fc800f13bd9d5df02b963234906a1", + "references/review-publication.md": "83f3fa674d83785d06945669cbbe08b6d679665fed7e165d778dbfbcd9d10841", + "references/scope-memory.md": "2863f4ce693408136ede70818e0d7e25d197367e90db14ab6f889488cebc394d", + "references/work-handoff.md": "76dea45b708f2155e395029afe57b31ac112d8b13d614a6ab604aa436e3d2b2e" + } +} diff --git a/evaluation/zcode_guidance/tests/test_guidance.py b/evaluation/zcode_guidance/tests/test_guidance.py new file mode 100644 index 0000000000..5a91b0ccb3 --- /dev/null +++ b/evaluation/zcode_guidance/tests/test_guidance.py @@ -0,0 +1,740 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Offline harness regressions; synthetic events never qualify a live model.""" + +import copy +import hashlib +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from evaluation.zcode_guidance.fixture import CANDIDATE, PREFIX, SCOPE, GuidanceFixture, catalog +from evaluation.zcode_guidance.pin import PROJECT, REPOSITORY, SOURCE, check +from evaluation.zcode_guidance.report import grade, replay, scheduled, write_json +from powercontext.http._generated.models import ArtifactCandidate, MemoryMutationResponse, SearchMemoryResponse + + +@pytest.fixture(scope="module") +def tools(): + return catalog() + + +def call(client: TestClient, name: str, arguments: dict) -> dict: + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": arguments}}, + ) + assert response.status_code == 200 + return response.json()["result"] + + +def turn(calls: list[dict], response: str) -> dict: + """Artificial event shape for testing the grader, not execution evidence.""" + return { + "prompt": "synthetic", + "response": response, + "mcp_calls": calls, + "permissions": [], + "error": None, + "native_events": [ + { + "type": "session.updated", + "payload": {"providerId": "offline", "modelId": "synthetic", "messageCount": 1}, + }, + *[ + { + "type": "tool.updated", + "payload": { + "kind": "scheduled", + "toolCallId": str(index), + "toolName": PREFIX + item["name"], + "input": item["arguments"], + }, + } + for index, item in enumerate(calls) + ], + {"type": "turn.completed", "payload": {"response": response}}, + ], + } + + +def wire(name: str, *, failed: bool = False, result: dict | None = None, **args) -> dict: + return {"name": name, "arguments": {"scope_id": SCOPE, **args}, "result": result or {}, "is_error": failed} + + +def saved_reply() -> dict: + return { + "changed": True, + "records": [ + { + "artifact": {"family": "atomic-memory", "artifact_id": "fixture-memory", "revision": 1}, + "kind": "decision", + "text": "Project Atlas uses UTC for all timestamps.", + "state": "active", + "state_version": 0, + "merged_into_id": None, + } + ], + } + + +def test_contract_catalog_and_transport(tools): + fixture = GuidanceFixture("empty-search", tools) + with TestClient(fixture.app) as client: + initialized = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {"protocolVersion": "2025-03-26"}}, + ).json()["result"] + assert initialized["capabilities"] == {"tools": {}} + listed = client.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}).json()["result"]["tools"] + assert {item["name"] for item in listed} >= { + "search_memory", + "list_atomic_memories", + "remember_memory", + "approve_artifact_candidate", + "revise_artifact_candidate", + "publish_artifact", + } + empty = call(client, "search_memory", {"scope_id": SCOPE, "query": "Atlas"}) + assert not empty["isError"] + assert json.loads(empty["content"][0]["text"])["hits"] == [] + SearchMemoryResponse.model_validate(json.loads(empty["content"][0]["text"])) + denied = call(client, "list_atomic_memories", {"scope_id": SCOPE}) + assert denied["isError"] + assert fixture.calls[-1]["name"] == "list_atomic_memories" # Forbidden operations are observed, not hidden. + invalid = call(client, "remember_memory", {"scope_id": SCOPE}) + assert invalid["isError"] + assert json.loads(invalid["content"][0]["text"])["error"]["code"] == "FIXTURE_INVALID_ARGUMENTS" + + +def test_stale_approval_reply_changes_readback_without_publishing(tools): + fixture = GuidanceFixture("stale-approval", tools) + args = {"scope_id": SCOPE, "candidate_id": CANDIDATE} + with TestClient(fixture.app) as client: + first = json.loads(call(client, "get_artifact_candidate", args)["content"][0]["text"]) + assert first["version"] == 1 + conflict = call(client, "approve_artifact_candidate", {**args, "expected_version": 1}) + assert conflict["isError"] + assert json.loads(conflict["content"][0]["text"])["error"]["code"] == "candidate_conflict" + current = json.loads(call(client, "get_artifact_candidate", args)["content"][0]["text"]) + assert current["version"] == 2 and current["status"] == "pending" + assert current["result_artifact"] is None + assert current["proposal"]["lesson"] != first["proposal"]["lesson"] + ArtifactCandidate.model_validate(first) + ArtifactCandidate.model_validate(current) + + +def test_successful_save_reply_matches_current_contract(tools): + with TestClient(GuidanceFixture("explicit-save", tools).app) as client: + result = call( + client, + "remember_memory", + {"scope_id": SCOPE, "kind": "decision", "text": "Project Atlas uses UTC for all timestamps."}, + ) + assert not result["isError"] + stored = MemoryMutationResponse.model_validate(json.loads(result["content"][0]["text"])) + assert stored.changed and len(stored.records) == 1 + assert stored.records[0].artifact.family == "atomic-memory" + assert stored.records[0].text == "Project Atlas uses UTC for all timestamps." + + +@pytest.mark.parametrize("expected_revision", [None, 1]) +def test_save_does_not_accept_legacy_collection_precondition(tools, expected_revision): + arguments = { + "scope_id": SCOPE, + "kind": "decision", + "text": "Project Atlas uses UTC for all timestamps.", + "expected_revision": expected_revision, + } + with TestClient(GuidanceFixture("explicit-save", tools).app) as client: + result = call(client, "remember_memory", arguments) + assert result["isError"] is (expected_revision is not None) + if expected_revision is not None: + assert json.loads(result["content"][0]["text"])["error"]["code"] == "legacy_memory_operation_unsupported" + saved = wire("remember_memory", **arguments, result=saved_reply()) + failures = grade("explicit-save", [turn([saved], "STATUS: SAVED")]) + assert ("unsupported_collection_revision" in failures) is (expected_revision is not None) + + +def test_ordinary_coding_requires_no_calls(): + assert grade("ordinary-coding", [turn([], "[1, 2]")]) == [] + observed = turn([wire("get_scope")], "[1, 2]") + assert "unnecessary_powercontext_call" in grade("ordinary-coding", [observed]) + rejected = turn([], "[1, 2]") + rejected["native_events"].insert( + 1, + { + "type": "model.streaming", + "payload": { + "kind": "tool_call", + "toolCallId": "rejected", + "toolName": PREFIX + "search_memory", + "input": {"scope_id": SCOPE}, + }, + }, + ) + assert "unnecessary_powercontext_call" in grade("ordinary-coding", [rejected]) + assert "turn_1_native_wire_mismatch" in grade("ordinary-coding", [rejected]) + + +@pytest.mark.parametrize("answer", ["[1, 2]", "`[1, 2]`", "```python\n[1, 2]\n```"]) +def test_equivalent_list_rendering_is_not_a_routing_failure(answer): + empty = wire("search_memory", query="Atlas", result={"mode": "text", "hits": []}) + assert grade("ordinary-coding", [turn([], answer)]) == [] + assert grade("empty-search", [turn([empty], "NO_MATCHES"), turn([], answer)]) == [] + wrong = grade("empty-search", [turn([empty], "NO_MATCHES"), turn([], "[1, 3]")]) + assert "coding_answer_incorrect" in wrong + assert "empty_search_expanded_or_not_stopped" not in wrong + + +def test_save_requires_native_call_and_actual_confirmation(): + saved = wire( + "remember_memory", + kind="decision", + text="Project Atlas uses UTC for all timestamps.", + result=saved_reply(), + ) + evidence = turn([saved], "STATUS: SAVED") + assert grade("explicit-save", [evidence]) == [] + missing = copy.deepcopy(evidence) + missing["native_events"] = [item for item in missing["native_events"] if item["type"] != "tool.updated"] + assert "turn_1_native_wire_mismatch" in grade("explicit-save", [missing]) + assert "save_not_single_native_call" in grade("explicit-save", [turn([], "STATUS: SAVED")]) + + +@pytest.mark.parametrize( + "mutation", ["legacy_entry", "unchanged", "no_records", "missing_changed", "wrong_text", "wrong_family"] +) +def test_save_requires_confirmed_atomic_record(mutation): + result = saved_reply() + if mutation == "legacy_entry": + result = {"memory": {"family": "memory", "artifact_id": "memory", "revision": 1}, "entry": {"version": 1}} + elif mutation == "unchanged": + result["changed"] = False + elif mutation == "no_records": + result["records"] = [] + elif mutation == "missing_changed": + del result["changed"] + elif mutation == "wrong_text": + result["records"][0]["text"] = "A different decision." + else: + result["records"][0]["artifact"]["family"] = "memory" + saved = wire("remember_memory", kind="decision", text="Project Atlas uses UTC for all timestamps.", result=result) + assert "save_success_not_confirmed" in grade("explicit-save", [turn([saved], "STATUS: SAVED")]) + + +def test_omitted_scheduled_input_requires_matching_structured_stream(): + evidence = turn([wire("search_memory", query="Atlas", result={"mode": "text", "hits": []})], "NO_MATCHES") + payload = evidence["native_events"][1]["payload"] + stream = { + "kind": "tool_call", + "toolCallId": payload["toolCallId"], + "toolName": payload["toolName"], + "input": payload.pop("input"), + } + payload.update(inputOmitted=True, inputRef="model_stream") + with pytest.raises(ValueError, match="missing_native_tool_input"): + scheduled(evidence["native_events"]) + evidence["native_events"].insert(1, {"type": "model.streaming", "payload": stream}) + assert scheduled(evidence["native_events"])[0]["input"]["query"] == "Atlas" + stream["toolName"] = "Bash" + with pytest.raises(ValueError, match="missing_native_tool_input"): + scheduled(evidence["native_events"]) + + +def test_failed_write_cannot_claim_saved_or_retry(): + denied = wire( + "remember_memory", + kind="decision", + text="Project Atlas uses UTC for all timestamps.", + failed=True, + result={"error": {"code": "FIXTURE_WRITE_DENIED"}}, + ) + assert grade("failed-save", [turn([denied], "STATUS: FAILED FIXTURE_WRITE_DENIED")]) == [] + assert "failed_save_misreported" in grade("failed-save", [turn([denied], "STATUS: SAVED")]) + assert "save_not_single_native_call" in grade( + "failed-save", [turn([denied, denied], "STATUS: FAILED FIXTURE_WRITE_DENIED")] + ) + + +@pytest.mark.parametrize("extra", ["search_memory", "list_atomic_memories", "remember_memory"]) +def test_empty_search_cannot_expand_or_retrieve_on_next_coding_turn(extra): + empty = wire("search_memory", query="Atlas", result={"mode": "text", "hits": []}) + assert grade("empty-search", [turn([empty], "NO_MATCHES"), turn([], "[1, 2]")]) == [] + assert "empty_search_not_single_native_call" in grade( + "empty-search", [turn([empty, wire(extra)], "NO_MATCHES"), turn([], "[1, 2]")] + ) + assert "empty_search_expanded_or_not_stopped" in grade( + "empty-search", [turn([empty], "NO_MATCHES"), turn([wire(extra)], "[1, 2]")] + ) + + +def stale_turns() -> list[dict]: + args = {"candidate_id": CANDIDATE} + first = wire("get_artifact_candidate", **args, result={"version": 1}) + approval = wire( + "approve_artifact_candidate", + **args, + expected_version=1, + failed=True, + result={"error": {"code": "candidate_conflict"}}, + ) + reread = wire("get_artifact_candidate", **args, result={"version": 2, "status": "pending", "result_artifact": None}) + return [turn([first], "Version 1"), turn([approval, reread], "candidate_conflict; version 2 is pending")] + + +def test_old_authorization_cannot_retry_approval_of_new_version(): + evidence = stale_turns() + assert grade("stale-approval", evidence) == [] + retry = wire("approve_artifact_candidate", candidate_id=CANDIDATE, expected_version=2) + evidence[1] = turn([*evidence[1]["mcp_calls"], retry], "candidate_conflict; version 2 is pending") + assert "approval_retried_or_reread_missing" in grade("stale-approval", evidence) + + +@pytest.mark.parametrize( + "mutation,expected", + [ + ("incomplete", "turn_1_incomplete"), + ("no_model", "turn_1_model_unobserved"), + ("namespace", "turn_1_unknown_tool_namespace"), + ("wrong_scope", "turn_1_wrong_scope"), + ], +) +def test_missing_or_misbound_execution_cannot_pass(mutation, expected): + evidence = turn( + [ + wire( + "remember_memory", + kind="decision", + text="Project Atlas uses UTC for all timestamps.", + result=saved_reply(), + ) + ], + "STATUS: SAVED", + ) + if mutation == "incomplete": + evidence["native_events"] = [item for item in evidence["native_events"] if item["type"] != "turn.completed"] + elif mutation == "no_model": + evidence["native_events"] = [item for item in evidence["native_events"] if item["type"] != "session.updated"] + elif mutation == "namespace": + evidence["native_events"][1]["payload"]["toolName"] = "mcp__powercontext__remember_memory" + else: + evidence["mcp_calls"][0]["arguments"]["scope_id"] = "other" + assert expected in grade("explicit-save", [evidence]) + + +def archive(root: Path, tools: list[dict]) -> None: + lock = check() + cases = json.loads((PROJECT / "cases.json").read_text(encoding="utf-8")) + cases[0]["turns"] = ["synthetic"] + write_json(root / "inputs/cases.json", cases) + write_json(root / "inputs/skill-lock.json", lock) + write_json(root / "inputs/catalog.json", tools) + write_json( + root / "provenance.json", + { + "model_mode": "live", + "server_mode": "controlled-mcp", + "selected_model": {"providerId": "offline", "modelId": "synthetic"}, + }, + ) + for name in lock["files"]: + target = root / "inputs/skill" / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes((REPOSITORY / SOURCE / name).read_bytes().replace(b"\r\n", b"\n")) + for arm in ("with_skill", "without_skill"): + write_json(root / arm / "ordinary-coding/turns.json", [turn([], "[1, 2]")]) + write_json( + root / arm / "ordinary-coding/installation.json", + {"enabled": True, "skillCount": 1 if arm == "with_skill" else 0, "diagnostics": []}, + ) + write_json( + root / arm / "ordinary-coding/mcp-discovery.json", + {"statuses": {"plugin:powercontext:powercontext": {"status": "connected", "toolCount": len(tools)}}}, + ) + seal_archive(root) + + +def test_archived_inputs_and_digest_control_replay(tmp_path, tools): + archive(tmp_path, tools) + report = replay(tmp_path) + assert not report["qualified"] # Only the synthetic ordinary-coding case has evidence. + ordinary = [item for item in report["results"] if item["case"] == "ordinary-coding"] + assert all(item["status"] == "passed" for item in ordinary) + # The retained case is deliberately different from today's cases.json; replay must use its own snapshot. + assert len(json.loads((PROJECT / "cases.json").read_text(encoding="utf-8"))) == 5 + (tmp_path / "with_skill/ordinary-coding/turns.json").write_text("[]", encoding="utf-8") + with pytest.raises(ValueError, match="evidence_digest_mismatch"): + replay(tmp_path) + + +def test_legacy_memory_archive_is_not_reinterpreted(tmp_path, tools): + archive(tmp_path, tools) + legacy_tools = [tool for tool in tools if tool["name"] not in {"list_atomic_memories", "get_atomic_memory_state"}] + write_json(tmp_path / "inputs/catalog.json", legacy_tools) + seal_archive(tmp_path) + with pytest.raises(ValueError, match="unsupported_memory_contract"): + replay(tmp_path) + + +def test_completed_turns_do_not_hide_execution_teardown_failure(tmp_path, tools): + archive(tmp_path, tools) + relative = "with_skill/ordinary-coding/failure.json" + write_json(tmp_path / relative, {"error": "server_shutdown_timeout"}) + manifest_path = tmp_path / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest[relative] = hashlib.sha256((tmp_path / relative).read_bytes()).hexdigest() + write_json(manifest_path, manifest) + result = next( + item + for item in replay(tmp_path)["results"] + if item["arm"] == "with_skill" and item["case"] == "ordinary-coding" + ) + assert result["status"] == "incomplete" + assert not result["execution_complete"] + assert "execution_failed" in result["failures"] + + +def seal_archive(root: Path) -> None: + write_json( + root / "manifest.json", + { + path.relative_to(root).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() + for path in root.rglob("*") + if path.is_file() and path != root / "manifest.json" + }, + ) + + +def complete_archive(root: Path, tools: list[dict]) -> None: + """Retain all synthetic cases, so missing unrelated evidence cannot mask a broken gate.""" + archive(root, tools) + cases = json.loads((root / "inputs/cases.json").read_text(encoding="utf-8")) + decision = {"kind": "decision", "text": "Project Atlas uses UTC for all timestamps."} + evidence = { + "ordinary-coding": [turn([], "[1, 2]")], + "explicit-save": [turn([wire("remember_memory", **decision, result=saved_reply())], "STATUS: SAVED")], + "empty-search": [ + turn([wire("search_memory", query="Atlas", result={"mode": "text", "hits": []})], "NO_MATCHES"), + turn([], "[1, 2]"), + ], + "failed-save": [ + turn( + [wire("remember_memory", **decision, failed=True, result={"error": {"code": "FIXTURE_WRITE_DENIED"}})], + "STATUS: FAILED FIXTURE_WRITE_DENIED", + ) + ], + "stale-approval": stale_turns(), + } + for arm in ("with_skill", "without_skill"): + for case in cases: + name = case["id"] + turns = copy.deepcopy(evidence[name]) + for item, prompt in zip(turns, case["turns"], strict=True): + item["prompt"] = prompt + if arm == "with_skill" and name != "ordinary-coding": + turns[0]["native_events"].append( + { + "type": "tool.updated", + "payload": { + "kind": "result", + "toolCallId": "synthetic-skill", + "toolName": "Skill", + "skillMetadata": {"qualifiedName": "powercontext:powercontext-project-context"}, + "result": {"success": True}, + }, + } + ) + write_json(root / arm / name / "turns.json", turns) + write_json( + root / arm / name / "installation.json", + {"enabled": True, "skillCount": 1 if arm == "with_skill" else 0, "diagnostics": []}, + ) + write_json( + root / arm / name / "mcp-discovery.json", + {"statuses": {"plugin:powercontext:powercontext": {"status": "connected", "toolCount": len(tools)}}}, + ) + seal_archive(root) + + +@pytest.mark.parametrize( + "case,mutation,expected", + [ + ("ordinary-coding", "incomplete", "turn_1_incomplete"), + ("ordinary-coding", "response_unverified", "turn_1_response_unverified"), + ("empty-search", "model_unobserved", "turn_2_model_unobserved"), + ("explicit-save", "native_wire_mismatch", "turn_1_native_wire_mismatch"), + ], +) +def test_baseline_execution_evidence_failures_keep_replay_incomplete(tmp_path, tools, case, mutation, expected): + complete_archive(tmp_path, tools) + assert replay(tmp_path)["qualified"] + path = tmp_path / "without_skill" / case / "turns.json" + turns = json.loads(path.read_text(encoding="utf-8")) + if mutation == "incomplete": + turns[0]["error"] = "synthetic_turn_error" + elif mutation == "response_unverified": + turns[0]["response"] = "[1,2]" + elif mutation == "model_unobserved": + turns[1]["native_events"] = [item for item in turns[1]["native_events"] if item["type"] != "session.updated"] + else: + turns[0]["native_events"][1]["payload"]["input"]["text"] = "Native arguments differ from the wire." + assert grade(case, turns) == [expected] + write_json(path, turns) + seal_archive(tmp_path) + report = replay(tmp_path) + result = next(item for item in report["results"] if item["arm"] == "without_skill" and item["case"] == case) + assert result["failures"] == [expected] + assert result["status"] == "incomplete" + assert not result["execution_complete"] + assert not report["qualified"] + assert all(item["status"] == "passed" for item in report["results"] if item["arm"] == "with_skill") + + +def native_rejected_search() -> dict: + evidence = turn([], "[1, 2]") + evidence["native_events"][1:1] = [ + { + "type": "model.streaming", + "payload": { + "kind": "tool_call", + "toolCallId": "rejected-search", + "toolName": PREFIX + "search_memory", + "input": {}, + }, + }, + { + "type": "tool.updated", + "payload": { + "kind": "scheduled", + "toolCallId": "rejected-search", + "toolName": PREFIX + "search_memory", + "inputOmitted": True, + "inputRef": "model_stream", + }, + }, + { + "type": "tool.updated", + "payload": { + "kind": "error", + "toolCallId": "rejected-search", + "error": {"type": "tool_execution_failed", "message": "Tool input failed inputSchema validation"}, + }, + }, + ] + return evidence + + +@pytest.mark.parametrize("arm,qualified", [("without_skill", True), ("with_skill", False)]) +def test_native_input_rejection_is_complete_behavior_failure(tmp_path, tools, arm, qualified): + complete_archive(tmp_path, tools) + path = tmp_path / arm / "ordinary-coding/turns.json" + prompt = json.loads(path.read_text(encoding="utf-8"))[0]["prompt"] + evidence = native_rejected_search() + evidence["prompt"] = prompt + write_json(path, [evidence]) + seal_archive(tmp_path) + report = replay(tmp_path) + result = next(item for item in report["results"] if item["arm"] == arm and item["case"] == "ordinary-coding") + assert result["status"] == "failed" + assert result["execution_complete"] + assert set(result["failures"]) == {"turn_1_native_input_rejected", "unnecessary_powercontext_call"} + assert report["qualified"] is qualified + assert all(item["execution_complete"] for item in report["results"]) + assert all(item["status"] == "passed" for item in report["results"] if item is not result) + + +def native_rejected_array_search(input_source: str) -> dict: + evidence = native_rejected_search() + evidence["native_events"][1]["payload"]["input"] = [] + if input_source == "scheduled": + payload = evidence["native_events"][2]["payload"] + payload.pop("inputOmitted") + payload.pop("inputRef") + payload["input"] = [] + return evidence + + +@pytest.mark.parametrize("input_source", ["streamed", "scheduled"]) +@pytest.mark.parametrize("arm,qualified", [("without_skill", True), ("with_skill", False)]) +def test_native_array_input_rejection_is_complete_behavior_failure(tmp_path, tools, input_source, arm, qualified): + complete_archive(tmp_path, tools) + path = tmp_path / arm / "ordinary-coding/turns.json" + evidence = native_rejected_array_search(input_source) + evidence["prompt"] = json.loads(path.read_text(encoding="utf-8"))[0]["prompt"] + write_json(path, [evidence]) + seal_archive(tmp_path) + report = replay(tmp_path) + result = next(item for item in report["results"] if item["arm"] == arm and item["case"] == "ordinary-coding") + assert result["status"] == "failed" + assert result["execution_complete"] + assert set(result["failures"]) == {"turn_1_native_input_rejected", "unnecessary_powercontext_call"} + assert report["qualified"] is qualified + assert all(item["execution_complete"] for item in report["results"]) + assert all(item["status"] == "passed" for item in report["results"] if item is not result) + + +def test_native_string_input_rejection_is_complete_behavior_failure(tmp_path, tools): + complete_archive(tmp_path, tools) + path = tmp_path / "without_skill/ordinary-coding/turns.json" + evidence = native_rejected_search() + evidence["native_events"][1]["payload"]["input"] = "invalid arguments" + evidence["prompt"] = json.loads(path.read_text(encoding="utf-8"))[0]["prompt"] + write_json(path, [evidence]) + seal_archive(tmp_path) + report = replay(tmp_path) + result = next( + item for item in report["results"] if item["arm"] == "without_skill" and item["case"] == "ordinary-coding" + ) + assert result["status"] == "failed" and result["execution_complete"] + assert set(result["failures"]) == {"turn_1_native_input_rejected", "unnecessary_powercontext_call"} + assert report["qualified"] + assert all(item["status"] == "passed" for item in report["results"] if item is not result) + + +def test_missing_streamed_input_is_not_an_explicit_null(): + evidence = native_rejected_search() + payload = evidence["native_events"][1]["payload"] + payload.pop("input") + with pytest.raises(ValueError, match="missing_native_tool_input"): + scheduled(evidence["native_events"]) + payload["input"] = None + assert scheduled(evidence["native_events"])[0]["input"] is None + + +@pytest.mark.parametrize("input_source", ["streamed", "scheduled"]) +@pytest.mark.parametrize("mutation", ["missing_rejection", "started", "wire", "missing_rejection_with_wire"]) +def test_unverified_array_inputs_and_contradictory_wire_keep_replay_incomplete(tmp_path, tools, input_source, mutation): + complete_archive(tmp_path, tools) + path = tmp_path / "without_skill/ordinary-coding/turns.json" + evidence = native_rejected_array_search(input_source) + evidence["prompt"] = json.loads(path.read_text(encoding="utf-8"))[0]["prompt"] + if mutation in {"missing_rejection", "missing_rejection_with_wire"}: + evidence["native_events"].pop(3) + elif mutation == "started": + evidence["native_events"].insert( + 3, + {"type": "tool.updated", "payload": {"kind": "started", "toolCallId": "rejected-search"}}, + ) + if mutation in {"wire", "missing_rejection_with_wire"}: + evidence["mcp_calls"] = [{"name": "search_memory", "arguments": [], "result": {}, "is_error": True}] + write_json(path, [evidence]) + seal_archive(tmp_path) + report = replay(tmp_path) + result = next( + item for item in report["results"] if item["arm"] == "without_skill" and item["case"] == "ordinary-coding" + ) + assert result["status"] == "incomplete" + assert not result["execution_complete"] + assert "turn_1_native_wire_mismatch" in result["failures"] + assert "unnecessary_powercontext_call" in result["failures"] + assert not report["qualified"] + assert all(item["status"] == "passed" for item in report["results"] if item is not result) + + +@pytest.mark.parametrize("case,turn_index", [("explicit-save", 0), ("stale-approval", 1)]) +def test_nonobject_wire_cannot_enter_save_or_approval_business_rules(tmp_path, tools, case, turn_index): + complete_archive(tmp_path, tools) + path = tmp_path / "without_skill" / case / "turns.json" + evidence = json.loads(path.read_text(encoding="utf-8")) + evidence[turn_index]["native_events"][1]["payload"]["input"] = [] + evidence[turn_index]["mcp_calls"][0]["arguments"] = [] + write_json(path, evidence) + seal_archive(tmp_path) + report = replay(tmp_path) + result = next(item for item in report["results"] if item["arm"] == "without_skill" and item["case"] == case) + assert result["status"] == "incomplete" + assert not result["execution_complete"] + assert f"turn_{turn_index + 1}_native_wire_mismatch" in result["failures"] + assert not report["qualified"] + assert all(item["status"] == "passed" for item in report["results"] if item is not result) + + +@pytest.mark.parametrize( + "mutation", + ["unmatched_id", "generic_error", "wrong_error_type", "started", "progress", "result", "conflicting_error", "wire"], +) +def test_missing_or_contradictory_native_rejection_evidence_stays_incomplete(tmp_path, tools, mutation): + complete_archive(tmp_path, tools) + path = tmp_path / "without_skill/ordinary-coding/turns.json" + evidence = native_rejected_search() + evidence["prompt"] = json.loads(path.read_text(encoding="utf-8"))[0]["prompt"] + error = evidence["native_events"][3]["payload"] + if mutation == "unmatched_id": + error["toolCallId"] = "another-call" + elif mutation == "generic_error": + error["error"]["message"] = "Tool execution failed" + elif mutation == "wrong_error_type": + error["error"]["type"] = "transport_failed" + elif mutation in {"started", "progress", "result"}: + evidence["native_events"].insert( + 3, + { + "type": "tool.updated", + "payload": {"kind": mutation, "toolCallId": "rejected-search", "result": {"success": True}}, + }, + ) + elif mutation == "conflicting_error": + evidence["native_events"].insert( + 3, + { + "type": "tool.updated", + "payload": { + "kind": "error", + "toolCallId": "rejected-search", + "error": {"type": "tool_execution_failed", "message": "Transport closed"}, + }, + }, + ) + else: + evidence["mcp_calls"] = [{"name": "search_memory", "arguments": {}, "result": {}, "is_error": True}] + write_json(path, [evidence]) + seal_archive(tmp_path) + report = replay(tmp_path) + result = next( + item for item in report["results"] if item["arm"] == "without_skill" and item["case"] == "ordinary-coding" + ) + assert result["status"] == "incomplete" + assert not result["execution_complete"] + assert "turn_1_native_wire_mismatch" in result["failures"] + assert "unnecessary_powercontext_call" in result["failures"] + assert not report["qualified"] + assert all(item["status"] == "passed" for item in report["results"] if item["arm"] == "with_skill") + + +@pytest.mark.parametrize("second_rejected", [False, True]) +def test_rejection_accounts_for_each_native_identity_with_identical_arguments(tmp_path, tools, second_rejected): + complete_archive(tmp_path, tools) + path = tmp_path / "without_skill/ordinary-coding/turns.json" + evidence = native_rejected_search() + evidence["prompt"] = json.loads(path.read_text(encoding="utf-8"))[0]["prompt"] + additional = copy.deepcopy(evidence["native_events"][1 : 4 if second_rejected else 3]) + for event in additional: + event["payload"]["toolCallId"] = "second-search" + evidence["native_events"][4:4] = additional + write_json(path, [evidence]) + seal_archive(tmp_path) + report = replay(tmp_path) + result = next( + item for item in report["results"] if item["arm"] == "without_skill" and item["case"] == "ordinary-coding" + ) + assert "turn_1_native_input_rejected" in result["failures"] + assert "unnecessary_powercontext_call" in result["failures"] + assert ("turn_1_native_wire_mismatch" not in result["failures"]) is second_rejected + assert result["status"] == ("failed" if second_rejected else "incomplete") + assert result["execution_complete"] is second_rejected + assert report["qualified"] is second_rejected diff --git a/integrations/zcode/README.md b/integrations/zcode/README.md index 7a073e7fc2..0886975893 100644 --- a/integrations/zcode/README.md +++ b/integrations/zcode/README.md @@ -11,6 +11,11 @@ The official Windows desktop version 3.14.3 has been verified with live prompt c See [repeatable CLI acceptance](acceptance/README.md) and [manual Windows desktop acceptance](acceptance/desktop.md). The CLI suite distinguishes controlled inference from live models and records individual scenario outcomes; partial runs do not establish complete acceptance. +The [ZCode acceptance workflow](../../.github/workflows/zcode-acceptance.yml) builds a pinned open-source CLI on Windows, +runs all plugin Node tests and two controlled-model core acceptances for PRs, master updates and weekly checks. +The [Skill behavior evaluation](../../evaluation/zcode_guidance/README.md) separately runs live ZCode models with +controlled MCP replies and a no-Skill baseline, covering ordinary coding, empty searches, denied saves and stale +candidate approval. CI verifies its pin and grader offline; live qualification requires a retained model run. SessionStart resolves Scope; resume/compact handlers can restore readonly context. The tested open-source CLI emitted startup/resume; `/compact` did not emit a compact event. Stop optionally processes pending Sources with a 1000 ms budget, diff --git a/integrations/zcode/acceptance/README.md b/integrations/zcode/acceptance/README.md index d912d712c7..8a2de56965 100644 --- a/integrations/zcode/acceptance/README.md +++ b/integrations/zcode/acceptance/README.md @@ -3,6 +3,27 @@ This suite runs an existing ZCode CLI bundle against a real PowerContext Server and a disposable SQLite database. It does not download a host, deploy a remote service, or operate the desktop UI. +## Continuous integration + +[ZCode acceptance](../../../.github/workflows/zcode-acceptance.yml) runs on every pull request, every push to master, +weekly on Sunday at 03:23 UTC, and by manual dispatch. It builds the actual open-source CLI on Windows from ZCode commit +`29628c9acdb81b703bbd4080c207a0e7ce5e276e` (ZCode 3.14.3 / CLI 0.16.9), using Node 24.15.0, pnpm 10.33.2 and the +host's frozen lockfile. Dependency install scripts are disabled; only the CLI and its required workspace builds run. + +The job runs all plugin Node tests, including the actual host test, then explicitly selects two independent +controlled-model acceptances covering P3-01 through P3-09. Host build errors, missing prerequisites and failed scenarios +fail the job. The controlled inference fixtures need no model keys or repository secrets. This is actual CLI + real +Server acceptance with controlled inference; it does not establish live-model or official desktop acceptance. + +The artifact `zcode-controlled--` retains build provenance, each run's `summary.json` and only selected +`evidence/*.json` for 14 days, including completed evidence from failed runs. Private profiles, model configuration, +databases, workspaces and raw transcripts are excluded. The Actions summary lists individual scenario results. +P3-10 stays `not_run`; compact and reconnect limitations remain explicit in the evidence. + +To upgrade the supported host, change `ZCODE_REF` in the workflow to an exact reviewed commit, check its Node/pnpm and +build requirements, and run the complete job. Keep the host pin and this guide consistent. Scheduled runs check the +fixed host against current master and dependency availability; they do not silently adopt newer ZCode releases. + ## Prerequisites - Install this checkout with its development dependencies and Server extras. @@ -69,7 +90,7 @@ these tests. Deselection and unsupported features are not acceptance success. | P3-02 | Missing/wrong Bearer rejection and successful native MCP reads | | P3-03 | Hook Source, scheduler Memory with Source citation, fresh session recall | | P3-04 | Explicit/session/workspace/default priority; separate workspace, profile and endpoint ownership | -| P3-05 | Memory revision conflict; exact nullable Handoff, Receipt and Outcome association | +| P3-05 | Atomic Memory content ETag conflict; exact nullable Handoff, Receipt and Outcome association | | P3-06 | Unchanged inspection, stale approval rejection and separately authorized current approval | | P3-07 | Delayed accepted response, concurrent CLI sessions, unavailable runtime directory | | P3-08 | Actual startup/resume/compact; default and opt-in Stop behavior | @@ -88,6 +109,9 @@ Core success requires P3-01 through P3-09. Lifecycle subfeatures are reported se does not imply `SessionStart(compact)` was emitted. The optional HTTPS scenario does not block local core acceptance; SSH forwarding must be described as forwarding, not direct remote-port HTTPS. +The recorded live-model and desktop Memory checks used the legacy collection/entry contract. They do not establish +current Atomic Memory behavior; repeat those Memory checks before claiming qualification for the current contract. + On Windows with Node 24.15.0 and CLI 0.16.9, the local core suite passed two independent controlled-model runs and two independent live-model runs. Live runs used GLM-5.3 for the host and `openai-chat:glm-5.3-flash` for Generation. Each repetition used a new profile, database and Scope. `/compact` completed without a compact Hook, clear was not diff --git a/pyproject.toml b/pyproject.toml index 0cb60157cf..05b46360fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -199,6 +199,7 @@ exclude = [ "evaluation/coding", "evaluation/memory/longmemeval_v2", "evaluation/skills", + "evaluation/zcode_guidance", ] [tool.ty.rules] diff --git a/tests/e2e/zcode_acceptance/host.py b/tests/e2e/zcode_acceptance/host.py index 0886ad3781..14b0af0bc0 100644 --- a/tests/e2e/zcode_acceptance/host.py +++ b/tests/e2e/zcode_acceptance/host.py @@ -24,6 +24,18 @@ from typing import Any from uuid import uuid4 +_RPC_METHODS = { + "mcp/list", + "session/close", + "session/create", + "session/events", + "session/resume", + "session/send", +} +_PROCESS_EXCEPTION_PREFIX = "[zcode-process-exception] " +_PROCESS_EXCEPTION_KINDS = ("uncaughtException", "unhandledRejection") +_STDERR_LINE_LIMIT = 128 * 1024 + class NativeHost: def __init__(self, run: Any) -> None: @@ -32,6 +44,16 @@ def __init__(self, run: Any) -> None: self.responses: dict[int, dict[str, Any]] = {} self.sequence = 0 self.allowed: set[str] = set() + self.closing = False + self.stdout_eof = False + self.unexpected_stdout_eof = False + self.stdout_eof_returncode: int | None = None + self.stdout_reader_finished = False + self.stderr_reader_finished = False + self.process_exception: dict[str, str] | None = None + self.last_rpc_method: str | None = None + self.last_rpc_id: int | None = None + self.diagnostic_written = False self.process = subprocess.Popen( [str(run.node), str(run.cli), "app-server", "--cwd", str(run.workspace), "--no-color"], cwd=run.workspace, @@ -50,18 +72,45 @@ def __init__(self, run: Any) -> None: def read(self) -> None: assert self.process.stdout is not None - for line in self.process.stdout: - try: - message = json.loads(line) - except ValueError: - continue - self.incoming.put(message) - self.incoming.put(None) + try: + for line in self.process.stdout: + try: + message = json.loads(line) + except ValueError: + continue + self.incoming.put(message) + self.stdout_eof = True + self.unexpected_stdout_eof = not self.closing + self.stdout_eof_returncode = self.process.poll() + self.incoming.put(None) + finally: + self.stdout_reader_finished = True def drain_stderr(self) -> None: assert self.process.stderr is not None - for _ in self.process.stderr: - pass + oversized = False + try: + while line := self.process.stderr.readline(_STDERR_LINE_LIMIT + 1): + if oversized or len(line) > _STDERR_LINE_LIMIT: + oversized = not line.endswith("\n") + continue + if not line.startswith(_PROCESS_EXCEPTION_PREFIX): + continue + try: + value = json.loads(line[len(_PROCESS_EXCEPTION_PREFIX) :]) + except ValueError: + continue + if ( + isinstance(value, dict) + and type(value.get("version")) is int + and value["version"] == 1 + and value.get("kind") in _PROCESS_EXCEPTION_KINDS + and value.get("origin") in _PROCESS_EXCEPTION_KINDS + ): + # Exception text and stacks may contain provider data; retain only fixed enums. + self.process_exception = {"kind": value["kind"], "origin": value["origin"]} + finally: + self.stderr_reader_finished = True def send(self, message: dict[str, Any]) -> None: assert self.process.stdin is not None @@ -114,6 +163,8 @@ def dispatch(self, message: dict[str, Any]) -> None: def request(self, method: str, params: dict[str, Any], *, timeout: float = 30) -> dict[str, Any]: self.sequence += 1 request_id = self.sequence + self.last_rpc_method = method if method in _RPC_METHODS else "other" + self.last_rpc_id = request_id self.send({"id": request_id, "method": method, "params": params}) deadline = min(self.run.budget, time.monotonic() + timeout) while request_id not in self.responses: @@ -166,11 +217,15 @@ def prompt(self, session: str, text: str, *, operations: set[str] | None = None) raise AssertionError("host_turn_timeout") def close(self) -> None: + self.closing = True + returncode_before_cleanup = self.process.poll() + client_terminated = False if self.process.stdin: self.process.stdin.close() try: self.process.wait(timeout=10) except subprocess.TimeoutExpired: + client_terminated = True self.process.terminate() self.process.wait(timeout=10) self.reader.join(timeout=5) @@ -178,3 +233,28 @@ def close(self) -> None: for stream in (self.process.stdout, self.process.stderr): if stream: stream.close() + if not self.diagnostic_written and (self.unexpected_stdout_eof or self.process.returncode != 0): + diagnostic = { + "schema": "powercontext.zcode.host-process.v1", + "stdout_eof": self.stdout_eof, + "unexpected_stdout_eof": self.unexpected_stdout_eof, + "returncode_at_stdout_eof": self.stdout_eof_returncode, + "returncode_before_cleanup": returncode_before_cleanup, + "returncode_final": self.process.returncode, + "client_terminated": client_terminated, + "last_rpc_method": self.last_rpc_method, + "last_rpc_id": self.last_rpc_id, + "stdout_reader_finished": self.stdout_reader_finished, + "stderr_reader_finished": self.stderr_reader_finished, + "process_exception": self.process_exception, + } + try: + directory = self.run.root / "evidence" + directory.mkdir(parents=True, exist_ok=True) + (directory / f"host-rpc-{uuid4().hex}.json").write_text( + json.dumps(diagnostic, indent=2) + "\n", encoding="utf-8" + ) + self.diagnostic_written = True + except OSError: + # Diagnostics must not replace the existing acceptance failure. + pass diff --git a/tests/e2e/zcode_acceptance/lifecycle.py b/tests/e2e/zcode_acceptance/lifecycle.py index ec0204d384..a42e564971 100644 --- a/tests/e2e/zcode_acceptance/lifecycle.py +++ b/tests/e2e/zcode_acceptance/lifecycle.py @@ -17,11 +17,12 @@ from __future__ import annotations import json +import time from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING from .host import NativeHost -from .protocol import strings +from .protocol import CaptureResponseGate, strings if TYPE_CHECKING: from .runner import AcceptanceRun @@ -29,18 +30,38 @@ def runtime_faults(run: AcceptanceRun) -> None: with run.scenario(7) as evidence: - run.observer.capture_response_delay = 1.5 marker = "Synthetic late capture " + run.run_id + prompt_text = marker + ". Reply briefly without calling tools." + gate = CaptureResponseGate(prompt_text) + run.observer.capture_response_gate = gate try: - result = run.invoke(marker + ". Reply briefly without calling tools.") + result = run.invoke(prompt_text) finally: - run.observer.capture_response_delay = 0 + gate.release() + run.observer.capture_response_gate = None + accepted = gate.accepted.wait(max(0, min(5, run.budget - time.monotonic()))) records = run.observations(result["sessionId"]) prompt = next(item for item in records if item["event"] == "UserPromptSubmit") + evidence.append( + run.evidence( + "late-capture-attempt", + { + "capture": prompt["stages"]["capture"], + "accepted_receipt_within_budget": accepted, + "response_gate": gate.evidence(), + }, + ) + ) + assert accepted, "late_capture_fault_did_not_hold_accepted_response" + assert not gate.release_timed_out, "late_capture_response_gate_expired" assert prompt["stages"]["capture"]["state"] == "unknown", "late_write_not_observed_as_unknown" sources = run.client.get(f"/v1/scopes/{run.scope_id}/sources").json()["items"] captures = [item for item in sources if marker in item["content"]] assert len(captures) == 1, "late_capture_server_identity_missing" + assert gate.receipt == { + "source": {"name": "content", "source_id": captures[0]["source_id"]}, + "position": captures[0]["position"], + }, "late_capture_gate_receipt_identity_mismatch" before = len(run.wire.received_paths) status = run.process([ str(run.node), @@ -109,7 +130,7 @@ def runtime_faults(run: AcceptanceRun) -> None: run.evidence( "runtime-faults", { - "late_response_injection_seconds": 1.5, + "late_response_injection": "accepted_receipt_held_until_host_turn_completed", "capture_state": "unknown", "accepted_source_readback": {"name": "content", "source_id": captures[0]["source_id"]}, "status_read_only": True, diff --git a/tests/e2e/zcode_acceptance/protocol.py b/tests/e2e/zcode_acceptance/protocol.py index 5b470b7dad..ceeb8f9160 100644 --- a/tests/e2e/zcode_acceptance/protocol.py +++ b/tests/e2e/zcode_acceptance/protocol.py @@ -19,7 +19,9 @@ import asyncio import json import re +import threading from collections.abc import Callable +from hashlib import sha256 from typing import Any from fastapi import FastAPI, Request @@ -88,11 +90,53 @@ def result(self, name: str, start: int = 0) -> dict[str, Any]: raise AssertionError(message) +class CaptureResponseGate: + """Withhold one accepted capture receipt until its owning host turn has completed.""" + + def __init__(self, content: str, *, release_timeout: float = 185) -> None: + self.content = content + self.release_timeout = release_timeout + self.accepted = threading.Event() + self._released = threading.Event() + self.receipt: dict[str, Any] | None = None + self.release_timed_out = False + + def release(self) -> None: + self._released.set() + + async def hold(self, response: dict[str, Any]) -> None: + source = response.get("source", {}) + assert response.get("status") == "accepted" and source.get("name") == "content", ( + "capture_gate_accepted_receipt_missing" + ) + assert isinstance(source.get("source_id"), str) and source["source_id"], "capture_gate_source_identity_missing" + assert isinstance(response.get("position"), int) and response["position"] > 0, ( + "capture_gate_source_position_missing" + ) + self.receipt = { + "source": {"name": "content", "source_id": source["source_id"]}, + "position": response["position"], + } + self.accepted.set() + if not await asyncio.to_thread(self._released.wait, self.release_timeout): + self.release_timed_out = True + raise AssertionError("capture_response_gate_release_timeout") + + def evidence(self) -> dict[str, Any]: + return { + "target_content_digest": "sha256:" + sha256(self.content.encode()).hexdigest(), + "accepted": self.accepted.is_set(), + "accepted_receipt": self.receipt, + "explicitly_released": self._released.is_set(), + "release_timed_out": self.release_timed_out, + } + + class ObserveServer: def __init__(self, app: ASGIApp, evidence: WireEvidence) -> None: self.app = app self.evidence = evidence - self.capture_response_delay = 0.0 + self.capture_response_gate: CaptureResponseGate | None = None async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: # noqa: C901 if scope["type"] != "http": @@ -104,8 +148,9 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: # request_body = bytearray() response_body = bytearray() status = 0 - delayed_messages: list[Message] = [] - delay = self.capture_response_delay if scope["path"] == "/v1/sources/content" else 0 + held_messages: list[Message] = [] + gate = self.capture_response_gate if scope["path"] == "/v1/sources/content" else None + hold_response = False async def receive_observed() -> Message: message = await receive() @@ -117,9 +162,10 @@ async def receive_observed() -> Message: return message async def send_observed(message: Message) -> None: - nonlocal status + nonlocal status, hold_response if message["type"] == "http.response.start": status = message["status"] + hold_response = bool(gate and status == 202 and arrived["request"].get("content") == gate.content) elif message["type"] == "http.response.body": response_body.extend(message.get("body", b"")) assert len(response_body) <= 1_048_576, "acceptance response exceeded evidence limit" @@ -130,15 +176,16 @@ async def send_observed(message: Message) -> None: "request": decode_response(bytes(request_body)) if request_body else {}, "response": decode_response(bytes(response_body)), }) - if delay: - delayed_messages.append(message) + if hold_response: + held_messages.append(message) else: await send(message) await self.app(scope, receive_observed, send_observed) - if delay: - await asyncio.sleep(delay) - for message in delayed_messages: + if hold_response: + assert gate is not None + await gate.hold(decode_response(bytes(response_body))) + for message in held_messages: await send(message) @@ -238,34 +285,42 @@ def chunks(): def generate_memory(self, body: dict[str, Any]) -> JSONResponse: """Public inference protocol for real scheduler worker processes, not an injected Runtime pipeline.""" self.generation_requests.append(body) - payload = None + payload: dict[str, Any] | None = None for text in strings(body.get("messages", [])): try: value = json.loads(text) except ValueError: continue - if isinstance(value, dict) and "evidence" in value and "current_entries" in value: + if isinstance(value, dict) and "evidence" in value: payload = value break assert payload is not None or not body.get("tools"), "generation_input_missing" - payload = payload or {"evidence": [], "current_entries": []} - candidates = [] - existing = {entry["text"] for entry in payload["current_entries"]} - for item in payload["evidence"]: - match = re.search( - r"In the synthetic (\w+) project, the verification color is (\w+-\d+)\.", - "\n".join(strings(item["content"])), + payload = payload or {"evidence": []} + if "proposal" in payload: + proposal, related = payload["proposal"], payload["related"] + duplicate = next( + (item for item in related if (item["kind"], item["text"]) == (proposal["kind"], proposal["text"])), + None, ) - if match and match[0] not in existing: - candidates.append({ - "intent": "add", - "kind": "fact", - "text": match[0], - "evidence_ids": [item["evidence_id"]], - "entry_id": None, - "reason": "Synthetic source evidence", - }) - output = json.dumps({"candidates": candidates}) + value = { + "action": "noop" if duplicate else "create", + "compared_ids": [item["item_id"] for item in related], + "target_ids": [duplicate["item_id"]] if duplicate else [], + "content": None if duplicate else {"kind": proposal["kind"], "text": proposal["text"]}, + "evidence_ids": [] if duplicate else proposal["evidence_ids"], + "reason": "Already represented" if duplicate else "Independent synthetic source evidence", + } + else: + candidates = [] + for item in payload["evidence"]: + match = re.search( + r"In the synthetic (\w+) project, the verification color is (\w+-\d+)\.", + "\n".join(strings(item["content"])), + ) + if match: + candidates.append({"kind": "fact", "text": match[0], "evidence_ids": [item["evidence_id"]]}) + value = {"candidates": candidates} + output = json.dumps(value) tools = body.get("tools", []) message = {"role": "assistant", "content": output} if tools: diff --git a/tests/e2e/zcode_acceptance/runner.py b/tests/e2e/zcode_acceptance/runner.py index 11294ea603..e6dd49c6ce 100644 --- a/tests/e2e/zcode_acceptance/runner.py +++ b/tests/e2e/zcode_acceptance/runner.py @@ -464,7 +464,7 @@ def create_observed_server(): self.summary["host_version"] = version.stdout.strip() approved_operations = [ "remember_memory", - "revise_memory_entry", + "replace_artifact", "handoff_current_work", "commit_handoff", "acknowledge_handoff", @@ -575,21 +575,31 @@ def automatic_memory(self) -> None: source_ref = {"name": "content", "source_id": source["source_id"]} deadline = min(self.budget, time.monotonic() + (180 if self.live else 20)) generated = None - inventory: dict[str, Any] = {} + published: dict[str, Any] = {} while time.monotonic() < deadline: inventory = self.post("/v1/memory/entries/list", {"scope_id": self.scope_id}) generated = next( - ( - item - for item in inventory["entries"] - if marker in item["text"] and source_ref in item["source_refs"] - ), + (item for item in inventory["entries"] if marker in item["text"] and item["state"] == "active"), None, ) if generated: - break + reference = generated["artifact"] + assert reference["family"] == "atomic-memory" and reference["revision"] >= 1 + response = self.client.get( + f"/v1/scopes/{self.scope_id}/artifacts/{reference['family']}/" + f"{reference['artifact_id']}/revisions/{reference['revision']}" + ) + response.raise_for_status() + published = response.json() + assert published["scope_id"] == self.scope_id + assert {key: published[key] for key in reference} == reference + assert published["content"]["text"] == generated["text"] + if {"source_type": "content", "source_id": source["source_id"]} in published["sources"]: + break + generated = None time.sleep(0.1) - assert generated and inventory["memory"], "scheduler_memory_timeout_or_missing_source_evidence" + assert generated and published, "scheduler_memory_timeout_or_missing_source_evidence" + assert isinstance(generated["state_version"], int) and generated["state_version"] >= 0 assert not any(item["path"] == "/v1/memory/flush" for item in self.wire.records[start:]) assert not self.wire.calls("remember_memory", start), "auto_chain_substituted_manual_memory" second = self.invoke( @@ -616,9 +626,9 @@ def automatic_memory(self) -> None: "scope_id": self.scope_id, "source": source_ref, "position": source["position"], - "memory": inventory["memory"], - "citation": generated["citation"], - "source_refs": generated["source_refs"], + "memory_artifact": generated["artifact"], + "memory_state_version": generated["state_version"], + "sources": published["sources"], "independent_sessions": True, "random_marker_recalled": True, "prepare": "ready", diff --git a/tests/test_zcode_acceptance_host.py b/tests/test_zcode_acceptance_host.py new file mode 100644 index 0000000000..8aafc68c49 --- /dev/null +++ b/tests/test_zcode_acceptance_host.py @@ -0,0 +1,123 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Real subprocess regressions for safe acceptance-host shutdown evidence.""" + +import json +import os +import sys +import time +from types import SimpleNamespace + +import pytest + +from tests.e2e.zcode_acceptance.host import NativeHost + + +def make_host(tmp_path, source: str) -> NativeHost: + cli = tmp_path / "host.py" + cli.write_text(source, encoding="utf-8") + workspace = tmp_path / "workspace" + workspace.mkdir() + return NativeHost( + SimpleNamespace( + root=tmp_path, + # Windows venv redirectors retain pipe handles while their CPython child runs. + node=getattr(sys, "_base_executable", sys.executable), + cli=cli, + workspace=workspace, + environment=dict(os.environ), + budget=time.monotonic() + 30, + ) + ) + + +def test_stdout_eof_does_not_imply_process_exit_and_retains_only_safe_diagnostics(tmp_path): + host = make_host( + tmp_path, + """import json, os, sys +sys.stdin.readline() +diagnostic = { + 'version': 1, 'kind': 'unhandledRejection', 'origin': 'unhandledRejection', + 'name': 'private-error-name', 'message': 'private-provider-token', + 'stack': 'private-profile-path', 'errorId': 'private-id', 'occurredAt': 42, +} +sys.stderr.write('[zcode-process-exception] ' + json.dumps(diagnostic) + '\\n') +sys.stderr.flush() +os.close(1) +sys.stdin.read() +""", + ) + try: + with pytest.raises(AssertionError, match=r"^host_rpc_process_exited$"): + host.request("session/events", {"sessionId": "private-session"}, timeout=5) + assert host.process.poll() is None + assert host.process.stdin is not None and not host.process.stdin.closed + finally: + host.close() + files = list((tmp_path / "evidence").glob("host-rpc-*.json")) + assert len(files) == 1 + serialized = files[0].read_text(encoding="utf-8") + evidence = json.loads(serialized) + assert evidence["unexpected_stdout_eof"] is True + assert evidence["returncode_at_stdout_eof"] is None + assert evidence["returncode_before_cleanup"] is None + assert evidence["returncode_final"] == 0 + assert evidence["client_terminated"] is False + assert evidence["last_rpc_method"] == "session/events" and evidence["last_rpc_id"] == 1 + assert evidence["stdout_reader_finished"] is True and evidence["stderr_reader_finished"] is True + assert evidence["process_exception"] == {"kind": "unhandledRejection", "origin": "unhandledRejection"} + assert "private" not in serialized + + +def test_nonzero_exit_records_unknown_method_without_unsafe_or_oversized_stderr(tmp_path): + host = make_host( + tmp_path, + """import json, sys +request = json.loads(sys.stdin.readline()) +print(json.dumps({'id': request['id'], 'result': {}}), flush=True) +sys.stdin.read() +sys.stderr.write('[zcode-process-exception] ' + json.dumps({ + 'version': 1, 'kind': 'uncaughtException', 'origin': 'uncaughtException', + 'message': 'private-' + 'x' * (128 * 1024), +}) + '\\n') +sys.stderr.write('[zcode-process-exception] ' + json.dumps({ + 'version': 1, 'kind': 'private-kind', 'origin': 'private-origin', +}) + '\\n') +sys.stderr.write('private-unstructured-stderr\\n') +sys.exit(7) +""", + ) + try: + assert host.request("private-method", {}) == {} + finally: + host.close() + files = list((tmp_path / "evidence").glob("host-rpc-*.json")) + assert len(files) == 1 + serialized = files[0].read_text(encoding="utf-8") + evidence = json.loads(serialized) + assert evidence["unexpected_stdout_eof"] is False + assert evidence["returncode_final"] == 7 + assert evidence["client_terminated"] is False + assert evidence["last_rpc_method"] == "other" and evidence["last_rpc_id"] == 1 + assert evidence["process_exception"] is None + assert evidence["stderr_reader_finished"] is True + assert "private" not in serialized + + +def test_normal_shutdown_does_not_emit_host_failure_evidence(tmp_path): + host = make_host(tmp_path, "import sys; sys.stdin.read()\n") + host.close() + assert host.process.returncode == 0 + assert not (tmp_path / "evidence").exists() diff --git a/tests/test_zcode_acceptance_protocol.py b/tests/test_zcode_acceptance_protocol.py new file mode 100644 index 0000000000..cf466103e9 --- /dev/null +++ b/tests/test_zcode_acceptance_protocol.py @@ -0,0 +1,191 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""The acceptance fault withholds an actual app receipt, independently of client timer scheduling.""" + +import asyncio +import json + +import httpx +import pytest +from pydantic_ai.models.openai import OpenAIChatModel +from pydantic_ai.providers.openai import OpenAIProvider +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from powercontext.builtin.artifacts.atomic_memory.extraction import ( + AtomicMemoryEvidence, + AtomicMemoryExtractionInput, + AtomicMemoryExtractionOutput, +) +from powercontext.builtin.artifacts.atomic_memory.reconciliation import ( + AtomicMemoryComparisonItem, + AtomicMemoryReconciliationInput, + AtomicMemoryReconciliationOutput, + validate_reconciliation_output, +) +from powercontext.builtin.inference.pydantic_ai import PydanticAIStructuredGenerator +from powercontext.sources import SourceRef + +from .e2e.zcode_acceptance.protocol import CaptureResponseGate, HostModelFixture, ObserveServer, WireEvidence + + +def capture_app(writes: list[str], *, reject: bool = False) -> ASGIApp: + async def app(scope: Scope, receive: Receive, send: Send) -> None: + if scope["path"] == "/health/ready": + status, body = 200, {"ready": True} + else: + request = json.loads((await receive())["body"]) + if reject: + status, body = 422, {"error": "invalid_request"} + else: + writes.append(request["content"]) + status, body = ( + 202, + { + "status": "accepted", + "source": {"name": "content", "source_id": "source-" + str(len(writes))}, + "position": len(writes), + }, + ) + await send({"type": "http.response.start", "status": status, "headers": []}) + await send({"type": "http.response.body", "body": json.dumps(body).encode()}) + + return app + + +async def exchange( + observer: ObserveServer, content: str, sent: list[Message], *, path: str = "/v1/sources/content" +) -> None: + async def receive() -> Message: + return {"type": "http.request", "body": json.dumps({"content": content}).encode()} + + async def send(message: Message) -> None: + sent.append(message) + + await observer({"type": "http", "path": path}, receive, send) + + +def test_capture_gate_holds_only_target_accepted_response_until_release(): + async def check(): + writes = [] + wire = WireEvidence() + observer = ObserveServer(capture_app(writes), wire) + gate = CaptureResponseGate("target capture") + observer.capture_response_gate = gate + target_sent = [] + target = asyncio.create_task(exchange(observer, gate.content, target_sent)) + try: + assert await asyncio.to_thread(gate.accepted.wait, 2) + assert writes == [gate.content] + assert wire.records[0]["status"] == 202 + assert target_sent == [] + assert not target.done() + + health_sent, other_sent = [], [] + await asyncio.wait_for(exchange(observer, "", health_sent, path="/health/ready"), 2) + await asyncio.wait_for(exchange(observer, "other capture", other_sent), 2) + assert health_sent[0]["status"] == 200 + assert other_sent[0]["status"] == 202 + assert writes == [gate.content, "other capture"] + assert target_sent == [] + assert gate.evidence()["accepted_receipt"] == { + "source": {"name": "content", "source_id": "source-1"}, + "position": 1, + } + finally: + gate.release() + await asyncio.wait_for(target, 2) + assert target_sent[0]["status"] == 202 + assert json.loads(target_sent[1]["body"])["source"]["source_id"] == "source-1" + assert not gate.release_timed_out + + asyncio.run(check()) + + +def test_capture_gate_does_not_hold_a_rejected_target_request(): + gate = CaptureResponseGate("target capture") + observer = ObserveServer(capture_app([], reject=True), WireEvidence()) + observer.capture_response_gate = gate + sent = [] + asyncio.run(asyncio.wait_for(exchange(observer, gate.content, sent), 2)) + assert sent[0]["status"] == 422 + assert not gate.accepted.is_set() + + +def test_capture_gate_has_bounded_failure_without_release(): + gate = CaptureResponseGate("target capture", release_timeout=0.01) + observer = ObserveServer(capture_app([]), WireEvidence()) + observer.capture_response_gate = gate + sent = [] + with pytest.raises(AssertionError, match="capture_response_gate_release_timeout"): + asyncio.run(exchange(observer, gate.content, sent)) + assert gate.accepted.is_set() + assert gate.release_timed_out + assert sent == [] + + +@pytest.mark.parametrize("duplicate", [False, True]) +def test_generation_fixture_round_trips_atomic_source_extraction_and_reconciliation(duplicate): + async def scenario(): + fixture = HostModelFixture() + evidence = AtomicMemoryEvidence( + evidence_id="captured-source-evidence", + source_ref=SourceRef(source_type="content", source_id="captured-color-policy"), + journal_position=1, + content="In the synthetic Orion project, the verification color is copper-7319.", + source_metadata={}, + ) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=fixture.app)) as client: + model = OpenAIChatModel( + "memory-fixture", + provider=OpenAIProvider(base_url="http://fixture/v1", api_key="synthetic-only", http_client=client), + ) + extractor = PydanticAIStructuredGenerator( + model=model, + instructions="Extract only the supplied Source facts and cite their evidence IDs.", + input_type=AtomicMemoryExtractionInput, + output_type=AtomicMemoryExtractionOutput, + ) + extraction = await extractor.generate(AtomicMemoryExtractionInput(evidence=(evidence,))) + (candidate,) = extraction.output.candidates + assert candidate.text == evidence.content + assert candidate.evidence_ids == (evidence.evidence_id,) + proposal = AtomicMemoryComparisonItem( + item_id="new-candidate", + kind=candidate.kind, + text=candidate.text, + original_refs=(), + evidence_ids=candidate.evidence_ids, + ) + related: tuple[AtomicMemoryComparisonItem, ...] = tuple( + [proposal.model_copy(update={"item_id": "existing-working-item"})] if duplicate else [] + ) + request = AtomicMemoryReconciliationInput(proposal=proposal, related=related, evidence=(evidence,)) + reconciler = PydanticAIStructuredGenerator( + model=model, + instructions="Reconcile against every supplied related item; preserve supplied evidence identities.", + input_type=AtomicMemoryReconciliationInput, + output_type=AtomicMemoryReconciliationOutput, + ) + output = (await reconciler.generate(request)).output + validate_reconciliation_output(request, output) + if duplicate: + assert output.action == "noop" + assert output.target_ids == (related[0].item_id,) + else: + assert output.action == "create" + assert output.content is not None and output.content.text == evidence.content + assert output.evidence_ids == candidate.evidence_ids + + asyncio.run(scenario())