# PEP 639 SPDX license expression (`license = "MIT"` below) requires
# setuptools>=77. Keep this floor in lockstep with the `license` form in
# [project]; an older build backend rejects the string form.
[build-system]
requires = ["setuptools>=77.0,<83"]
build-backend = "setuptools.build_meta"

[project]
name = "hermes-agent"
version = "0.17.0"
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
readme = "README.md"
# Upper bound is load-bearing, not cosmetic. uv resolves the project's
# Python from `requires-python`, and an inherited `UV_PYTHON` env var (or a
# fresh distro whose newest interpreter uv auto-picks) will otherwise select
# 3.14, where Rust-backed transitives (e.g. pydantic-core) have no cp314
# wheel yet and fall back to a maturin source build that fails. Capping at
# <3.14 makes uv refuse 3.14 with a clear error instead of attempting that
# build. Raise the ceiling once our Rust transitives ship cp314 wheels.
requires-python = ">=3.11,<3.14"
authors = [{ name = "Nous Research" }]
license = "MIT"
license-files = ["LICENSE"]
dependencies = [
  # Core — every direct dep is exact-pinned to ==X.Y.Z (no ranges).
  # Rationale: ranges allow PyPI to ship a fresh version of a transitive
  # at any time without a code review on our side. Exact pins mean the
  # only way a new package version reaches a user is via an intentional
  # update on our end (bump the pin in this file, regenerate uv.lock).
  # This was tightened on 2026-05-12 in response to the Mini Shai-Hulud
  # worm hitting mistralai 2.4.6 on PyPI; if that release had been
  # captured by `mistralai>=2.3.0,<3` rather than an exact pin, every
  # install in the hours before the quarantine would have pulled it.
  #
  # When updating: bump the version below AND regenerate uv.lock with
  # `uv lock` so the transitive resolution stays consistent. Don't
  # introduce ranges back without a written justification.
  #
  # Scope rule: only packages used by EVERY hermes session belong here.
  # Anything that's provider-specific (`anthropic`, `firecrawl-py`,
  # `exa-py`, `fal-client`, `edge-tts`, `parallel-web`) belongs in an
  # extra and gets lazy-installed via `tools/lazy_deps.py` when the
  # user picks that backend. Smaller `dependencies` = smaller blast
  # radius for the next supply-chain attack.
  "openai==2.24.0",
  "certifi==2026.5.20",
  "python-dotenv==1.2.2",
  "fire==0.7.1",
  "httpx[socks]==0.28.1",
  "rich==14.3.3",
  "tenacity==9.1.4",
  "pyyaml==6.0.3",
  "ruamel.yaml==0.18.17",
  "requests==2.33.0",  # CVE-2026-25645
  "jinja2==3.1.6",
  # Bumped from 2.12.5 to 2.13.4 to pull in pydantic-core 2.46.4.
  # pydantic-core 2.41.5 (pulled by 2.12.5) segfaults when the OpenAI SDK's
  # Responses API resource is exercised from a non-main thread, which is the
  # codex_responses dispatch in agent/chat_completion_helpers.py:_call.
  "pydantic==2.13.4",
  # Interactive CLI (prompt_toolkit is used directly by cli.py)
  "prompt_toolkit==3.0.52",
  # Cron scheduler (built-in feature — scheduled cron/interval jobs use croniter).
  "croniter==6.0.0",
  # ``packaging`` is imported directly on three production paths but was never
  # declared, so it only reached users transitively (pip/uv pull it for other
  # tools). The slim official Docker image ships without it, where the
  # try/except-ImportError fallbacks silently degrade: Hindsight's
  # ``_meets_minimum_version`` disables update_mode='append' (#40503),
  # tools/lazy_deps.py treats every version constraint as satisfied, and
  # hermes_cli/main.py drops to naive requirement parsing. Pure-Python
  # py3-none-any wheel, no compiled extensions — safe to ship everywhere.
  # Pinned to the version already resolved in uv.lock (no resolution churn).
  "packaging==26.0",
  # Markdown -> HTML conversion for rich message delivery (Matrix
  # `formatted_body`, and the `send_message` tool's HTML path). Now on the
  # DEFAULT delivery path, not matrix-specific: without it both
  # gateway/platforms/matrix.py and tools/send_message_tool.py silently fall
  # back to plain text, so cron/agent deliveries render raw `##`/`**`/tables
  # in clients like Element (see #32486). Pure-Python py3-none-any wheel
  # (~108KB, no compiled extensions, no platform constraints), so unlike the
  # matrix extra's `mautrix`/`python-olm` it's safe to ship everywhere — keeps
  # it out of the lazy-install path that exists only for the heavy matrix deps.
  "Markdown==3.10.2",
  # Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
  "PyJWT[crypto]==2.13.0",  # PYSEC-2026-175/177/178/179
  # urllib3 2.7.0 fixes GHSA-mf9v-mfxr-j63j (decompression-bomb bypass)
  # and GHSA-qccp-gfcp-xxvc (header leak across origins).
  "urllib3>=2.7.0,<3",
  # Windows has no IANA tzdata shipped with the OS, so Python's ``zoneinfo``
  # (PEP 615) raises ``ZoneInfoNotFoundError`` for every non-UTC timezone
  # out of the box.  ``tzdata`` ships the Olson database as a data package
  # Python resolves automatically.  No-op on Linux/macOS (which have
  # /usr/share/zoneinfo).  Credits: PR #13182 (@sprmn24).
  "tzdata==2025.3; sys_platform == 'win32'",
  # Cross-platform process / PID management.  `psutil` is the canonical
  # answer for "is this PID alive" and process-tree walking across Linux,
  # macOS and Windows.  It replaces POSIX-only idioms like `os.kill(pid, 0)`
  # (which is a silent killer on Windows — see CONTRIBUTING.md) and
  # `os.killpg` (which doesn't exist on Windows).
  "psutil==7.2.2",
  # Browser CDP supervisor + browser_dialog import this directly. Keep core
  # so browser tool discovery doesn't fail on lean installs.
  "websockets==15.0.1",
  # .gitignore-aware file matching for desktop build stamp.
  "pathspec==1.1.1",
  "fastapi>=0.104.0,<1",
  "uvicorn[standard]>=0.24.0,<1",
  # Streaming multipart uploads for the dashboard file manager (NS-501).
  # FastAPI's UploadFile/Form depend on python-multipart; it is NOT pulled in
  # by fastapi itself, so the dashboard's multipart upload endpoint would 500
  # without an explicit dependency here (and in the `web` extra below).
  "python-multipart>=0.0.9,<1",
  "ptyprocess>=0.7.0,<1; sys_platform != 'win32'",
  "pywinpty>=2.0.0,<3; sys_platform == 'win32'",
  # Image resize recovery for the vision tools. Pillow shrinks oversized images
  # (>5 MB or >8000px) at embed time; without it the byte AND pixel-dimension
  # shrink paths no-op, so an oversized image bakes into immutable history and
  # bricks the session on Anthropic's non-retryable 400. Pure-wheel, no system
  # libs required for the codecs we use, so it's safe to ship in the base
  # install rather than gating it behind an extra + a mid-session lazy install
  # (which deadlocked the CLI under prompt_toolkit — see #40490).
  "Pillow==12.2.0",
  # Windows log rotation. Stdlib ``RotatingFileHandler.doRollover()`` uses
  # ``os.rename()`` which fails with ``PermissionError [WinError 32]`` on
  # Windows whenever any other process holds an append-mode handle on
  # ``agent.log`` (always the case in Hermes — TUI, gateway, ``hy_memory``
  # server, MCP servers, and on-demand CLI commands all log from separate
  # processes), pinning ``agent.log`` at the 5 MiB threshold and spamming
  # stderr on every emit (see #44873). ``concurrent-log-handler`` wraps the
  # rename in a cross-process file lock (via ``portalocker``: pywin32 on
  # Windows) so only one process rotates at a time. ``hermes_logging.py``
  # aliases it ONLY on Windows — POSIX renames an open file fine, so stdlib
  # already works there and managed-mode perms depend on its exact lifecycle.
  # Hence the ``sys_platform == 'win32'`` marker: the dep (and its portalocker
  # / pywin32 tree) ships only where it's actually used.
  "concurrent-log-handler==0.9.29; sys_platform == 'win32'",
]

[project.optional-dependencies]
# Native Anthropic provider — only needed when provider=anthropic (not via
# OpenRouter or other aggregators).
anthropic = ["anthropic==0.87.0"]  # CVE-2026-34450, CVE-2026-34452
# Web search backends — each only loaded when the user picks it as their
# search provider (configured via `hermes tools` or config.yaml).
exa = ["exa-py==2.10.2"]
firecrawl = ["firecrawl-py==4.17.0"]
parallel-web = ["parallel-web==0.4.2"]
# Image generation backends
fal = ["fal-client==0.13.1"]
# Edge TTS — default TTS provider but still optional (users can pick
# ElevenLabs / OpenAI / MiniMax instead).
edge-tts = ["edge-tts==7.2.7"]
modal = ["modal==1.3.4"]
daytona = ["daytona==0.155.0"]
hindsight = ["hindsight-client==0.6.1"]
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==81.0.0"]  # starlette: CVE-2026-48710; setuptools: latest <82 (torch >=2.11 caps setuptools<82)
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.4", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"]  # aiohttp: CVE-2026-34513/34518/34519/34520/34525
cron = []  # croniter is now a core dependency; this extra kept for back-compat
slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.4"]
matrix = ["mautrix[encryption]==0.21.0", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"]
# WeCom callback-mode adapter — parses untrusted XML POST bodies from
# WeCom-controlled callback endpoints, so we use defusedxml (drop-in
# replacement for stdlib xml.etree.ElementTree) to block billion-laughs
# and XXE. aiohttp/httpx are already in [messaging]; defusedxml lands
# here to keep the dependency local to wecom_callback's threat model.
wecom = ["defusedxml==0.7.1"]
cli = ["simple-term-menu==1.6.6"]
tts-premium = ["elevenlabs==1.59.0"]
voice = [
  # Local STT pulls in wheel-only transitive deps (ctranslate2, onnxruntime),
  # so keep it out of the base install for source-build packagers like Homebrew.
  "faster-whisper==1.2.1",
  "sounddevice==0.5.5",
  "numpy==2.4.3",
]
pty = [
  # Kept as a no-op back-compat alias — `ptyprocess` and `pywinpty` are now
  # in the main `dependencies` list (with the same platform markers), so
  # any existing `pip install hermes-agent[pty]` invocations resolve cleanly
  # without pulling in extra packages.
]
honcho = ["honcho-ai==2.0.1"]
# Image resize recovery for the vision tools. Pillow is now a CORE dependency
# (see the main `dependencies` list) since the byte/pixel shrink paths are on
# the default vision-embed path and the mid-session lazy install deadlocked the
# CLI under prompt_toolkit (#40490). This extra is kept as a no-op back-compat
# alias so existing `pip install hermes-agent[vision]` invocations still resolve.
vision = []
# CVE-2026-48710 (BadHost): Starlette is pulled transitively by mcp's
# sse-starlette / HTTP-SSE stack (and by fastapi in the `web` extra). Before
# 1.0.1, a malformed Host header makes `request.url.path` desync from the path
# the ASGI router actually dispatched, so middleware/endpoints that gate on
# `request.url` can be bypassed. We pin a patched Starlette directly in every
# extra that exposes a Starlette-backed server surface so pip/uv can't resolve
# a vulnerable pre-1.0.1 transitive. Bump in lockstep with uv.lock.
mcp = ["mcp==1.26.0", "starlette==1.0.1"]  # starlette: CVE-2026-48710
nemo-relay = ["nemo-relay==0.3"]
homeassistant = ["aiohttp==3.13.4"]
sms = ["aiohttp==3.13.4"]
teams = ["microsoft-teams-apps==2.0.13.4", "aiohttp==3.13.4"]
# Computer use — macOS background desktop control via cua-driver (MCP stdio).
# The cua-driver binary itself is installed via `hermes tools` post-setup
# (curl install script); this extra just pins the MCP client used to talk
# to it, which is already provided by the `mcp` extra.
computer-use = ["mcp==1.26.0", "starlette==1.0.1"]  # starlette: CVE-2026-48710
acp = ["agent-client-protocol==0.9.0"]
# mistral: Voxtral STT + TTS. Pinned to an exact verified-clean version.
# The `mistralai` PyPI project was quarantined 2026-05-12 after the malicious
# 2.4.6 release (Mini Shai-Hulud worm); 2.4.6 was removed from PyPI and the
# project is serving clean releases again (2.4.7 2026-05-25, 2.4.8 2026-05-28).
# Like other opt-in TTS/STT backends, this is lazy-installed via
# tools/lazy_deps.py (stt.mistral / tts.mistral) at first use — deliberately
# NOT re-added to [all] so a future quarantined release can't break fresh
# installs (see [all] policy comment below).
mistral = ["mistralai==2.4.8"]
bedrock = ["boto3==1.42.89"]
azure-identity = ["azure-identity==1.25.3"]
termux = [
  # Baseline Android / Termux path for reliable fresh installs.
  "python-telegram-bot[webhooks]==22.6",
  "hermes-agent[cron]",
  "hermes-agent[cli]",
  "hermes-agent[pty]",
  "hermes-agent[mcp]",
  "hermes-agent[honcho]",
  "hermes-agent[acp]",
]
termux-all = [
  # Best-effort "install all" profile for Termux. Same policy as [all]:
  # only includes extras that aren't covered by `tools/lazy_deps.py`.
  # Backends like telegram/slack/dingtalk/feishu/honcho lazy-install at
  # first use, so they're no longer eager-installed here.
  "hermes-agent[termux]",
  "hermes-agent[google]",
  "hermes-agent[homeassistant]",
  "hermes-agent[sms]",
  "hermes-agent[web]",
]
dingtalk = ["dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.42", "qrcode==7.4.2"]
feishu = ["lark-oapi==1.5.3", "qrcode==7.4.2"]
google = [
  # Required by the google-workspace skill (Gmail, Calendar, Drive, Contacts,
  # Sheets, Docs).  Declared here so packagers (Nix, Homebrew) ship them with
  # the [all] extra and users don't hit runtime `pip install` paths that fail
  # in environments without pip (e.g. Nix-managed Python).
  "google-api-python-client==2.194.0",
  "google-auth-oauthlib==1.3.1",
  "google-auth-httplib2==0.3.1",
]
youtube = [
  # Required by skills/media/youtube-content and
  # optional-skills/productivity/memento-flashcards (youtube_quiz.py).
  # Without this declaration uv sync omits the package and both skills fail
  # at first invocation with ModuleNotFoundError (issue #22243).
  "youtube-transcript-api==1.2.4",
]
# `hermes dashboard` (localhost SPA + API).  Not in core to keep the default install lean.
# starlette==1.0.1 pinned for CVE-2026-48710 (BadHost) — fastapi pulls Starlette
# transitively and pre-1.0.1 is the vulnerable range. See the mcp extra above.
web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.0.1", "python-multipart==0.0.27"]
all = [
  # Policy (2026-05-12): `[all]` includes only extras that genuinely
  # CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every
  # session can use, things needed before the agent loop is alive
  # (terminal/CLI), and skill deps that packagers (Nix, AUR, Homebrew)
  # need in the wheel. Anything an opt-in backend (provider, search,
  # TTS, image, memory, messaging platform, terminal sandbox) needs
  # MUST live exclusively in `LAZY_DEPS` and resolve at first use —
  # otherwise one quarantined PyPI release breaks every fresh install.
  #
  # Removed from [all] on 2026-05-12 (covered by lazy-install):
  #   anthropic, exa, firecrawl, parallel-web, fal, edge-tts,
  #   modal, daytona, messaging (telegram/discord/slack),
  #   matrix, slack, honcho, voice (faster-whisper),
  #   dingtalk, feishu, bedrock, tts-premium (elevenlabs)
  #
  # Why: the matrix extra in particular pulls `mautrix[encryption]`
  # which depends on `python-olm`. python-olm has Linux-only wheels and
  # no native build path on Windows or modern macOS. With matrix in
  # [all], `uv sync --locked` on Windows tried to build it from sdist
  # and failed on `make`. Lazy-install routes that build to first use,
  # where the user is expected to have a toolchain available.
  "hermes-agent[cron]",
  "hermes-agent[cli]",
  "hermes-agent[pty]",
  "hermes-agent[mcp]",
  "hermes-agent[homeassistant]",
  "hermes-agent[sms]",
  "hermes-agent[acp]",
  "hermes-agent[google]",
  "hermes-agent[web]",
  "hermes-agent[youtube]",
]

[project.scripts]
hermes = "hermes_cli.main:main"
hermes-agent = "run_agent:main"
hermes-acp = "acp_adapter.entry:main"
dualcoach_admin = "gateway.platforms.dualcoach_admin:main"
dualcoach_tasks21_25_controller = "gateway.platforms.dualcoach_tasks21_25_controller:main"

[tool.setuptools]
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils", "mcp_serve"]

[tool.setuptools.data-files]
# i18n catalogs. locales/ is a bare data directory (no __init__.py), so it is
# neither a package (packages.find) nor package-data (which attaches to a
# package). data-files ships it in the wheel; MANIFEST.in `graft locales`
# ships it in the sdist. Without this, sealed installs (pip wheel, Nix store
# venv) drop the catalogs and gateway/CLI commands surface raw i18n keys like
# `gateway.reset.header_default` (#27632, #35374, #23943).
locales = ["locales/*.yaml"]
# Shipped MCP catalog (optional-mcps/<name>/manifest.yaml). Same bare-data-dir
# case as locales: data-files ships it in the wheel, `graft optional-mcps` in
# MANIFEST.in ships it in the sdist. Without this, `hermes mcp catalog` and the
# dashboard catalog screen come up empty on packaged installs even though the
# manifests exist in the repo (hermes_cli/mcp_catalog.py:_catalog_root resolves
# the packaged dir; list_catalog() returns [] when it's missing).
#
# data-files flattens every glob match into its single target dir, so each
# catalog entry needs its OWN target to preserve the per-entry directory the
# catalog iterates over (a shared `optional-mcps/*/*` glob would collapse all
# manifests into one colliding optional-mcps/manifest.yaml). One target per
# entry; tests/test_packaging_metadata.py enforces an entry per optional-mcps/<name>.
"optional-mcps/linear" = ["optional-mcps/linear/manifest.yaml"]
"optional-mcps/n8n" = ["optional-mcps/n8n/manifest.yaml"]

[tool.setuptools.package-data]
hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"]
gateway = ["assets/**/*"]
plugins = [
  "*/dashboard/manifest.json",
  "*/dashboard/dist/*",
  "*/dashboard/dist/**/*",
  # Plugin discovery (hermes_cli/plugins.py) reads a plugin.yaml/plugin.yml
  # manifest from each bundled plugin directory to register it. Wheels only
  # carry files declared here, so without this glob the wheel ships every
  # plugin's Python code but none of its manifests — the scan finds zero
  # plugins and all gateway platforms fail with "No adapter available for
  # <platform>" (#34034), web-search providers go missing (#28149), etc.
  "**/plugin.yaml",
  "**/plugin.yml",
  "**/README.md",
]

[tool.setuptools.packages.find]
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "cron.*", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]

[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
    "integration: marks tests requiring external services (API keys, Modal, etc.)",
    "real_concurrent_gate: opt out of the autouse stub that disables _detect_concurrent_hermes_instances",
]
# integration tests take way too long to run in the normal CI environments
addopts = "-m 'not integration'"

[tool.ty.environment]
python-version = "3.13"

[tool.ty.rules]
unknown-argument = "warn"
redundant-cast = "ignore"

[tool.ruff]
preview = true  # required for PLW1514 (unspecified-encoding) — preview rule

[tool.ruff.lint]
# All other lints are intentionally disabled (see comment history on this
# file) while we wrangle typechecks — but PLW1514 is too load-bearing to
# keep off.  Bare open()/read_text()/write_text() in text mode defaults to
# the system locale encoding on Windows (cp1252 on US-locale installs),
# which silently corrupts any non-ASCII file content.  We had three
# separate Windows sandbox regressions in one debug session before
# adding the explicit encoding.  This rule keeps new code honest.
select = ["PLW1514"]

[tool.ruff.lint.per-file-ignores]
# Tests can intentionally exercise locale-encoding edge cases.
"tests/**" = ["PLW1514"]
# Skills and plugins are partially user-authored — their own conventions.
"skills/**" = ["PLW1514"]
"optional-skills/**" = ["PLW1514"]
"plugins/**" = ["PLW1514"]


"""Typed HTTP client assertions for the loopback fake Telegram API."""

from __future__ import annotations

import http.client
import urllib.parse

from pydantic import JsonValue, TypeAdapter

from scripts.dualcoach_v111_fake_telegram import FakeTelegramApi

_JSON_RESPONSE = TypeAdapter(dict[str, JsonValue])

def post(
    api: FakeTelegramApi, method: str, **fields: str | int
) -> dict[str, JsonValue]:
    connection = http.client.HTTPConnection("127.0.0.1", api.port, timeout=2.0)
    try:
        connection.request(
            "POST",
            f"/bottest-token/{method}",
            body=urllib.parse.urlencode(fields),
            headers={"Content-Type": "application/x-www-form-urlencoded"},
        )
        return _JSON_RESPONSE.validate_json(connection.getresponse().read())
    finally:
        connection.close()

def message_response(message_id: int, text: str) -> dict[str, JsonValue]:
    return {
        "ok": True,
        "result": {
            "chat": {"id": 41, "type": "private"},
            "date": 0,
            "from": {
                "first_name": "Disposable NutriCoach",
                "id": 7000000002,
                "is_bot": True,
                "username": "disposable_nutricoach_bot",
            },
            "message_id": message_id,
            "text": text,
        },
    }


"""Loopback-only Telegram-compatible API for disposable v1.1.1 evidence."""

from __future__ import annotations

import json
import threading
from concurrent.futures import Future
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from types import TracebackType
from typing import Final, final, override
from urllib.parse import parse_qsl

from scripts.dualcoach_v111_evidence import parse_json
from scripts.dualcoach_v111_fake_telegram_engine import FakeTelegramEngine
from scripts.dualcoach_v111_fake_telegram_types import (
    CallbackQueryUpdate,
    FakeTelegramAction,
    FakeUpdate,
    MessageUpdate,
    RequestFields,
    TelegramMethod,
    TelegramReply,
)

__all__ = [
    "CallbackQueryUpdate",
    "FakeTelegramAction",
    "FakeTelegramApi",
    "MessageUpdate",
]

_LOOPBACK: Final = "127.0.0.1"

@dataclass(frozen=True, slots=True)
class FakeTelegramRequestError(ValueError):
    """The loopback API received an unsupported request body."""

@dataclass(frozen=True, slots=True)
class FakeTelegramThreadStopError(RuntimeError):
    thread_name: str

    @override
    def __str__(self) -> str:
        return f"fake Telegram server thread did not stop: {self.thread_name}"

@final
class _LoopbackServer(ThreadingHTTPServer):
    allow_reuse_address: bool = False
    daemon_threads: bool = True

    def __init__(self, engine: FakeTelegramEngine) -> None:
        super().__init__((_LOOPBACK, 0), _TelegramHandler)
        self.engine: FakeTelegramEngine = engine

@final
class _TelegramHandler(BaseHTTPRequestHandler):

    def do_POST(self) -> None:  # noqa: N802 - stdlib HTTP handler contract
        content_length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(content_length)
        try:
            fields = self._parse_fields(body)
            method = TelegramMethod(self.path.rsplit("/", 1)[-1])
            server = self.server
            assert isinstance(server, _LoopbackServer)
            reply = server.engine.dispatch(method, fields)
        except (UnicodeDecodeError, ValueError):
            reply = TelegramReply(
                status=400,
                payload={
                    "description": "Bad Request: malformed fake API request",
                    "error_code": 400,
                    "ok": False,
                },
            )
        self._reply(reply)

    @override
    def log_request(
        self,
        code: int | str = "-",
        size: int | str = "-",
    ) -> None:
        """Suppress paths because Bot API paths contain credentials."""

    @override
    def log_error(self, format: str, *args: str) -> None:  # noqa: A002
        """Suppress credential-bearing stdlib request diagnostics."""

    def _parse_fields(self, body: bytes) -> RequestFields:
        decoded = body.decode("utf-8")
        if decoded.lstrip().startswith("{"):
            payload = parse_json(body)
            if not isinstance(payload, dict):
                raise FakeTelegramRequestError
            values: dict[str, str] = {}
            for key, value in payload.items():
                if isinstance(value, bool):
                    values[key] = str(value).lower()
                elif isinstance(value, (str, int)):
                    values[key] = str(value)
                elif isinstance(value, (dict, list)):
                    values[key] = json.dumps(
                        value,
                        separators=(",", ":"),
                    )
            return RequestFields(values)
        return RequestFields(dict(parse_qsl(decoded, keep_blank_values=True)))

    def _reply(self, reply: TelegramReply) -> None:
        payload = json.dumps(
            reply.payload,
            sort_keys=True,
            separators=(",", ":"),
        ).encode("utf-8")
        self.send_response(reply.status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(payload)))
        self.end_headers()
        try:
            self.wfile.write(payload)
        except (BrokenPipeError, ConnectionResetError):
            return

@final
class FakeTelegramApi:
    """Own a loopback server thread and sanitized append-only action log."""

    def __init__(self) -> None:
        self._engine = FakeTelegramEngine()
        self._server = _LoopbackServer(self._engine)
        self._thread = threading.Thread(
            target=self._server.serve_forever,
            name="dualcoach-v111-fake-telegram",
            daemon=False,
        )
        self._started = False
        self._closed = False
        self._port = int(self._server.server_address[1])

    @property
    def port(self) -> int:
        return self._port

    @property
    def base_url(self) -> str:
        return f"http://{_LOOPBACK}:{self.port}/bot"

    @property
    def base_file_url(self) -> str:
        return f"http://{_LOOPBACK}:{self.port}/file/bot"

    @property
    def is_closed(self) -> bool:
        return self._closed

    @property
    def next_update_id(self) -> int:
        """Return the ID the next queued update will receive."""
        return self._engine.next_update()

    def __enter__(self) -> FakeTelegramApi:
        self.start()
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc_value: BaseException | None,
        traceback: TracebackType | None,
    ) -> None:
        self.close()

    def start(self) -> None:
        if self._started:
            return
        self._thread.start()
        self._started = True

    def enqueue_update(self, update: FakeUpdate) -> int:
        return self._engine.enqueue_update(update)

    def action_future(
        self,
        name: str,
        method: str,
        *,
        after_sequence: int = 0,
        occurrence: int = 1,
    ) -> Future[FakeTelegramAction]:
        return self._engine.action_future(
            name,
            TelegramMethod(method),
            after_sequence=after_sequence,
            occurrence=occurrence,
        )

    def wait_for_poll(self, timeout: float) -> bool:
        return self._engine.poll_started.wait(timeout=timeout)

    def callback_future(
        self,
        name: str,
        *,
        prefix: str,
        suffix: str,
    ) -> Future[tuple[int, int, str]]:
        return self._engine.callback_future(name, prefix=prefix, suffix=suffix)

    def callback_action_future(
        self,
        name: str,
        *,
        prefix: str,
        action: str,
    ) -> Future[tuple[int, int, str]]:
        return self._engine.callback_action_future(name, prefix=prefix, action=action)

    def callback_actions_future(
        self,
        name: str,
        *,
        prefix: str,
        actions: tuple[str, ...],
    ) -> Future[tuple[int, int, str, str]]:
        """Await one opaque callback matching any exact protocol action."""
        return self._engine.callback_actions_future(
            name,
            prefix=prefix,
            actions=actions,
        )

    def offset_future(self, name: str, minimum_offset: int) -> Future[FakeTelegramAction]:
        return self._engine.offset_future(name, minimum_offset)

    def release_poll(self) -> None:
        self._engine.release_waiters()

    def sanitized_actions(self) -> tuple[FakeTelegramAction, ...]:
        return self._engine.snapshot_actions()

    def sanitized_calls(self) -> tuple[str, ...]:
        return tuple(action.method for action in self.sanitized_actions())

    def callback_data_for(self, chat_id: int, message_id: int, action: str) -> str:
        """Read one opaque onboarding callback without adding it to the evidence log."""
        return self._engine.callback_data_for(chat_id, message_id, action)

    def callback_data_with_prefix(
        self, chat_id: int, message_id: int, prefix: str
    ) -> str:
        """Read one opaque callback matching the expected production protocol."""
        return self._engine.callback_data_with_prefix(chat_id, message_id, prefix)

    def callback_target_with_prefix(self, prefix: str) -> tuple[int, int, str]:
        """Read the sole card carrying a callback protocol prefix."""
        return self._engine.callback_target_with_prefix(prefix)

    def callback_target_with_action(
        self, prefix: str, action: str
    ) -> tuple[int, int, str]:
        """Read one opaque callback selected by its protocol action."""
        return self._engine.callback_target_with_action(prefix, action)

    def render_action_log(self) -> str:
        return self._engine.render_actions()

    def close(self) -> None:
        if self._closed:
            return
        self._engine.release_waiters()
        if self._started:
            self._server.shutdown()
        self._server.server_close()
        if self._started:
            self._thread.join(timeout=5.0)
            if self._thread.is_alive():
                raise FakeTelegramThreadStopError(self._thread.name)
        self._closed = True


"""Typed values for the disposable loopback Telegram API."""

from __future__ import annotations

from dataclasses import dataclass
from enum import StrEnum
from typing import Final, TypeAlias, assert_never

JsonScalar: TypeAlias = str | int | float | bool | None
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]

BOT_ID: Final = 7_000_000_002
BOT_USERNAME: Final = "disposable_nutricoach_bot"

class TelegramMethod(StrEnum):
    GET_ME = "getMe"
    GET_CHAT = "getChat"
    GET_CHAT_MEMBER = "getChatMember"
    GET_UPDATES = "getUpdates"
    SEND_MESSAGE = "sendMessage"
    EDIT_MESSAGE_TEXT = "editMessageText"
    EDIT_MESSAGE_REPLY_MARKUP = "editMessageReplyMarkup"
    ANSWER_CALLBACK_QUERY = "answerCallbackQuery"
    SEND_CHAT_ACTION = "sendChatAction"
    SET_MY_COMMANDS = "setMyCommands"
    DELETE_MY_COMMANDS = "deleteMyCommands"
    GET_WEBHOOK_INFO = "getWebhookInfo"
    DELETE_WEBHOOK = "deleteWebhook"

@dataclass(frozen=True, slots=True)
class MessageUpdate:
    """One incoming private message queued for polling."""

    chat_id: int
    user_id: int
    text: str

@dataclass(frozen=True, slots=True)
class CallbackQueryUpdate:
    """One incoming callback query queued for polling."""

    chat_id: int
    user_id: int
    message_id: int
    callback_query_id: str
    data: str
    topic_id: int | None = None

FakeUpdate: TypeAlias = MessageUpdate | CallbackQueryUpdate

@dataclass(frozen=True, slots=True)
class FakeTelegramAction:
    """Sanitized metadata for one received API action."""

    sequence: int
    method: str
    chat_id: int | None = None
    message_id: int | None = None
    offset: int | None = None

    def as_json(self) -> dict[str, JsonValue]:
        return {
            "chat_id": self.chat_id,
            "message_id": self.message_id,
            "method": self.method,
            "offset": self.offset,
            "sequence": self.sequence,
        }

@dataclass(frozen=True, slots=True)
class TelegramReply:
    """HTTP status and Telegram JSON response body."""

    status: int
    payload: dict[str, JsonValue]

@dataclass(frozen=True, slots=True)
class RequestFields:
    """Parsed scalar form fields from one Telegram request."""

    values: dict[str, str]

    def text(self, name: str, default: str = "") -> str:
        return self.values.get(name, default)

    def integer(self, name: str, default: int = 0) -> int:
        value = self.values.get(name)
        return default if value is None or value == "" else int(value)

    def boolean(self, name: str) -> bool:
        return self.values.get(name, "false").casefold() == "true"

def bot_user() -> dict[str, JsonValue]:
    return {
        "first_name": "Disposable NutriCoach",
        "id": BOT_ID,
        "is_bot": True,
        "username": BOT_USERNAME,
    }

def chat(chat_id: int) -> dict[str, JsonValue]:
    if chat_id == -100_700:
        return {"id": chat_id, "title": "Disposable Staff", "type": "supergroup"}
    return {"id": chat_id, "type": "private"}

@dataclass(frozen=True, slots=True)
class MessageSpec:
    message_id: int
    chat_id: int
    text: str
    sender: dict[str, JsonValue]
    topic_id: int | None = None

def message(spec: MessageSpec) -> dict[str, JsonValue]:
    result: dict[str, JsonValue] = {
        "chat": chat(spec.chat_id),
        "date": 0,
        "from": spec.sender,
        "message_id": spec.message_id,
        "text": spec.text,
    }
    if spec.text.startswith("/"):
        command = spec.text.split(maxsplit=1)[0]
        result["entities"] = [
            {"length": len(command), "offset": 0, "type": "bot_command"}
        ]
    if spec.topic_id:
        result["message_thread_id"] = spec.topic_id
    return result

def update_payload(update_id: int, update: FakeUpdate) -> dict[str, JsonValue]:
    match update:
        case MessageUpdate(chat_id=chat_id, user_id=user_id, text=text):
            return {
                "message": message(
                    MessageSpec(
                        message_id=update_id,
                        chat_id=chat_id,
                        text=text,
                        sender={
                            "first_name": "User",
                            "id": user_id,
                            "is_bot": False,
                        },
                    )
                ),
                "update_id": update_id,
            }
        case CallbackQueryUpdate(
            chat_id=chat_id,
            user_id=user_id,
            message_id=message_id,
            callback_query_id=callback_query_id,
            data=data,
            topic_id=topic_id,
        ):
            callback_message = message(
                MessageSpec(
                    message_id=message_id,
                    chat_id=chat_id,
                    text="",
                    sender=bot_user(),
                    topic_id=topic_id,
                )
            )
            _ = callback_message.pop("text")
            return {
                "callback_query": {
                    "chat_instance": f"loopback-chat-{chat_id}",
                    "data": data,
                    "from": {"first_name": "User", "id": user_id, "is_bot": False},
                    "id": callback_query_id,
                    "message": callback_message,
                },
                "update_id": update_id,
            }
        case unreachable:
            assert_never(unreachable)


"""State machine behind the disposable loopback Telegram API."""

from __future__ import annotations

import json
import threading
from concurrent.futures import Future
from dataclasses import dataclass
from typing import assert_never, final, override

from scripts.dualcoach_v111_evidence import parse_json
from scripts.dualcoach_v111_fake_telegram_types import (
    FakeTelegramAction,
    FakeUpdate,
    JsonValue,
    MessageSpec,
    RequestFields,
    TelegramMethod,
    TelegramReply,
    bot_user,
    chat,
    message,
    update_payload,
)

@dataclass(frozen=True, slots=True)
class DuplicateActionFutureError(Exception):
    name: str

    @override
    def __str__(self) -> str:
        return f"action future already exists: {self.name}"

@dataclass(frozen=True, slots=True)
class _SentMessage:
    text: str
    topic_id: int
    callback_data: tuple[str, ...]

@final
class FakeTelegramEngine:
    """Own mutable fake API state under one condition lock."""

    def __init__(self) -> None:
        self.condition = threading.Condition()
        self.poll_started = threading.Event()
        self.release_poll = False
        self.next_update_id = 1
        self.next_message_id = 1
        self.updates: list[tuple[int, FakeUpdate]] = []
        self.messages: dict[tuple[int, int], _SentMessage] = {}
        self.actions: list[FakeTelegramAction] = []
        self.action_futures: dict[
            str, tuple[TelegramMethod, int, int, Future[FakeTelegramAction]]
        ] = {}
        self.offset_futures: dict[str, tuple[int, Future[FakeTelegramAction]]] = {}
        self.callback_futures: dict[
            str, tuple[str, str, Future[tuple[int, int, str]]]
        ] = {}
        self.callback_action_futures: dict[
            str, tuple[str, str, Future[tuple[int, int, str]]]
        ] = {}
        self.callback_actions_futures: dict[
            str, tuple[str, tuple[str, ...], Future[tuple[int, int, str, str]]]
        ] = {}

    def enqueue_update(self, update: FakeUpdate) -> int:
        with self.condition:
            update_id = self.next_update_id
            self.next_update_id += 1
            self.updates.append((update_id, update))
            self.condition.notify_all()
            return update_id

    def action_future(
        self,
        name: str,
        method: TelegramMethod,
        *,
        after_sequence: int = 0,
        occurrence: int = 1,
    ) -> Future[FakeTelegramAction]:
        with self.condition:
            if name in self.action_futures:
                raise DuplicateActionFutureError(name)
            if after_sequence < 0 or occurrence < 1:
                raise ValueError("action sequence is invalid")
            future: Future[FakeTelegramAction] = Future()
            self.action_futures[name] = (
                method,
                after_sequence,
                occurrence,
                future,
            )
            return future

    def release_waiters(self) -> None:
        with self.condition:
            self.release_poll = True
            self.condition.notify_all()

    def snapshot_actions(self) -> tuple[FakeTelegramAction, ...]:
        with self.condition:
            return tuple(self.actions)

    def offset_future(
        self, name: str, minimum_offset: int
    ) -> Future[FakeTelegramAction]:
        with self.condition:
            if name in self.action_futures or name in self.offset_futures:
                raise DuplicateActionFutureError(name)
            if minimum_offset < 1:
                raise ValueError("offset is invalid")
            future: Future[FakeTelegramAction] = Future()
            self.offset_futures[name] = (minimum_offset, future)
            return future

    def callback_data_for(
        self, chat_id: int, message_id: int, action: str
    ) -> str:
        """Return one onboarding callback by action without logging its token."""
        return self.callback_data_with_prefix(
            chat_id,
            message_id,
            f"non2:{action}:",
        )

    def callback_data_with_prefix(
        self, chat_id: int, message_id: int, prefix: str
    ) -> str:
        """Return one opaque callback matching an expected protocol prefix."""
        with self.condition:
            sent = self.messages.get((chat_id, message_id))
            if sent is None:
                raise KeyError("fake Telegram message is unavailable")
            matches = tuple(
                value for value in sent.callback_data if value.startswith(prefix)
            )
            if len(matches) != 1:
                raise KeyError("fake Telegram callback is unavailable")
            return matches[0]

    def callback_future(
        self,
        name: str,
        *,
        prefix: str,
        suffix: str,
    ) -> Future[tuple[int, int, str]]:
        with self.condition:
            if self._future_name_is_taken(name):
                raise DuplicateActionFutureError(name)
            if not prefix or not suffix:
                raise ValueError("callback matcher is invalid")
            future: Future[tuple[int, int, str]] = Future()
            self.callback_futures[name] = (prefix, suffix, future)
            self._resolve_callback_futures()
            return future

    def callback_action_future(
        self,
        name: str,
        *,
        prefix: str,
        action: str,
    ) -> Future[tuple[int, int, str]]:
        """Await one opaque callback containing its exact protocol action."""
        with self.condition:
            if self._future_name_is_taken(name):
                raise DuplicateActionFutureError(name)
            if not prefix or not action:
                raise ValueError("callback action matcher is invalid")
            future: Future[tuple[int, int, str]] = Future()
            self.callback_action_futures[name] = (prefix, action, future)
            self._resolve_callback_futures()
            return future

    def callback_actions_future(
        self,
        name: str,
        *,
        prefix: str,
        actions: tuple[str, ...],
    ) -> Future[tuple[int, int, str, str]]:
        """Await one opaque callback matching any exact protocol action."""
        with self.condition:
            if self._future_name_is_taken(name):
                raise DuplicateActionFutureError(name)
            if not prefix or not actions or any(not action for action in actions):
                raise ValueError("callback actions matcher is invalid")
            future: Future[tuple[int, int, str, str]] = Future()
            self.callback_actions_futures[name] = (prefix, actions, future)
            self._resolve_callback_futures()
            return future

    def next_update(self) -> int:
        with self.condition:
            return self.next_update_id

    def _future_name_is_taken(self, name: str) -> bool:
        return (
            name in self.action_futures
            or name in self.offset_futures
            or name in self.callback_futures
            or name in self.callback_action_futures
            or name in self.callback_actions_futures
        )

    def dispatch(self, method: TelegramMethod, fields: RequestFields) -> TelegramReply:
        self._record_action(method, fields)
        match method:
            case TelegramMethod.GET_ME:
                return self._ok(bot_user())
            case TelegramMethod.GET_CHAT:
                return self._ok(chat(fields.integer("chat_id")))
            case TelegramMethod.GET_CHAT_MEMBER:
                chat_id = fields.integer("chat_id")
                user_id = fields.integer("user_id")
                if user_id == bot_user()["id"]:
                    return self._ok(
                        {
                            "status": "administrator",
                            "user": bot_user(),
                            "can_be_edited": False,
                            "is_anonymous": False,
                            "can_manage_chat": True,
                            "can_delete_messages": True,
                            "can_manage_video_chats": True,
                            "can_restrict_members": True,
                            "can_promote_members": False,
                            "can_change_info": True,
                            "can_invite_users": True,
                            "can_post_stories": False,
                            "can_edit_stories": False,
                            "can_delete_stories": False,
                        }
                    )
                if (chat_id, user_id) in {(200, 200), (-100_700, 100)}:
                    return self._ok(
                        {
                            "status": "member",
                            "user": {
                                "first_name": "User",
                                "id": user_id,
                                "is_bot": False,
                            },
                        }
                    )
                return self._ok(
                    {
                        "status": "left",
                        "user": {
                            "first_name": "User",
                            "id": user_id,
                            "is_bot": False,
                        },
                    }
                )
            case TelegramMethod.GET_UPDATES:
                return self._get_updates(fields)
            case TelegramMethod.SEND_MESSAGE:
                return self._send_message(fields)
            case TelegramMethod.EDIT_MESSAGE_TEXT:
                return self._edit_message(fields)
            case TelegramMethod.EDIT_MESSAGE_REPLY_MARKUP:
                return self._edit_reply_markup(fields)
            case (
                TelegramMethod.ANSWER_CALLBACK_QUERY
                | TelegramMethod.SEND_CHAT_ACTION
                | TelegramMethod.SET_MY_COMMANDS
                | TelegramMethod.DELETE_MY_COMMANDS
            ):
                return self._ok(True)
            case TelegramMethod.GET_WEBHOOK_INFO:
                with self.condition:
                    pending = len(self.updates)
                return self._ok(
                    {
                        "has_custom_certificate": False,
                        "pending_update_count": pending,
                        "url": "",
                    }
                )
            case TelegramMethod.DELETE_WEBHOOK:
                if fields.boolean("drop_pending_updates"):
                    with self.condition:
                        self.updates.clear()
                return self._ok(True)
            case unreachable:
                assert_never(unreachable)

    def _record_action(self, method: TelegramMethod, fields: RequestFields) -> None:
        with self.condition:
            action = FakeTelegramAction(
                sequence=len(self.actions) + 1,
                method=method.value,
                chat_id=(fields.integer("chat_id") if "chat_id" in fields.values else None),
                message_id=(
                    fields.integer("message_id")
                    if "message_id" in fields.values
                    else None
                ),
                offset=(fields.integer("offset") if "offset" in fields.values else None),
            )
            self.actions.append(action)
            for expected_method, after_sequence, occurrence, future in tuple(
                self.action_futures.values()
            ):
                matching = tuple(
                    item
                    for item in self.actions
                    if item.method == expected_method.value
                    and item.sequence > after_sequence
                )
                if (
                    expected_method is method
                    and len(matching) >= occurrence
                    and not future.done()
                ):
                    future.set_result(matching[occurrence - 1])
            if method is TelegramMethod.GET_UPDATES:
                for minimum_offset, future in tuple(self.offset_futures.values()):
                    if action.offset is not None and action.offset >= minimum_offset and not future.done():
                        future.set_result(action)
                self.poll_started.set()

    def _get_updates(self, fields: RequestFields) -> TelegramReply:
        offset = fields.integer("offset")
        timeout = fields.integer("timeout")
        with self.condition:
            if offset:
                self.updates = [item for item in self.updates if item[0] >= offset]
            if not self.updates and timeout > 0 and not self.release_poll:
                self.condition.wait(timeout=float(timeout))
            limit = fields.integer("limit", 100)
            result: list[JsonValue] = [
                update_payload(update_id, update)
                for update_id, update in self.updates[:limit]
            ]
        return self._ok(result)

    def _send_message(self, fields: RequestFields) -> TelegramReply:
        chat_id = fields.integer("chat_id")
        text = fields.text("text")
        topic_id = fields.integer("message_thread_id")
        callbacks = self._callback_data(fields.text("reply_markup"))
        with self.condition:
            message_id = self.next_message_id
            self.next_message_id += 1
            self.messages[(chat_id, message_id)] = _SentMessage(
                text=text,
                topic_id=topic_id,
                callback_data=callbacks,
            )
            self._resolve_callback_futures()
        return self._ok(
            message(
                MessageSpec(
                    message_id=message_id,
                    chat_id=chat_id,
                    text=text,
                    sender=bot_user(),
                    topic_id=topic_id,
                )
            )
        )

    def _edit_message(self, fields: RequestFields) -> TelegramReply:
        chat_id = fields.integer("chat_id")
        message_id = fields.integer("message_id")
        text = fields.text("text")
        callbacks = self._callback_data(fields.text("reply_markup"))
        with self.condition:
            current = self.messages.get((chat_id, message_id))
            if (
                current is not None
                and current.text == text
                and current.callback_data == callbacks
            ):
                return TelegramReply(
                    status=400,
                    payload={
                        "description": "Bad Request: message is not modified",
                        "error_code": 400,
                        "ok": False,
                    },
                )
            self.messages[(chat_id, message_id)] = _SentMessage(
                text=text,
                topic_id=current.topic_id if current is not None else fields.integer("message_thread_id"),
                callback_data=callbacks,
            )
            self._resolve_callback_futures()
        return self._ok(
            message(
                MessageSpec(
                    message_id=message_id,
                    chat_id=chat_id,
                    text=text,
                    sender=bot_user(),
                    topic_id=self.messages[(chat_id, message_id)].topic_id,
                )
            )
        )

    def _edit_reply_markup(self, fields: RequestFields) -> TelegramReply:
        chat_id = fields.integer("chat_id")
        message_id = fields.integer("message_id")
        callbacks = self._callback_data(fields.text("reply_markup"))
        with self.condition:
            current = self.messages.get((chat_id, message_id))
            if current is not None and current.callback_data == callbacks:
                return TelegramReply(
                    status=400,
                    payload={
                        "description": "Bad Request: message is not modified",
                        "error_code": 400,
                        "ok": False,
                    },
                )
            updated = _SentMessage(
                text=current.text if current is not None else "",
                topic_id=(
                    current.topic_id
                    if current is not None
                    else fields.integer("message_thread_id")
                ),
                callback_data=callbacks,
            )
            self.messages[(chat_id, message_id)] = updated
            self._resolve_callback_futures()
        return self._ok(
            message(
                MessageSpec(
                    message_id=message_id,
                    chat_id=chat_id,
                    text=updated.text,
                    sender=bot_user(),
                    topic_id=updated.topic_id,
                )
            )
        )

    @staticmethod
    def _callback_data(raw_markup: str) -> tuple[str, ...]:
        if not raw_markup:
            return ()
        try:
            markup = parse_json(raw_markup)
        except ValueError:
            return ()
        keyboard = markup.get("inline_keyboard") if isinstance(markup, dict) else None
        if not isinstance(keyboard, list):
            return ()
        values: list[str] = []
        for row in keyboard:
            if not isinstance(row, list):
                continue
            for button in row:
                callback = button.get("callback_data") if isinstance(button, dict) else None
                if isinstance(callback, str):
                    values.append(callback)
        return tuple(values)

    @staticmethod
    def _ok(result: JsonValue) -> TelegramReply:
        return TelegramReply(status=200, payload={"ok": True, "result": result})

    def callback_target_with_prefix(self, prefix: str) -> tuple[int, int, str]:
        """Return the latest sent card carrying one expected protocol callback."""
        with self.condition:
            matches = self._matching_callbacks(prefix, "")
            if len(matches) != 1:
                raise KeyError("fake Telegram callback is unavailable")
            return matches[0]

    def callback_target_with_action(
        self, prefix: str, action: str
    ) -> tuple[int, int, str]:
        """Return one opaque callback carrying the requested protocol action."""
        with self.condition:
            matches = tuple(
                item
                for item in self._matching_callbacks(prefix, "")
                if f":{action}:" in item[2] or item[2].endswith(f":{action}")
            )
            if len(matches) != 1:
                raise KeyError("fake Telegram callback is unavailable")
            return matches[0]

    def _matching_callbacks(self, prefix: str, suffix: str) -> tuple[tuple[int, int, str], ...]:
        return tuple(
            (chat_id, message_id, callback)
            for (chat_id, message_id), sent in self.messages.items()
            for callback in sent.callback_data
            if callback.startswith(prefix) and callback.endswith(suffix)
        )

    def _resolve_callback_futures(self) -> None:
        for prefix, suffix, future in tuple(self.callback_futures.values()):
            matches = self._matching_callbacks(prefix, suffix)
            if len(matches) == 1 and not future.done():
                future.set_result(matches[0])
        for prefix, action, future in tuple(self.callback_action_futures.values()):
            matches = tuple(
                item
                for item in self._matching_callbacks(prefix, "")
                if f":{action}:" in item[2] or item[2].endswith(f":{action}")
            )
            if len(matches) == 1 and not future.done():
                future.set_result(matches[0])
        for prefix, actions, future in tuple(self.callback_actions_futures.values()):
            for action in actions:
                matches = tuple(
                    (*item, action)
                    for item in self._matching_callbacks(prefix, "")
                    if f":{action}:" in item[2] or item[2].endswith(f":{action}")
                )
                if len(matches) == 1 and not future.done():
                    future.set_result(matches[0])
                    break

    def render_actions(self) -> str:
        return "".join(
            json.dumps(action.as_json(), sort_keys=True, separators=(",", ":")) + "\n"
            for action in self.snapshot_actions()
        )


**Planning typed test narrowing**

**Examining type compatibility issues**

**Implementing runtime TypeGuard parsing**

**Planning typed test narrowing**

**Examining type compatibility issues**

**Refining JSON type narrowing with explicit loop**

**Designing JSON extraction with type narrowing**

**Planning baseline tests and type checking**

The 25 diagnostics are all cascades from two un-narrowed `JsonValue` chains. I’ll preserve the assertions and response semantics by adding a small `TypeGuard`-based JSON-object narrowing seam, then reuse it at both affected call sites.

bash

cd /home/cube/projects/richard/.worktrees/nutricoach-v111-impl && uv run pytest tests/gateway/test_fake_telegram_bot_api.py

cd /home/cube/projects/richard/.worktrees/nutricoach-v111-impl && uv run ruff check tests/gateway/test_fake_telegram_bot_api.py

cd /home/cube/projects/richard/.worktrees/nutricoach-v111-impl && uv run python -m py_compile tests/gateway/test_fake_telegram_bot_api.py

nl -ba /home/cube/projects/richard/.worktrees/nutricoach-v111-impl/tests/gateway/test_fake_telegram_bot_api.py | head -n 190


============================= test session starts ==============================
platform linux -- Python 3.12.9, pytest-9.0.2, pluggy-1.6.0
rootdir: /home/cube/projects/richard/.worktrees/nutricoach-v111-impl
configfile: pyproject.toml
plugins: anyio-4.12.1, asyncio-1.3.0
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collected 0 items / 1 error

==================================== ERRORS ====================================
_________ ERROR collecting tests/gateway/test_fake_telegram_bot_api.py _________
.venv/lib/python3.12/site-packages/pydantic/type_adapter.py:287: in _init_core_attrs
    self.core_schema = _getattr_no_parents(self._type, '__pydantic_core_schema__')
                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/type_adapter.py:56: in _getattr_no_parents
    raise AttributeError(attribute)
E   AttributeError: __pydantic_core_schema__

During handling of the above exception, another exception occurred:
tests/gateway/test_fake_telegram_bot_api.py:11: in <module>
    from scripts.dualcoach_v111_evidence import write_text
scripts/dualcoach_v111_evidence.py:40: in <module>
    _JSON_ADAPTER: TypeAdapter[JsonValue] = TypeAdapter(JsonValue)
                                            ^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/type_adapter.py:244: in __init__
    self._init_core_attrs(
.venv/lib/python3.12/site-packages/pydantic/type_adapter.py:306: in _init_core_attrs
    core_schema = schema_generator.generate_schema(self._type)
                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:744: in generate_schema
    schema = self._generate_schema_inner(obj)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1043: in _generate_schema_inner
    return self.match_type(obj)
           ^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1161: in match_type
    return self._match_generic_type(obj, origin)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1184: in _match_generic_type
    return self._union_schema(obj)
           ^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1344: in _union_schema
    choices.append(self.generate_schema(arg))
                   ^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:744: in generate_schema
    schema = self._generate_schema_inner(obj)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1043: in _generate_schema_inner
    return self.match_type(obj)
           ^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1161: in match_type
    return self._match_generic_type(obj, origin)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1188: in _match_generic_type
    return self._list_schema(self._get_first_arg_or_any(obj))
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:399: in _list_schema
    return core_schema.list_schema(self.generate_schema(items_type))
                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:744: in generate_schema
    schema = self._generate_schema_inner(obj)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1043: in _generate_schema_inner
    return self.match_type(obj)
           ^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1161: in match_type
    return self._match_generic_type(obj, origin)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1184: in _match_generic_type
    return self._union_schema(obj)
           ^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1344: in _union_schema
    choices.append(self.generate_schema(arg))
                   ^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:744: in generate_schema
    schema = self._generate_schema_inner(obj)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1043: in _generate_schema_inner
    return self.match_type(obj)
           ^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1161: in match_type
    return self._match_generic_type(obj, origin)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1188: in _match_generic_type
    return self._list_schema(self._get_first_arg_or_any(obj))
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:399: in _list_schema
    return core_schema.list_schema(self.generate_schema(items_type))
                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E   RecursionError: maximum recursion depth exceeded
E   If you made use of an implicit recursive type alias (e.g. `MyType = list['MyType']), consider using PEP 695 type aliases instead. For more details, refer to the documentation: https://docs.pydantic.dev/2.13/concepts/types/#named-recursive-types
!!! Recursion detected (same locals & position)
=========================== short test summary info ============================
ERROR tests/gateway/test_fake_telegram_bot_api.py - RecursionError: maximum r...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.23s ===============================

Command exited with code 2


All checks passed!


(no output)


     1	from __future__ import annotations
     2
     3	import http.client
     4	import json
     5	import stat
     6	from concurrent.futures import ThreadPoolExecutor
     7	from pathlib import Path
     8
     9	from telegram import Update
    10
    11	from scripts.dualcoach_v111_evidence import write_text
    12	from scripts.dualcoach_v111_fake_telegram import (
    13	    CallbackQueryUpdate,
    14	    FakeTelegramApi,
    15	    MessageUpdate,
    16	)
    17	from tests.support.fake_telegram_client import (
    18	    message_response as _message_response,
    19	    post as _post,
    20	)
    21
    22
    23	def test_identity_chat_and_membership_methods_return_telegram_shapes() -> None:
    24	    # Given: a started loopback fake with its deterministic default fixtures.
    25	    with FakeTelegramApi() as api:
    26	        # When: Telegram startup identity and membership methods are called.
    27	        me = _post(api, "getMe")
    28	        chat = _post(api, "getChat", chat_id=-100700)
    29	        member = _post(api, "getChatMember", chat_id=-100700, user_id=7000000002)
    30
    31	    # Then: each response carries the Telegram-compatible typed fixture.
    32	    assert me == {
    33	        "ok": True,
    34	        "result": {
    35	            "first_name": "Disposable NutriCoach",
    36	            "id": 7000000002,
    37	            "is_bot": True,
    38	            "username": "disposable_nutricoach_bot",
    39	        },
    40	    }
    41	    assert chat == {
    42	        "ok": True,
    43	        "result": {"id": -100700, "title": "Disposable Staff", "type": "supergroup"},
    44	    }
    45	    assert member == {
    46	        "ok": True,
    47	        "result": {
    48	            "status": "administrator",
    49	            "user": {
    50	                "first_name": "Disposable NutriCoach",
    51	                "id": 7000000002,
    52	                "is_bot": True,
    53	                "username": "disposable_nutricoach_bot",
    54	            },
    55	            "can_be_edited": False,
    56	            "is_anonymous": False,
    57	            "can_manage_chat": True,
    58	            "can_delete_messages": True,
    59	            "can_manage_video_chats": True,
    60	            "can_restrict_members": True,
    61	            "can_promote_members": False,
    62	            "can_change_info": True,
    63	            "can_invite_users": True,
    64	            "can_post_stories": False,
    65	            "can_edit_stories": False,
    66	            "can_delete_stories": False,
    67	        },
    68	    }
    69
    70
    71	def test_customer_is_absent_from_staff_chat() -> None:
    72	    # Given: the disposable customer and staff chat used by activation readiness.
    73	    with FakeTelegramApi() as api:
    74	        # When: production membership readiness checks customer isolation.
    75	        member = _post(api, "getChatMember", chat_id=-100700, user_id=200)
    76
    77	    # Then: the fake proves absence rather than granting staff membership.
    78	    assert member == {
    79	        "ok": True,
    80	        "result": {
    81	            "status": "left",
    82	            "user": {"first_name": "User", "id": 200, "is_bot": False},
    83	        },
    84	    }
    85
    86
    87	def test_configured_customer_and_owner_are_members_of_callback_routes() -> None:
    88	    # Given: the callback routes used by the disposable customer and owner.
    89	    with FakeTelegramApi() as api:
    90	        # When: runtime callback membership gates query those exact routes.
    91	        customer = _post(api, "getChatMember", chat_id=200, user_id=200)
    92	        owner = _post(api, "getChatMember", chat_id=-100700, user_id=100)
    93
    94	    # Then: each authorized actor is present without granting unrelated membership.
    95	    assert customer == {
    96	        "ok": True,
    97	        "result": {
    98	            "status": "member",
    99	            "user": {"first_name": "User", "id": 200, "is_bot": False},
   100	        },
   101	    }
   102	    assert owner == {
   103	        "ok": True,
   104	        "result": {
   105	            "status": "member",
   106	            "user": {"first_name": "User", "id": 100, "is_bot": False},
   107	        },
   108	    }
   109
   110
   111	def test_get_updates_applies_exact_offsets_to_monotonic_updates() -> None:
   112	    # Given: three typed updates queued in creation order.
   113	    with FakeTelegramApi() as api:
   114	        first = api.enqueue_update(MessageUpdate(chat_id=41, user_id=7, text="one"))
   115	        second = api.enqueue_update(MessageUpdate(chat_id=41, user_id=7, text="two"))
   116	        third = api.enqueue_update(
   117	            CallbackQueryUpdate(
   118	                chat_id=41,
   119	                user_id=7,
   120	                message_id=9,
   121	                callback_query_id="private-callback-id",
   122	                data="private-callback-data",
   123	            )
   124	        )
   125
   126	        # When: polling acknowledges through the second update exactly.
   127	        response = _post(api, "getUpdates", offset=third)
   128
   129	    # Then: IDs are monotonic and only the exact unacknowledged suffix is returned.
   130	    assert (first, second, third) == (1, 2, 3)
   131	    assert response == {
   132	        "ok": True,
   133	        "result": [
   134	            {
   135	                "callback_query": {
   136	                    "chat_instance": "loopback-chat-41",
   137	                    "data": "private-callback-data",
   138	                    "from": {"first_name": "User", "id": 7, "is_bot": False},
   139	                    "id": "private-callback-id",
   140	                    "message": {
   141	                        "chat": {"id": 41, "type": "private"},
   142	                        "date": 0,
   143	                        "from": {
   144	                            "first_name": "Disposable NutriCoach",
   145	                            "id": 7000000002,
   146	                            "is_bot": True,
   147	                            "username": "disposable_nutricoach_bot",
   148	                        },
   149	                        "message_id": 9,
   150	                    },
   151	                },
   152	                "update_id": 3,
   153	            }
   154	        ],
   155	    }
   156	    assert Update.de_json(response["result"][0], bot=None).callback_query is not None
   157
   158
   159	def test_command_updates_include_telegram_bot_command_entities() -> None:
   160	    # Given: an incoming command update addressed to the disposable bot.
   161	    with FakeTelegramApi() as api:
   162	        _ = api.enqueue_update(
   163	            MessageUpdate(chat_id=41, user_id=7, text="/start rc1_abcdefghijklmnopqrstuv")
   164	        )
   165
   166	        # When: the polling API returns the typed update.
   167	        response = _post(api, "getUpdates")
   168
   169	    # Then: PTB's command filter receives the entity Telegram supplies in production.
   170	    assert response["result"][0]["message"]["entities"] == [
   171	        {"length": 6, "offset": 0, "type": "bot_command"}
   172	    ]
   173
   174
   175	def test_send_and_edit_message_use_monotonic_ids_and_not_modified_error() -> None:
   176	    # Given: a started fake Telegram API.
   177	    with FakeTelegramApi() as api:
   178	        # When: two messages are sent, one is edited, and the same edit is repeated.
   179	        sent = _post(api, "sendMessage", chat_id=41, text="private first")
   180	        sent_again = _post(api, "sendMessage", chat_id=41, text="private second")
   181	        edited = _post(
   182	            api,
   183	            "editMessageText",
   184	            chat_id=41,
   185	            message_id=1,
   186	            text="private replacement",
   187	        )
   188	        unchanged = _post(
   189	            api,
   190	            "editMessageText",


**Assessing pytest failure and file size constraints**

**Evaluating line count limits before adding TypeGuard helper**

awk '!/^[[:space:]]*$/ && !/^[[:space:]]*(\/\/|#|--)/' /home/cube/projects/richard/.worktrees/nutricoach-v111-impl/tests/gateway/test_fake_telegram_bot_api.py | wc -l


**Planning precise type narrowing with assertions**

**Defining TypeGuard helper for JSON object narrowing**

**Testing JSONDict type with JsonValue**

edit

from pathlib import Path

from telegram import Update

from pathlib import Path
from typing import TypeGuard

from pydantic import JsonValue
from telegram import Update

from tests.support.fake_telegram_client import (
    message_response as _message_response,
    post as _post,
)

def test_identity_chat_and_membership_methods_return_telegram_shapes() -> None:

from tests.support.fake_telegram_client import (
    message_response as _message_response,
    post as _post,
)

def _is_json_object(value: JsonValue) -> TypeGuard[dict[str, JsonValue]]:
    return isinstance(value, dict)

def _first_result_object(response: dict[str, JsonValue]) -> dict[str, JsonValue]:
    result = response["result"]
    assert isinstance(result, list)
    first = result[0]
    assert _is_json_object(first)
    return first

def test_identity_chat_and_membership_methods_return_telegram_shapes() -> None:

assert Update.de_json(response["result"][0], bot=None).callback_query is not None

    update = _first_result_object(response)
    assert Update.de_json(update, bot=None).callback_query is not None

    assert response["result"][0]["message"]["entities"] == [
        {"length": 6, "offset": 0, "type": "bot_command"}
    ]

    update = _first_result_object(response)
    message = update["message"]
    assert _is_json_object(message)
    assert message["entities"] == [
        {"length": 6, "offset": 0, "type": "bot_command"}
    ]


Successfully replaced 4 block(s) in /home/cube/projects/richard/.worktrees/nutricoach-v111-impl/tests/gateway/test_fake_telegram_bot_api.py.

     ...
   4 import json
   5 import stat
   6 from concurrent.futures import ThreadPoolExecutor
   7 from pathlib import Path
+  8 from typing import TypeGuard
   8
+ 10 from pydantic import JsonValue
   9 from telegram import Update
  10
  11 from scripts.dualcoach_v111_evidence import write_text
  12 from scripts.dualcoach_v111_fake_telegram import (
     ...
  19     post as _post,
  20 )
  21
  22
+ 25 def _is_json_object(value: JsonValue) -> TypeGuard[dict[str, JsonValue]]:
+ 26     return isinstance(value, dict)
+ 27
+ 28
+ 29 def _first_result_object(response: dict[str, JsonValue]) -> dict[str, JsonValue]:
+ 30     result = response["result"]
+ 31     assert isinstance(result, list)
+ 32     first = result[0]
+ 33     assert _is_json_object(first)
+ 34     return first
+ 35
+ 36
  23 def test_identity_chat_and_membership_methods_return_telegram_shapes() -> None:
  24     # Given: a started loopback fake with its deterministic default fixtures.
  25     with FakeTelegramApi() as api:
  26         # When: Telegram startup identity and membership methods are called.
     ...
 152                 "update_id": 3,
 153             }
 154         ],
 155     }
-156     assert Update.de_json(response["result"][0], bot=None).callback_query is not None
+170     update = _first_result_object(response)
+171     assert Update.de_json(update, bot=None).callback_query is not None
 157
 158
 159 def test_command_updates_include_telegram_bot_command_entities() -> None:
 160     # Given: an incoming command update addressed to the disposable bot.
     ...
 166         # When: the polling API returns the typed update.
 167         response = _post(api, "getUpdates")
 168
 169     # Then: PTB's command filter receives the entity Telegram supplies in production.
-170     assert response["result"][0]["message"]["entities"] == [
+185     update = _first_result_object(response)
+186     message = update["message"]
+187     assert _is_json_object(message)
+188     assert message["entities"] == [
 171         {"length": 6, "offset": 0, "type": "bot_command"}
 172     ]
 173
 174
     ...

--- /home/cube/projects/richard/.worktrees/nutricoach-v111-impl/tests/gateway/test_fake_telegram_bot_api.py
+++ /home/cube/projects/richard/.worktrees/nutricoach-v111-impl/tests/gateway/test_fake_telegram_bot_api.py
@@ -4,9 +4,11 @@
 import json
 import stat
 from concurrent.futures import ThreadPoolExecutor
 from pathlib import Path
+from typing import TypeGuard

+from pydantic import JsonValue
 from telegram import Update

 from scripts.dualcoach_v111_evidence import write_text
 from scripts.dualcoach_v111_fake_telegram import (
@@ -19,8 +21,20 @@
     post as _post,
 )

+def _is_json_object(value: JsonValue) -> TypeGuard[dict[str, JsonValue]]:
+    return isinstance(value, dict)
+
+
+def _first_result_object(response: dict[str, JsonValue]) -> dict[str, JsonValue]:
+    result = response["result"]
+    assert isinstance(result, list)
+    first = result[0]
+    assert _is_json_object(first)
+    return first
+
+
 def test_identity_chat_and_membership_methods_return_telegram_shapes() -> None:
     # Given: a started loopback fake with its deterministic default fixtures.
     with FakeTelegramApi() as api:
         # When: Telegram startup identity and membership methods are called.
@@ -152,9 +166,10 @@
                 "update_id": 3,
             }
         ],
     }
-    assert Update.de_json(response["result"][0], bot=None).callback_query is not None
+    update = _first_result_object(response)
+    assert Update.de_json(update, bot=None).callback_query is not None

 def test_command_updates_include_telegram_bot_command_entities() -> None:
     # Given: an incoming command update addressed to the disposable bot.
@@ -166,9 +181,12 @@
         # When: the polling API returns the typed update.
         response = _post(api, "getUpdates")

     # Then: PTB's command filter receives the entity Telegram supplies in production.
-    assert response["result"][0]["message"]["entities"] == [
+    update = _first_result_object(response)
+    message = update["message"]
+    assert _is_json_object(message)
+    assert message["entities"] == [
         {"length": 6, "offset": 0, "type": "bot_command"}
     ]


**Planning parallel code diagnostics**

The repair is now in place: the result payload is narrowed to a typed JSON object before PTB parsing, and the command-message payload is narrowed before nested access. I’m running the checker and all requested single-file gates now.

bash

cd /home/cube/projects/richard/.worktrees/nutricoach-v111-impl && uv run basedpyright tests/gateway/test_fake_telegram_bot_api.py

cd /home/cube/projects/richard/.worktrees/nutricoach-v111-impl && uv run scripts/python/check-no-excuse-rules.py tests/gateway/test_fake_telegram_bot_api.py


0 errors, 0 warnings, 0 notes


error: Failed to spawn: `scripts/python/check-no-excuse-rules.py`
  Caused by: No such file or directory (os error 2)

Command exited with code 2


============================= test session starts ==============================
platform linux -- Python 3.12.9, pytest-9.0.2, pluggy-1.6.0
rootdir: /home/cube/projects/richard/.worktrees/nutricoach-v111-impl
configfile: pyproject.toml
plugins: anyio-4.12.1, asyncio-1.3.0
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collected 0 items / 1 error

==================================== ERRORS ====================================
_________ ERROR collecting tests/gateway/test_fake_telegram_bot_api.py _________
.venv/lib/python3.12/site-packages/pydantic/type_adapter.py:287: in _init_core_attrs
    self.core_schema = _getattr_no_parents(self._type, '__pydantic_core_schema__')
                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/type_adapter.py:56: in _getattr_no_parents
    raise AttributeError(attribute)
E   AttributeError: __pydantic_core_schema__

During handling of the above exception, another exception occurred:
tests/gateway/test_fake_telegram_bot_api.py:13: in <module>
    from scripts.dualcoach_v111_evidence import write_text
scripts/dualcoach_v111_evidence.py:40: in <module>
    _JSON_ADAPTER: TypeAdapter[JsonValue] = TypeAdapter(JsonValue)
                                            ^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/type_adapter.py:244: in __init__
    self._init_core_attrs(
.venv/lib/python3.12/site-packages/pydantic/type_adapter.py:306: in _init_core_attrs
    core_schema = schema_generator.generate_schema(self._type)
                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:744: in generate_schema
    schema = self._generate_schema_inner(obj)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1043: in _generate_schema_inner
    return self.match_type(obj)
           ^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1161: in match_type
    return self._match_generic_type(obj, origin)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1184: in _match_generic_type
    return self._union_schema(obj)
           ^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1344: in _union_schema
    choices.append(self.generate_schema(arg))
                   ^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:744: in generate_schema
    schema = self._generate_schema_inner(obj)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1043: in _generate_schema_inner
    return self.match_type(obj)
           ^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1161: in match_type
    return self._match_generic_type(obj, origin)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1188: in _match_generic_type
    return self._list_schema(self._get_first_arg_or_any(obj))
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:399: in _list_schema
    return core_schema.list_schema(self.generate_schema(items_type))
                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:744: in generate_schema
    schema = self._generate_schema_inner(obj)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1043: in _generate_schema_inner
    return self.match_type(obj)
           ^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1161: in match_type
    return self._match_generic_type(obj, origin)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1184: in _match_generic_type
    return self._union_schema(obj)
           ^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1344: in _union_schema
    choices.append(self.generate_schema(arg))
                   ^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:744: in generate_schema
    schema = self._generate_schema_inner(obj)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1043: in _generate_schema_inner
    return self.match_type(obj)
           ^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1161: in match_type
    return self._match_generic_type(obj, origin)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:1188: in _match_generic_type
    return self._list_schema(self._get_first_arg_or_any(obj))
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py:399: in _list_schema
    return core_schema.list_schema(self.generate_schema(items_type))
                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E   RecursionError: maximum recursion depth exceeded
E   If you made use of an implicit recursive type alias (e.g. `MyType = list['MyType']), consider using PEP 695 type aliases instead. For more details, refer to the documentation: https://docs.pydantic.dev/2.13/concepts/types/#named-recursive-types
!!! Recursion detected (same locals & position)
=========================== short test summary info ============================
ERROR tests/gateway/test_fake_telegram_bot_api.py - RecursionError: maximum r...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.23s ===============================

Command exited with code 2


**Locating project script path**

find /home/cube/projects/richard/.worktrees/nutricoach-v111-impl -path '*check-no-excuse-rules.py' -o -path '*check_no_excuse*'

git -C /home/cube/projects/richard/.worktrees/nutricoach-v111-impl diff -- tests/gateway/test_fake_telegram_bot_api.py


**Verifying untracked file edit and pytest failure causes**

**Locating and planning no-excuse rule checks**

find /projects/richard/omo-native-pirate/packages/omo-senpi/plugin/skills/programming -name 'check-no-excuse-rules.py' -print


LSP file path must be inside request cwd: /home/cube/projects/richard/.worktrees/nutricoach-v111-impl/tests/gateway/test_fake_telegram_bot_api.py


from __future__ import annotations

import http.client
import json
import stat
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import TypeGuard

from pydantic import JsonValue
from telegram import Update

from scripts.dualcoach_v111_evidence import write_text
from scripts.dualcoach_v111_fake_telegram import (
    CallbackQueryUpdate,
    FakeTelegramApi,
    MessageUpdate,
)
from tests.support.fake_telegram_client import (
    message_response as _message_response,
    post as _post,
)

def _is_json_object(value: JsonValue) -> TypeGuard[dict[str, JsonValue]]:
    return isinstance(value, dict)

def _first_result_object(response: dict[str, JsonValue]) -> dict[str, JsonValue]:
    result = response["result"]
    assert isinstance(result, list)
    first = result[0]
    assert _is_json_object(first)
    return first

def test_identity_chat_and_membership_methods_return_telegram_shapes() -> None:
    # Given: a started loopback fake with its deterministic default fixtures.
    with FakeTelegramApi() as api:
        # When: Telegram startup identity and membership methods are called.
        me = _post(api, "getMe")
        chat = _post(api, "getChat", chat_id=-100700)
        member = _post(api, "getChatMember", chat_id=-100700, user_id=7000000002)

    # Then: each response carries the Telegram-compatible typed fixture.

[461 more lines in file. Use offset=46 to continue.]


    assert response == {
        "ok": True,
        "result": [
            {
                "callback_query": {
                    "chat_instance": "loopback-chat-41",
                    "data": "private-callback-data",
                    "from": {"first_name": "User", "id": 7, "is_bot": False},
                    "id": "private-callback-id",
                    "message": {
                        "chat": {"id": 41, "type": "private"},
                        "date": 0,
                        "from": {
                            "first_name": "Disposable NutriCoach",
                            "id": 7000000002,
                            "is_bot": True,
                            "username": "disposable_nutricoach_bot",
                        },
                        "message_id": 9,
                    },
                },
                "update_id": 3,
            }
        ],
    }
    update = _first_result_object(response)
    assert Update.de_json(update, bot=None).callback_query is not None

def test_command_updates_include_telegram_bot_command_entities() -> None:
    # Given: an incoming command update addressed to the disposable bot.
    with FakeTelegramApi() as api:
        _ = api.enqueue_update(
            MessageUpdate(chat_id=41, user_id=7, text="/start rc1_abcdefghijklmnopqrstuv")
        )

        # When: the polling API returns the typed update.
        response = _post(api, "getUpdates")

[324 more lines in file. Use offset=183 to continue.]


**Evaluating LSP diagnostics path limitations**

cd /home/cube/projects/richard/.worktrees/nutricoach-v111-impl && uv run python /projects/richard/omo-native-pirate/packages/omo-senpi/plugin/skills/programming/scripts/python/check-no-excuse-rules.py tests/gateway/test_fake_telegram_bot_api.py


/home/cube/projects/richard/.worktrees/nutricoach-v111-impl/tests/gateway/test_fake_telegram_bot_api.py:1:1: [oversized-module] 391 pure LOC (limit: 250) — split by responsibility

1 violation(s) in 1 file(s)

Command exited with code 1


LSP file path must be inside request cwd: ../.worktrees/nutricoach-v111-impl/tests/gateway/test_fake_telegram_bot_api.py


**Considering suppressing size violation with noqa**

**Investigating test import recursion issues**

**Analyzing fake module import causing test failures**


"""Private evidence persistence for the disposable DualCoach v1.1.1 run."""

from __future__ import annotations

import json
import os
import stat
import tempfile
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Final, TypeAlias, override

from pydantic import TypeAdapter

_GENERATED_NAMES: Final = frozenset(
    {
        "candidate-binding.json",
        "capability-issuance.json",
        "child-after-restart.json",
        "child-before-crash.json",
        "cleanup.json",
        "crash-restart-state.json",
        "disposable-e2e-red.txt",
        "failure.json",
        "fake-api-calls.jsonl",
        "model-provider-calls.json",
        "network-boundary.json",
        "offsets.json",
        "pass.json",
        "process-events.json",
        "process-events.jsonl",
        "publication-counts.json",
        "receipt-assertions.json",
        "transcript.txt",
    }
)
JsonScalar: TypeAlias = str | int | float | bool | None
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
_JSON_ADAPTER: TypeAdapter[JsonValue] = TypeAdapter(JsonValue)

@dataclass(frozen=True, slots=True)
class EvidenceError(RuntimeError):
    reason: str

    @override
    def __str__(self) -> str:
        return self.reason

def prepare_evidence_dir(path: Path) -> None:
    """Create a private directory and remove stale generated alternatives."""
    path.mkdir(mode=0o700, parents=True, exist_ok=True)
    _ = path.chmod(0o700)
    for name in _GENERATED_NAMES:
        (path / name).unlink(missing_ok=True)

def write_private(path: Path, payload: bytes) -> None:
    """Atomically replace an artifact via an exclusive mode-0600 temporary."""
    path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
    _ = path.parent.chmod(0o700)
    descriptor, temporary_name = tempfile.mkstemp(
        dir=path.parent,
        prefix=f".{path.name}.",
        suffix=".tmp",
    )
    temporary = Path(temporary_name)
    try:
        os.fchmod(descriptor, 0o600)
        with os.fdopen(descriptor, "wb", closefd=True) as stream:
            stream.write(payload)
            stream.flush()
            os.fsync(stream.fileno())
        os.replace(temporary, path)
    finally:
        temporary.unlink(missing_ok=True)

def write_json(path: Path, payload: Mapping[str, JsonValue]) -> None:
    write_private(
        path,
        (json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n").encode(),
    )

def write_text(path: Path, text: str) -> None:
    write_private(path, text.encode("utf-8"))

def verify_evidence(evidence_dir: Path, transcript: Path, marker: str) -> None:
    """Prove privacy, loopback isolation, final marker, and cleanup."""
    if stat.S_IMODE(evidence_dir.stat().st_mode) != 0o700:
        raise EvidenceError("evidence directory is not private")
    for artifact in (*evidence_dir.iterdir(), transcript):
        if (
            artifact.is_symlink()
            or not artifact.is_file()
            or stat.S_IMODE(artifact.stat().st_mode) != 0o600

[38 more lines in file. Use offset=101 to continue.]


**Planning Python 3.13 testing for Pydantic issue**

cd /home/cube/projects/richard/.worktrees/nutricoach-v111-impl && uv python list

cd /home/cube/projects/richard/.worktrees/nutricoach-v111-impl && python --version && uv run python --version && uv run pydantic --version 2>/dev/null || true

git -C /home/cube/projects/richard/.worktrees/nutricoach-v111-impl status --short -- tests/gateway/test_fake_telegram_bot_api.py


cpython-3.15.0b4-linux-x86_64-gnu                 <download available>
cpython-3.15.0b4+freethreaded-linux-x86_64-gnu    <download available>
cpython-3.14.6-linux-x86_64-gnu                   <download available>
cpython-3.14.6+freethreaded-linux-x86_64-gnu      <download available>
cpython-3.13.14-linux-x86_64-gnu                  <download available>
cpython-3.13.14+freethreaded-linux-x86_64-gnu     <download available>
cpython-3.13.7-linux-x86_64-gnu                   /usr/bin/python3.13
cpython-3.13.7-linux-x86_64-gnu                   /usr/bin/python3 -> python3.13
cpython-3.12.13-linux-x86_64-gnu                  /home/cube/.local/bin/python3.12 -> /home/cube/.local/share/uv/python/cpython-3.12-linux-x86_64-gnu/bin/python3.12
cpython-3.12.13-linux-x86_64-gnu                  /home/cube/.local/share/uv/python/cpython-3.12-linux-x86_64-gnu/bin/python3.12
cpython-3.12.9-linux-x86_64-gnu                   /home/cube/miniconda3/bin/python3.12
cpython-3.12.9-linux-x86_64-gnu                   /home/cube/miniconda3/bin/python3 -> python3.12
cpython-3.12.9-linux-x86_64-gnu                   /home/cube/miniconda3/bin/python -> python3.12
cpython-3.11.15-linux-x86_64-gnu                  <download available>
cpython-3.11.0-linux-x86_64-gnu                   /usr/local/bin/python3.11
cpython-3.10.20-linux-x86_64-gnu                  <download available>
cpython-3.9.25-linux-x86_64-gnu                   <download available>
cpython-3.8.20-linux-x86_64-gnu                   <download available>
pypy-3.11.15-linux-x86_64-gnu                     <download available>
pypy-3.10.16-linux-x86_64-gnu                     <download available>
pypy-3.9.19-linux-x86_64-gnu                      <download available>
pypy-3.8.16-linux-x86_64-gnu                      <download available>
graalpy-3.12.0-linux-x86_64-gnu                   <download available>
graalpy-3.11.0-linux-x86_64-gnu                   <download available>
graalpy-3.10.0-linux-x86_64-gnu                   <download available>
graalpy-3.8.5-linux-x86_64-gnu                    <download available>


Python 3.12.9
Python 3.12.9


?? tests/gateway/test_fake_telegram_bot_api.py


**Planning Python 3.13 test environment setup**

cd /home/cube/projects/richard/.worktrees/nutricoach-v111-impl && uv run --python /usr/bin/python3.13 pytest tests/gateway/test_fake_telegram_bot_api.py