From 0ffe0232f58d0b8d9121de7b5235d55c703db91e Mon Sep 17 00:00:00 2001 From: Pat Sukprasert Date: Mon, 6 Jul 2026 10:01:30 +0700 Subject: [PATCH 001/546] fix(harness-bench): streaming=False declares UNSUPPORTED, not PARTIAL (#1991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(harness-bench): streaming=False declares UNSUPPORTED, not PARTIAL #1990 corrected the transcript-mirror natives to streaming=False, but the manifest mapped False → PARTIAL while the streaming probe reports a zero-delta harness as UNSUPPORTED — so kiro-native still drifted (!!~>✗: declared PARTIAL, observed UNSUPPORTED). streaming is a binary capability: True → SUPPORTED, False → UNSUPPORTED. PARTIAL is a probe *observation* (the ambiguous coalesced-single-delta retry case against a SUPPORTED declaration), never a declared value. Map False → UNSUPPORTED so a non-streaming harness's declaration matches what the probe observes. Live-verified: kiro-native now renders a clean ✗ with no drift (exit 0). - Add a regression test locking the binary mapping (True→SUPPORTED, False→UNSUPPORTED, never PARTIAL declared). - Document in the design doc: how to run/read the bench (a subset suffices; own-auth natives skip cleanly; read DRIFT + unexpected ✗/· only), and that streaming is a binary declared capability. Offline 51 passed / 14 skipped, ruff clean. * docs(harness-bench): tighten streaming-verdict comments The binary-streaming rule was explained at length in both the manifest and the test. Keep the canonical 4-line "why" in the manifest; reduce the test comment to a one-line pointer. No behavior change. --- docs/harness-bench-design.md | 48 +++++++++++++++++++++++++++++++ tests/harness_bench/manifest.py | 7 +++-- tests/harness_bench/test_bench.py | 19 ++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/docs/harness-bench-design.md b/docs/harness-bench-design.md index cdd7cfe3984..43757e3a1aa 100644 --- a/docs/harness-bench-design.md +++ b/docs/harness-bench-design.md @@ -263,6 +263,54 @@ is not. gated on CLI + creds, P0 blocking, P1 report-only. Follows the existing nightly/flake-stress pattern rather than blocking every PR on live turns. +## Running the bench and reading the result + +``` +# Offline: the declared matrix, no creds, every harness. Fast. +python -m tests.harness_bench + +# Live: probe one harness against a gateway profile. +python -m tests.harness_bench --harness codex-native --profile oss + +# Live: probe every official harness (SDK + native) sequentially. +python -m tests.harness_bench --profile oss + +# A community harness that ships its own BenchProfile. +python -m tests.harness_bench --harness mypkg.harness:PROFILE --profile oss +``` + +**You do not need to live-probe every harness on every host — and you cannot.** +Each native harness needs its own vendor CLI logged in (a login the bench +cannot provision), so no single host has them all. The two layers split the +work: + +- **Offline conformance** already covers every harness in CI — registration, + the declared matrix, capability derivation. No host access needed. +- **Live probes** only answer "does observed behavior match the declaration?" + You get value from live-probing a harness where the declaration is unverified + or might be wrong — not from chasing 100% coverage on one box. + +Run the full set on whatever host you have (`--profile oss`); harnesses whose +vendor CLI is absent or logged out **skip cleanly** (they do not fail or abort +the run). Read two signals only: any `!!` DRIFT, and any harness you *can* run +that shows an unexpected `✗` / `·`. A single live run is a spot-check, not a +gate — live probes are non-deterministic (model behavior, timing), so re-run +before treating one `·`/timeout as a regression. Drift coverage is cumulative: +each host that has harness X logged in contributes a live check for X. + +## Streaming is a binary declared capability + +A recurring subtlety worth stating: the `streaming` capability is **binary** — +a harness either forwards token-level deltas (`SUPPORTED`) or it does not +(`UNSUPPORTED`). `PARTIAL` is a *probe observation only*: the streaming probe +returns it for the ambiguous coalesced-single-delta case against a `SUPPORTED` +declaration. It is **never a declared value**. Declaring a non-streaming +harness as `PARTIAL` drifts against reality, because the probe reports zero +deltas as `UNSUPPORTED`, not `PARTIAL`. This bit the transcript-mirror natives +(kiro/goose/qwen/hermes/cursor/kimi/pi), which deliver each complete assistant +message rather than streaming deltas: they declare `streaming=False` → +`UNSUPPORTED`, matching what the probe observes. + ## Open items - Exact `BenchProfile` field set and whether it subsumes `HarnessProbe` or wraps diff --git a/tests/harness_bench/manifest.py b/tests/harness_bench/manifest.py index cb6a6b12b31..6adaddada80 100644 --- a/tests/harness_bench/manifest.py +++ b/tests/harness_bench/manifest.py @@ -100,8 +100,11 @@ def _declared_from_capabilities(harness: str) -> dict[str, Verdict]: caps = harness_capabilities().get(harness) if caps is not None: - # streaming: True → deltas (SUPPORTED); False → complete-only (PARTIAL). - declared["streaming"] = Verdict.SUPPORTED if caps.streaming else Verdict.PARTIAL + # streaming is binary: True → SUPPORTED, False → UNSUPPORTED. PARTIAL is + # a probe observation (coalesced single delta), never a declared value — + # declaring False as PARTIAL would drift against a harness the probe + # reports UNSUPPORTED (0 deltas). + declared["streaming"] = Verdict.SUPPORTED if caps.streaming else Verdict.UNSUPPORTED # interrupt: True → SUPPORTED; False → UNSUPPORTED. declared["interrupt"] = Verdict.SUPPORTED if caps.interrupt else Verdict.UNSUPPORTED diff --git a/tests/harness_bench/test_bench.py b/tests/harness_bench/test_bench.py index c98e6116b0e..2670269e58c 100644 --- a/tests/harness_bench/test_bench.py +++ b/tests/harness_bench/test_bench.py @@ -67,6 +67,25 @@ def test_declared_covers_every_p0_dimension(profile: BenchProfile) -> None: ) +def test_streaming_capability_declares_binary_verdict() -> None: + # Guards the kiro-native drift: streaming declares binary (True→SUPPORTED, + # False→UNSUPPORTED), never PARTIAL. + from omnigent.harness_plugins import harness_capabilities + from tests.harness_bench.manifest import _declared_from_capabilities + + caps = harness_capabilities() + for harness, cap in caps.items(): + declared = _declared_from_capabilities(harness).get("streaming") + if declared is None: + continue + expected = Verdict.SUPPORTED if cap.streaming else Verdict.UNSUPPORTED + assert declared is expected, ( + f"{harness!r}: streaming={cap.streaming} should declare {expected.name}, " + f"got {declared.name}" + ) + assert declared is not Verdict.PARTIAL, f"{harness!r}: PARTIAL is never a declared verdict" + + def test_reconcile_flags_concrete_mismatch() -> None: assert reconcile(Verdict.UNSUPPORTED, Verdict.SUPPORTED) is Verdict.DRIFT assert reconcile(Verdict.SUPPORTED, Verdict.UNSUPPORTED) is Verdict.DRIFT From 61a1d76b89f8cd86dbbea1334cb9079039b72f49 Mon Sep 17 00:00:00 2001 From: Volo Vragov Date: Sun, 5 Jul 2026 21:12:48 -0600 Subject: [PATCH 002/546] fix(runner): return structured result instead of KeyError on environment shell timeout (#1976) Signed-off-by: Volodymyr Vragov Co-authored-by: Volodymyr Vragov --- omnigent/entities/environment_filesystem.py | 4 +-- omnigent/inner/os_env.py | 1 + tests/inner/test_os_env.py | 27 ++++++++++++++++++++- tests/runner/test_environment_filesystem.py | 19 +++++++++++++++ 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/omnigent/entities/environment_filesystem.py b/omnigent/entities/environment_filesystem.py index 2f832d5d77e..26b419344af 100644 --- a/omnigent/entities/environment_filesystem.py +++ b/omnigent/entities/environment_filesystem.py @@ -249,14 +249,14 @@ class ShellResult: :param stdout: Standard output of the command. :param stderr: Standard error of the command. - :param exit_code: Process exit code. + :param exit_code: Process exit code, or ``None`` when no status exists. :param timed_out: Whether the command was killed by timeout. :param cwd: Working directory the command ran in, if known. """ stdout: str stderr: str - exit_code: int + exit_code: int | None timed_out: bool cwd: str | None = None diff --git a/omnigent/inner/os_env.py b/omnigent/inner/os_env.py index b413d98e03b..ee6835bdab4 100644 --- a/omnigent/inner/os_env.py +++ b/omnigent/inner/os_env.py @@ -1347,6 +1347,7 @@ def _shell_impl( return { "stdout": _truncate_output(stdout, "stdout", max_output), "stderr": _truncate_output(stderr, "stderr", max_output), + "exit_code": None, "timed_out": True, "error": f"Command timed out after {timeout} seconds", "shell": shell_path, diff --git a/tests/inner/test_os_env.py b/tests/inner/test_os_env.py index 464d20707e7..fee79fcfdb7 100644 --- a/tests/inner/test_os_env.py +++ b/tests/inner/test_os_env.py @@ -3,10 +3,11 @@ from __future__ import annotations import base64 +import shutil import tracemalloc from pathlib import Path -from omnigent.inner.os_env import _read_impl, build_helper_env +from omnigent.inner.os_env import _read_impl, _shell_impl, build_helper_env from omnigent.inner.sandbox import SandboxPolicy from omnigent.runner.identity import ( OMNIGENT_SESSION_ENV_VALUE, @@ -111,6 +112,30 @@ def test_build_helper_env_active_passes_omnigent_session_marker() -> None: assert env[OMNIGENT_SESSION_ENV_VAR] == OMNIGENT_SESSION_ENV_VALUE +# --------------------------------------------------------------------------- +# _shell_impl — timeout result shape +# --------------------------------------------------------------------------- + + +def test_shell_impl_timeout_includes_exit_code(tmp_path: Path) -> None: + """Timed-out shell commands still return the documented result fields.""" + shell_path = shutil.which("bash") or shutil.which("sh") + assert shell_path is not None + + result = _shell_impl( + command="sleep 2", + timeout=1, + shell_path=shell_path, + cwd=tmp_path, + ) + + assert result["stdout"] == "" + assert result["stderr"] == "" + assert result["exit_code"] is None + assert result["timed_out"] is True + assert result["error"] == "Command timed out after 1 seconds" + + # --------------------------------------------------------------------------- # _read_impl — binary file handling # --------------------------------------------------------------------------- diff --git a/tests/runner/test_environment_filesystem.py b/tests/runner/test_environment_filesystem.py index e3051ce15fd..8e580672ab8 100644 --- a/tests/runner/test_environment_filesystem.py +++ b/tests/runner/test_environment_filesystem.py @@ -646,6 +646,25 @@ async def test_shell_nonzero_exit( assert resp.json()["exit_code"] == 42 +@pytest.mark.asyncio +async def test_shell_timeout_returns_structured_result( + client: httpx.AsyncClient, +) -> None: + """POST /shell returns the timeout result instead of raising.""" + resp = await client.post( + f"/v1/sessions/conv_test/resources/environments/{DEFAULT_ENVIRONMENT_ID}/shell", + json={"command": "sleep 2", "timeout": 1}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["object"] == "session.environment.shell_result" + assert body["stdout"] == "" + assert body["stderr"] == "" + assert body["exit_code"] is None + assert body["timed_out"] is True + assert body["cwd"] is not None + + @pytest.mark.asyncio async def test_shell_missing_command_returns_400( client: httpx.AsyncClient, From 7f5ffc0d83a070e02c21289610cd6cb5b578aff4 Mon Sep 17 00:00:00 2001 From: Tomu Hirata Date: Mon, 6 Jul 2026 12:55:12 +0900 Subject: [PATCH 003/546] refactor(policies): move nessie policies to builtins/orchestration (#1682) * refactor(policies): move nessie policies to builtins/orchestration Move all policy factory functions (blast_radius, spawn_bounds, headless_subagent_purpose_guard, worktree_guard, read_only_os) and POLICY_REGISTRY from omnigent.inner.nessie.policies into the proper omnigent.policies.builtins.orchestration module. Leave omnigent/inner/nessie/policies.py as a thin re-export shim so deployed configs that reference handler paths by the old module string continue to work without any changes. Update BUILTIN_POLICY_MODULES and all in-repo YAML configs to point at the new canonical path. * fix(policies): remove redundant F401 noqa on wildcard import in nessie shim * docs(policies): remove dangling designs/NESSIE.md references * revert(configs): keep example configs on legacy nessie policy paths The new orchestration module paths are only safe once all runners have been updated. The shim at omnigent.inner.nessie.policies handles old configs indefinitely, so in-repo examples don't need to change. * fix(policies): add MultiEdit to worktree_guard write-tool set --- omnigent/inner/nessie/__init__.py | 10 +- omnigent/inner/nessie/policies.py | 674 +------------------- omnigent/policies/builtins/__init__.py | 2 +- omnigent/policies/builtins/orchestration.py | 662 +++++++++++++++++++ 4 files changed, 672 insertions(+), 676 deletions(-) create mode 100644 omnigent/policies/builtins/orchestration.py diff --git a/omnigent/inner/nessie/__init__.py b/omnigent/inner/nessie/__init__.py index c875657a873..c8d2db48e6c 100644 --- a/omnigent/inner/nessie/__init__.py +++ b/omnigent/inner/nessie/__init__.py @@ -1,9 +1,7 @@ """Runner-side support for the polly coding orchestrator (examples/polly). -Currently holds the bounds + blast-radius FunctionPolicy callables that -enforce polly's hard rules at tool dispatch — no server routes involved. -The package keeps its historical ``nessie`` name: agent specs (polly's -config.yaml and already-deployed bundles) reference -``omnigent.inner.nessie.policies.*`` by module path, so a rename would -break them. See designs/NESSIE.md "Layer 1 — enforcement". +The policy implementations have moved to +``omnigent.policies.builtins.orchestration``; ``omnigent.inner.nessie.policies`` +is now a thin re-export shim so already-deployed configs that reference handler +paths by the old module path continue to work without changes. """ diff --git a/omnigent/inner/nessie/policies.py b/omnigent/inner/nessie/policies.py index 3accc9bd23e..6164fddfd61 100644 --- a/omnigent/inner/nessie/policies.py +++ b/omnigent/inner/nessie/policies.py @@ -1,671 +1,7 @@ -"""Bounds and blast-radius policies for the coding orchestrator. - -Each public function is a :class:`FunctionPolicy` *factory*: it takes the -YAML ``factory_params`` as keyword arguments and returns an evaluator -callable ``fn(event[, config]) -> {"result": ..., "reason": ...}``. -The evaluators run runner-side at tool dispatch -(``omnigent/runner/policy.py``) and add no server routes. See -``designs/NESSIE.md`` "Layer 1 — enforcement". +"""Backward-compat shim — policy handler paths in deployed configs still reference +``omnigent.inner.nessie.policies.*``. Real implementation lives at +``omnigent.policies.builtins.orchestration``. """ -from __future__ import annotations - -import re -import shlex -from collections.abc import Callable -from typing import Any, TypeAlias - -# Heterogeneous JSON-shaped maps — the V0 policy event + decision payloads. -_Json: TypeAlias = dict[str, Any] # type: ignore[explicit-any] - -# A ready ALLOW decision (the common case — most tool calls pass). -_ALLOW: _Json = {"result": "ALLOW"} - - -def _decision(result: str, reason: str) -> _Json: - """ - Build a Service-Policies-V0 decision dict. - - :param result: One of ``"ALLOW"``, ``"DENY"``, ``"ASK"``. - :param reason: Human-readable explanation surfaced to the user - (shown on ASK prompts and DENY messages), e.g. - ``"git push is gated; approve to proceed."``. - :returns: A decision dict, e.g. - ``{"result": "ASK", "reason": "..."}``. - """ - return {"result": result, "reason": reason} - - -def _tool_call(event: _Json, tool_names: set[str]) -> _Json | None: - """ - Return the args dict of a matching ``tool_call`` event, else ``None``. - - :param event: A V0 event dict with ``type`` and ``data`` keys. For a - tool call, ``data`` is ``{"name": "", "arguments": {...}}``. - :param tool_names: Tool names this policy acts on, e.g. - ``{"sys_os_write", "sys_os_edit"}``. - :returns: The ``args`` dict when *event* is a ``tool_call`` for one - of *tool_names*, otherwise ``None`` (caller should ALLOW). - """ - if event.get("type") != "tool_call": - return None - data = event.get("data") - if not isinstance(data, dict) or data.get("name") not in tool_names: - return None - args = data.get("arguments") - return args if isinstance(args, dict) else {} - - -# Catastrophic, effectively-irreversible commands — always DENY. ``rm`` and -# ``git push`` are NOT here: a single regex missed split/long flag forms -# (``rm -r -f``, ``rm --recursive --force``), root children (``rm -rf /etc``), -# and force/delete refspecs (``git push origin +main`` / ``--delete``). They are -# classified by the flag/refspec-robust helpers below instead. -_DENY_PATTERNS: tuple[re.Pattern[str], ...] = ( - re.compile(r"\bgit\b.*\breset\s+--hard\s+\w+/"), # hard-reset to a remote ref -) - -# Outward / destructive but recoverable — ASK the human first. -_ASK_PATTERNS: tuple[re.Pattern[str], ...] = ( - re.compile(r"\bgh\s+(pr\s+merge|release|repo\s+delete)\b"), - re.compile(r"\b(kubectl|helm|terraform|databricks)\b.*\b(apply|deploy|destroy|delete)\b"), -) - -# Recursive-force ``rm`` of one of these (the directory itself) is catastrophic. -_RM_CRITICAL_DIRS: frozenset[str] = frozenset( - { - "/", - "/etc", - "/usr", - "/bin", - "/sbin", - "/lib", - "/lib64", - "/var", - "/boot", - "/root", - "/home", - "/opt", - "/dev", - "/proc", - "/sys", - } -) -# Recursive-force ``rm`` of a path UNDER one of these system dirs is also -# catastrophic (system files). ``/home`` / ``/opt`` / ``/root`` are excluded: a -# path under them is scoped/recoverable and is gated at the ASK tier instead. -_RM_SYSTEM_PARENTS: frozenset[str] = frozenset( - {"/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/var", "/boot", "/dev", "/proc", "/sys"} -) -# Common sudo options that consume the following argv token as their value. -_SUDO_VALUE_OPTS: frozenset[str] = frozenset( - { - "-C", - "-D", - "-g", - "-h", - "-p", - "-R", - "-r", - "-T", - "-t", - "-U", - "-u", - "--chdir", - "--chroot", - "--close-from", - "--command-timeout", - "--group", - "--host", - "--other-user", - "--prompt", - "--role", - "--type", - "--user", - } -) -_GIT_GLOBAL_VALUE_OPTS: frozenset[str] = frozenset( - {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"} -) -_PUSH_SHORT_VALUE_OPTS: frozenset[str] = frozenset({"o"}) -_ENV_ASSIGNMENT_RE: re.Pattern[str] = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=.*") - - -def _shell_statements(command: str) -> list[list[str]]: - """ - Best-effort split of a shell command line into per-statement token lists. - - Splits on the common statement / pipe separators (``;`` ``&&`` ``||`` ``|`` - newline) and tokenizes each piece with :func:`shlex.split` (falling back to - a whitespace split on a quoting error). This is a heuristic for catching - obvious destructive commands — it deliberately does NOT model subshells, - command substitution, or ``eval``, which a determined caller could use to - evade it. The policy is a safety net against accidental / obvious damage, - not a security boundary (that is sandboxing). - - :param command: A shell command string, e.g. ``"cd repo && rm -rf build"``. - :returns: One token list per statement, e.g. - ``[["cd", "repo"], ["rm", "-rf", "build"]]``. - """ - statements: list[list[str]] = [] - for piece in re.split(r"&&|\|\||[;|\n]", command): - piece = piece.strip() - if not piece: - continue - try: - argv = shlex.split(piece) - except ValueError: - argv = piece.split() - if argv: - statements.append(argv) - return statements - - -def _rm_target_is_catastrophic(target: str) -> bool: - """ - Whether ``rm -rf`` of *target* would be catastrophic / irreversible. - - Catastrophic = root, the whole home dir, a top-level critical dir itself - (:data:`_RM_CRITICAL_DIRS`), or any path under a system dir - (:data:`_RM_SYSTEM_PARENTS`, e.g. ``/etc/...``). A scoped path under - ``/home`` / ``/opt`` / ``/tmp`` or a relative path is NOT catastrophic here - (recoverable / the worker's own tree) — those fall to the ASK tier. - - :param target: A single tokenized ``rm`` argument, e.g. ``"/etc"``, - ``"~"``, ``"build"``. - :returns: ``True`` if deleting *target* recursively is catastrophic. - """ - norm = target.rstrip("/") or "/" - if norm in ("~", "$HOME", "${HOME}"): - return True - if target == "/*" or target.startswith("/*"): - return True - if norm in _RM_CRITICAL_DIRS: - return True - if target.startswith("/"): - top = "/" + target.lstrip("/").split("/", 1)[0] - if top in _RM_SYSTEM_PARENTS: - return True - return False - - -def _skip_shell_assignments(argv: list[str], start: int) -> int: - """ - Return the first index after leading shell-style env assignments. - - Shell statements may prefix a command with temporary environment variables, - e.g. ``CI=1 git push ...``. Those tokens are not the command itself and - should not hide the destructive command from classification. - - :param argv: One statement's tokens, e.g. ``["CI=1", "git", "push"]``. - :param start: Index where assignment scanning begins, e.g. ``0``. - :returns: The first non-assignment index at or after *start*. - """ - i = start - while i < len(argv) and _ENV_ASSIGNMENT_RE.fullmatch(argv[i]): - i += 1 - return i - - -def _command_index_after_shell_prefixes(argv: list[str]) -> int: - """ - Return the command index after env assignments and optional ``sudo``. - - Parses shell-style env assignments plus common sudo flags so - ``CI=1 sudo -n rm ...`` and ``sudo -u root rm ...`` classify the underlying - command the same way as bare ``rm ...``. - - :param argv: One statement's tokens, e.g. ``["sudo", "-n", "rm", "-rf", "/"]``. - :returns: The argv index of the command after any supported prefixes. - """ - i = _skip_shell_assignments(argv, 0) - if i >= len(argv) or argv[i] != "sudo": - return i - i += 1 - while i < len(argv): - tok = argv[i] - if tok == "--": - return _skip_shell_assignments(argv, i + 1) - if tok.startswith("--"): - i += 2 if tok in _SUDO_VALUE_OPTS and "=" not in tok and i + 1 < len(argv) else 1 - continue - if tok.startswith("-") and tok != "-": - value_opt_pos = next( - (pos for pos, opt in enumerate(tok[1:]) if f"-{opt}" in _SUDO_VALUE_OPTS), - None, - ) - if value_opt_pos is None: - i += 1 - continue - value_is_attached = value_opt_pos < len(tok[1:]) - 1 - i += 1 if value_is_attached else 2 - continue - return _skip_shell_assignments(argv, i) - return len(argv) - - -def _rm_severity(argv: list[str]) -> str | None: - """ - Classify a single ``rm`` statement by blast radius (flag-form robust). - - Detects a recursive ``rm`` in any spelling — combined (``-rf``, ``-Rf``), - short (``-r``), or long (``--recursive``) — and a leading ``sudo`` wrapper, - which the previous single regex matched only narrowly. Recursion is the - blast-radius signal (mass deletion); ``-f`` does not change the verdict - (matching the prior policy, which gated recursion with force optional). A - recursive ``rm`` of a catastrophic target (:func:`_rm_target_is_catastrophic`) - is ``"DENY"``; of any other target it is ``"ASK"``. A non-recursive ``rm`` - (single-file delete) returns ``None``. - - :param argv: One statement's tokens, e.g. ``["rm", "-rf", "/etc"]``. - :returns: ``"DENY"``, ``"ASK"``, or ``None``. - """ - i = _command_index_after_shell_prefixes(argv) - if i >= len(argv) or argv[i] != "rm": - return None - recursive = False - targets: list[str] = [] - positional_only = False # everything after a bare ``--`` is a filename, not a flag - for tok in argv[i + 1 :]: - if positional_only: - targets.append(tok) - elif tok == "--": - positional_only = True - elif tok == "--force": - continue - elif tok == "--recursive": - recursive = True - elif tok.startswith("-") and len(tok) > 1 and not tok.startswith("--"): - recursive = recursive or "r" in tok[1:] or "R" in tok[1:] - elif not tok.startswith("-"): - targets.append(tok) - if not recursive: - return None - return "DENY" if any(_rm_target_is_catastrophic(t) for t in targets) else "ASK" - - -def _push_short_option_is_destructive(token: str) -> bool: - """ - Whether a bundled ``git push`` short option token force-pushes or deletes. - - Git accepts combined short options such as ``-uf`` and ``-df``. A short - option that takes an attached value (currently ``-o`` / push-option) stops - flag parsing for the rest of that token so values like ``-o=fast`` are not - mistaken for force/delete flags. - - :param token: A short-option token from after ``git push``, e.g. ``"-uf"``. - :returns: ``True`` if the token contains destructive ``-f`` or ``-d`` flags. - """ - for opt in token[1:]: - if opt in ("f", "d"): - return True - if opt in _PUSH_SHORT_VALUE_OPTS: - return False - return False - - -def _push_severity(argv: list[str]) -> str | None: - """ - Classify a single ``git push`` statement by blast radius. - - A force-push (``--force`` / ``--force-with-lease`` / ``-f`` / a - ``+``-prefixed refspec / ``--mirror``) or a remote-branch deletion - (``--delete`` / ``--prune`` / ``-d`` / a ``:``-prefixed refspec) is - irreversible → ``"DENY"``. Any other ``git push`` is outward → ``"ASK"``. - The ``git`` subcommand is resolved past global options - (``git -C push …``) so ``"push"`` appearing as an argument value - (e.g. a commit message) is not mistaken for the subcommand. Anything that - is not a ``git push`` returns ``None``. - - :param argv: One statement's tokens, e.g. - ``["git", "push", "origin", "+main"]``. - :returns: ``"DENY"``, ``"ASK"``, or ``None``. - """ - i = _command_index_after_shell_prefixes(argv) - if i >= len(argv) or argv[i] != "git": - return None - j = i + 1 - while j < len(argv) and argv[j].startswith("-"): - j += 2 if argv[j] in _GIT_GLOBAL_VALUE_OPTS and j + 1 < len(argv) else 1 - if j >= len(argv) or argv[j] != "push": - return None - for tok in argv[j + 1 :]: - if tok.startswith("--force") or tok in ("--delete", "--mirror", "--prune"): - return "DENY" - if ( - tok.startswith("-") - and not tok.startswith("--") - and _push_short_option_is_destructive(tok) - ): - return "DENY" - if len(tok) > 1 and tok[0] in "+:": # +refspec (force) / :refspec (delete) - return "DENY" - return "ASK" - - -def blast_radius( - *, - gate_pushes: bool = True, - deny_reason: str = "Blocked by the blast-radius policy.", -) -> Callable[[_Json, _Json], _Json]: - """ - Factory: gate high-blast-radius shell commands by reversibility. - - Catastrophic, irreversible commands (force-push, ``rm -rf /``, - hard-reset to a remote ref) are DENIED. Outward or destructive but - recoverable commands (``git push``, ``gh pr merge``, ``rm -rf`` of a - path, infra deploy/destroy) return ASK so the human approves before - they run. Everything else — reads, tests, edits, and local git - (commit / merge / worktree) — is ALLOWED. - - :param gate_pushes: When ``True`` (default), recoverable-but-outward - commands return ASK. When ``False`` only the catastrophic DENY - set is enforced — use only for trusted unattended batch runs. - :param deny_reason: Reason text surfaced on a DENY decision. - :returns: An evaluator ``fn(event, config)`` returning a V0 decision. - """ - - def _evaluate(event: _Json, config: _Json) -> _Json: # noqa: ARG001 - """ - Classify a ``sys_os_shell`` command by blast radius. - - :param event: V0 ``tool_call`` event for ``sys_os_shell``. - :param config: Runtime config dict (unused; bounds come from the - factory params). - :returns: ALLOW / ASK / DENY decision dict. - """ - # Match the Omnigent built-in OS shell, the Claude/Codex native - # Bash tool, and Pi's native lowercase ``bash``. The PreToolUse hook - # reports BOTH CLI harnesses' shell tool as ``Bash`` with a string - # ``command`` (codex normalizes to this shape); Pi's ``tool_call`` - # hook reports ``bash`` with the same ``command`` key — so one match - # set covers all three. - args = _tool_call(event, {"sys_os_shell", "Bash", "bash"}) - if args is None: - return _ALLOW - command = args.get("command") - # A Bash / sys_os_shell call always carries a string ``command`` by - # contract; a non-str is a malformed payload no pattern can classify, so - # there is nothing to gate. - if not isinstance(command, str): - return _ALLOW - # rm + git push are classified by flag/refspec-robust helpers (a regex - # missed split/long rm flags, root children, and force/delete refspecs); - # the remaining regex patterns cover git-reset / gh / infra tools. - statements = _shell_statements(command) - severities = { - sev for stmt in statements for sev in (_rm_severity(stmt), _push_severity(stmt)) - } - if "DENY" in severities or any(p.search(command) for p in _DENY_PATTERNS): - return _decision("DENY", f"{deny_reason} (irreversible: {command!r})") - if gate_pushes and ("ASK" in severities or any(p.search(command) for p in _ASK_PATTERNS)): - return _decision("ASK", f"High-blast-radius command needs approval: {command!r}") - return _ALLOW - - return _evaluate - - -def spawn_bounds( - *, - max_dispatches_per_turn: int = 5, - dispatch_tools: tuple[str, ...] = ("sys_session_send",), -) -> Callable[[_Json], _Json]: - """ - Factory: cap how many workers the orchestrator may dispatch per turn. - - Counts the *dispatch_tools* tool calls within a single orchestrator turn - and DENIES once *max_dispatches_per_turn* is exceeded, forcing fan-out in - bounded waves rather than an unbounded fleet. The orchestrator dispatches - every worker through a sub-agent send (``sys_session_send``), so that is the - default counted tool. The counter resets each turn via the ``reset_turn`` - hook the runner calls (``omnigent/runner/policy.py``). This is the v1 - concurrency bound; true cross-turn live-concurrency accounting is a v1.x - refinement. - - :param max_dispatches_per_turn: Maximum worker dispatches allowed in one - turn, e.g. ``5``. - :param dispatch_tools: Tool names that count as a worker dispatch, e.g. - ``("sys_session_send",)``. A YAML list is accepted (coerced to a set). - :returns: A stateful evaluator ``fn(event)`` carrying a ``reset_turn`` - attribute, returning a V0 decision dict. - """ - counted = set(dispatch_tools) - state = {"count": 0} - - def _evaluate(event: _Json) -> _Json: - """ - Count and bound worker dispatches in the current turn. - - :param event: V0 event; a dispatch is a ``tool_call`` whose - ``data["name"]`` is one of *dispatch_tools*. - :returns: ALLOW, or DENY once the per-turn cap is exceeded. - """ - if _tool_call(event, counted) is None: - return _ALLOW - state["count"] += 1 - if state["count"] > max_dispatches_per_turn: - return _decision( - "DENY", - f"Exceeded {max_dispatches_per_turn} worker dispatches this turn; " - "fan out in waves (collect the running batch before dispatching more).", - ) - return _ALLOW - - def reset_turn() -> None: - """ - Reset the per-turn dispatch counter at each turn boundary. - - :returns: ``None``. - """ - state["count"] = 0 - - # FunctionPolicy looks for this attribute to reset per-turn state. - _evaluate.reset_turn = reset_turn # type: ignore[attr-defined] - return _evaluate - - -def headless_subagent_purpose_guard( - *, - allowed_purposes: tuple[str, ...] = ("implement", "review", "explore", "search"), - deny_reason: str = ( - "Every sys_session_send must declare what kind of work it is. Set " - "args.purpose to one of `implement` (write product code — any code " - "change, however small), `review` (judge a diff against its contract), " - "or `explore` / `search` (read-only investigation). All sub-agents " - "(`claude_code`, `codex`, `pi`) accept all of these." - ), -) -> Callable[[_Json], _Json]: - """ - Factory: require every ``sys_session_send`` to declare its ``args.purpose``. - - The orchestrator delegates all work through sub-agents, so each dispatch must be - tagged with an explicit ``args.purpose`` drawn from *allowed_purposes*. - The policy fails loud on an unmarked or out-of-set purpose, keeping - dispatches intentional rather than letting the model spawn a sub-agent - with no declared role. - - :param allowed_purposes: Explicit ``args.purpose`` values accepted for a - sub-agent dispatch, e.g. ``"review"`` or ``"implement"``. - :param deny_reason: Human-facing reason returned on DENY. - :returns: An evaluator ``fn(event)`` returning DENY for unmarked or - out-of-set ``sys_session_send`` calls. - """ - allowed = set(allowed_purposes) - - def _evaluate(event: _Json) -> _Json: - """ - Deny unmarked or disallowed sub-agent dispatches. - - :param event: V0 ``tool_call`` event for ``sys_session_send``. - :returns: ALLOW when ``args.purpose`` is allowed, DENY otherwise. - """ - args = _tool_call(event, {"sys_session_send"}) - if args is None: - return _ALLOW - child_args = args.get("args") - if not isinstance(child_args, dict): - return _decision("DENY", f"{deny_reason} Missing object args with purpose.") - purpose = child_args.get("purpose") - if not isinstance(purpose, str) or purpose not in allowed: - return _decision( - "DENY", - f"{deny_reason} Set args.purpose to one of {sorted(allowed)!r} " - "when this is a legitimate sub-agent task.", - ) - return _ALLOW - - return _evaluate - - -def worktree_guard( - *, - allowed_root: str = ".worktrees", - deny_reason: str = "Worker writes must stay inside its worktree.", -) -> Callable[[_Json, _Json], _Json]: - """ - Factory: confine a worker's file writes to its worktree subtree. - - DENIES ``sys_os_write`` / ``sys_os_edit`` whose ``path`` is absolute - or escapes upward (a ``..`` segment) — what a worker would do to write - outside *allowed_root*. Relative in-tree paths are ALLOWED. Workers run - with their worktree as cwd, so legitimate edits are always relative and - in-tree; this catches escapes. Intended for the (unsandboxed) - implementer worker specs, not the orchestrator. - - :param allowed_root: The worktree root workers are confined to, e.g. - ``".worktrees"``. Used only in the deny message. - :param deny_reason: Reason text surfaced on a DENY decision. - :returns: An evaluator ``fn(event, config)`` returning a V0 decision. - """ - - # Match Omnigent built-in OS write/edit, Claude/Codex native Write/Edit/ - # MultiEdit (surfaced via the PreToolUse hook), and Pi's native lowercase - # write/edit (surfaced via the pi ``tool_call`` hook). Pi uses the same - # ``path`` argument key as the Omnigent tools, so no Pi-specific arg - # branch is needed below. ``MultiEdit`` carries ``file_path`` like the - # other Claude native edit tools, so the extraction below already covers it. - _write_tools = {"sys_os_write", "sys_os_edit", "Write", "Edit", "MultiEdit", "write", "edit"} - - def _evaluate(event: _Json, config: _Json) -> _Json: # noqa: ARG001 - """ - Reject worker file writes that escape the worktree subtree. - - :param event: V0 ``tool_call`` event for ``sys_os_write`` / - ``sys_os_edit`` / Claude native ``Write`` / ``Edit``. - :param config: Runtime config dict (unused). - :returns: DENY on an absolute or ``..``-escaping path, else ALLOW. - """ - args = _tool_call(event, _write_tools) - if args is None: - return _ALLOW - # Omnigent tools use ``path``; Claude native tools use ``file_path``. - path = args.get("path") or args.get("file_path") - if not isinstance(path, str): - return _ALLOW - if path.startswith(("/", "~")) or ".." in path.split("/"): - return _decision("DENY", f"{deny_reason} (outside {allowed_root}/: {path!r})") - return _ALLOW - - return _evaluate - - -def read_only_os( - *, - deny_reason: str = ( - "This agent is report-only: it may read files and run shell, but never " - "write or edit them. Describe the change in your report instead of applying it." - ), -) -> Callable[[_Json, _Json], _Json]: - """ - Factory: deny the file-write/edit tools (best-effort report-only guardrail). - - DENIES ``sys_os_write`` / ``sys_os_edit`` and the Claude/Codex/Pi native - ``Write`` / ``Edit`` / ``MultiEdit`` aliases, so an accidental edit is - refused at the policy layer rather than only discouraged in prose. - - NOT a containment boundary. Reads, searches, and shell are left enabled, so - an agent can still mutate files via the shell (``echo > f``, ``sed -i``, - ``tee``) — this policy does not gate that, and command parsing cannot - reliably catch it. For a hard guarantee (e.g. reviewing untrusted input), - run the agent sandboxed — ``os_env.sandbox.type: linux_bwrap`` (Linux) / - ``darwin_seatbelt`` (macOS) binds cwd read-only — and treat this policy as - defense-in-depth. Use for agents whose contract is to investigate and - report (a security reviewer and its read-only sub-agents). - - :param deny_reason: Reason text surfaced on a DENY decision. - :returns: An evaluator ``fn(event, config)`` returning DENY for any - write/edit tool call, ALLOW otherwise. - """ - - # Match Omnigent built-in OS write/edit, Claude/Codex native Write/Edit/ - # MultiEdit, and Pi's native lowercase write/edit — the same tool set - # worktree_guard gates, so the two write policies stay in lockstep. - write_tools = { - "sys_os_write", - "sys_os_edit", - "Write", - "Edit", - "MultiEdit", - "write", - "edit", - } - - def _evaluate(event: _Json, config: _Json) -> _Json: # noqa: ARG001 - """ - Deny any file-mutating tool call. - - :param event: V0 ``tool_call`` event. - :param config: Runtime config dict (unused). - :returns: DENY for a write/edit tool, ALLOW otherwise. - """ - if _tool_call(event, write_tools) is None: - return _ALLOW - return _decision("DENY", deny_reason) - - return _evaluate - - -# ── Registry ───────────────────────────────────────────────────────────────── - -POLICY_REGISTRY: list[dict[str, Any]] = [ - { - "handler": "omnigent.inner.nessie.policies.blast_radius", - "kind": "factory", - "name": "Block Dangerous Shell Commands force-push, rm -rf", - "description": "Classifies shell commands (sys_os_shell, Claude/Codex native Bash, " - "and Pi native bash) as safe, risky (ASK), or catastrophic (DENY) to prevent " - "destructive operations like force-push or rm -rf /", - }, - { - "handler": "omnigent.inner.nessie.policies.spawn_bounds", - "kind": "factory", - "name": "Limit Sub-Agent Dispatches Per Turn", - "description": "Limits the number of sub-agent dispatches per turn " - "to prevent runaway fan-out", - }, - { - "handler": "omnigent.inner.nessie.policies.headless_subagent_purpose_guard", - "kind": "factory", - "name": "Require Purpose on Sub-Agent Dispatches", - "description": "Requires every sub-agent dispatch to declare a purpose " - "(implement, review, explore, search)", - }, - { - "handler": "omnigent.inner.nessie.policies.worktree_guard", - "kind": "factory", - "name": "Restrict Writes to Git Worktree", - "description": "Blocks file writes (sys_os_write/edit, Claude/Codex native " - "Write/Edit, and Pi native write/edit) outside the worker's git worktree to " - "prevent cross-branch contamination", - }, - { - "handler": "omnigent.inner.nessie.policies.read_only_os", - "kind": "factory", - "name": "Report-Only (Deny File-Write Tools)", - "description": "Best-effort report-only guardrail: denies the file-write/edit tools " - "(sys_os_write/edit, Claude/Codex native Write/Edit/MultiEdit, and Pi native " - "write/edit). Shell stays enabled, so shell-based writes (echo >, sed -i) are NOT " - "blocked -- for a hard boundary against untrusted input, sandbox the agent " - "(os_env.sandbox.type: linux_bwrap / darwin_seatbelt binds cwd read-only)", - }, -] +from omnigent.policies.builtins.orchestration import * # noqa: F403 +from omnigent.policies.builtins.orchestration import POLICY_REGISTRY # noqa: F401 diff --git a/omnigent/policies/builtins/__init__.py b/omnigent/policies/builtins/__init__.py index d98207a64f2..82245e67c06 100644 --- a/omnigent/policies/builtins/__init__.py +++ b/omnigent/policies/builtins/__init__.py @@ -45,5 +45,5 @@ "omnigent.policies.builtins.cel", "omnigent.policies.builtins.prompt", "omnigent.policies.builtins.context", - "omnigent.inner.nessie.policies", + "omnigent.policies.builtins.orchestration", ] diff --git a/omnigent/policies/builtins/orchestration.py b/omnigent/policies/builtins/orchestration.py new file mode 100644 index 00000000000..4274bd4709b --- /dev/null +++ b/omnigent/policies/builtins/orchestration.py @@ -0,0 +1,662 @@ +"""Bounds and blast-radius policies for the coding orchestrator. + +Each public function is a :class:`FunctionPolicy` *factory*: it takes the +YAML ``factory_params`` as keyword arguments and returns an evaluator +callable ``fn(event[, config]) -> {"result": ..., "reason": ...}``. +The evaluators run runner-side at tool dispatch +(``omnigent/runner/policy.py``) and add no server routes. +""" + +from __future__ import annotations + +import re +import shlex +from collections.abc import Callable +from typing import Any, TypeAlias + +# Heterogeneous JSON-shaped maps — the V0 policy event + decision payloads. +_Json: TypeAlias = dict[str, Any] # type: ignore[explicit-any] + +# A ready ALLOW decision (the common case — most tool calls pass). +_ALLOW: _Json = {"result": "ALLOW"} + + +def _decision(result: str, reason: str) -> _Json: + """ + Build a Service-Policies-V0 decision dict. + + :param result: One of ``"ALLOW"``, ``"DENY"``, ``"ASK"``. + :param reason: Human-readable explanation surfaced to the user + (shown on ASK prompts and DENY messages), e.g. + ``"git push is gated; approve to proceed."``. + :returns: A decision dict, e.g. + ``{"result": "ASK", "reason": "..."}``. + """ + return {"result": result, "reason": reason} + + +def _tool_call(event: _Json, tool_names: set[str]) -> _Json | None: + """ + Return the args dict of a matching ``tool_call`` event, else ``None``. + + :param event: A V0 event dict with ``type`` and ``data`` keys. For a + tool call, ``data`` is ``{"name": "", "arguments": {...}}``. + :param tool_names: Tool names this policy acts on, e.g. + ``{"sys_os_write", "sys_os_edit"}``. + :returns: The ``args`` dict when *event* is a ``tool_call`` for one + of *tool_names*, otherwise ``None`` (caller should ALLOW). + """ + if event.get("type") != "tool_call": + return None + data = event.get("data") + if not isinstance(data, dict) or data.get("name") not in tool_names: + return None + args = data.get("arguments") + return args if isinstance(args, dict) else {} + + +# Catastrophic, effectively-irreversible commands — always DENY. ``rm`` and +# ``git push`` are NOT here: a single regex missed split/long flag forms +# (``rm -r -f``, ``rm --recursive --force``), root children (``rm -rf /etc``), +# and force/delete refspecs (``git push origin +main`` / ``--delete``). They are +# classified by the flag/refspec-robust helpers below instead. +_DENY_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile(r"\bgit\b.*\breset\s+--hard\s+\w+/"), # hard-reset to a remote ref +) + +# Outward / destructive but recoverable — ASK the human first. +_ASK_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile(r"\bgh\s+(pr\s+merge|release|repo\s+delete)\b"), + re.compile(r"\b(kubectl|helm|terraform|databricks)\b.*\b(apply|deploy|destroy|delete)\b"), +) + +# Recursive-force ``rm`` of one of these (the directory itself) is catastrophic. +_RM_CRITICAL_DIRS: frozenset[str] = frozenset( + { + "/", + "/etc", + "/usr", + "/bin", + "/sbin", + "/lib", + "/lib64", + "/var", + "/boot", + "/root", + "/home", + "/opt", + "/dev", + "/proc", + "/sys", + } +) +# Recursive-force ``rm`` of a path UNDER one of these system dirs is also +# catastrophic (system files). ``/home`` / ``/opt`` / ``/root`` are excluded: a +# path under them is scoped/recoverable and is gated at the ASK tier instead. +_RM_SYSTEM_PARENTS: frozenset[str] = frozenset( + {"/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/var", "/boot", "/dev", "/proc", "/sys"} +) +# Common sudo options that consume the following argv token as their value. +_SUDO_VALUE_OPTS: frozenset[str] = frozenset( + { + "-C", + "-D", + "-g", + "-h", + "-p", + "-R", + "-r", + "-T", + "-t", + "-U", + "-u", + "--chdir", + "--chroot", + "--close-from", + "--command-timeout", + "--group", + "--host", + "--other-user", + "--prompt", + "--role", + "--type", + "--user", + } +) +_GIT_GLOBAL_VALUE_OPTS: frozenset[str] = frozenset( + {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"} +) +_PUSH_SHORT_VALUE_OPTS: frozenset[str] = frozenset({"o"}) +_ENV_ASSIGNMENT_RE: re.Pattern[str] = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=.*") + + +def _shell_statements(command: str) -> list[list[str]]: + """ + Best-effort split of a shell command line into per-statement token lists. + + Splits on the common statement / pipe separators (``;`` ``&&`` ``||`` ``|`` + newline) and tokenizes each piece with :func:`shlex.split` (falling back to + a whitespace split on a quoting error). This is a heuristic for catching + obvious destructive commands — it deliberately does NOT model subshells, + command substitution, or ``eval``, which a determined caller could use to + evade it. The policy is a safety net against accidental / obvious damage, + not a security boundary (that is sandboxing). + + :param command: A shell command string, e.g. ``"cd repo && rm -rf build"``. + :returns: One token list per statement, e.g. + ``[["cd", "repo"], ["rm", "-rf", "build"]]``. + """ + statements: list[list[str]] = [] + for piece in re.split(r"&&|\|\||[;|\n]", command): + piece = piece.strip() + if not piece: + continue + try: + argv = shlex.split(piece) + except ValueError: + argv = piece.split() + if argv: + statements.append(argv) + return statements + + +def _rm_target_is_catastrophic(target: str) -> bool: + """ + Whether ``rm -rf`` of *target* would be catastrophic / irreversible. + + Catastrophic = root, the whole home dir, a top-level critical dir itself + (:data:`_RM_CRITICAL_DIRS`), or any path under a system dir + (:data:`_RM_SYSTEM_PARENTS`, e.g. ``/etc/...``). A scoped path under + ``/home`` / ``/opt`` / ``/tmp`` or a relative path is NOT catastrophic here + (recoverable / the worker's own tree) — those fall to the ASK tier. + + :param target: A single tokenized ``rm`` argument, e.g. ``"/etc"``, + ``"~"``, ``"build"``. + :returns: ``True`` if deleting *target* recursively is catastrophic. + """ + norm = target.rstrip("/") or "/" + if norm in ("~", "$HOME", "${HOME}"): + return True + if target == "/*" or target.startswith("/*"): + return True + if norm in _RM_CRITICAL_DIRS: + return True + if target.startswith("/"): + top = "/" + target.lstrip("/").split("/", 1)[0] + if top in _RM_SYSTEM_PARENTS: + return True + return False + + +def _skip_shell_assignments(argv: list[str], start: int) -> int: + """ + Return the first index after leading shell-style env assignments. + + Shell statements may prefix a command with temporary environment variables, + e.g. ``CI=1 git push ...``. Those tokens are not the command itself and + should not hide the destructive command from classification. + + :param argv: One statement's tokens, e.g. ``["CI=1", "git", "push"]``. + :param start: Index where assignment scanning begins, e.g. ``0``. + :returns: The first non-assignment index at or after *start*. + """ + i = start + while i < len(argv) and _ENV_ASSIGNMENT_RE.fullmatch(argv[i]): + i += 1 + return i + + +def _command_index_after_shell_prefixes(argv: list[str]) -> int: + """ + Return the command index after env assignments and optional ``sudo``. + + Parses shell-style env assignments plus common sudo flags so + ``CI=1 sudo -n rm ...`` and ``sudo -u root rm ...`` classify the underlying + command the same way as bare ``rm ...``. + + :param argv: One statement's tokens, e.g. ``["sudo", "-n", "rm", "-rf", "/"]``. + :returns: The argv index of the command after any supported prefixes. + """ + i = _skip_shell_assignments(argv, 0) + if i >= len(argv) or argv[i] != "sudo": + return i + i += 1 + while i < len(argv): + tok = argv[i] + if tok == "--": + return _skip_shell_assignments(argv, i + 1) + if tok.startswith("--"): + i += 2 if tok in _SUDO_VALUE_OPTS and "=" not in tok and i + 1 < len(argv) else 1 + continue + if tok.startswith("-") and tok != "-": + value_opt_pos = next( + (pos for pos, opt in enumerate(tok[1:]) if f"-{opt}" in _SUDO_VALUE_OPTS), + None, + ) + if value_opt_pos is None: + i += 1 + continue + value_is_attached = value_opt_pos < len(tok[1:]) - 1 + i += 1 if value_is_attached else 2 + continue + return _skip_shell_assignments(argv, i) + return len(argv) + + +def _rm_severity(argv: list[str]) -> str | None: + """ + Classify a single ``rm`` statement by blast radius (flag-form robust). + + Detects a recursive ``rm`` in any spelling — combined (``-rf``, ``-Rf``), + short (``-r``), or long (``--recursive``) — and a leading ``sudo`` wrapper, + which the previous single regex matched only narrowly. Recursion is the + blast-radius signal (mass deletion); ``-f`` does not change the verdict + (matching the prior policy, which gated recursion with force optional). A + recursive ``rm`` of a catastrophic target (:func:`_rm_target_is_catastrophic`) + is ``"DENY"``; of any other target it is ``"ASK"``. A non-recursive ``rm`` + (single-file delete) returns ``None``. + + :param argv: One statement's tokens, e.g. ``["rm", "-rf", "/etc"]``. + :returns: ``"DENY"``, ``"ASK"``, or ``None``. + """ + i = _command_index_after_shell_prefixes(argv) + if i >= len(argv) or argv[i] != "rm": + return None + recursive = False + targets: list[str] = [] + positional_only = False # everything after a bare ``--`` is a filename, not a flag + for tok in argv[i + 1 :]: + if positional_only: + targets.append(tok) + elif tok == "--": + positional_only = True + elif tok == "--force": + continue + elif tok == "--recursive": + recursive = True + elif tok.startswith("-") and len(tok) > 1 and not tok.startswith("--"): + recursive = recursive or "r" in tok[1:] or "R" in tok[1:] + elif not tok.startswith("-"): + targets.append(tok) + if not recursive: + return None + return "DENY" if any(_rm_target_is_catastrophic(t) for t in targets) else "ASK" + + +def _push_short_option_is_destructive(token: str) -> bool: + """ + Whether a bundled ``git push`` short option token force-pushes or deletes. + + Git accepts combined short options such as ``-uf`` and ``-df``. A short + option that takes an attached value (currently ``-o`` / push-option) stops + flag parsing for the rest of that token so values like ``-o=fast`` are not + mistaken for force/delete flags. + + :param token: A short-option token from after ``git push``, e.g. ``"-uf"``. + :returns: ``True`` if the token contains destructive ``-f`` or ``-d`` flags. + """ + for opt in token[1:]: + if opt in ("f", "d"): + return True + if opt in _PUSH_SHORT_VALUE_OPTS: + return False + return False + + +def _push_severity(argv: list[str]) -> str | None: + """ + Classify a single ``git push`` statement by blast radius. + + A force-push (``--force`` / ``--force-with-lease`` / ``-f`` / a + ``+``-prefixed refspec / ``--mirror``) or a remote-branch deletion + (``--delete`` / ``--prune`` / ``-d`` / a ``:``-prefixed refspec) is + irreversible → ``"DENY"``. Any other ``git push`` is outward → ``"ASK"``. + The ``git`` subcommand is resolved past global options + (``git -C push …``) so ``"push"`` appearing as an argument value + (e.g. a commit message) is not mistaken for the subcommand. Anything that + is not a ``git push`` returns ``None``. + + :param argv: One statement's tokens, e.g. + ``["git", "push", "origin", "+main"]``. + :returns: ``"DENY"``, ``"ASK"``, or ``None``. + """ + i = _command_index_after_shell_prefixes(argv) + if i >= len(argv) or argv[i] != "git": + return None + j = i + 1 + while j < len(argv) and argv[j].startswith("-"): + j += 2 if argv[j] in _GIT_GLOBAL_VALUE_OPTS and j + 1 < len(argv) else 1 + if j >= len(argv) or argv[j] != "push": + return None + for tok in argv[j + 1 :]: + if tok.startswith("--force") or tok in ("--delete", "--mirror", "--prune"): + return "DENY" + if ( + tok.startswith("-") + and not tok.startswith("--") + and _push_short_option_is_destructive(tok) + ): + return "DENY" + if len(tok) > 1 and tok[0] in "+:": # +refspec (force) / :refspec (delete) + return "DENY" + return "ASK" + + +def blast_radius( + *, + gate_pushes: bool = True, + deny_reason: str = "Blocked by the blast-radius policy.", +) -> Callable[[_Json, _Json], _Json]: + """ + Factory: gate high-blast-radius shell commands by reversibility. + + Catastrophic, irreversible commands (force-push, ``rm -rf /``, + hard-reset to a remote ref) are DENIED. Outward or destructive but + recoverable commands (``git push``, ``gh pr merge``, ``rm -rf`` of a + path, infra deploy/destroy) return ASK so the human approves before + they run. Everything else — reads, tests, edits, and local git + (commit / merge / worktree) — is ALLOWED. + + :param gate_pushes: When ``True`` (default), recoverable-but-outward + commands return ASK. When ``False`` only the catastrophic DENY + set is enforced — use only for trusted unattended batch runs. + :param deny_reason: Reason text surfaced on a DENY decision. + :returns: An evaluator ``fn(event, config)`` returning a V0 decision. + """ + + def _evaluate(event: _Json, config: _Json) -> _Json: # noqa: ARG001 + """ + Classify a ``sys_os_shell`` command by blast radius. + + :param event: V0 ``tool_call`` event for ``sys_os_shell``. + :param config: Runtime config dict (unused; bounds come from the + factory params). + :returns: ALLOW / ASK / DENY decision dict. + """ + # Match the Omnigent built-in OS shell, the Claude/Codex native + # Bash tool, and Pi's native lowercase ``bash``. The PreToolUse hook + # reports BOTH CLI harnesses' shell tool as ``Bash`` with a string + # ``command`` (codex normalizes to this shape); Pi's ``tool_call`` + # hook reports ``bash`` with the same ``command`` key — so one match + # set covers all three. + args = _tool_call(event, {"sys_os_shell", "Bash", "bash"}) + if args is None: + return _ALLOW + command = args.get("command") + # A Bash / sys_os_shell call always carries a string ``command`` by + # contract; a non-str is a malformed payload no pattern can classify, so + # there is nothing to gate. + if not isinstance(command, str): + return _ALLOW + # rm + git push are classified by flag/refspec-robust helpers (a regex + # missed split/long rm flags, root children, and force/delete refspecs); + # the remaining regex patterns cover git-reset / gh / infra tools. + statements = _shell_statements(command) + severities = { + sev for stmt in statements for sev in (_rm_severity(stmt), _push_severity(stmt)) + } + if "DENY" in severities or any(p.search(command) for p in _DENY_PATTERNS): + return _decision("DENY", f"{deny_reason} (irreversible: {command!r})") + if gate_pushes and ("ASK" in severities or any(p.search(command) for p in _ASK_PATTERNS)): + return _decision("ASK", f"High-blast-radius command needs approval: {command!r}") + return _ALLOW + + return _evaluate + + +def spawn_bounds( + *, + max_dispatches_per_turn: int = 5, + dispatch_tools: tuple[str, ...] = ("sys_session_send",), +) -> Callable[[_Json], _Json]: + """ + Factory: cap how many workers the orchestrator may dispatch per turn. + + Counts the *dispatch_tools* tool calls within a single orchestrator turn + and DENIES once *max_dispatches_per_turn* is exceeded, forcing fan-out in + bounded waves rather than an unbounded fleet. The orchestrator dispatches + every worker through a sub-agent send (``sys_session_send``), so that is the + default counted tool. The counter resets each turn via the ``reset_turn`` + hook the runner calls (``omnigent/runner/policy.py``). This is the v1 + concurrency bound; true cross-turn live-concurrency accounting is a v1.x + refinement. + + :param max_dispatches_per_turn: Maximum worker dispatches allowed in one + turn, e.g. ``5``. + :param dispatch_tools: Tool names that count as a worker dispatch, e.g. + ``("sys_session_send",)``. A YAML list is accepted (coerced to a set). + :returns: A stateful evaluator ``fn(event)`` carrying a ``reset_turn`` + attribute, returning a V0 decision dict. + """ + counted = set(dispatch_tools) + state = {"count": 0} + + def _evaluate(event: _Json) -> _Json: + """ + Count and bound worker dispatches in the current turn. + + :param event: V0 event; a dispatch is a ``tool_call`` whose + ``data["name"]`` is one of *dispatch_tools*. + :returns: ALLOW, or DENY once the per-turn cap is exceeded. + """ + if _tool_call(event, counted) is None: + return _ALLOW + state["count"] += 1 + if state["count"] > max_dispatches_per_turn: + return _decision( + "DENY", + f"Exceeded {max_dispatches_per_turn} worker dispatches this turn; " + "fan out in waves (collect the running batch before dispatching more).", + ) + return _ALLOW + + def reset_turn() -> None: + """ + Reset the per-turn dispatch counter at each turn boundary. + + :returns: ``None``. + """ + state["count"] = 0 + + # FunctionPolicy looks for this attribute to reset per-turn state. + _evaluate.reset_turn = reset_turn # type: ignore[attr-defined] + return _evaluate + + +def headless_subagent_purpose_guard( + *, + allowed_purposes: tuple[str, ...] = ("implement", "review", "explore", "search"), + deny_reason: str = ( + "Every sys_session_send must declare what kind of work it is. Set " + "args.purpose to one of `implement` (write product code — any code " + "change, however small), `review` (judge a diff against its contract), " + "or `explore` / `search` (read-only investigation). All sub-agents " + "(`claude_code`, `codex`, `pi`) accept all of these." + ), +) -> Callable[[_Json], _Json]: + """ + Factory: require every ``sys_session_send`` to declare its ``args.purpose``. + + The orchestrator delegates all work through sub-agents, so each dispatch must be + tagged with an explicit ``args.purpose`` drawn from *allowed_purposes*. + The policy fails loud on an unmarked or out-of-set purpose, keeping + dispatches intentional rather than letting the model spawn a sub-agent + with no declared role. + + :param allowed_purposes: Explicit ``args.purpose`` values accepted for a + sub-agent dispatch, e.g. ``"review"`` or ``"implement"``. + :param deny_reason: Human-facing reason returned on DENY. + :returns: An evaluator ``fn(event)`` returning DENY for unmarked or + out-of-set ``sys_session_send`` calls. + """ + allowed = set(allowed_purposes) + + def _evaluate(event: _Json) -> _Json: + """ + Deny unmarked or disallowed sub-agent dispatches. + + :param event: V0 ``tool_call`` event for ``sys_session_send``. + :returns: ALLOW when ``args.purpose`` is allowed, DENY otherwise. + """ + args = _tool_call(event, {"sys_session_send"}) + if args is None: + return _ALLOW + child_args = args.get("args") + if not isinstance(child_args, dict): + return _decision("DENY", f"{deny_reason} Missing object args with purpose.") + purpose = child_args.get("purpose") + if not isinstance(purpose, str) or purpose not in allowed: + return _decision( + "DENY", + f"{deny_reason} Set args.purpose to one of {sorted(allowed)!r} " + "when this is a legitimate sub-agent task.", + ) + return _ALLOW + + return _evaluate + + +def worktree_guard( + *, + allowed_root: str = ".worktrees", + deny_reason: str = "Worker writes must stay inside its worktree.", +) -> Callable[[_Json, _Json], _Json]: + """ + Factory: confine a worker's file writes to its worktree subtree. + + DENIES ``sys_os_write`` / ``sys_os_edit`` whose ``path`` is absolute + or escapes upward (a ``..`` segment) — what a worker would do to write + outside *allowed_root*. Relative in-tree paths are ALLOWED. Workers run + with their worktree as cwd, so legitimate edits are always relative and + in-tree; this catches escapes. Intended for the (unsandboxed) + implementer worker specs, not the orchestrator. + + :param allowed_root: The worktree root workers are confined to, e.g. + ``".worktrees"``. Used only in the deny message. + :param deny_reason: Reason text surfaced on a DENY decision. + :returns: An evaluator ``fn(event, config)`` returning a V0 decision. + """ + + # Match Omnigent built-in OS write/edit, Claude/Codex native Write/Edit + # (surfaced via the PreToolUse hook), and Pi's native lowercase + # write/edit (surfaced via the pi ``tool_call`` hook). Pi uses the same + # ``path`` argument key as the Omnigent tools, so no Pi-specific arg + # branch is needed below. + _write_tools = {"sys_os_write", "sys_os_edit", "Write", "Edit", "MultiEdit", "write", "edit"} + + def _evaluate(event: _Json, config: _Json) -> _Json: # noqa: ARG001 + """ + Reject worker file writes that escape the worktree subtree. + + :param event: V0 ``tool_call`` event for ``sys_os_write`` / + ``sys_os_edit`` / Claude native ``Write`` / ``Edit``. + :param config: Runtime config dict (unused). + :returns: DENY on an absolute or ``..``-escaping path, else ALLOW. + """ + args = _tool_call(event, _write_tools) + if args is None: + return _ALLOW + # Omnigent tools use ``path``; Claude native tools use ``file_path``. + path = args.get("path") or args.get("file_path") + if not isinstance(path, str): + return _ALLOW + if path.startswith(("/", "~")) or ".." in path.split("/"): + return _decision("DENY", f"{deny_reason} (outside {allowed_root}/: {path!r})") + return _ALLOW + + return _evaluate + + +def read_only_os( + *, + deny_reason: str = ( + "This agent is report-only: it may read files and run shell, but never " + "write or edit them. Describe the change in your report instead of applying it." + ), +) -> Callable[[_Json, _Json], _Json]: + """ + Factory: deny every file-mutating tool call (report-only agents). + + DENIES ``sys_os_write`` / ``sys_os_edit`` and the Claude/Codex/Pi native + ``Write`` / ``Edit`` / ``MultiEdit`` aliases. Reads, searches, and shell + commands are left untouched — pair with :func:`blast_radius` to also bound + shell blast radius. Use on agents whose contract is to investigate and + report, never to change code (e.g. a security reviewer and its read-only + sub-agents): unlike prompt discipline alone, an accidental ``sys_os_edit`` + is refused at the policy layer. + + :param deny_reason: Reason text surfaced on a DENY decision. + :returns: An evaluator ``fn(event, config)`` returning DENY for any + write/edit tool call, ALLOW otherwise. + """ + + # Match Omnigent built-in OS write/edit, Claude/Codex native Write/Edit/ + # MultiEdit, and Pi's native lowercase write/edit — the same tool set + # worktree_guard gates, so the two write policies stay in lockstep. + write_tools = { + "sys_os_write", + "sys_os_edit", + "Write", + "Edit", + "MultiEdit", + "write", + "edit", + } + + def _evaluate(event: _Json, config: _Json) -> _Json: # noqa: ARG001 + """ + Deny any file-mutating tool call. + + :param event: V0 ``tool_call`` event. + :param config: Runtime config dict (unused). + :returns: DENY for a write/edit tool, ALLOW otherwise. + """ + if _tool_call(event, write_tools) is None: + return _ALLOW + return _decision("DENY", deny_reason) + + return _evaluate + + +# ── Registry ───────────────────────────────────────────────────────────────── + +POLICY_REGISTRY: list[dict[str, Any]] = [ + { + "handler": "omnigent.policies.builtins.orchestration.blast_radius", + "kind": "factory", + "name": "Block Dangerous Shell Commands (force-push, rm -rf)", + "description": "Classifies shell commands (sys_os_shell, Claude/Codex native Bash, " + "and Pi native bash) as safe, risky (ASK), or catastrophic (DENY) to prevent " + "destructive operations like force-push or rm -rf /", + }, + { + "handler": "omnigent.policies.builtins.orchestration.spawn_bounds", + "kind": "factory", + "name": "Limit Sub-Agent Dispatches Per Turn", + "description": "Limits the number of sub-agent dispatches per turn " + "to prevent runaway fan-out", + }, + { + "handler": "omnigent.policies.builtins.orchestration.headless_subagent_purpose_guard", + "kind": "factory", + "name": "Require Purpose on Sub-Agent Dispatches", + "description": "Requires every sub-agent dispatch to declare a purpose " + "(implement, review, explore, search)", + }, + { + "handler": "omnigent.policies.builtins.orchestration.worktree_guard", + "kind": "factory", + "name": "Restrict Writes to Git Worktree", + "description": "Blocks file writes (sys_os_write/edit, Claude/Codex native " + "Write/Edit, and Pi native write/edit) outside the worker's git worktree to " + "prevent cross-branch contamination", + }, + { + "handler": "omnigent.policies.builtins.orchestration.read_only_os", + "kind": "factory", + "name": "Report-Only (Deny File Writes)", + "description": "Denies every file-mutating tool (sys_os_write/edit, Claude/Codex " + "native Write/Edit/MultiEdit, and Pi native write/edit) so a report-only agent " + "can read and run shell but never change code", + }, +] From c32e7dbde2d5035c6f26992d819e269f08d85ca9 Mon Sep 17 00:00:00 2001 From: Tomu Hirata Date: Mon, 6 Jul 2026 14:01:13 +0900 Subject: [PATCH 004/546] fix(nessie): remove example commands from blast_radius policy name (#1995) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The policy name "Block Dangerous Shell Commands force-push, rm -rf" read like an incomplete sentence. Trimmed to "Block Dangerous Shell Commands" — the description already lists the specific examples. --- omnigent/policies/builtins/orchestration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/omnigent/policies/builtins/orchestration.py b/omnigent/policies/builtins/orchestration.py index 4274bd4709b..e27bf073317 100644 --- a/omnigent/policies/builtins/orchestration.py +++ b/omnigent/policies/builtins/orchestration.py @@ -624,7 +624,7 @@ def _evaluate(event: _Json, config: _Json) -> _Json: # noqa: ARG001 { "handler": "omnigent.policies.builtins.orchestration.blast_radius", "kind": "factory", - "name": "Block Dangerous Shell Commands (force-push, rm -rf)", + "name": "Block Dangerous Shell Commands", "description": "Classifies shell commands (sys_os_shell, Claude/Codex native Bash, " "and Pi native bash) as safe, risky (ASK), or catastrophic (DENY) to prevent " "destructive operations like force-push or rm -rf /", From 91255320663914ad2ba484c5b8ae56fb3784bbb1 Mon Sep 17 00:00:00 2001 From: Serena Ruan <82044803+serena-ruan@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:17:45 +0800 Subject: [PATCH 005/546] docs: add client-side queue + steer design (#1999) Design for a client-side message queue (edit / delete / steer / reorder) before POST, with auto-flush-on-idle and per-harness steer semantics for both SDK and native harnesses. Co-authored-by: Isaac --- docs/QUEUE_STEER_DESIGN.md | 150 +++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 docs/QUEUE_STEER_DESIGN.md diff --git a/docs/QUEUE_STEER_DESIGN.md b/docs/QUEUE_STEER_DESIGN.md new file mode 100644 index 00000000000..f5b6d2df4cb --- /dev/null +++ b/docs/QUEUE_STEER_DESIGN.md @@ -0,0 +1,150 @@ +# Queue + steer design + +Client-side message queue with edit / delete / steer / reorder, for both SDK and +native harnesses. + +## 1. Motivation + +Today every message is **POSTed the moment the user hits send** — including +follow-ups typed while the agent is still working — and rendered immediately as an +optimistic bubble. The runner buffers a mid-turn message behind the active turn +and delivers it later, but the UI has already committed it. Problems: + +- **No edit / delete / reorder.** Once POSTed the message is server-owned, so the + user can't take back or fix a follow-up they queued in a hurry. +- **No queued-vs-sent visibility.** A follow-up sent mid-turn looks identical to a + normal send — the user can't tell it's waiting behind the active turn, or when + it will be picked up. +- **Silent cross-harness inconsistency.** The *same* action — "send a follow-up + while the agent is working" — behaves differently per harness (mid-turn steer + for live-queue SDKs, next-turn for everyone else) with no signal telling the + user which they'll get. + +The redesign fixes all three by holding the message in a **client-side queue +before it is POSTed**: the user can edit / delete / reorder while it waits, sees +it explicitly as "queued", and controls when it's sent (auto-flush on idle, or +steer now). + +## 2. Proposal + +Move the queue **client-side**. The strip becomes a pre-POST draft buffer; a +message is only sent to the server when it's flushed or steered. + +``` + type → client queue "⏱ Queued" (NOT posted) → flush/steer → POST → bubble + (strip = "not yet sent, still editable"; bubble = "sent, in flight") +``` + +### Queue behavior + +- **Show as queued** when the agent is **not idle** (`sessionStatus` busy) — same + signal for SDK and native. +- **Auto-flush head on idle (FIFO):** when the agent goes idle, send the head of + the queue as the next turn. Type-ahead "just works" without any click. +- Persist the queue in `localStorage` (keyed by session) so it survives a hard + refresh. (Trade-off: no cross-device sync — acceptable for unsent drafts.) + +### Per-message actions + +| Action | Behavior | +|--------|----------| +| **Edit** | pull the message back into the composer, purely client-side; persists across navigation/refresh | +| **Delete** | drop the message from the queue | +| **Steer** | POST it now (jump the queue) — deliver mid-turn where the harness supports it | +| **Reorder** *(optional, follow-up)* | client-side drag to reorder the queue | + +### Promote-to-bubble rule + +Promote a message from the strip into a normal chat bubble **as soon as it is +POSTed** (on flush or steer) — *not* when the agent consumes it. Once it's sent +there's no longer anything to edit / delete / steer / reorder, so the strip has +no reason to hold it. + +The gap between (a) sent to server and (b) consumed by the agent becomes an +**implementation detail** the user need not see — because the strip no longer +represents server state, only the still-editable client buffer. This removes the +consume-timing dependency entirely. + +### What "steer" means per harness + +Steer always POSTs immediately; how it lands depends on the harness: + +| Harness | Steer delivery | Mid-turn? | +|---------|----------------|-----------| +| claude-sdk / codex-sdk / pi-sdk | runner **live injection** (`_live_response_id` gate) | ✅ deterministic | +| cursor-sdk / copilot-sdk | buffer & drain | ❌ next turn | +| **codex-native** | explicit **`turn/steer`** RPC when a turn is active | ✅ deterministic | +| **claude-native** (and paste-based natives) | runner drains → `send-keys` into the **live pane**; the app treats the paste as a steer | ⚠️ best-effort (drain-vs-response race) | + +> **TODO:** sanity-check the remaining harnesses (cursor-native, pi-native, +> qwen-native, opencode-native, goose-native, hermes-native, kimi-native, +> antigravity-native, kiro-native, …) — confirm whether each is deterministic +> (`turn/steer`-style RPC) or best-effort (paste into live pane) before relying on +> steer behavior. + +**No runner change is required for native steer** — native `run_turn` clears the +turn right after the paste, so the drain fires the next message quickly and it +lands in the live pane, where the native app does its own steering. Frame the UX +honestly: *"send now; the agent folds it into current work if it can"* — which is +exactly how native type-ahead already feels. Do **not** promise deterministic +mid-turn for paste-based natives. + +**Steer is not interrupt.** In every case above, steer *does not cancel* the +running turn — the message is folded in at the agent's next natural breakpoint +(after the current tool/step completes), the same feel as steering native Claude +by typing while it works. For SDK, `enqueue_session_message` adds the message to +the running session's queue; the SDK surfaces it at its next turn-boundary — no +teardown. This is distinct from the **Interrupt** button, which really does +cancel the turn (`turn.cancel()`). + +### Edges to handle + +| Edge | Rule | +|------|------| +| POST fails after promote | revert the bubble to the queue (or error-badge it) | +| Agent goes idle mid-edit | editing pins the message out of auto-flush until re-committed | +| Native mirror-back | consume/mirror still needed as a **reconcile** signal (id-match the optimistic bubble to the real transcript item) so native round-trips don't double-render | + +## 3. Appendix — lifecycle & topology + +### Component topology + +``` +┌──────────┐ HTTPS+SSE ┌──────────────┐ HTTP ┌──────────┐ HTTP/UNIX socket ┌─────────────────┐ +│ CLIENT │◄───────────►│ AP SERVER │◄──────►│ RUNNER │◄──────────────────►│ HARNESS SUBPROC │ +│ (browser)│ │ persist+relay│ │ buffer + │ (1 per conv) │ EXECUTOR=agent │ +└──────────┘ └──────────────┘ │ schedule │ │ SDK: in-process │ + └──────────┘ │ native: →app ───┼─► tmux / RPC + └─────────────────┘ +``` + +The agent runs **inside the harness subprocess** (SDK loop) or is **bridged out** +of it to a real app (native). It does **not** live in the runner process. + +### Busy/idle signal (drives the queue) + +| Harness | "running" from | "idle" from | +|---------|----------------|-------------| +| SDK | `response.created` → `_live_response_id` set | `response.completed` / stream-end | +| native | `UserPromptSubmit` hook | `Stop` / `StopFailure` hook (relayed by the transcript forwarder) | + +Both surface to the client as the same `sessionStatus` field, seeded from the +snapshot on bind (correct after refresh, across tabs). + +### Live-injection gate (SDK steer) + +```python +_can_forward = ( + not _native # native uses paste / turn-steer, not this path + and not _awaiting_approval # don't steer a turn parked on a human gate + and conversation_id in _live_response_id # a response is actually streaming +) +``` + +### Native decoupling (why paste-steer works) + +Native `run_turn` returns as soon as `send-keys` finishes pasting (not when the +agent finishes). `_active_turns` clears immediately, so the buffer drains the +next message quickly and it pastes into the still-live pane — the native app then +decides to steer it. `_native_pane_status` is the reliable liveness signal for a +long autonomous native turn (since `_active_turns` clears early). From 6e8fc196637f2e5e0f6ec68ee66aa98d10835c00 Mon Sep 17 00:00:00 2001 From: Daniel Lok Date: Mon, 6 Jul 2026 13:33:11 +0800 Subject: [PATCH 006/546] Update CHANGELOG for version 0.4.0 release (#2000) Added release notes for version 0.4.0. --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 291edf57d25..dd62ce442fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ generated at release time from each PR's `## Changelog` section, tagged by the PR's `Type of change` (e.g. `[UI]`); the concise, curated highlights live on the website under `/releases`. +## [v0.4.0] — 2026-07-03 + +Highlights and full notes: + ## [v0.3.0] — 2026-06-26 Highlights and full notes: From 427c3b44419e56981c401e501801ffe835f25c0e Mon Sep 17 00:00:00 2001 From: Serena Ruan <82044803+serena-ruan@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:46:34 +0800 Subject: [PATCH 007/546] fix(claude-native): stop false "terminal not ready" on mid-turn inject (#2001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Injecting a web-UI message while Claude Code is mid-turn grows the footer with running-state rows (a ○ Explore subagent line, extra spinners) that push the ❯ input glyph to the 6th non-empty line from the bottom — one past the readiness gate's 5-line scan window. The gate then times out and the web UI renders a spurious "did not become ready" runtime-error card, even though the terminal is healthy and the prompt is on screen. Widening the window alone would resurrect the scrollback false positive (an echoed ❯ sits at the same depth). Distinguish them structurally: the live input box always renders a ──── box rule directly below ❯, which a scrollback echo never has. Keep the 5-line fast path, and additionally trust a glyph in a wider 8-line window only when a box rule sits below it. Co-authored-by: Isaac --- omnigent/claude_native_bridge.py | 44 ++++++++++++++++++++++++++- tests/test_claude_native_bridge.py | 49 ++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/omnigent/claude_native_bridge.py b/omnigent/claude_native_bridge.py index f94d929cbb7..aefb9adbfa5 100644 --- a/omnigent/claude_native_bridge.py +++ b/omnigent/claude_native_bridge.py @@ -124,6 +124,10 @@ # The glyph persists while Claude is busy responding, so its presence # means "input box mounted" (not "idle"), which is what injection needs. _CLAUDE_PROMPT_GLYPH = "❯" +# Box-drawing glyphs Claude Code's input-box frame is made of. A line of +# these below ``❯`` marks the live input box (see ``_is_box_rule``), +# distinguishing it from a bare prompt echoed into scrollback. +_BOX_RULE_CHARS = frozenset("─━╭╮╰╯│┃╌╍") # How many trailing non-empty lines to scan for the prompt glyph. The # input box sits near the bottom of the pane; scanning only the tail # avoids false positives from the glyph appearing in scrollback output. @@ -131,6 +135,12 @@ # people's statuslines run ~3 lines — so the ``❯`` row isn't the last # non-empty line. _PROMPT_SCAN_TAIL_LINES = 5 +# Injecting a message mid-turn grows the footer with running-state rows +# (a ``○ Explore …`` subagent line, extra spinners) that push ``❯`` above +# the window above. We trust a glyph this deep only when it's framed by a +# box rule (the live input box), so this wider window can't false-match a +# bare ``❯`` echoed into scrollback output. +_PROMPT_SCAN_TAIL_LINES_FRAMED = 8 _CLAUDE_READY_POLL_INTERVAL_S = 0.15 _PASTE_SETTLE_S = 0.1 # let the TUI commit a paste before the separate submit Enter # How long to wait for the pasted draft to visibly land in Claude's @@ -2831,11 +2841,43 @@ def _claude_prompt_rendered(pane: str) -> bool: positives from the glyph appearing in scrollback (e.g. echoed in a prior response), since the live input box always sits at the bottom. + A mid-turn injection grows the footer with running-state rows (a + ``○ Explore …`` subagent line, extra spinners) that can push ``❯`` + past that window. To reach it without also matching a scrollback + echo, a glyph in the wider :data:`_PROMPT_SCAN_TAIL_LINES_FRAMED` + window counts only when it's framed by a box rule — the ``────`` + closing line the live input box always renders below ``❯`` but a + bare echoed prompt never has. + :param pane: Captured pane text from :func:`_capture_pane`. :returns: ``True`` when the input box appears mounted. """ non_empty = [line for line in pane.splitlines() if line.strip()] - return any(_CLAUDE_PROMPT_GLYPH in line for line in non_empty[-_PROMPT_SCAN_TAIL_LINES:]) + if any(_CLAUDE_PROMPT_GLYPH in line for line in non_empty[-_PROMPT_SCAN_TAIL_LINES:]): + return True + # Deeper in the tail, trust the glyph only when a box rule sits below + # it — the live input box's closing frame, absent from scrollback. + tail = non_empty[-_PROMPT_SCAN_TAIL_LINES_FRAMED:] + for idx, line in enumerate(tail): + if _CLAUDE_PROMPT_GLYPH in line and any(_is_box_rule(rule) for rule in tail[idx + 1 :]): + return True + return False + + +def _is_box_rule(line: str) -> bool: + """ + Return whether a line is a TUI box-drawing horizontal rule. + + Claude Code frames its input box with rows of ``─`` (plus corner + glyphs). Such a rule below ``❯`` marks the live input box, letting + the readiness scan reach a prompt buried under a tall running-turn + footer without matching a bare ``❯`` echoed into scrollback. + + :param line: A single pane line, e.g. ``"──────────"``. + :returns: ``True`` when the line is predominantly box-rule glyphs. + """ + stripped = line.strip() + return len(stripped) >= 3 and all(ch in _BOX_RULE_CHARS for ch in stripped) def _submit_needle(content: str) -> str: diff --git a/tests/test_claude_native_bridge.py b/tests/test_claude_native_bridge.py index e8ea40a7479..06203ba0092 100644 --- a/tests/test_claude_native_bridge.py +++ b/tests/test_claude_native_bridge.py @@ -4852,6 +4852,55 @@ def test_claude_prompt_rendered_sees_prompt_above_default_footer() -> None: assert _claude_prompt_rendered(pane) is True +def test_claude_prompt_rendered_sees_prompt_above_running_turn_footer() -> None: + """ + The readiness scan reaches the prompt above a tall running-turn footer. + + When a web-UI message is injected while Claude is mid-turn, the footer + grows extra status rows below the input box — a running-subagent line + (``○ Explore …``) on top of the usual box rule, model, auto-mode, and + branch rows. That pushes the live ``❯`` row to the 6th non-empty line + from the bottom, one past the old 5-line window, so the readiness gate + timed out and the web UI rendered a spurious "did not become ready" + runtime-error card even though the terminal was healthy. + """ + pane = "\n".join( + [ + "────────────────────────────────────────", # input box top rule + "❯ ", # the live prompt row (6th non-empty line from bottom) + "────────────────────────────────────────", # box closing rule + " Opus 4.8 (1M context) | thinking medium", # model + effort line + " ⏵⏵ auto mode on (shift+tab to cycle)", # permission-mode hint + " main", # branch label + " ○ Explore Find session sidebar state… 1m 4s", # subagent status + ] + ) + assert _claude_prompt_rendered(pane) is True + + +def test_claude_prompt_rendered_ignores_unframed_glyph_deep_in_tail() -> None: + """ + A glyph in the wider window without a box rule below is not trusted. + + The framed window that lets the scan reach a prompt under a tall + running-turn footer must not resurrect the scrollback false positive: + a ``❯`` echoed into prior output sits in the wider window too, but + without the input box's closing ``────`` rule beneath it. Only plain + output follows here, so the gate must still report "not ready". + """ + pane = "\n".join( + [ + "❯ old prompt echo", # 6th non-empty line from bottom, no rule below + "output line 1", + "output line 2", + "output line 3", + "output line 4", + "output line 5", + ] + ) + assert _claude_prompt_rendered(pane) is False + + def _write_deltas_lines(bridge_dir: Path, lines: list[str]) -> None: """ Append raw JSONL lines to the bridge deltas file. From e5bd7cc0f3293de3c29974b9fbdabb6a4ca131e2 Mon Sep 17 00:00:00 2001 From: Tomu Hirata Date: Mon, 6 Jul 2026 14:49:50 +0900 Subject: [PATCH 008/546] fix(triage): prioritise load over LLM rank when assigning issues and PR reviewers (#1996) * fix(triage): prioritise load over LLM rank when assigning issues and PR reviewers LLM rank was the primary sort key, so the first owner listed in areas.json always won even when their open-issue/review load was far higher than other eligible owners. Swap to (load, rank, login) so load is the primary signal and LLM rank only breaks ties within the same load bucket. * test(triage): update cases 17-19 and stale comment for load-primary sort order Cases 17-19 previously asserted rank-primary / load-secondary behaviour. Update them (and their descriptions) to reflect the new load-primary ordering. Also fix a stale block comment in issue-triage.yml that still said "rank primary, load secondary". * ci: re-trigger E2E (previous run canceled by automerge label event) --- .github/workflows/auto-assign-reviewer.js | 13 +++++----- .../workflows/auto-assign-reviewer.test.js | 26 +++++++++---------- .github/workflows/issue-triage.yml | 16 ++++++------ 3 files changed, 26 insertions(+), 29 deletions(-) diff --git a/.github/workflows/auto-assign-reviewer.js b/.github/workflows/auto-assign-reviewer.js index 0e795f13228..f7b4c945a91 100644 --- a/.github/workflows/auto-assign-reviewer.js +++ b/.github/workflows/auto-assign-reviewer.js @@ -210,16 +210,15 @@ module.exports = async ({ github, context, core }) => { } const loadOf = (u) => load.get(u.toLowerCase()) || 0; - // Helper: take the N most-preferred from a list. Sort key is (rank, load, - // random): LLM area-fit rank first (lower = better; Infinity for unranked, so - // an all-unranked list -- no rank file -- sorts purely by load, i.e. today's - // behavior), then fewest open review requests, then a pre-rolled random value - // to break any remaining same-rank-same-load tie. The `!==` guards avoid - // subtracting two Infinities (which would be NaN). + // Helper: take the N most-preferred from a list. Sort key is (load, rank, + // random): fewest open review requests first so workload stays balanced; + // LLM area-fit rank breaks ties within the same load bucket; a pre-rolled + // random value breaks any remaining tie. The `!==` guards avoid subtracting + // two Infinities (which would be NaN). const takeLowest = (list, n) => { const keyed = list.map((u) => ({ u, r: rankOf(u), l: loadOf(u), j: Math.random() })); keyed.sort((a, b) => - a.r !== b.r ? a.r - b.r : a.l !== b.l ? a.l - b.l : a.j - b.j + a.l !== b.l ? a.l - b.l : a.r !== b.r ? a.r - b.r : a.j - b.j ); return keyed.slice(0, n).map((x) => x.u); }; diff --git a/.github/workflows/auto-assign-reviewer.test.js b/.github/workflows/auto-assign-reviewer.test.js index 4642fb68c8f..c240d0f3cf0 100644 --- a/.github/workflows/auto-assign-reviewer.test.js +++ b/.github/workflows/auto-assign-reviewer.test.js @@ -285,41 +285,39 @@ function assert(name, cond, detail) { assert("capped overflow is warned", r.warnings.some((w) => /capping push-down/.test(w)), JSON.stringify(r.warnings)); - // 17. LLM ranking overrides load within the candidate pool: dhruv0811 has the - // lowest load (would win on load alone), but the rank prefers dbczumar, an - // inner owner -- so dbczumar is chosen. + // 17. Load beats LLM rank: dhruv0811 has the lowest load (0) and wins even + // though the rank prefers dbczumar (rank 0 but load 1). r = await run({ files: ["omnigent/inner/foo.py"], load: { SabhyaC26: 5, TomeHirata: 4, dhruv0811: 0, dbczumar: 1 }, rank: ["dbczumar", "TomeHirata", "SabhyaC26", "dhruv0811"], }); - assert("LLM rank beats load within the area pool", - JSON.stringify(r.added) === JSON.stringify(["dbczumar"]), JSON.stringify(r)); + assert("load beats LLM rank within the area pool", + JSON.stringify(r.added) === JSON.stringify(["dhruv0811"]), JSON.stringify(r)); // 18. Allowlist enforcement: a rank naming someone who does NOT own the touched // area (PattaraS is a maintainer + pool member, but not an inner owner) is - // ignored for that entry; the ranking only reorders actual candidates, so - // the next ranked inner owner (dbczumar) wins -- never PattaraS. + // ignored; the ranking only reorders actual candidates. Load is primary, so + // dhruv0811 (load 0) wins over dbczumar (load 1) -- never PattaraS. r = await run({ files: ["omnigent/inner/foo.py"], load: { SabhyaC26: 5, TomeHirata: 4, dhruv0811: 0, dbczumar: 1, PattaraS: 0 }, rank: ["PattaraS", "dbczumar", "TomeHirata", "SabhyaC26", "dhruv0811"], }); assert("LLM rank cannot route outside the area owners", - JSON.stringify(r.added) === JSON.stringify(["dbczumar"]) && !r.added.includes("PattaraS"), + JSON.stringify(r.added) === JSON.stringify(["dhruv0811"]) && !r.added.includes("PattaraS"), JSON.stringify(r)); - // 19. Unranked candidates (rank omits them) sort after ranked ones but still by - // load: rank lists only SabhyaC26 (highest load); the rest are unranked, so - // SabhyaC26 -- despite load 5 -- is preferred because a finite rank beats - // Infinity. Confirms the rank-primary / load-secondary ordering. + // 19. Load is primary even when only one candidate is ranked: rank lists only + // SabhyaC26 (load 5); dhruv0811 is unranked but has load 0, so dhruv0811 + // wins. Confirms the load-primary / rank-secondary ordering. r = await run({ files: ["omnigent/inner/foo.py"], load: { SabhyaC26: 5, TomeHirata: 4, dhruv0811: 0, dbczumar: 1 }, rank: ["SabhyaC26"], }); - assert("a ranked high-load owner beats unranked low-load owners", - JSON.stringify(r.added) === JSON.stringify(["SabhyaC26"]), JSON.stringify(r)); + assert("unranked low-load owner beats ranked high-load owner", + JSON.stringify(r.added) === JSON.stringify(["dhruv0811"]), JSON.stringify(r)); // 20. Adoption still overrides the LLM rank: a linked-issue maintainer assignee // (TomeHirata) is adopted as reviewer even when the rank prefers someone diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index c77ad137338..34d0d2c4d88 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -503,10 +503,10 @@ jobs: maintainer_assigned=true fi - # Otherwise, assign an owner for P0/P1 issues: the LLM's top-ranked area - # owner, breaking ties by open-assigned-issue load (fairness). Symmetric - # with the PR reviewer path (rank primary, load secondary). Skipped if - # the maintainer-author was already assigned above. + # Otherwise, assign an owner for P0/P1 issues: the least-loaded area + # owner, with LLM rank as a tiebreaker (load primary, rank secondary). + # Symmetric with the PR reviewer path. Skipped if the maintainer-author + # was already assigned above. priority=$(jq -r '.priority // empty' /tmp/triage_result.json) if [ "$maintainer_assigned" = "false" ] && { [ "$priority" = "P0-critical" ] || [ "$priority" = "P1-high" ]; }; then # Open-issue load per candidate (fewest assigned open issues wins ties). @@ -533,12 +533,12 @@ jobs: if a.get("login"): load[a["login"]] += 1 - # Sort by (rank, load, login): LLM rank first, then fewest open issues, - # then a stable alphabetical tie-break (deterministic, unlike a random - # one — matches the previous round-robin's determinism guarantee). + # Sort by (load, rank, login): fewest open assigned issues first so + # the workload stays balanced; LLM rank breaks ties within the same + # load bucket; alphabetical login is the final deterministic tiebreak. candidates = sorted( candidates, - key=lambda u: (rank_of.get(u, float("inf")), load[u], u), + key=lambda u: (load[u], rank_of.get(u, float("inf")), u), ) assignee = candidates[0] if candidates else "" if assignee: From 2a1d793815c67fe43298c519ecb74e1178fe24d4 Mon Sep 17 00:00:00 2001 From: Tomu Hirata Date: Mon, 6 Jul 2026 15:54:19 +0900 Subject: [PATCH 009/546] fix(ci): isolate label-event concurrency in e2e.yml to prevent automerge canceling running suite (#2011) Label events share the same PR-number concurrency key as code-push events. With cancel-in-progress: true, applying automerge mid-run fired a new workflow run that immediately killed the in-progress E2E suite. Two-part fix: - Append the label name to the concurrency key for label events (other events get the suffix '-run'), so each label gets its own isolated slot and can never preempt a synchronize/push run. - Add an if: on the gate job to short-circuit for label events that are not skip-security-scan (e.g. automerge): those runs exit immediately in their isolated slot rather than spinning up the full suite. labeled/unlabeled stay in the trigger: they are the fallback recovery path for skip-security-scan (rerun-security-gate-run.yml calls this out on line 105). --- .github/workflows/e2e.yml | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 35e02f0181e..739844f58db 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -19,6 +19,9 @@ on: schedule: - cron: "0 9 * * *" pull_request: + # labeled/unlabeled: kept for the skip-security-scan recovery path + # (rerun-security-gate-run.yml falls back to this trigger). The concurrency + # group key isolates label events so they never cancel a code-push run. types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] paths-ignore: ['web/**', 'tests/e2e_ui/**'] workflow_dispatch: @@ -34,8 +37,9 @@ on: concurrency: # PRs key by number, dispatch by branch (so re-runs cancel); schedule keys - # by SHA so each merge to `main` gets its own run. - group: e2e-${{ github.workflow }}-${{ github.event.pull_request.number || github.event.inputs.branch || github.sha }} + # by SHA so each merge to `main` gets its own run. Label events append the + # label name so they get an isolated slot and never cancel a code-push run. + group: e2e-${{ github.workflow }}-${{ github.event.pull_request.number || github.event.inputs.branch || github.sha }}-${{ (github.event.action == 'labeled' || github.event.action == 'unlabeled') && github.event.label.name || 'run' }} cancel-in-progress: true permissions: @@ -54,11 +58,14 @@ env: jobs: # Security gate: untrusted PRs wait on the deterministic scan # (security-gate.yml); trusted authors and non-PR events pass instantly. - # Skip when the automerge label is applied/removed -- safe to short-circuit - # here because every non-gate job is transitively downstream of gate, so - # no skipped check-run can overwrite an existing result on this SHA. + # Short-circuit for label events that aren't skip-security-scan (e.g. + # automerge): those run in their own isolated concurrency slot (above) and + # don't need the full suite — just exit fast. gate: - if: github.event.label.name != 'automerge' + if: >- + github.event_name != 'pull_request' || + (github.event.action != 'labeled' && github.event.action != 'unlabeled') || + github.event.label.name == 'skip-security-scan' uses: ./.github/workflows/security-gate.yml # Shard matrix (e2e-shard-matrix.sh, shared with e2e-ui.yml). Fork PRs run by From 5508060e99922d73aa046cf0b47b9f855dd410c6 Mon Sep 17 00:00:00 2001 From: Daniel Lok Date: Mon, 6 Jul 2026 14:56:15 +0800 Subject: [PATCH 010/546] feat(doc-sync): label site PRs with release version and assign reviewer (#2002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staged omnigent-site doc PRs all target the per-minor X.Y-docs branch and carried only the automated-docs label, so maintainers couldn't filter them by the release they'll ship in. Derive vX.Y.Z from omnigent/version.py in the existing "Resolve docs branch" step and apply it as a label on both the create and update paths (backfilling PRs opened before the label existed). Also add the resolved reviewer as an assignee alongside the review request, so the PR is filterable by assignee from the site's PR list. The two calls are independent and best-effort — GitHub rejects non-collaborators with 422, which stays tolerated as before. Co-authored-by: Isaac --- .github/workflows/doc-sync.yml | 58 +++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/.github/workflows/doc-sync.yml b/.github/workflows/doc-sync.yml index 26b11cda08c..55b7be01edc 100644 --- a/.github/workflows/doc-sync.yml +++ b/.github/workflows/doc-sync.yml @@ -201,25 +201,30 @@ jobs: ref: ${{ github.event.repository.default_branch }} persist-credentials: false - # Derive the per-minor docs staging branch from the runtime version. main - # carries X.Y.Z.dev0, so 0.5.0.dev0 → "0.5-docs". All docs for the 0.5 line - # (incl. patches) stage on this one branch until release publishes it. + # Derive the per-minor docs staging branch and the release version from the + # runtime version. main carries X.Y.Z.dev0, so 0.5.0.dev0 → branch "0.5-docs" + # and label "v0.5.0". All docs for the 0.5 line (incl. patches) stage on the + # one branch until release publishes it; the vX.Y.Z label lets maintainers + # filter the staged PRs by the release they'll ship in. - name: Resolve docs branch id: docsbranch if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' run: | set -euo pipefail - minor="$(python3 - <<'PYEOF' - import pathlib, re + python3 - <<'PYEOF' + import os, pathlib, re text = pathlib.Path("omnigent/version.py").read_text() - m = re.search(r'VERSION\s*=\s*["\']([0-9]+)\.([0-9]+)', text) + m = re.search(r'VERSION\s*=\s*["\']([0-9]+)\.([0-9]+)\.([0-9]+)', text) if not m: - raise SystemExit("could not parse X.Y from omnigent/version.py") - print(f"{m.group(1)}.{m.group(2)}") + raise SystemExit("could not parse X.Y.Z from omnigent/version.py") + major, minor, patch = m.groups() + branch = f"{major}.{minor}-docs" + version = f"v{major}.{minor}.{patch}" + with open(os.environ["GITHUB_OUTPUT"], "a") as fh: + fh.write(f"branch={branch}\n") + fh.write(f"version={version}\n") + print(f"::notice::Docs stage on branch {branch} (release {version})") PYEOF - )" - echo "branch=${minor}-docs" >> "$GITHUB_OUTPUT" - echo "::notice::Docs stage on branch ${minor}-docs" - name: Set up Python if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' @@ -635,6 +640,7 @@ jobs: PR_NUMBER: ${{ steps.plan.outputs.pr }} REVIEWER: ${{ steps.sitepr.outputs.reviewer }} DOCS_BRANCH: ${{ steps.docsbranch.outputs.branch }} + VERSION_LABEL: ${{ steps.docsbranch.outputs.version }} run: | set -euo pipefail BRANCH="auto/docs/pr-${PR_NUMBER}" @@ -685,17 +691,27 @@ jobs: # bot commits. git push --force "$PUSH_URL" "$BRANCH" + # The vX.Y.Z label marks which release the staged docs will ship in, so + # maintainers can filter the site PRs by release. Ensure it exists (with + # automated-docs) before applying it below. + gh label create automated-docs --repo "$SITE_REPO_SLUG" --color 0E8A16 \ + --description "Automated documentation update" 2>/dev/null || true + gh label create "$VERSION_LABEL" --repo "$SITE_REPO_SLUG" --color FBCA04 \ + --description "Docs staged for the ${VERSION_LABEL} release" 2>/dev/null || true + EXISTING="$(gh pr list --repo "$SITE_REPO_SLUG" --head "$BRANCH" --state open \ --json number --jq '.[0].number // empty' 2>/dev/null || true)" if [ -n "$EXISTING" ]; then - gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" --body-file /tmp/site_pr_body.md || true + # --add-label backfills PRs opened before the label existed; it's a no-op + # when already present. + gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" \ + --add-label "automated-docs" --add-label "$VERSION_LABEL" \ + --body-file /tmp/site_pr_body.md || true echo "Updated site PR #$EXISTING." else - gh label create automated-docs --repo "$SITE_REPO_SLUG" --color 0E8A16 \ - --description "Automated documentation update" 2>/dev/null || true if gh pr create --repo "$SITE_REPO_SLUG" --base "$DOCS_BRANCH" --head "$BRANCH" \ --title "docs: document ${CODE_REPO}#${PR_NUMBER}" \ - --label automated-docs --body-file /tmp/site_pr_body.md; then + --label automated-docs --label "$VERSION_LABEL" --body-file /tmp/site_pr_body.md; then EXISTING="$(gh pr list --repo "$SITE_REPO_SLUG" --head "$BRANCH" --state open \ --json number --jq '.[0].number // empty' 2>/dev/null || true)" echo "Opened site PR for $BRANCH." @@ -704,13 +720,17 @@ jobs: fi fi - # Always attempt the review request, decoupled from PR creation so a - # non-addable reviewer can't fail the open. GitHub returns 422 for users it - # can't add (non-collaborators / concealed org members); tolerate it — the - # reviewer is also @-mentioned in the body as a durable fallback ping. + # Always attempt the review request + assignment, decoupled from PR creation + # so a non-addable reviewer can't fail the open. GitHub returns 422 for users + # it can't add (non-collaborators / concealed org members); tolerate it — the + # reviewer is also @-mentioned in the body as a durable fallback ping. The two + # calls are independent so one failing doesn't skip the other. Assigning makes + # the PR filterable by assignee from the site's PR list. if [ -n "${REVIEWER}" ] && [ -n "${EXISTING}" ]; then gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" --add-reviewer "${REVIEWER}" \ || echo "::notice::Could not request review from ${REVIEWER} (not addable); they're @-mentioned in the PR body." + gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" --add-assignee "${REVIEWER}" \ + || echo "::notice::Could not assign ${REVIEWER} (not addable); they're @-mentioned in the PR body." fi - name: Note draft skipped (no site token) From e8313ac5d02bd00dd453954ea9d1d958d1285c57 Mon Sep 17 00:00:00 2001 From: Tomu Hirata Date: Mon, 6 Jul 2026 16:28:28 +0900 Subject: [PATCH 011/546] fix(server): signal SSE streams to exit on shutdown, reduce graceful timeout (#1998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(server): signal SSE streams to exit on shutdown, reduce graceful timeout Ctrl-C would hang for up to 30 s because open SSE session streams waited for their next heartbeat (15 s cadence) before discovering the server was going away. After the timeout, uvicorn force-cancelled them, producing spurious "Exception in ASGI application / CancelledError: timeout graceful shutdown exceeded" tracebacks. Fix by broadcasting the end-of-stream sentinel to every subscriber queue in the lifespan shutdown handler (session_stream.shutdown_all()), so SSE generators return cleanly without waiting for a heartbeat tick. The graceful-shutdown window is also reduced from 30 s to 5 s: SSE connections now drain on their own; the remaining window is sized for WebSocket tunnel teardown, which is fast. * fix(ci): drop labeled/unlabeled from e2e.yml to prevent automerge label from canceling running E2E label events share the PR-number concurrency key, so applying automerge mid-run triggered a new workflow run that immediately canceled the in-progress suite (cancel-in-progress: true), leaving no E2E result. e2e-ui.yml and integration.yml already removed these trigger types for the same reason. Remove labeled/unlabeled from e2e.yml and drop the now- unnecessary gate `if: github.event.label.name != 'automerge'` condition. * Revert "fix(ci): drop labeled/unlabeled from e2e.yml to prevent automerge label from canceling running E2E" This reverts commit f1985283731f7f3b1c2cd03792fe124458d4fb85. * fix(server): move shutdown_all() into Server.shutdown override before graceful wait The lifespan finally block runs AFTER uvicorn's graceful-shutdown timer has already expired and force-cancelled in-flight tasks, so calling shutdown_all() there was a no-op. Move the call into a uvicorn.Server subclass (_ShutdownSignalingServer) that overrides shutdown(): the sentinel is broadcast to all SSE subscriber queues before asyncio.wait_for(_wait_tasks_to_complete(), ...) starts, so generators exit cleanly within the graceful window instead of being force-cancelled. Also clean up session_stream.shutdown_all(): remove the contextlib.suppress guard (queues are unbounded asyncio.Queue(), so QueueFull is unreachable). * fix(ci): drop labeled/unlabeled from e2e.yml to stop automerge label canceling running E2E Applying the automerge label mid-run triggered a new workflow run sharing the same PR-number concurrency key. With cancel-in-progress: true, that killed the running suite, leaving no E2E result on the PR. e2e-ui.yml and integration.yml already removed labeled/unlabeled for the same reason. Remove them from e2e.yml and drop the now-dead gate condition `if: github.event.label.name != 'automerge'`. * fix(server): yield event-loop turn after shutdown_all() before closing transports Without this pause, generators receive _DONE but cannot run until super().shutdown() calls connection.shutdown()/transport.close() — at which point they try to flush "data: [DONE]\n\n" to an already-closing transport. Writing to a closing transport leaves connections open past the graceful window, which prevents clear_local_server_record() from running and leaves the port bound. One asyncio.sleep(0) turn lets generators consume _DONE, flush their final chunk, and exit before the transports are torn down. * fix(server): catch KeyboardInterrupt, use SO_REUSEADDR in port probe Two issues introduced by the faster shutdown: 1. KeyboardInterrupt now propagates from Server.run() to Click (since we dropped the uvicorn.run() wrapper that swallowed it), printing "Aborted!" and exiting non-zero. Add except KeyboardInterrupt: pass to match uvicorn.run()'s original behaviour. 2. pick_local_port() probed with a plain socket (no SO_REUSEADDR), which fails on macOS/BSD when recently closed connections are still in TIME_WAIT with local address 127.0.0.1:6767. The server's listening socket is already gone, and uvicorn would bind fine (it uses SO_REUSEADDR), so the probe socket must match. * revert unrelated e2e.yml change from branch history * test(cli): update server tests to mock uvicorn.server.Server.run instead of uvicorn.run The server command now uses uvicorn.Config + _ShutdownSignalingServer(config).run() rather than uvicorn.run(), so the four tests that monkeypatched uvicorn.run to skip the blocking server loop were no longer intercepting anything — the real Server.run() was called, binding to the test port and hanging. Switch to patching uvicorn.server.Server.run (which _ShutdownSignalingServer inherits) and capture the same kwarg fields via self.config attributes. --- omnigent/cli.py | 101 ++++++++++++++++++++--------- omnigent/host/local_server.py | 5 ++ omnigent/runtime/session_stream.py | 17 +++++ tests/cli/test_cli.py | 53 ++++++++------- 4 files changed, 121 insertions(+), 55 deletions(-) diff --git a/omnigent/cli.py b/omnigent/cli.py index 5db130ad864..0085d808e11 100644 --- a/omnigent/cli.py +++ b/omnigent/cli.py @@ -233,11 +233,14 @@ def _migrate_legacy_state_dir() -> None: _DAEMON_REUSE_MIN_AGE_S = 6.0 # How long uvicorn waits for active connections (WebSocket, SSE) after -# SIGTERM before force-closing them. 30 s gives in-flight responses time -# to drain while still guaranteeing the port is released promptly. +# SIGTERM before force-closing them. SSE streams signal themselves via +# session_stream.shutdown_all() in _ShutdownSignalingServer.shutdown(), +# so the main remaining consumers of this window are WebSocket tunnels +# that need a moment to drain. 5 s is enough for a clean tunnel teardown +# while keeping Ctrl-C feeling instant. # Overridable via OMNIGENT_SERVER_SHUTDOWN_TIMEOUT_S for deployments that # need a longer drain window (e.g. large file uploads). -_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S_DEFAULT = 30 +_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S_DEFAULT = 5 _SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S = int( os.environ.get( "OMNIGENT_SERVER_SHUTDOWN_TIMEOUT_S", @@ -2972,6 +2975,7 @@ def server( port = _picked import uvicorn + import uvicorn.server from omnigent.runner.transports.ws_tunnel.limits import ( RUNNER_TUNNEL_MAX_MESSAGE_BYTES, @@ -3220,34 +3224,71 @@ def server( # this foreground server instead of tearing it down on a spurious # sig mismatch. register_local_server(port) + + class _ShutdownSignalingServer(uvicorn.server.Server): + """uvicorn.Server that signals active SSE subscribers before the + graceful-shutdown wait starts. + + uvicorn calls ``Server.shutdown()`` in this order: + 1. close listening sockets / call connection.shutdown() + 2. ``asyncio.wait_for(_wait_tasks_to_complete(), timeout=…)`` + 3. force-cancel remaining tasks on timeout + 4. run the ASGI lifespan shutdown handler + + The ASGI lifespan ``finally`` block runs at step 4 — too late. SSE + generators waiting on a heartbeat tick are already force-cancelled by + step 3, which produces spurious ``CancelledError`` tracebacks. + Overriding here lets us drain SSE streams before step 2 so they exit + cleanly within the graceful window. + """ + + async def shutdown(self, sockets=None) -> None: # type: ignore[override] + import asyncio as _asyncio + + from omnigent.runtime import session_stream as _session_stream + + _session_stream.shutdown_all() + # Yield to the event loop so generators can consume _DONE, + # flush their final "data: [DONE]\n\n" chunk, and exit before + # super().shutdown() calls connection.shutdown() / transport.close(). + # Without this pause the generators write to an already-closing + # transport, leaving connections open past the graceful window. + await _asyncio.sleep(0) + await super().shutdown(sockets) + + _config = uvicorn.Config( + app, + host=host, + port=port, + log_config=_server_uvicorn_log_config(), + ws_max_size=RUNNER_TUNNEL_MAX_MESSAGE_BYTES, + # Server side of the runner/host tunnels' protocol keepalive, aligned + # to the 90 s app-level budget instead of uvicorn's 20 s default that + # drops a busy-but-healthy tunnel with 1011 — issue #1116. + # + # uvicorn's ws_ping_* is server-global (no per-route override), so this + # 30 s/90 s budget also applies to the app's other WebSocket routes — + # /v1/sessions/updates (browser stream) and .../terminals/{id}/attach. + # Deliberate and acceptable: for an IDLE such socket the protocol + # PING/PONG is the only half-open detector (the sessions-updates + # heartbeat is a server->client send, and an idle terminal has no + # traffic), so widening it means a dead idle browser/terminal socket is + # reaped at worst ~120 s (30 s interval + 90 s timeout) instead of + # ~40 s — a slightly later half-open cleanup (e.g. the out-of-process + # terminal-attach proxy holds its runner socket + tmux child ~80 s + # longer), bounded and eventually reaped, not a leak or correctness + # change. The tunnels are the sockets that actually need the looser + # budget (issue #1116). + ws_ping_interval=TUNNEL_KEEPALIVE_PING_INTERVAL_S, + ws_ping_timeout=TUNNEL_KEEPALIVE_PING_TIMEOUT_S, + timeout_graceful_shutdown=_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S, + ) try: - uvicorn.run( - app, - host=host, - port=port, - log_config=_server_uvicorn_log_config(), - ws_max_size=RUNNER_TUNNEL_MAX_MESSAGE_BYTES, - # Server side of the runner/host tunnels' protocol keepalive, aligned - # to the 90 s app-level budget instead of uvicorn's 20 s default that - # drops a busy-but-healthy tunnel with 1011 — issue #1116. - # - # uvicorn's ws_ping_* is server-global (no per-route override), so this - # 30 s/90 s budget also applies to the app's other WebSocket routes — - # /v1/sessions/updates (browser stream) and .../terminals/{id}/attach. - # Deliberate and acceptable: for an IDLE such socket the protocol - # PING/PONG is the only half-open detector (the sessions-updates - # heartbeat is a server->client send, and an idle terminal has no - # traffic), so widening it means a dead idle browser/terminal socket is - # reaped at worst ~120 s (30 s interval + 90 s timeout) instead of - # ~40 s — a slightly later half-open cleanup (e.g. the out-of-process - # terminal-attach proxy holds its runner socket + tmux child ~80 s - # longer), bounded and eventually reaped, not a leak or correctness - # change. The tunnels are the sockets that actually need the looser - # budget (issue #1116). - ws_ping_interval=TUNNEL_KEEPALIVE_PING_INTERVAL_S, - ws_ping_timeout=TUNNEL_KEEPALIVE_PING_TIMEOUT_S, - timeout_graceful_shutdown=_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S, - ) + _ShutdownSignalingServer(_config).run() + except KeyboardInterrupt: + # uvicorn.run() swallows KeyboardInterrupt; match that behaviour so + # a Ctrl-C exit doesn't print Click's "Aborted!" or exit non-zero. + pass finally: if _is_canonical_local_server: clear_local_server_record() diff --git a/omnigent/host/local_server.py b/omnigent/host/local_server.py index 4f8d1295edb..93c00579b4d 100644 --- a/omnigent/host/local_server.py +++ b/omnigent/host/local_server.py @@ -708,6 +708,11 @@ def pick_local_port(preferred: int = _DEFAULT_LOCAL_PORT) -> int: import socket with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + # SO_REUSEADDR mirrors what uvicorn sets when it binds. Without + # it, a fast server restart sees EADDRINUSE on macOS/BSD because + # recently closed connections are still in TIME_WAIT even though + # the listening socket is gone and uvicorn could successfully bind. + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: s.bind(("127.0.0.1", preferred)) except OSError: diff --git a/omnigent/runtime/session_stream.py b/omnigent/runtime/session_stream.py index 2df9907e448..3fc66585d4a 100644 --- a/omnigent/runtime/session_stream.py +++ b/omnigent/runtime/session_stream.py @@ -125,6 +125,23 @@ def close(conversation_id: str) -> None: loop.call_soon_threadsafe(queue.put_nowait, _DONE) +def shutdown_all() -> None: + """Signal all active subscribers across every conversation to exit. + + Broadcasts the end-of-stream sentinel to every queued subscriber so + SSE generators return at their next iteration without waiting for a + heartbeat timeout or forced task cancellation. Called from the asyncio + event loop (``_ShutdownSignalingServer.shutdown`` in ``cli.py``) before + uvicorn's graceful-shutdown wait starts, so streams drain within the + window rather than being force-cancelled. Sync callers should use + :func:`close` per-conversation instead. + """ + with _lock: + all_subs = [entry for subs in _subscribers.values() for entry in subs] + for queue, _ in all_subs: + queue.put_nowait(_DONE) + + async def subscribe( conversation_id: str, *, diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 85250651e58..86e0cbf6075 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -1151,6 +1151,7 @@ def test_server_command_reads_tunnel_token_and_does_not_spawn_runner( :returns: None. """ import uvicorn + import uvicorn.server captured: dict[str, Any] = {} @@ -1165,22 +1166,27 @@ def _spy_create_app(**kwargs: Any) -> Any: captured["create_app_kwargs"] = kwargs return _original_create_app(**kwargs) - def _fake_uvicorn_run(app: Any, **kwargs: Any) -> None: - """Skip the blocking server loop. + def _fake_server_run(self: Any) -> None: + """Skip the blocking server loop; capture config as flat kwargs dict. - :param app: FastAPI app instance built by ``create_app``. - :param kwargs: Uvicorn options (host, port). + :param self: The uvicorn Server instance whose config holds all options. :returns: None. """ - del app - captured["uvicorn_kwargs"] = kwargs + captured["uvicorn_kwargs"] = { + "ws_max_size": self.config.ws_max_size, + "ws_ping_interval": self.config.ws_ping_interval, + "ws_ping_timeout": self.config.ws_ping_timeout, + "log_config": self.config.log_config, + "port": self.config.port, + "host": self.config.host, + } captured["uvicorn_called"] = True from omnigent.server import app as app_module _original_create_app = app_module.create_app monkeypatch.setattr(app_module, "create_app", _spy_create_app) - monkeypatch.setattr(uvicorn, "run", _fake_uvicorn_run) + monkeypatch.setattr(uvicorn.server.Server, "run", _fake_server_run) monkeypatch.setenv("OMNIGENT_RUNNER_TUNNEL_TOKEN", "test-tunnel-token-abc") # On a loopback bind the `server` command reuses an already-running @@ -1248,6 +1254,7 @@ def test_server_with_explicit_db_does_not_reuse_canonical_server( shared pidfile. """ import uvicorn + import uvicorn.server captured: dict[str, Any] = {} _original_create_app = None @@ -1261,22 +1268,20 @@ def _spy_create_app(**kwargs: Any) -> Any: captured["create_app_kwargs"] = kwargs return _original_create_app(**kwargs) - def _fake_uvicorn_run(app: Any, **kwargs: Any) -> None: + def _fake_server_run(self: Any) -> None: """Skip the blocking server loop, record that it was called. - :param app: FastAPI app built by ``create_app``. - :param kwargs: Uvicorn options (host, port, ...). + :param self: The uvicorn Server instance. :returns: None. """ - del app - captured["uvicorn_kwargs"] = kwargs + captured["uvicorn_kwargs"] = {"port": self.config.port} captured["uvicorn_called"] = True from omnigent.server import app as app_module _original_create_app = app_module.create_app monkeypatch.setattr(app_module, "create_app", _spy_create_app) - monkeypatch.setattr(uvicorn, "run", _fake_uvicorn_run) + monkeypatch.setattr(uvicorn.server.Server, "run", _fake_server_run) # A healthy canonical server EXISTS. A bare `omnigent server` would # reuse it; an explicit-DB server must ignore it. register/clear must @@ -1334,19 +1339,18 @@ def test_server_with_explicit_port_does_not_check_canonical_server( :returns: None. """ import uvicorn + import uvicorn.server captured: dict[str, Any] = {} - def _fake_uvicorn_run(app: Any, **kwargs: Any) -> None: + def _fake_server_run(self: Any) -> None: """ Skip the blocking server loop. - :param app: FastAPI app instance built by ``create_app``. - :param kwargs: Uvicorn options (host, port). + :param self: The uvicorn Server instance. :returns: None. """ - del app - captured["uvicorn_kwargs"] = kwargs + captured["uvicorn_kwargs"] = {"port": self.config.port} def _must_not_check_existing() -> str | None: """ @@ -1367,7 +1371,7 @@ def _must_not_touch_pidfile(_port: int | None = None) -> None: from omnigent.host import local_server as _local_server_mod - monkeypatch.setattr(uvicorn, "run", _fake_uvicorn_run) + monkeypatch.setattr(uvicorn.server.Server, "run", _fake_server_run) monkeypatch.setattr(_local_server_mod, "local_server_url_if_healthy", _must_not_check_existing) monkeypatch.setattr(_local_server_mod, "register_local_server", _must_not_touch_pidfile) monkeypatch.setattr(_local_server_mod, "clear_local_server_record", _must_not_touch_pidfile) @@ -1447,19 +1451,18 @@ def test_server_command_explicit_port_uses_bind_probe_not_connect_probe( import socket import uvicorn + import uvicorn.server captured: dict[str, Any] = {} - def _fake_uvicorn_run(app: Any, **kwargs: Any) -> None: + def _fake_server_run(self: Any) -> None: """ Skip the blocking server loop. - :param app: FastAPI app instance built by ``create_app``. - :param kwargs: Uvicorn options (host, port). + :param self: The uvicorn Server instance. :returns: None. """ - del app - captured["uvicorn_kwargs"] = kwargs + captured["uvicorn_kwargs"] = {"port": self.config.port} with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: probe.bind(("127.0.0.1", 0)) @@ -1468,7 +1471,7 @@ def _fake_uvicorn_run(app: Any, **kwargs: Any) -> None: with pytest.raises(OSError): socket.create_connection(("127.0.0.1", port), timeout=0.01) - monkeypatch.setattr(uvicorn, "run", _fake_uvicorn_run) + monkeypatch.setattr(uvicorn.server.Server, "run", _fake_server_run) monkeypatch.setenv("OMNIGENT_AUTH_ENABLED", "0") db_path = tmp_path / "chat.db" From b3e220ba977c9d98a0b2b72ae2b4a2c247375c9d Mon Sep 17 00:00:00 2001 From: Pat Sukprasert Date: Mon, 6 Jul 2026 14:36:31 +0700 Subject: [PATCH 012/546] fix(harness-bench): classify full-server token-provisioning failures + document transport coverage (#1994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(harness-bench): classify token-provisioning failures as infra skips A full-server run over the SDK harnesses exposed a false-drift: codex and pi fail basic_turn on that transport with a provider/gateway token-provisioning error ("provider auth command `sh` produced an empty token"; "could not fetch a gateway token"), which infra_failure_reason did not recognize — so the turn read as UNSUPPORTED and drifted (!!✓>✗) against the SUPPORTED declaration. That is an environment/auth gap in the full-server driver's spawn path, not a capability the harness lacks. Add the token-provisioning phrasings to the infra markers (with a dedicated skip reason), so such a failure is reported SKIPPED — matching how a 403 / connectivity error is already handled — instead of a false capability drift. claude-sdk on full-server is unaffected: it completes the full matrix (Tool calling + Policy DENY both SUPPORTED and enforced). Extends the infra-classification test with the codex/pi token-provisioning messages. Offline 50 passed / 14 skipped, ruff clean. * docs(harness-bench): document which transport exercises Tool calling / Policy DENY A default `--profile oss` run shows `·` for Tool calling and Policy DENY, which reads as "untested" but is really a transport limitation: those two dimensions only get a real verdict on `full-server` (sdk-inproc harnesses dispatch tools internally; native-tui isn't wired for them yet). Add a transport-vs-dimension coverage table, the `--transport full-server` recipe, and the live-verified result (claude-sdk: Tool calling ✓, Policy DENY ✓ enforced). Record the codex/pi full-server gateway-auth gap and the native-tui tool/policy gap as open items. * fix(harness-bench): accurate skip message for a native harness on full-server Under --transport full-server, a native profile was rejected with "transport 'native-tui' not supported by the 'sdk-inproc' driver" — misleading, since it is the full-server driver rejecting it and the fix is to use native-tui. FullServerDriver.unavailable now rejects native profiles itself with an accurate message ("... is a native-tui harness; ... use --transport native-tui") and only borrows the SDK driver's CLI gate, not its sdk-inproc-specific transport check. Add a test asserting the message names native-tui and never sdk-inproc. Context: verified on the oss profile that all four SDK harnesses (claude-sdk, codex, pi, openai-agents) complete the full matrix on full-server with Tool calling and Policy DENY both SUPPORTED and enforced. The codex "timeout" seen earlier was a transient cold-start flake under sequential load (codex completes a basic turn in ~15s solo), not a hang and not an auth failure once the local Databricks profile was re-authed — no code change needed for it. Offline 52 passed / 14 skipped, ruff clean. --- docs/harness-bench-design.md | 42 +++++++++++++++++++++++ tests/harness_bench/driver.py | 22 ++++++++++++ tests/harness_bench/full_server_driver.py | 21 +++++++++--- tests/harness_bench/test_bench.py | 34 ++++++++++++++++++ 4 files changed, 115 insertions(+), 4 deletions(-) diff --git a/docs/harness-bench-design.md b/docs/harness-bench-design.md index 43757e3a1aa..574e22f8040 100644 --- a/docs/harness-bench-design.md +++ b/docs/harness-bench-design.md @@ -311,6 +311,39 @@ deltas as `UNSUPPORTED`, not `PARTIAL`. This bit the transcript-mirror natives message rather than streaming deltas: they declare `streaming=False` → `UNSUPPORTED`, matching what the probe observes. +## Which transport exercises which dimension + +Not every dimension is observable on every transport, so a `·` (SKIPPED) in a +default run often means "this transport can't exercise it here," not "the +harness lacks it." Two dimensions in particular only get a real verdict on the +`full-server` transport: + +| Dimension | sdk-inproc | full-server | native-tui | +|---|---|---|---| +| Basic turn, Streaming, Model override, Interrupt | ✓ | ✓ | ✓ | +| **Tool calling** | · (harness dispatches tools internally) | ✓ (server-dispatched builtin) | · (not yet wired) | +| **Policy DENY** | · (wrap-direct: no tool-call policy hook) | ✓ (spec-baked deny, enforced) | · (not yet wired) | + +So to see Tool calling and Policy DENY actually proven, run the SDK harnesses +over `full-server`: + +``` +python -m tests.harness_bench --harness claude-sdk --profile oss --transport full-server +``` + +Live-verified: `claude-sdk` completes the full matrix on `full-server` — +Tool calling `✓` and Policy DENY `✓` (the deny is delivered and the blocked +call does not stall the turn). The default `--profile oss` run shows `·` for +those two columns only because it uses `sdk-inproc` (for SDK harnesses) and +`native-tui` (for natives), neither of which routes a tool call through a +server policy evaluation. + +`full-server` covers **SDK harnesses only** — it registers the harness via an +agent bundle, which is the SDK-wrap path; native harnesses need the host-daemon +provisioning the `native-tui` driver owns. So Tool calling / Policy DENY for +native harnesses remain genuinely unwired (a follow-up), distinct from the +sdk-inproc `·` which is a transport limitation with `full-server` as the answer. + ## Open items - Exact `BenchProfile` field set and whether it subsumes `HarnessProbe` or wraps @@ -319,3 +352,12 @@ message rather than streaming deltas: they declare `streaming=False` → an exported CSV so the sheet stays canonical during transition. - Native transport drivers are the larger half of the work; sequence them by which harnesses matter most for the matrix. +- `full-server` cannot yet provision codex / pi gateway auth (their basic turn + fails with an empty/absent gateway token), so Tool calling / Policy DENY are + only live-proven on `claude-sdk` today; wiring codex/pi full-server auth would + extend that coverage. (The token-provisioning failure is classified as a SKIP, + not a false capability drift.) +- Tool calling / Policy DENY on the `native-tui` transport are unwired — native + tool calls are the vendor's own and a native deny is a vendor permission + decision, not a server-dispatched `function_call_output`; observing them needs + new driver work. diff --git a/tests/harness_bench/driver.py b/tests/harness_bench/driver.py index b679fa6a786..5b1bb05c3c0 100644 --- a/tests/harness_bench/driver.py +++ b/tests/harness_bench/driver.py @@ -91,6 +91,15 @@ # Sequencing, not capability: a prior turn on the shared session had not # fully settled. Reported SKIPPED so it never reads as a capability gap. "already processing", + # Token provisioning failed before the harness could reach the model — an + # environment/auth gap (a missing/empty gateway token, a provider auth + # command that produced nothing), not a capability the harness lacks. + # Seen on full-server for codex ("provider auth command ... empty token") + # and pi ("could not fetch a gateway token"). + "could not fetch a gateway token", + "provider auth command", + "empty token", + "Failed to resolve external API key auth", ) @@ -125,6 +134,19 @@ def infra_failure_reason(result: TurnResult) -> str | None: ) if "already processing" in text: return "session busy from a prior turn (sequencing, not a capability gap)" + if any( + marker in text + for marker in ( + "could not fetch a gateway token", + "provider auth command", + "empty token", + "Failed to resolve external API key auth", + ) + ): + return ( + "gateway/provider token could not be provisioned for this transport " + "(environment/auth gap, not a capability the harness lacks)" + ) if "unexpected status" in text: return "gateway returned an unexpected status (environment/auth issue)" return "environment/connectivity error reaching the gateway" diff --git a/tests/harness_bench/full_server_driver.py b/tests/harness_bench/full_server_driver.py index 659f057e5da..48ddc88809f 100644 --- a/tests/harness_bench/full_server_driver.py +++ b/tests/harness_bench/full_server_driver.py @@ -173,16 +173,29 @@ def __init__(self, profile: BenchProfile, *, databricks_profile: str) -> None: @staticmethod def unavailable(profile: BenchProfile, *, databricks_profile: str | None) -> str | None: """Return a skip reason if this driver cannot run *profile*, else ``None``.""" + # full-server registers the harness via an agent bundle (the SDK-wrap + # path); a native harness needs the host-daemon/tmux provisioning only + # the native-tui driver does, so it cannot run here even under an + # explicit --transport full-server override. + if profile.transport == "native-tui": + return ( + f"{profile.harness!r} is a native-tui harness; the full-server transport " + "registers via an agent bundle and cannot drive it (use --transport native-tui)" + ) if not databricks_profile: return "no --profile / databricks profile provided; full-server needs a gateway route" if lookup_databricks_host(databricks_profile) is None: return ( f"databricks profile {databricks_profile!r} missing/hostless in ~/.databrickscfg" ) - # Reuse the wrap driver's CLI gate (same binary requirement). - from tests.harness_bench.driver import SdkInprocDriver - - return SdkInprocDriver.unavailable(profile, databricks_profile=databricks_profile) + # Same CLI gate as the wrap driver (same binary requirement), but skip + # its transport check — that is sdk-inproc-specific and would misreport + # the driver name; the native case is already handled above. + from tests.e2e._harness_probes import cli_unavailable_reason + + if profile.cli_binary is not None: + return cli_unavailable_reason(profile.cli_binary) + return None def __enter__(self) -> FullServerDriver: self._tmp.mkdir(mode=0o700, parents=True, exist_ok=True) diff --git a/tests/harness_bench/test_bench.py b/tests/harness_bench/test_bench.py index 2670269e58c..6d255360192 100644 --- a/tests/harness_bench/test_bench.py +++ b/tests/harness_bench/test_bench.py @@ -125,6 +125,17 @@ def test_infra_failure_reason_classifies_auth_and_ignores_capability_gaps() -> N # A successful turn is never an infra failure. assert infra_failure_reason(TurnResult(completed=True, text="ok")) is None + # Token-provisioning failures on full-server (codex/pi) are env/auth gaps, + # not capability gaps -> must yield a skip reason, never a false UNSUPPORTED + # that drifts against a SUPPORTED declaration. + for msg in ( + "inner executor error: provider auth command `sh` produced an empty token", + "PiExecutor(gateway=True) could not fetch a gateway token for the workspace host.", + "Failed to resolve external API key auth", + ): + result = TurnResult(failed=True, error={"message": msg}) + assert infra_failure_reason(result) is not None, msg + async def test_offline_render_produces_matrix() -> None: matrix = await run_bench(_OFFICIAL, live=False) @@ -298,3 +309,26 @@ def test_native_tui_registered_and_gates() -> None: # No profile → the same capability-neutral skip contract as other drivers. assert NativeTuiDriver.unavailable(claude_native, databricks_profile=None) is not None + + +def test_full_server_skips_native_with_accurate_message() -> None: + """full-server rejects a native profile by naming the native transport. + + A native harness forced onto full-server (via --transport) cannot run + there (bundle registration, not host-daemon provisioning). The skip must + name native-tui as the answer, not misreport the 'sdk-inproc' driver. + """ + from tests.harness_bench.full_server_driver import FullServerDriver + + # Real native profiles carry transport="native-tui" (set in the manifest); + # that is what the full-server gate keys on. + claude_native = BenchProfile( + harness="claude-native", + model="m", + env_prefix="HARNESS_CLAUDE_NATIVE_", + marker="X", + transport="native-tui", + ) + reason = FullServerDriver.unavailable(claude_native, databricks_profile="oss") + assert reason is not None + assert "native-tui" in reason and "sdk-inproc" not in reason From 5315349c83ecb21b9a4c1087d6642cd21bfa6448 Mon Sep 17 00:00:00 2001 From: Tomu Hirata Date: Mon, 6 Jul 2026 16:38:14 +0900 Subject: [PATCH 013/546] fix(members): show friendly message in single-user/header mode (#2013) * fix(members): show friendly message in single-user/header mode instead of auth error In plain header mode (no accounts, no OIDC), the /auth/users endpoint does not exist, causing the Members page to show a misleading error. Add an early return after all hooks when accounts_enabled is false and login_url is null, rendering a "not available in single-user mode" message. * fix(members): skip fetch and show not-available message in single-user mode - Derive isSingleUser from server_version (non-null on a live server, null on the _OFF probe-failure sentinel) to distinguish real single-user header mode from a transient /v1/info failure. - Gate the useEffect on isSingleUser so the identity probe and /auth/users fetch are skipped entirely in that mode. - Add a test case asserting the message renders and listUsers is never called; update mock to expose login_url + server_version so OIDC and single-user cases are distinguishable. --- web/src/pages/MembersPage.test.tsx | 40 ++++++++++++++++++++++++++---- web/src/pages/MembersPage.tsx | 32 +++++++++++++++++++----- 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/web/src/pages/MembersPage.test.tsx b/web/src/pages/MembersPage.test.tsx index c568242ce46..145791fdb14 100644 --- a/web/src/pages/MembersPage.test.tsx +++ b/web/src/pages/MembersPage.test.tsx @@ -15,10 +15,18 @@ import type { AccountListEntry } from "@/lib/accountsApi"; import * as accountsApi from "@/lib/accountsApi"; import * as identity from "@/lib/identity"; -const mocks = vi.hoisted(() => ({ accountsEnabled: true })); +const mocks = vi.hoisted(() => ({ + accountsEnabled: true, + loginUrl: null as string | null, + serverVersion: "0.3.0.dev0" as string | null, +})); vi.mock("@/lib/CapabilitiesContext", () => ({ - useServerInfo: () => ({ accounts_enabled: mocks.accountsEnabled }), + useServerInfo: () => ({ + accounts_enabled: mocks.accountsEnabled, + login_url: mocks.loginUrl, + server_version: mocks.serverVersion, + }), })); vi.mock("@/lib/identity", () => ({ resolveIdentity: vi.fn(), @@ -52,6 +60,8 @@ function renderPage() { beforeEach(() => { mocks.accountsEnabled = true; + mocks.loginUrl = null; + mocks.serverVersion = "0.3.0.dev0"; vi.mocked(identity.resolveIdentity).mockResolvedValue("admin"); vi.mocked(identity.getCurrentIsAdmin).mockReturnValue(true); vi.mocked(accountsApi.listUsers).mockResolvedValue([]); @@ -183,12 +193,32 @@ describe("MembersPage actions", () => { }); }); +describe("MembersPage in plain header/single-user mode", () => { + beforeEach(() => { + // Single-user mode: no accounts, no IdP (login_url is null). The + // /auth/users endpoint does not exist, so the page must skip the fetch + // and show a "not available" message instead. + mocks.accountsEnabled = false; + mocks.loginUrl = null; + mocks.serverVersion = "0.3.0.dev0"; + }); + + it("shows a not-available message and never calls listUsers", async () => { + renderPage(); + expect( + await screen.findByText("Member management is not available in single-user mode."), + ).toBeInTheDocument(); + expect(accountsApi.listUsers).not.toHaveBeenCalled(); + }); +}); + describe("MembersPage under OIDC (read-only)", () => { beforeEach(() => { - // OIDC: accounts disabled → no password-based management. The list still - // renders (admins can see who's provisioned), but every management - // affordance is gone. + // OIDC: accounts disabled but login_url is non-null (IdP present). + // The list still renders (admins can see who's provisioned), but every + // management affordance is gone. mocks.accountsEnabled = false; + mocks.loginUrl = "/auth/login"; }); it("lists users but offers no management actions", async () => { diff --git a/web/src/pages/MembersPage.tsx b/web/src/pages/MembersPage.tsx index 6aab68be091..54f066a0a64 100644 --- a/web/src/pages/MembersPage.tsx +++ b/web/src/pages/MembersPage.tsx @@ -55,6 +55,14 @@ export function MembersPage() { // accounts mode — OIDC identities are owned by the IdP, so under OIDC // this page is a read-only user list (no action column, no modals). const manageable = info !== "loading" && info.accounts_enabled; + // Plain header/single-user mode: no auth endpoints exist. server_version + // distinguishes a live single-user server from a failed /v1/info probe + // (which uses the same accounts_enabled:false / login_url:null sentinel). + const isSingleUser = + info !== "loading" && + !info.accounts_enabled && + info.login_url === null && + info.server_version !== null; const [meIsAdmin, setMeIsAdmin] = useState(null); const [meId, setMeId] = useState(null); const [users, setUsers] = useState(null); @@ -82,12 +90,11 @@ export function MembersPage() { setUsers(list); }, []); - // Initial load: identity probe + members list. The identity probe - // gates the UI (non-admins see "no access"); the list is what we - // render the table from. Uses the mode-agnostic `/v1/me` identity - // (via resolveIdentity) rather than the accounts-only `/auth/me`, so - // the page also works under OIDC where `/auth/me` doesn't exist. + // Initial load: identity probe + members list. Skipped in single-user + // mode since no auth endpoints exist. isSingleUser is a stable boolean + // so it is safe as a dep without risking infinite re-renders. useEffect(() => { + if (isSingleUser) return; void (async () => { const userId = await resolveIdentity(); if (userId === null) { @@ -101,10 +108,23 @@ export function MembersPage() { setMeIsAdmin(isAdmin); if (isAdmin) await refresh(); })(); - }, [refresh]); + }, [refresh, isSingleUser]); + + if (isSingleUser) { + return ( +
+

Members

+

+ Member management is not available in single-user mode. +

+
+ ); + } // Pre-admin-check render: blank loading state. min-h-full so the // AppShell's outlet container governs height — we're a child view, + // not a full-page replacement. min-h-full so the + // AppShell's outlet container governs height — we're a child view, // not a full-page replacement. if (meIsAdmin === null) { return ( From dfde90dc2fd3f35897453d3aca425b2f6bd44ab7 Mon Sep 17 00:00:00 2001 From: Pat Sukprasert Date: Mon, 6 Jul 2026 14:52:39 +0700 Subject: [PATCH 014/546] ci(codex-parity): cache the sidecar binary and skip recompiles (#2016) The codex-parity sidecar source is frozen (one commit ever) with rev-pinned deps, yet every CI run recompiled all 73 crates (~3 min) because the old cache stored the target dir, which restored as a hit but still forced a full rebuild. Cache the built binary keyed on sidecar/** + rustc version instead, and skip `cargo build` on a hit. Warm runs drop from ~4 min to ~15s; the key self-invalidates when the source, Cargo.lock, or toolchain changes. Signed-off-by: Pat Sukprasert Co-authored-by: Claude --- .github/workflows/ci.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20685ce528f..c14f3cef14b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -229,11 +229,20 @@ jobs: with: toolchain: stable - - name: Cache Rust build + - name: Capture Rust version + id: rustc + run: echo "version=$(rustc --version | tr ' ' '-')" >> "$GITHUB_OUTPUT" + + # The sidecar source is frozen and its deps are rev-pinned, so the binary is + # a pure function of sidecar/** + the toolchain. Cache the built binary (not + # the 1.6 GB target dir) and skip the ~3 min compile below on a hit; the key + # self-invalidates when the source, Cargo.lock, or rustc changes. + - name: Cache parity sidecar binary + id: sidecar-cache uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4 with: - path: .tmp-codex-parity-target - key: codex-parity-sidecar-${{ runner.os }}-${{ hashFiles('tests/codex_parity/sidecar/Cargo.lock') }} + path: .tmp-codex-parity-target/debug/codex-parity-sidecar + key: codex-parity-bin-${{ runner.os }}-${{ steps.rustc.outputs.version }}-${{ hashFiles('tests/codex_parity/sidecar/**') }} - name: Set up Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -255,6 +264,7 @@ jobs: run: uv sync --locked --extra all --extra dev - name: Build parity sidecar + if: steps.sidecar-cache.outputs.cache-hit != 'true' run: | cargo build \ --manifest-path tests/codex_parity/sidecar/Cargo.toml \ From 6b48cb06fef7e316dd791218d87f3daaa46770ce Mon Sep 17 00:00:00 2001 From: Bryan Qiu <55931436+bbqiu@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:13:54 +0900 Subject: [PATCH 015/546] fix(web): prevent editor crash on blockquote with inline-only content (#2004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A markdown file containing a blockquote whose only content is a lone inline image (`> ![x](img)`) or an empty blockquote (`>`) crashed the markdown editor's panel. @tiptap/markdown (beta) parses those into a blockquote holding an inline `image` (or nothing), which violates the blockquote's `block+` content model. ProseMirror builds the initial document via `nodeFromJSON`, which does not validate content, so the invalid doc loads silently — then the first edit transaction that touches the blockquote calls `contentMatchAt` on it and throws ("Called contentMatchAt on a node with invalid content"). The viewer's React panel boundary caught the throw and rendered a crash instead of the file. Normalize GitHubAlertBlockquote's parsed children to valid `block+` content (wrap loose inline runs in a paragraph; guarantee at least one block), so the parsed document is always schema-valid. Round-trip stays byte-faithful (`> ![x](img)` re-serialises from the wrapping paragraph). Co-authored-by: Isaac --- ...downRichTextViewer.blockquoteCrash.test.ts | 116 ++++++++++++++++++ web/src/shell/TipTapGitHubAlert.test.ts | 38 +++++- web/src/shell/TipTapGitHubAlert.ts | 59 ++++++++- 3 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 web/src/shell/MarkdownRichTextViewer.blockquoteCrash.test.ts diff --git a/web/src/shell/MarkdownRichTextViewer.blockquoteCrash.test.ts b/web/src/shell/MarkdownRichTextViewer.blockquoteCrash.test.ts new file mode 100644 index 00000000000..e0b74447479 --- /dev/null +++ b/web/src/shell/MarkdownRichTextViewer.blockquoteCrash.test.ts @@ -0,0 +1,116 @@ +/** + * Regression test: opening a markdown file that contains a blockquote whose + * content is a lone inline image (`> ![x](img)`) or an empty blockquote (`>`) + * used to crash the whole editor panel. + * + * `@tiptap/markdown` (beta) parses those into a blockquote holding an inline + * `image` (or nothing), which violates the blockquote's `block+` content + * model. ProseMirror builds the initial document with `nodeFromJSON`, which + * does NOT validate content, so the invalid doc loads silently — then the + * first edit transaction that touches the blockquote calls `contentMatchAt` + * on it and throws ("Called contentMatchAt on a node with invalid content"). + * The viewer's React panel boundary caught the throw and rendered a crash + * instead of the file. + * + * These tests use the EXACT extension stack from MarkdownRichTextViewer so a + * regression re-introducing invalid blockquote content fails here. Only the + * image extension's HTTP boundary (fetchFileContent) is mocked. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import { Table, TableRow, TableCell, TableHeader } from "@tiptap/extension-table"; +import { TaskItem, TaskList } from "@tiptap/extension-list"; +import { Markdown } from "@tiptap/markdown"; +import { createWorkspaceImageExtension, ImageAwareLink } from "./TipTapWorkspaceImage"; +import { GitHubAlertBlockquote } from "./TipTapGitHubAlert"; +import { HtmlPassthrough } from "./TipTapHtmlPassthrough"; + +vi.mock("@/hooks/useFileContent", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchFileContent: vi.fn().mockResolvedValue(undefined) }; +}); + +// jsdom leaves these undefined; the image node view needs both. +const originalCreateObjectURL = URL.createObjectURL; +const originalRevokeObjectURL = URL.revokeObjectURL; +beforeEach(() => { + URL.createObjectURL = vi.fn(() => "blob:mock"); + URL.revokeObjectURL = vi.fn(); +}); + +let editor: Editor | null = null; +afterEach(() => { + editor?.destroy(); + editor = null; + vi.clearAllMocks(); + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; +}); + +/** Editor with the viewer's full extension stack. */ +function makeEditor(markdown: string): Editor { + return new Editor({ + element: document.createElement("div"), + extensions: [ + StarterKit.configure({ link: false, blockquote: false }), + TaskList, + TaskItem.configure({ nested: true }), + Table.configure({ resizable: true }), + TableRow, + TableCell, + TableHeader, + ImageAwareLink.configure({ openOnClick: false, autolink: false }), + GitHubAlertBlockquote, + HtmlPassthrough, + Markdown, + createWorkspaceImageExtension("conv_test", "README.md"), + ], + content: markdown, + contentType: "markdown", + }); +} + +/** + * Simulate the user clicking into the blockquote and typing — the edit + * transaction that tripped the crash. `insertText` at position 2 lands inside + * the (previously invalid) blockquote and runs the fit that called + * contentMatchAt. + */ +function typeInsideFirstNode(ed: Editor): void { + ed.view.dispatch(ed.state.tr.insertText("a", 2)); +} + +describe("blockquote crash", () => { + // Inputs that previously crashed the editor: a quote whose only content is an + // inline image, an empty quote, and an image-only quote followed by a block. + const CRASHERS = ["> ![diagram](diagram.png)", ">", "> ![a](1.png)\n\ntext after"]; + + it.each(CRASHERS)("parses %j into a schema-valid document", (md) => { + editor = makeEditor(md); + // Node.check() recurses the whole tree and throws on invalid content; + // before the fix this threw for the blockquote. + expect(() => editor!.state.doc.check()).not.toThrow(); + }); + + it.each(CRASHERS)("survives an edit transaction without crashing: %j", (md) => { + editor = makeEditor(md); + expect(() => typeInsideFirstNode(editor!)).not.toThrow(); + }); + + it("wraps a lone blockquote image in a paragraph (valid block+ content)", () => { + editor = makeEditor("> ![diagram](diagram.png)"); + const quote = editor.state.doc.child(0); + expect(quote.type.name).toBe("blockquote"); + expect(quote.child(0).type.name).toBe("paragraph"); + expect(quote.child(0).child(0).type.name).toBe("image"); + expect(quote.child(0).child(0).attrs.src).toBe("diagram.png"); + }); + + it("keeps a lone-image blockquote byte-faithful on round-trip", () => { + const md = "> ![diagram](diagram.png)"; + editor = makeEditor(md); + expect(editor.getMarkdown().trim()).toBe(md); + }); +}); diff --git a/web/src/shell/TipTapGitHubAlert.test.ts b/web/src/shell/TipTapGitHubAlert.test.ts index 81b3920c8f1..0c0e6353d2e 100644 --- a/web/src/shell/TipTapGitHubAlert.test.ts +++ b/web/src/shell/TipTapGitHubAlert.test.ts @@ -14,7 +14,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { Editor } from "@tiptap/core"; import { Markdown } from "@tiptap/markdown"; import StarterKit from "@tiptap/starter-kit"; -import { extractAlert, GitHubAlertBlockquote } from "./TipTapGitHubAlert"; +import { extractAlert, GitHubAlertBlockquote, toBlockContent } from "./TipTapGitHubAlert"; let editor: Editor | null = null; afterEach(() => { @@ -81,6 +81,42 @@ describe("extractAlert", () => { }); }); +// --------------------------------------------------------------------------- +// toBlockContent — keeps blockquote content valid (block+). +// --------------------------------------------------------------------------- + +describe("toBlockContent", () => { + it("wraps a lone inline image in a paragraph", () => { + const image = { type: "image", attrs: { src: "x.png" } }; + expect(toBlockContent([image])).toEqual([{ type: "paragraph", content: [image] }]); + }); + + it("returns one empty paragraph for empty content (empty blockquote)", () => { + expect(toBlockContent([])).toEqual([{ type: "paragraph" }]); + }); + + it("passes block children through untouched", () => { + const blocks = [ + { type: "paragraph", content: [{ type: "text", text: "a" }] }, + { type: "bulletList", content: [] }, + ]; + expect(toBlockContent(blocks)).toEqual(blocks); + }); + + it("coalesces a run of inline nodes into a single paragraph", () => { + const a = { type: "text", text: "a" }; + const img = { type: "image", attrs: { src: "x.png" } }; + const b = { type: "text", text: "b" }; + expect(toBlockContent([a, img, b])).toEqual([{ type: "paragraph", content: [a, img, b] }]); + }); + + it("splits inline runs around interleaved blocks", () => { + const img = { type: "image", attrs: { src: "x.png" } }; + const para = { type: "paragraph", content: [{ type: "text", text: "body" }] }; + expect(toBlockContent([img, para])).toEqual([{ type: "paragraph", content: [img] }, para]); + }); +}); + // --------------------------------------------------------------------------- // Editor parse + render + round-trip // --------------------------------------------------------------------------- diff --git a/web/src/shell/TipTapGitHubAlert.ts b/web/src/shell/TipTapGitHubAlert.ts index 37836ec3159..7b18189bb21 100644 --- a/web/src/shell/TipTapGitHubAlert.ts +++ b/web/src/shell/TipTapGitHubAlert.ts @@ -40,6 +40,56 @@ interface ExtractedAlert { children: JSONContent[]; } +/** + * Node types that are inline in the editor schema. Everything else parsed as a + * blockquote child is block-level. `image` counts because the workspace image + * extension configures it `inline: true`. + */ +const INLINE_TYPES = new Set(["text", "image", "hardBreak"]); + +/** + * Coerce parsed blockquote children into valid `block+` content. + * + * `@tiptap/markdown` (beta) can hand back bare inline nodes for a blockquote + * whose content is a lone inline atom — `> ![img](x)` yields a single image + * with no wrapping paragraph — and yields nothing at all for an empty quote + * (`>`). A blockquote's content expression is `block+`, so both shapes make + * the parsed document schema-invalid. Because ProseMirror builds the initial + * doc via `nodeFromJSON` (which does NOT validate content), the bad doc loads + * silently; the first edit transaction that touches the blockquote then calls + * `contentMatchAt` on it and throws ("Called contentMatchAt on a node with + * invalid content"), which the editor's React panel boundary catches — the + * whole file view crashes instead of rendering. + * + * Wrapping each run of loose inline nodes in a paragraph, and guaranteeing at + * least one block, keeps the document valid and byte-faithful on round-trip + * (the serialiser re-emits `> ![img](x)` from the wrapping paragraph). + * + * :param children: Parsed block/inline children of the blockquote token. + * :returns: Equivalent content that satisfies the blockquote's `block+` model. + */ +export function toBlockContent(children: JSONContent[]): JSONContent[] { + const blocks: JSONContent[] = []; + let inlineRun: JSONContent[] = []; + const flushInline = () => { + if (inlineRun.length > 0) { + blocks.push({ type: "paragraph", content: inlineRun }); + inlineRun = []; + } + }; + for (const child of children) { + if (child.type != null && INLINE_TYPES.has(child.type)) { + inlineRun.push(child); + } else { + flushInline(); + blocks.push(child); + } + } + flushInline(); + // block+ requires at least one block; an empty quote keeps one empty paragraph. + return blocks.length > 0 ? blocks : [{ type: "paragraph" }]; +} + /** * Detect and strip a GitHub alert marker from parsed blockquote children. * @@ -119,7 +169,14 @@ export const GitHubAlertBlockquote = Blockquote.extend({ parseMarkdown: (token, helpers) => { const parseBlockChildren = helpers.parseBlockChildren ?? helpers.parseChildren; const { alertType, children } = extractAlert(parseBlockChildren(token.tokens || [])); - return helpers.createNode("blockquote", alertType ? { alertType } : undefined, children); + // toBlockContent guards against @tiptap/markdown emitting inline-only or + // empty blockquote content, which would make the doc schema-invalid and + // crash the editor on the first edit. + return helpers.createNode( + "blockquote", + alertType ? { alertType } : undefined, + toBlockContent(children), + ); }, renderMarkdown: (node, h) => { if (!node.content) { From 50faf0200b22c35ad08b0472bf2131027e6ad01d Mon Sep 17 00:00:00 2001 From: Tomu Hirata Date: Mon, 6 Jul 2026 17:18:06 +0900 Subject: [PATCH 016/546] fix(policies): show page in single-user/header mode regardless of admin gate (#2017) In header/single-user mode the backend already skips admin enforcement, but the frontend was still waiting on an identity probe that never resolves an is_admin flag, leaving the page stuck on "Loading..." or showing the "no permission" message. Mirror the MembersPage pattern: derive isSingleUser from useServerInfo and bypass the admin gate entirely when true. Also adds unit tests for the single-user path. --- web/src/pages/PoliciesPage.test.tsx | 41 +++++++++++++++++++++++++++++ web/src/pages/PoliciesPage.tsx | 21 +++++++++++---- web/src/pages/SettingsPage.tsx | 5 ++-- 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/web/src/pages/PoliciesPage.test.tsx b/web/src/pages/PoliciesPage.test.tsx index 68554da130d..2a684ccfd71 100644 --- a/web/src/pages/PoliciesPage.test.tsx +++ b/web/src/pages/PoliciesPage.test.tsx @@ -15,6 +15,20 @@ import * as identity from "@/lib/identity"; import * as defaultPolicies from "@/hooks/useDefaultPolicies"; import * as policies from "@/hooks/usePolicies"; +const serverInfoMocks = vi.hoisted(() => ({ + accountsEnabled: true, + loginUrl: null as string | null, + serverVersion: "0.3.0.dev0" as string | null, +})); + +vi.mock("@/lib/CapabilitiesContext", () => ({ + useServerInfo: () => ({ + accounts_enabled: serverInfoMocks.accountsEnabled, + login_url: serverInfoMocks.loginUrl, + server_version: serverInfoMocks.serverVersion, + }), +})); + const addMutate = vi.fn(); const updateMutate = vi.fn(); const deleteMutate = vi.fn(); @@ -73,6 +87,9 @@ function renderPage() { } beforeEach(() => { + serverInfoMocks.accountsEnabled = true; + serverInfoMocks.loginUrl = null; + serverInfoMocks.serverVersion = "0.3.0.dev0"; vi.mocked(identity.resolveIdentity).mockResolvedValue("admin"); vi.mocked(identity.getCurrentIsAdmin).mockReturnValue(true); setPolicies([]); @@ -194,3 +211,27 @@ describe("PoliciesPage actions", () => { ); }); }); + +describe("PoliciesPage single-user mode", () => { + beforeEach(() => { + serverInfoMocks.accountsEnabled = false; + serverInfoMocks.loginUrl = null; + serverInfoMocks.serverVersion = "0.3.0.dev0"; + }); + + it("shows the full page without the admin gate (empty state)", async () => { + renderPage(); + expect(await screen.findByText(/No global policies configured/)).toBeInTheDocument(); + expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); + expect( + screen.queryByText("You don't have permission to manage global policies."), + ).not.toBeInTheDocument(); + }); + + it("shows policies list directly without probing identity", async () => { + setPolicies([policy({ id: "p1", name: "block_canada", enabled: true })]); + renderPage(); + expect(await screen.findByText("block_canada")).toBeInTheDocument(); + expect(identity.resolveIdentity).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/pages/PoliciesPage.tsx b/web/src/pages/PoliciesPage.tsx index 4f3d593d909..ed5e0308fbf 100644 --- a/web/src/pages/PoliciesPage.tsx +++ b/web/src/pages/PoliciesPage.tsx @@ -33,6 +33,7 @@ import { } from "@/hooks/useDefaultPolicies"; import { usePolicyRegistry, type PolicyRegistryEntry } from "@/hooks/usePolicies"; import { getCurrentIsAdmin, resolveIdentity } from "@/lib/identity"; +import { useServerInfo } from "@/lib/CapabilitiesContext"; import { coercePolicyParams } from "@/lib/policyParams"; // --------------------------------------------------------------------------- @@ -350,6 +351,15 @@ function AddDefaultPolicyDialog({ // --------------------------------------------------------------------------- export function PoliciesPage() { + const info = useServerInfo(); + // Plain header/single-user mode: no auth endpoints exist. server_version + // distinguishes a live single-user server from a failed /v1/info probe + // (which uses the same accounts_enabled:false / login_url:null sentinel). + const isSingleUser = + info !== "loading" && + !info.accounts_enabled && + info.login_url === null && + info.server_version !== null; const [meIsAdmin, setMeIsAdmin] = useState(null); const { data: policies = [], refetch } = useDefaultPolicies(); const { data: registry = [] } = usePolicyRegistry(); @@ -368,17 +378,18 @@ export function PoliciesPage() { }, [refetch]); // Admin probe via the mode-agnostic `/v1/me` identity (works under OIDC - // too, unlike the accounts-only `/auth/me`). resolveIdentity handles the - // login redirect when unauthenticated, so we only set the admin flag here. + // too, unlike the accounts-only `/auth/me`). Skipped in single-user mode + // because no auth endpoints exist and the backend skips admin enforcement. useEffect(() => { + if (isSingleUser) return; void (async () => { const userId = await resolveIdentity(); if (userId === null) return; setMeIsAdmin(getCurrentIsAdmin()); })(); - }, []); + }, [isSingleUser]); - if (meIsAdmin === null) { + if (!isSingleUser && meIsAdmin === null) { return (
Loading... @@ -386,7 +397,7 @@ export function PoliciesPage() { ); } - if (meIsAdmin === false) { + if (!isSingleUser && meIsAdmin === false) { return (

Global Policies

diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 442ebf606f4..4d4b086fc8c 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -64,9 +64,8 @@ import { type CliStatus, getCliStatus, isElectronShell, resetCliPath } from "@/l import { cn } from "@/lib/utils"; // Admin-only management surfaces, rendered as the Members / Policies settings -// sub-categories. Lazy-loaded so non-accounts deploys (where these sections -// never appear) don't pull them into the settings chunk — mirrors the -// route-level lazy loading these had when they were standalone pages. +// sub-categories. Visible to admins in all modes (accounts, OIDC, single-user). +// Lazy-loaded to keep the settings chunk small. const MembersPage = lazy(() => import("@/pages/MembersPage").then((m) => ({ default: m.MembersPage })), ); From 61f6b725b5dbd9ee792cf305fe7ca89aae94c255 Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:51:20 +0530 Subject: [PATCH 017/546] feat(openai-agents): stream reasoning deltas as ReasoningChunk (#1647) The openai-agents harness only handled response.output_text.delta, so a flagship harness forwarded no reasoning while claude/codex/antigravity all emit ReasoningChunk. Surface the Responses-API reasoning deltas (response.reasoning_summary_text.delta and response.reasoning_text.delta) as ReasoningChunk(event_type="reasoning_text") when non-empty, mirroring codex. The reasoning_item ghost stays in _NON_OUTPUT_ITEM_TYPES; only the streaming deltas are mirrored. Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com> --- omnigent/inner/openai_agents_sdk_executor.py | 10 +++++ .../inner/test_openai_agents_sdk_executor.py | 41 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/omnigent/inner/openai_agents_sdk_executor.py b/omnigent/inner/openai_agents_sdk_executor.py index 61d367c0c83..c7add2a4bff 100644 --- a/omnigent/inner/openai_agents_sdk_executor.py +++ b/omnigent/inner/openai_agents_sdk_executor.py @@ -37,6 +37,7 @@ ExecutorError, ExecutorEvent, Message, + ReasoningChunk, TextChunk, ToolCallComplete, ToolCallRequest, @@ -1594,6 +1595,15 @@ async def run_turn( if text: response_text += text yield TextChunk(text=text) + elif data.type in ( + "response.reasoning_summary_text.delta", + "response.reasoning_text.delta", + ): + reasoning_delta = data.delta + if reasoning_delta: + yield ReasoningChunk( + delta=reasoning_delta, event_type="reasoning_text" + ) elif event.type == "run_item_stream_event": item_event = cast(_RunItemEvent, event) diff --git a/tests/inner/test_openai_agents_sdk_executor.py b/tests/inner/test_openai_agents_sdk_executor.py index f72abc226e0..2cfc5052a35 100644 --- a/tests/inner/test_openai_agents_sdk_executor.py +++ b/tests/inner/test_openai_agents_sdk_executor.py @@ -21,6 +21,7 @@ from omnigent.inner.executor import ( ExecutorConfig, ExecutorError, + ReasoningChunk, TextChunk, ToolCallComplete, ToolCallRequest, @@ -82,6 +83,12 @@ class _FakeRawTextDelta: type: str = "response.output_text.delta" +@dataclass +class _FakeRawReasoningDelta: + delta: str + type: str = "response.reasoning_summary_text.delta" + + @dataclass class _FakeRawEvent: data: object @@ -503,6 +510,40 @@ async def _t(): _run(_t()) + def test_streams_reasoning_deltas(self): + async def _t(): + _FakeRunner.last_calls = [] + _FakeRunner.next_result = _FakeResult( + events=[ + _FakeRawEvent(_FakeRawReasoningDelta("thinking...")), + _FakeRawEvent(_FakeRawReasoningDelta("")), + _FakeRawEvent(_FakeRawTextDelta("Hello")), + ], + final_output="Hello", + ) + executor = OpenAIAgentsSDKExecutor(client=object()) + with patch( + "omnigent.inner.openai_agents_sdk_executor._ensure_agents_sdk", + return_value=_fake_agents_sdk(), + ): + events = [ + e + async for e in executor.run_turn( + [{"role": "user", "content": "hi", "session_id": "s1"}], + [], + "Be helpful.", + ) + ] + + reasoning = [e for e in events if isinstance(e, ReasoningChunk)] + self.assertEqual(len(reasoning), 1) + self.assertEqual(reasoning[0].delta, "thinking...") + self.assertEqual(reasoning[0].event_type, "reasoning_text") + text = [e for e in events if isinstance(e, TextChunk)] + self.assertEqual([t.text for t in text], ["Hello"]) + + _run(_t()) + def test_databricks_client_default_model_uses_databricks_model(self): async def _t(): _FakeRunner.last_calls = [] From 47aedd525fd8ffa2bf88efd3690337b78bef5520 Mon Sep 17 00:00:00 2001 From: Serena Ruan <82044803+serena-ruan@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:23:01 +0800 Subject: [PATCH 018/546] feat(web): client-side message queue with auto-flush on idle (#2008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): client-side message queue with auto-flush on idle Follow-ups typed while the agent is busy are now held in a client-side queue shown in a docked strip above the composer, instead of being POSTed immediately. The queue head flushes FIFO (one per turn) when the session goes idle. The flush is level-triggered — a store action (maybeFlushQueuedHead) re-evaluated on every status/queue change and on enqueue — so a message queued just after a turn ends, or after an SSE reconnect that carries no fresh idle transition, still sends instead of stranding. In-memory only (no persistence); a hard reload clears the queue. Per-message actions (delete / edit / steer / reorder) land in follow-ups. Co-authored-by: Isaac * fix(web): address queue review — per-conversation flush + edge cases Fixes from the PR review of the client-side message queue: - Blocking: flush the first message OF THE BOUND CONVERSATION, not the global array head. The queue is one flat array across conversations, so an undrained message from another conversation sat at index 0 and permanently blocked the bound conversation's messages (the same never-sends stranding the feature set out to fix). Regression test covers a foreign head in front of a local entry. - Pin the agent at enqueue time so a message flushes to the agent it was composed for even if the binding changed (e.g. a /model switch). - Hold the flush while the session is unreachable so it doesn't POST into a void, bypassing the reconnect dialog; drains once reachable again. - Clear a conversation's queue when it is deleted so entries bound to a dead session can't linger in memory. Each fix has a regression test verified to fail without the fix. Co-authored-by: Isaac * test(e2e_ui): rewrite cross-session routing test for client-side queue The client-side message queue changes the routing model the old test encoded: a follow-up typed while a session is busy is now held in that session's client-side queue instead of being POSTed on the module-level send chain. The old repro (hold msg1's POST → msg2 queues on the chain → switch sessions → chain unblocks → msg2 POSTs to origin) no longer applies, so the test timed out waiting for a msg2 POST that never fires. Rewritten to assert the same no-leak guarantee under the new model: a message queued in B (busy) is held client-side, and switching to idle session A must never flush it into A. The positive FIFO-flush-on-idle path is covered by the chatStore unit tests. Also fixes a real gap the rewrite surfaced: the flush effect now depends on boundAgentId, so a queue drains correctly when a conversation binds after navigation (the binding lands after the status settles). Ran locally against a built web UI: 1 passed. Co-authored-by: Isaac --- .../sessions/test_cross_session_routing.py | 126 +++++------- web/src/hooks/useConversations.ts | 4 + web/src/pages/ChatPage.composer.test.tsx | 35 ++++ web/src/pages/ChatPage.tsx | 45 ++++ web/src/pages/QueuedMessagesStrip.test.tsx | 32 +++ web/src/pages/QueuedMessagesStrip.tsx | 47 +++++ web/src/store/chatStore.test.ts | 193 ++++++++++++++++++ web/src/store/chatStore.ts | 116 +++++++++++ 8 files changed, 527 insertions(+), 71 deletions(-) create mode 100644 web/src/pages/QueuedMessagesStrip.test.tsx create mode 100644 web/src/pages/QueuedMessagesStrip.tsx diff --git a/tests/e2e_ui/sessions/test_cross_session_routing.py b/tests/e2e_ui/sessions/test_cross_session_routing.py index 425a8473f5d..5b732147b15 100644 --- a/tests/e2e_ui/sessions/test_cross_session_routing.py +++ b/tests/e2e_ui/sessions/test_cross_session_routing.py @@ -1,38 +1,31 @@ -"""E2E: a queued message is delivered to the session it was composed in. - -Guards the cross-session message-routing regression: - - Session B's runner is slow to come up, so B's first message POST - stays in flight. A second message typed into B queues behind it on - the SPA's module-level send chain. While the chain is stalled the - user switches to a different, already-running session A. The queued - second message MUST still be POSTed to B — the session it was - composed in — not to whatever session is active when the chain - finally unblocks. - -The bug routed the second message to A because the POST target was -re-resolved from the live ``conversationId`` only AFTER the chain -unblocked (``chatStore.ensureBoundSession``), by which point the user -had navigated to A. The fix pins the destination at submit time. - -Why async Playwright (not the sync ``page`` fixture the other e2e_ui -tests use): the repro requires B's first ``POST /events`` to stay -outstanding WHILE the test performs more UI actions (type + send the -second message, then switch sessions). Holding a request open across -interleaved page actions needs a deferred ``route.continue_`` / -``route.fulfill``, which only the async API supports — a sync route -handler blocks the single greenlet and would deadlock. It is a sync -test that drives the async flow in a fresh thread (see -:func:`_run_in_fresh_loop`) rather than a pytest-asyncio test: the -suite's many sync pytest-playwright tests leave the main-thread loop in -a state where pytest-asyncio can't start one. The session switch is -driven via the in-app sidebar link (client-side navigation), NOT -``page.goto`` — a full reload would reset the JS module state (the send -chain) and dissolve the very race under test. - -The route handler fulfills every ``/events`` POST itself, so no real -turn runs and the test needs no working LLM — it asserts purely on -where the SPA addressed each POST. +"""E2E: a message queued in one session never leaks into another. + +Guards the cross-session message-routing regression under the client-side +queue model: + + Session B is busy (its first message's POST is held open, so B stays + "streaming"), so a follow-up typed into B is held in B's client-side + queue — NOT POSTed. While it waits there, the user switches to a + different, idle session A. The queued message MUST stay bound to B: it + must never be POSTed to A (the now-active session). + +The queue is a per-conversation client-side buffer: ``maybeFlushQueuedHead`` +only flushes the head whose ``conversationId`` matches the bound session, so a +message composed in B cannot be addressed to A. This test pins that no-leak +guarantee. (The positive path — a queued head flushing to its own session on +idle, in FIFO order — is covered by the ``chatStore`` unit tests.) + +Why async Playwright (not the sync ``page`` fixture): the test inspects the +body of every ``/events`` POST via a route handler and asserts on which +session each was addressed to, across interleaved UI actions (send, switch +sessions, switch back). The route handler fulfills every POST itself, so no +real turn runs and the test needs no working LLM. It is a sync test driving +the async flow in a fresh thread (see :func:`_run_in_fresh_loop`) because the +suite's many sync pytest-playwright tests leave the main-thread loop in a +state where pytest-asyncio can't start one. Session switches are driven via +the in-app sidebar link (client-side navigation), NOT ``page.goto`` — a full +reload would reset the JS module state (the client-side queue lives in the +store) and dissolve the scenario under test. """ from __future__ import annotations @@ -99,10 +92,10 @@ async def _wait_until(predicate, *, timeout_s: float = 15.0) -> None: raise AssertionError(f"condition not met within {timeout_s:.0f}s") -def test_queued_send_routes_to_origin_session_not_active_session( +def test_queued_message_stays_bound_to_origin_session( seeded_session_pair: tuple[str, str, str], ) -> None: - """Second message queued in B reaches B after switching to A. + """A follow-up queued in B reaches B, never the active session A. Failure mode this catches: the queued ``_MSG2`` POST is addressed to session A (the now-active session) instead of session B (where it was @@ -116,7 +109,7 @@ async def _drive_cross_session_routing(base_url: str, session_a: str, session_b: """Async body of the cross-session routing test. See the test docstring. :param base_url: Spawned server base URL. - :param session_a: The running session the user switches to. + :param session_a: The idle session the user switches to. :param session_b: The session both messages are composed in. """ async with async_playwright() as pw: @@ -125,8 +118,8 @@ async def _drive_cross_session_routing(base_url: str, session_a: str, session_b: try: # Every (session_id, text) POSTed to a /events endpoint. event_posts: list[tuple[str, str]] = [] - # Released to let B's first POST (msg1) finally complete; until - # then it stays in flight and the send chain is stalled. + # Held so B's first POST stays in flight → the local send lifecycle + # keeps B "streaming", so the follow-up queues instead of sending. release_first = asyncio.Event() first_b_post_held = False @@ -139,8 +132,8 @@ async def handle_events(route: Route) -> None: body = request.post_data_json text = body["data"]["content"][0]["text"] event_posts.append((session_id, text)) - # Hold ONLY B's first message open so a second send queues - # behind it on the chain while we switch sessions. + # Hold ONLY B's first message open, so B stays busy (streaming) + # while the follow-up is typed and queued. if session_id == session_b and not first_b_post_held: first_b_post_held = True await release_first.wait() @@ -152,56 +145,47 @@ async def handle_events(route: Route) -> None: await page.route("**/v1/sessions/*/events", handle_events) - # Start in session B (initial load may reload freely — the send - # chain only matters once we begin sending). + # Start in session B. await page.goto(f"{base_url}/c/{session_b}") # Locate the textarea by its stable aria-label, not the - # placeholder — the placeholder text changes once a turn starts - # streaming ("Send a follow-up (queued)…"), which would break a - # placeholder-pinned locator for the second send. + # placeholder — the placeholder changes once a turn starts + # streaming ("Send a follow-up (queued)…"). composer = page.get_by_label("Message the agent") - # The "Ask the agent anything…" placeholder only renders in the - # idle/enabled state, so waiting on it confirms the chat surface - # is ready to accept input (not "Waiting for agents…"). await page.get_by_placeholder(_COMPOSER_PLACEHOLDER).wait_for( state="visible", timeout=15_000 ) send_button = page.get_by_role("button", name="Send", exact=True) - # msg1 → POST to B, held open by the route handler above. + # msg1 → POST to B, held open by the route handler → B stays busy. await composer.fill(_MSG1) await send_button.click() await _wait_until(lambda: first_b_post_held) - # msg2 → parked behind msg1 on the module-level send chain. The - # composer keeps a working Send button while it holds a draft. + # msg2 → typed while B is busy → held in B's client-side queue, + # shown in the docked strip, NOT POSTed. await composer.fill(_MSG2) await send_button.click() - - # No msg2 POST has fired yet — it is blocked on the stalled chain. - # (If it had, serialization is broken and the repro is invalid.) + await page.get_by_test_id("composer-queued-strip").wait_for( + state="visible", timeout=15_000 + ) assert all(text != _MSG2 for _, text in event_posts), ( - f"msg2 POSTed before the chain unblocked: {event_posts}" + f"msg2 was POSTed while queued (should be held client-side): {event_posts}" ) - # Switch to the running session A via the sidebar link — a - # client-side navigation that preserves the JS send chain (a full - # page reload would reset it and dissolve the race). + # Switch to the idle session A via the sidebar link — a client-side + # navigation that preserves the store (a full reload would drop the + # queue). msg2 must NOT flush into A. await page.locator(f'a[href="/c/{session_a}"]').click() await page.wait_for_url(re.compile(rf"/c/{re.escape(session_a)}")) - - # Release B's first POST → the chain unblocks and msg2 is sent. + # Release B's first POST so the send lifecycle can settle; the queued + # msg2 is bound to B, so switching to A must not flush it there. release_first.set() - - # msg2 must be delivered to B (origin), never to A (now active). - await _wait_until(lambda: any(text == _MSG2 for _, text in event_posts)) - msg2_targets = [sid for sid, text in event_posts if text == _MSG2] - assert msg2_targets == [session_b], ( - f"msg2 was composed in session B ({session_b}) and must be " - f"delivered there, but POST targets were {msg2_targets}. A " - f"target of {session_a} (session A) is the cross-session leak." + # Give any errant flush a chance to fire before asserting the + # negative (the queue head is bound to B, so nothing should POST). + await asyncio.sleep(1.0) + assert all(text != _MSG2 for _, text in event_posts), ( + f"msg2 leaked out of B while A was active: {event_posts}" ) - # And nothing leaked into the running session A at all. assert all(sid != session_a for sid, _ in event_posts), ( f"a message leaked into the active session A: {event_posts}" ) diff --git a/web/src/hooks/useConversations.ts b/web/src/hooks/useConversations.ts index 94d5fe49c5c..5b243179227 100644 --- a/web/src/hooks/useConversations.ts +++ b/web/src/hooks/useConversations.ts @@ -34,6 +34,7 @@ import { type SessionListWireItem, } from "@/lib/sessionListCache"; import { stopSession } from "@/lib/sessionsApi"; +import { useChatStore } from "@/store/chatStore"; import type { Session } from "@/lib/types"; import { useSessionUpdatesConnected } from "./useSessionUpdatesConnected"; import { markConversationSeen } from "./useUnseenConversations"; @@ -286,6 +287,9 @@ export async function deleteConversation(id: string, deleteBranch = false): Prom method: "DELETE", }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + // Drop any client-side queued messages for the now-deleted session; bound to + // a dead conversation, they could never flush. + useChatStore.getState().clearQueuedMessages(id); } /** diff --git a/web/src/pages/ChatPage.composer.test.tsx b/web/src/pages/ChatPage.composer.test.tsx index 4c04c73017f..678807a354b 100644 --- a/web/src/pages/ChatPage.composer.test.tsx +++ b/web/src/pages/ChatPage.composer.test.tsx @@ -1188,3 +1188,38 @@ describe("Composer sub-agent tray", () => { expect(screen.getByText(/Chatting with sub-agent/)).toBeTruthy(); }); }); + +describe("Composer — queued-message flush gating", () => { + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + useChatStore.setState({ queuedMessages: [] }); + }); + + // Regression (Polly review 3a): the level-triggered flush effect must NOT + // drain the queue while the session is unreachable — flushing would POST + // into a void, bypassing onSend's reconnect dialog. It must drain once + // reachable again. + it("holds the queue while unreachable, then flushes when reachable", async () => { + const sendSpy = vi.fn().mockResolvedValue(undefined); + useChatStore.setState({ + conversationId: "conv_test", + boundAgentId: "agent_xyz", + status: "idle", + sessionStatus: "idle", + send: sendSpy, + queuedMessages: [{ queueId: "q_1", text: "held", conversationId: "conv_test" }], + }); + + // Idle + a waiting head, but unreachable → the effect must not flush. + const { rerender } = render(); + await waitFor(() => expect(sendSpy).not.toHaveBeenCalled()); + expect(useChatStore.getState().queuedMessages).toHaveLength(1); + + // Becomes reachable → the effect re-fires and drains the head. + rerender(); + await waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(1)); + expect(sendSpy.mock.calls[0]!.slice(0, 2)).toEqual(["held", "agent_xyz"]); + expect(useChatStore.getState().queuedMessages).toHaveLength(0); + }); +}); diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 8c9ebd7081b..8340477fddf 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -60,6 +60,7 @@ import { parseSystemMessage } from "@/lib/systemMessage"; import { Button } from "@/components/ui/button"; import { OttoIcon } from "@/components/icons/OttoIcon"; import { cn } from "@/lib/utils"; +import { QueuedMessagesStrip } from "@/pages/QueuedMessagesStrip"; import { validateAttachments } from "@/lib/attachments"; import { useSurfaceFrontmost } from "@/hooks/useNativeServerSwitcher"; import { @@ -970,6 +971,18 @@ export function ChatPage() { setReconnectDialogOpen(true); return; } + // Busy → hold the message in the client-side queue (shown in the strip + // above the composer) instead of POSTing now. The queue head is flushed + // FIFO when the session next goes idle. Only queue for an already-bound + // conversation; a brand-new chat (no conversationId) always sends so the + // session gets created. + const chat = useChatStore.getState(); + const isBusy = + chat.status === "streaming" || ["running", "waiting"].includes(chat.sessionStatus); + if (isBusy && chat.conversationId !== null) { + chat.enqueueMessage(text, files); + return; + } void useChatStore.getState().send(text, agentId, files, { onConversationCreated: (newId) => { // Eager URL update: the moment the server tells us this @@ -3635,6 +3648,31 @@ export function Composer({ // module scope (not useRef) because Composer unmounts during the // loading gate between session switches. const conversationId = useChatStore((s) => s.conversationId); + const queuedMessages = useChatStore((s) => s.queuedMessages); + const sessionStatus = useChatStore((s) => s.sessionStatus); + const flushBoundAgentId = useChatStore((s) => s.boundAgentId); + const maybeFlushQueuedHead = useChatStore((s) => s.maybeFlushQueuedHead); + // Drain the queue whenever idle with a waiting head — level-triggered so a + // message queued right after the turn ended (or after an SSE reconnect that + // carries no fresh idle transition) still sends instead of stranding. Hold + // while unreachable: flushing would POST into a void (no executor / no host + // to wake), bypassing onSend's reconnect dialog. The next reachable render + // re-fires this effect and drains. `boundAgentId` is a dep because the flush + // needs it: on navigate-back the binding lands after the status settles, and + // without this dep the effect wouldn't re-fire to drain a queue for the + // returned-to conversation. + useEffect(() => { + if (unreachable) return; + maybeFlushQueuedHead(); + }, [ + status, + sessionStatus, + queuedMessages, + conversationId, + flushBoundAgentId, + unreachable, + maybeFlushQueuedHead, + ]); const { goal: codexGoal, setGoal: setCodexGoal } = useCodexGoalState( conversationId, showCodexGoal, @@ -4322,6 +4360,13 @@ export function Composer({ } }} /> + {/* Queued messages — peeks above the card like the sub-agent tray. + Lists follow-ups held while the agent is busy; drains FIFO on idle. + Scope to this conversation so a queue held elsewhere never leaks in. */} + m.conversationId === conversationId)} + widthClassName={CHAT_COLUMN_WIDTH} + /> {/* Sub-agent context tray — peeks above the card; reserves its own layout slot so the card sits below it (see SubagentComposerTray). Truthy (not just non-null) so an empty label never peeks a diff --git a/web/src/pages/QueuedMessagesStrip.test.tsx b/web/src/pages/QueuedMessagesStrip.test.tsx new file mode 100644 index 00000000000..549aa988dd0 --- /dev/null +++ b/web/src/pages/QueuedMessagesStrip.test.tsx @@ -0,0 +1,32 @@ +// Tests for QueuedMessagesStrip — the presentational strip above the composer +// listing messages queued while the agent is busy. It's a pure prop-driven +// component (no store access), so we exercise it with plain props. + +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { QueuedMessage } from "@/store/chatStore"; +import { QueuedMessagesStrip } from "./QueuedMessagesStrip"; + +const msg = (queueId: string, text: string): QueuedMessage => ({ + queueId, + text, + conversationId: "conv_abc", +}); + +afterEach(cleanup); + +describe("QueuedMessagesStrip", () => { + it("renders nothing when the queue is empty", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders one row per queued message, in order", () => { + render(); + expect(screen.getByText("first")).toBeInTheDocument(); + expect(screen.getByText("second")).toBeInTheDocument(); + // Each row carries the "Queued" label. + expect(screen.getAllByText("Queued")).toHaveLength(2); + }); +}); diff --git a/web/src/pages/QueuedMessagesStrip.tsx b/web/src/pages/QueuedMessagesStrip.tsx new file mode 100644 index 00000000000..f7e84e292d2 --- /dev/null +++ b/web/src/pages/QueuedMessagesStrip.tsx @@ -0,0 +1,47 @@ +import { ClockIcon } from "lucide-react"; + +import type { QueuedMessage } from "@/store/chatStore"; +import { cn } from "@/lib/utils"; + +interface QueuedMessagesStripProps { + /** Messages waiting to be flushed, in FIFO order (head first). */ + messages: QueuedMessage[]; + /** Column-width class so the strip lines up with the composer card. */ + widthClassName?: string; +} + +/** + * Docked strip above the composer listing messages queued while the agent is + * busy. Peeks above the composer card (`-mb-4` + bottom padding), mirroring + * `SubagentComposerTray`. Renders nothing when the queue is empty. + * + * Read-only in this iteration — per-row actions (delete / edit / steer / + * reorder) land in later changes. + */ +export function QueuedMessagesStrip({ messages, widthClassName }: QueuedMessagesStripProps) { + if (messages.length === 0) return null; + return ( +
+ {/* Cap the list height and scroll when the queue is long, so a big + backlog never pushes the composer off-screen. ~5 rows tall. */} +
+ {messages.map((message) => ( +
+
+ ))} +
+
+ ); +} diff --git a/web/src/store/chatStore.test.ts b/web/src/store/chatStore.test.ts index 7c857196bc2..aa21478fa10 100644 --- a/web/src/store/chatStore.test.ts +++ b/web/src/store/chatStore.test.ts @@ -350,6 +350,7 @@ beforeEach(() => { conversationId: null, blocks: [], pendingUserMessages: [], + queuedMessages: [], // Reset the per-conversation stash too, or a stash entry left by one // navigation test leaks into the next (the entry survives switchTo by // design — that's the whole point — so beforeEach must clear it). @@ -7452,3 +7453,195 @@ describe("chatStore — policy deny renders once", () => { ).toBe(1); }); }); + +describe("chatStore — client-side message queue", () => { + it("enqueueMessage holds the message client-side without POSTing while busy", () => { + const sendSpy = vi.fn().mockResolvedValue(undefined); + // Busy: the enqueue-time flush must NOT fire, so both messages stay queued. + useChatStore.setState({ + conversationId: "conv_abc", + boundAgentId: "agent_xyz", + status: "streaming", + sessionStatus: "running", + send: sendSpy, + }); + useChatStore.getState().enqueueMessage("first", undefined); + useChatStore.getState().enqueueMessage("second", undefined); + + const state = useChatStore.getState(); + expect(state.queuedMessages.map((m) => m.text)).toEqual(["first", "second"]); + expect(state.queuedMessages.every((m) => m.conversationId === "conv_abc")).toBe(true); + // Nothing is sent to the server while the agent is busy. + expect(sendSpy).not.toHaveBeenCalled(); + }); + + it("enqueueMessage is a no-op with no bound conversation", () => { + useChatStore.setState({ conversationId: null }); + useChatStore.getState().enqueueMessage("orphan", undefined); + expect(useChatStore.getState().queuedMessages).toEqual([]); + }); + + it("maybeFlushQueuedHead flushes the head FIFO, one per idle", async () => { + // Spy on send so the flush's contract (which head, in what order) is + // asserted without depending on the full bind→/events network path. + const sendSpy = vi.fn().mockResolvedValue(undefined); + useChatStore.setState({ + conversationId: "conv_abc", + boundAgentId: "agent_xyz", + status: "idle", + sessionStatus: "idle", + send: sendSpy, + queuedMessages: [ + { queueId: "q_1", text: "first", conversationId: "conv_abc" }, + { queueId: "q_2", text: "second", conversationId: "conv_abc" }, + ], + }); + + // Idle + head present → head ("first") is removed and sent; tail remains. + useChatStore.getState().maybeFlushQueuedHead(); + await tick(); + expect(useChatStore.getState().queuedMessages.map((m) => m.text)).toEqual(["second"]); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy.mock.calls[0]!.slice(0, 2)).toEqual(["first", "agent_xyz"]); + + // Next idle → the next message flushes; queue empties. + useChatStore.getState().maybeFlushQueuedHead(); + await tick(); + expect(useChatStore.getState().queuedMessages).toEqual([]); + expect(sendSpy).toHaveBeenCalledTimes(2); + expect(sendSpy.mock.calls[1]!.slice(0, 2)).toEqual(["second", "agent_xyz"]); + }); + + it("does not flush a queue owned by a different conversation", () => { + const sendSpy = vi.fn().mockResolvedValue(undefined); + useChatStore.setState({ + conversationId: "conv_abc", + boundAgentId: "agent_xyz", + status: "idle", + sessionStatus: "idle", + send: sendSpy, + queuedMessages: [{ queueId: "q_1", text: "elsewhere", conversationId: "conv_other" }], + }); + useChatStore.getState().maybeFlushQueuedHead(); + // The only queued message belongs to conv_other, so nothing flushes here. + expect(useChatStore.getState().queuedMessages.map((m) => m.text)).toEqual(["elsewhere"]); + expect(sendSpy).not.toHaveBeenCalled(); + }); + + // Regression: the queue is one flat array across conversations. An undrained + // message from another conversation must NOT block the bound conversation's + // messages — flush the first message OF THE BOUND CONVERSATION, not the global + // array head. A head-only guard stranded the local messages forever. + it("flushes past a foreign head to reach the bound conversation's message", async () => { + const sendSpy = vi.fn().mockResolvedValue(undefined); + useChatStore.setState({ + conversationId: "conv_abc", + boundAgentId: "agent_xyz", + status: "idle", + sessionStatus: "idle", + send: sendSpy, + queuedMessages: [ + // Foreign message sits at index 0 (queued in conv_other, never drained). + { queueId: "q_1", text: "foreign", conversationId: "conv_other" }, + { queueId: "q_2", text: "mine-1", conversationId: "conv_abc" }, + { queueId: "q_3", text: "mine-2", conversationId: "conv_abc" }, + ], + }); + + useChatStore.getState().maybeFlushQueuedHead(); + await tick(); + // The bound conversation's FIRST message flushes; the foreign head is left + // untouched, and the bound conversation's FIFO order is preserved. + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy.mock.calls[0]!.slice(0, 2)).toEqual(["mine-1", "agent_xyz"]); + expect(useChatStore.getState().queuedMessages.map((m) => m.text)).toEqual([ + "foreign", + "mine-2", + ]); + }); + + // Regression: a message flushes to the agent it was COMPOSED for, even if the + // binding changed (e.g. a /model switch) between enqueue and drain. + it("flushes to the agent captured at enqueue time, not the current binding", async () => { + const sendSpy = vi.fn().mockResolvedValue(undefined); + // Bound to agent_one when queuing. + useChatStore.setState({ + conversationId: "conv_abc", + boundAgentId: "agent_one", + status: "streaming", + sessionStatus: "running", + send: sendSpy, + }); + useChatStore.getState().enqueueMessage("composed for one", undefined); + expect(useChatStore.getState().queuedMessages[0]!.agentId).toBe("agent_one"); + + // Binding changes to agent_two, then the session idles and flushes. + useChatStore.setState({ boundAgentId: "agent_two", status: "idle", sessionStatus: "idle" }); + useChatStore.getState().maybeFlushQueuedHead(); + await tick(); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy.mock.calls[0]!.slice(0, 2)).toEqual(["composed for one", "agent_one"]); + }); + + it("clearQueuedMessages drops only the given conversation's messages", () => { + useChatStore.setState({ + conversationId: "conv_abc", + queuedMessages: [ + { queueId: "q_1", text: "a1", conversationId: "conv_abc" }, + { queueId: "q_2", text: "b1", conversationId: "conv_other" }, + { queueId: "q_3", text: "a2", conversationId: "conv_abc" }, + ], + }); + useChatStore.getState().clearQueuedMessages("conv_abc"); + // Only conv_other's message survives. + expect(useChatStore.getState().queuedMessages.map((m) => m.text)).toEqual(["b1"]); + }); + + it("does not flush while busy (streaming or running/waiting)", () => { + const sendSpy = vi.fn().mockResolvedValue(undefined); + const base = { + conversationId: "conv_abc", + boundAgentId: "agent_xyz", + send: sendSpy, + queuedMessages: [{ queueId: "q_1", text: "wait", conversationId: "conv_abc" }], + }; + + // Local send still in flight. + useChatStore.setState({ ...base, status: "streaming", sessionStatus: "idle" }); + useChatStore.getState().maybeFlushQueuedHead(); + // Server-side turn still running. + useChatStore.setState({ ...base, status: "idle", sessionStatus: "running" }); + useChatStore.getState().maybeFlushQueuedHead(); + // Draining background work. + useChatStore.setState({ ...base, status: "idle", sessionStatus: "waiting" }); + useChatStore.getState().maybeFlushQueuedHead(); + + expect(sendSpy).not.toHaveBeenCalled(); + expect(useChatStore.getState().queuedMessages.map((m) => m.text)).toEqual(["wait"]); + }); + + // Regression: a message queued while the agent was ALREADY idle (the send + // routed to the queue on a stale busy read, but no future idle edge follows) + // must still flush. Edge-triggering on the idle SSE event stranded it — the + // bug this level-triggered design fixes. + it("flushes a message queued while already idle (no future idle edge)", async () => { + const sendSpy = vi.fn().mockResolvedValue(undefined); + useChatStore.setState({ + conversationId: "conv_abc", + boundAgentId: "agent_xyz", + status: "idle", + sessionStatus: "idle", + send: sendSpy, + queuedMessages: [], + }); + + // Enqueue while idle — enqueueMessage triggers a flush itself, so no + // session_status event is needed to unstick it. + useChatStore.getState().enqueueMessage("stranded?", undefined); + await tick(); + + expect(useChatStore.getState().queuedMessages).toEqual([]); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy.mock.calls[0]!.slice(0, 2)).toEqual(["stranded?", "agent_xyz"]); + }); +}); diff --git a/web/src/store/chatStore.ts b/web/src/store/chatStore.ts index 0206a987ca9..8b454e9c276 100644 --- a/web/src/store/chatStore.ts +++ b/web/src/store/chatStore.ts @@ -143,6 +143,33 @@ export interface PendingUserMessage { posted?: boolean; } +/** + * A message the user submitted while the agent was busy. It is held + * client-side — NOT yet POSTed — and shown in the docked queue strip above + * the composer until the agent goes idle, when the head is flushed FIFO (one + * per turn). This is the opposite of {@link PendingUserMessage}, which is + * already POSTed and renders as an optimistic bubble in the transcript. + * + * In-memory only: a hard reload clears the queue, so `files` can be held + * directly (no serialization concern). + */ +export interface QueuedMessage { + /** Client-only id, e.g. `q_1`. */ + queueId: string; + /** Fully-assembled message text (mentions/quotes already applied). */ + text: string; + /** Attachments to send with the message. */ + files?: File[]; + /** Owning conversation, so a switch/idle only flushes its own queue. */ + conversationId: string; + /** + * Agent bound when the message was queued, so it flushes to the agent it was + * composed for even if the binding changed meanwhile (e.g. a `/model` switch). + * Falls back to the current `boundAgentId` when absent. + */ + agentId?: string; +} + /** * A conversation's in-flight optimistic bubbles, stashed so they survive * in-app navigation. See {@link ChatState.pendingByConversation}. @@ -222,6 +249,12 @@ export interface ChatState { blocks: AnyBlock[]; /** User messages POSTed but not yet acked via session.input.consumed. */ pendingUserMessages: PendingUserMessage[]; + /** + * Messages submitted while the agent is busy, held client-side (not yet + * POSTed) and shown in the composer's queue strip. The head is flushed + * FIFO — one per turn — when the session goes idle. In-memory only. + */ + queuedMessages: QueuedMessage[]; /** * In-flight optimistic bubbles stashed per conversation so they survive * in-app navigation (`switchTo`), keyed by conversation id. @@ -494,6 +527,26 @@ export interface ChatState { // Actions. send: (text: string, agentId: string, files?: File[], opts?: SendOptions) => Promise; + /** + * Queue a message client-side instead of POSTing it now, for a send made + * while the agent is busy. The head is flushed automatically (FIFO, one per + * turn) when the session next goes idle — see the `session_status` handler. + */ + enqueueMessage: (text: string, files?: File[]) => void; + /** + * Drop all queued messages for a conversation. Called when a conversation is + * deleted so its queue can't linger in memory (it would never flush — you + * can't be bound to a deleted session). + */ + clearQueuedMessages: (conversationId: string) => void; + /** + * Flush the queue head if the session is idle and ready. Level-triggered: + * safe to call on any state change (idempotent — no-ops when busy, when the + * queue is empty, or when the head isn't for the bound conversation). POSTing + * the head starts a turn → the session goes busy → this no-ops until the next + * idle, so the queue drains FIFO one per turn. + */ + maybeFlushQueuedHead: () => void; /** * Invoke a skill by posting a ``slash_command`` event — the same wire * shape the REPL sends. The server resolves the skill, persists the @@ -573,6 +626,7 @@ export interface ChatState { let queryClient: QueryClient | null = null; let pendingSeq = 0; +let queueSeq = 0; // Tail of the send chain. Each `send` waits on the previous send's network // work before issuing its own POST, so rapid-fire messages reach the server // in submission order. Concurrent `fetch` POSTs have no ordering guarantee, @@ -735,6 +789,7 @@ export const useChatStore = create((set, get) => ({ redirectToConversationId: null, blocks: [], pendingUserMessages: [], + queuedMessages: [], pendingByConversation: {}, activeResponse: null, interruptedResponseIds: [], @@ -774,6 +829,62 @@ export const useChatStore = create((set, get) => ({ abortController: null, historyGeneration: 0, + enqueueMessage: (text, files) => { + const { conversationId, boundAgentId } = get(); + if (conversationId === null) return; + queueSeq += 1; + const queueId = `q_${queueSeq}`; + set((s) => ({ + queuedMessages: [ + ...s.queuedMessages, + { + queueId, + text, + conversationId, + ...(boundAgentId !== null ? { agentId: boundAgentId } : {}), + ...(files && files.length > 0 ? { files } : {}), + }, + ], + })); + // A message queued while the agent is idle (a race where the send routed + // to the queue but the turn had already ended) would otherwise wait for an + // idle edge that never comes — flush now. + get().maybeFlushQueuedHead(); + }, + + clearQueuedMessages: (conversationId) => { + set((s) => { + if (!s.queuedMessages.some((m) => m.conversationId === conversationId)) return {}; + return { + queuedMessages: s.queuedMessages.filter((m) => m.conversationId !== conversationId), + }; + }); + }, + + maybeFlushQueuedHead: () => { + const s = get(); + // Only when fully idle: both the local send lifecycle AND the server-side + // session status. No agent → nothing to send to. + if ( + s.conversationId === null || + s.boundAgentId === null || + s.status === "streaming" || + s.sessionStatus === "running" || + s.sessionStatus === "waiting" + ) { + return; + } + // Flush the FIRST message OF THE BOUND CONVERSATION (FIFO within it), not + // the global array head. The queue is one flat array across conversations, + // so an undrained message from another conversation can sit at index 0; a + // head-only guard would let it block this conversation's messages forever. + const head = s.queuedMessages.find((m) => m.conversationId === s.conversationId); + if (head === undefined) return; + // Remove it BEFORE the POST so a re-entrant flush can't double-send. + set({ queuedMessages: s.queuedMessages.filter((m) => m.queueId !== head.queueId) }); + void s.send(head.text, head.agentId ?? s.boundAgentId, head.files); + }, + send: async (text, agentId, files, opts) => { if (!agentId) { throw new Error("chatStore.send: no agentId"); @@ -3741,6 +3852,11 @@ export function handleSessionEvent(event: StreamEvent): void { }); } } + // Draining the queue is level-triggered (a React effect calls + // maybeFlushQueuedHead on every status/queue change), NOT edge-triggered + // here — a single "flush on the idle event" is fragile: a message queued + // just after the idle edge, or an SSE reconnect that replays state + // without a fresh transition, would strand the queue forever. return; } case "session_input_consumed": From a4d0f2789eb4c67f3317e7cbfac55eb55a0fdbea Mon Sep 17 00:00:00 2001 From: Sunny Yang Date: Mon, 6 Jul 2026 02:37:12 -0600 Subject: [PATCH 019/546] feat(web): render .ipynb notebooks as read-only previews in the file viewer (#1848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): render .ipynb notebooks as read-only previews in the file viewer Notebooks currently open as raw JSON in Monaco, which is unusable for reviewing notebook-heavy work. Add a NotebookPreview that renders cells in order — markdown through the existing react-markdown/GFM pipeline, code through the shared Shiki CodeBlockContent with execution counts, and outputs from each cell's mime bundle — with zero new dependencies. Output handling is safety-first: text/html is never injected into the DOM (rich outputs like pandas DataFrames fall back to their text/plain repr with a note), only raster image mimes render as inert data-URIs (SVG excluded), and stream/error outputs go through the same ansi-to-react the terminal uses, so colored tracebacks render properly. Notebooks join markdown/html as previewable: preview is the default view, with the raw-JSON Monaco source view kept as the escape hatch. Invalid or truncated notebook JSON shows a parse-error state pointing at the source view. * fix(web): make notebook preview robust to real-world .ipynb quirks The NotebookPreview handled clean, spec-perfect notebooks but broke on files exported by real kernels: - Recover from raw C0 control chars (unescaped ANSI in tracebacks/output) that strict JSON.parse rejects with "Bad control character in string literal" — retry once after escaping stray control chars inside string literals. - Strip all whitespace (not just \n) from base64 image payloads; a data-URI containing CRLF or spaces is rejected by the browser as a broken image. - Validate base64 before building the data-URI (charset + length % 4); on a corrupt payload show a "could not be decoded" note and fall back to the text/plain repr instead of an ERR_INVALID_URL broken image. - Let long unbreakable traceback runs (separator rules, paths) scroll within the cell (overflow-x-auto + overflow-wrap:anywhere) instead of widening the whole preview. Adds regression tests for each case. Co-authored-by: Isaac --------- Co-authored-by: Serena Ruan --- tests/e2e_ui/files/test_notebook_preview.py | 204 ++++++++++++++ web/src/shell/CodeViewer.test.tsx | 35 ++- web/src/shell/CodeViewer.tsx | 10 +- web/src/shell/FileViewer.tsx | 19 +- web/src/shell/NotebookPreview.test.tsx | 166 ++++++++++++ web/src/shell/NotebookPreview.tsx | 250 ++++++++++++++++++ web/src/shell/__fixtures__/01_typical.ipynb | 90 +++++++ web/src/shell/__fixtures__/02_edgecases.ipynb | 107 ++++++++ web/src/shell/__fixtures__/03_broken.ipynb | 1 + web/src/shell/codeViewerHelpers.test.ts | 17 ++ web/src/shell/codeViewerHelpers.ts | 6 + 11 files changed, 894 insertions(+), 11 deletions(-) create mode 100644 tests/e2e_ui/files/test_notebook_preview.py create mode 100644 web/src/shell/NotebookPreview.test.tsx create mode 100644 web/src/shell/NotebookPreview.tsx create mode 100644 web/src/shell/__fixtures__/01_typical.ipynb create mode 100644 web/src/shell/__fixtures__/02_edgecases.ipynb create mode 100644 web/src/shell/__fixtures__/03_broken.ipynb diff --git a/tests/e2e_ui/files/test_notebook_preview.py b/tests/e2e_ui/files/test_notebook_preview.py new file mode 100644 index 00000000000..e10f992c908 --- /dev/null +++ b/tests/e2e_ui/files/test_notebook_preview.py @@ -0,0 +1,204 @@ +"""E2E: read-only .ipynb notebook preview and source toggle in the FileViewer. + +Counterpart to ``test_markdown_rich_rendering.py`` for notebooks: a seeded +``.ipynb`` must open as a rendered notebook (markdown cells as HTML, code +cells with execution counts, mime-bundle outputs) rather than raw JSON, with +the security posture pinned — ``text/html`` outputs are never injected into +the DOM (a hostile ``' + "
injected
" + ], + "text/plain": [" amount\ncount 1523.0"], + }, + } + ], + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "source": ["df.plot()\n"], + "outputs": [ + { + "output_type": "display_data", + "metadata": {}, + "data": { + "image/png": _PNG_B64, + "text/plain": ["
"], + }, + } + ], + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "source": ["1/0\n"], + "outputs": [ + { + "output_type": "error", + "ename": "ZeroDivisionError", + "evalue": "division by zero", + "traceback": ["\x1b[0;31mZeroDivisionError\x1b[0m: division by zero"], + } + ], + }, + ], + } +) + + +# --------------------------------------------------------------------------- +# Fixture +# --------------------------------------------------------------------------- + + +@pytest.fixture +def seeded_notebook_session( + seeded_session: tuple[str, str], +) -> Iterator[tuple[str, str, str]]: + """Seed the notebook file and yield (base_url, session_id, path). + + :param seeded_session: Runner-bound (base_url, session_id) pair. + :returns: ``(base_url, session_id, file_path)`` for the test body. + """ + base_url, session_id = seeded_session + file_url = ( + f"{base_url}/v1/sessions/{session_id}" + f"/resources/environments/default/filesystem/{_NOTEBOOK_FILE_PATH}" + ) + resp = httpx.put( + file_url, + json={"content": _NOTEBOOK_CONTENT, "encoding": "utf-8"}, + timeout=10.0, + ) + resp.raise_for_status() + yield (base_url, session_id, _NOTEBOOK_FILE_PATH) + + +# --------------------------------------------------------------------------- +# Test +# --------------------------------------------------------------------------- + + +def test_notebook_renders_preview_and_source_toggle( + page: Page, + seeded_notebook_session: tuple[str, str, str], +) -> None: + """Notebook cells render as a preview; source toggle shows the raw JSON.""" + base_url, session_id, _file_path = seeded_notebook_session + page.goto(f"{base_url}/c/{session_id}?view=explore") + + file_button = page.get_by_role( + "button", name=re.compile(rf"^{re.escape(_NOTEBOOK_FILE_PATH)}\b") + ) + expect(file_button).to_be_visible(timeout=30_000) + file_button.click() + + # Match the visible FileViewer instance directly (mobile + desktop both + # mount with the same test id; order is not guaranteed). + file_viewer = page.locator('[data-testid="file-viewer"]:visible') + expect(file_viewer).to_be_visible() + expect( + page.get_by_role("button", name=f"Close {_NOTEBOOK_FILE_PATH}", exact=True).first + ).to_be_visible() + + # Preview is the default for notebooks: the markdown cell renders as + # semantic HTML (a heading, not "# Quarterly Analysis" verbatim). + heading = file_viewer.locator("h1").filter(has_text="Quarterly Analysis") + expect(heading).to_be_visible(timeout=10_000) + expect(heading).not_to_contain_text("#") + expect(file_viewer.locator("strong").filter(has_text="Q3 data")).to_be_visible() + + # Code cells carry their source and execution counts; stream output shows. + expect(file_viewer.get_by_text("In [1]:", exact=False)).to_be_visible() + expect(file_viewer.get_by_text("rows: 1523").last).to_be_visible() + + # Security: the hostile text/html output is never injected — no script or + # table mounts, no side effect runs, and the text/plain fallback shows + # with the suppression note. + expect(file_viewer.locator("#nb-xss")).to_have_count(0) + expect(file_viewer.locator("table")).to_have_count(0) + assert page.evaluate("() => window.__nb_xss") is None + expect(file_viewer.get_by_text("Rich HTML output hidden", exact=False)).to_be_visible() + expect(file_viewer.get_by_text("count 1523.0", exact=False)).to_be_visible() + + # The image/png output renders as an inert data-URI image. + image = file_viewer.locator('img[src^="data:image/png;base64,"]') + expect(image).to_be_visible() + + # The error traceback renders with its ANSI escapes consumed, not verbatim. + expect(file_viewer.get_by_text("division by zero", exact=False).first).to_be_visible() + expect(file_viewer.get_by_text("[0;31m", exact=False)).to_have_count(0) + + # Source toggle: the raw notebook JSON becomes visible. + file_viewer.get_by_role("button", name="View source").click() + expect(file_viewer.get_by_text('"nbformat"', exact=False).first).to_be_visible(timeout=10_000) + expect(file_viewer.get_by_text('"cell_type"', exact=False).first).to_be_visible() + + # Toggle back to the rendered preview. + file_viewer.get_by_role("button", name="View preview").click() + expect(file_viewer.locator("h1").filter(has_text="Quarterly Analysis")).to_be_visible( + timeout=10_000 + ) diff --git a/web/src/shell/CodeViewer.test.tsx b/web/src/shell/CodeViewer.test.tsx index 44cad0ea23c..a6b23e9da3e 100644 --- a/web/src/shell/CodeViewer.test.tsx +++ b/web/src/shell/CodeViewer.test.tsx @@ -10,7 +10,11 @@ import { HTML_PREVIEW_SANDBOX } from "./codeViewerHelpers"; vi.mock("@/hooks/usePermissions", () => ({ useCanEdit: vi.fn() })); // Stub Shiki so the highlighting effect never fires an async callback that // would mutate state after the test cleans up. -vi.mock("@/components/ai-elements/code-block", () => ({ highlightCode: vi.fn(() => null) })); +vi.mock("@/components/ai-elements/code-block", () => ({ + highlightCode: vi.fn(() => null), + // NotebookPreview renders notebook code cells through CodeBlockContent. + CodeBlockContent: ({ code }: { code: string }) =>
{code}
, +})); vi.mock("./MarkdownRichTextViewer", () => ({ MarkdownRichTextViewer: () => null })); // Stub the lazy Monaco editor so the heavy monaco-editor bundle isn't loaded in // jsdom; its presence in the DOM is the signal that a file was routed to Monaco. @@ -475,3 +479,32 @@ describe("CodeViewer image rendering", () => { expect(screen.getByLabelText("Zoom out")).toBeDefined(); }); }); + +describe("CodeViewer .ipynb routing", () => { + const MINIMAL_NB = JSON.stringify({ + nbformat: 4, + cells: [{ cell_type: "markdown", metadata: {}, source: ["# Notebook Title\n"] }], + }); + + it("renders the notebook preview in preview mode", () => { + renderViewer(MINIMAL_NB, true, "analysis.ipynb", { viewMode: "preview" }); + expect(screen.getByRole("heading", { name: "Notebook Title" })).toBeDefined(); + expect(screen.queryByTestId("monaco-editor-stub")).toBeNull(); + }); + + it("keeps raw-JSON Monaco as the source-view escape hatch", () => { + renderViewer(MINIMAL_NB, true, "analysis.ipynb", { viewMode: "source" }); + expect(screen.getByTestId("monaco-editor-stub")).toBeDefined(); + }); + + it("warns about truncated notebooks instead of rendering silently-incomplete cells", () => { + renderViewer(MINIMAL_NB.slice(0, 40), true, "analysis.ipynb", { + viewMode: "preview", + truncated: true, + }); + // Truncated JSON cannot parse — the preview shows its parse-error state… + expect(screen.getByText(/Cannot render notebook/)).toBeDefined(); + // …and the shared truncation banner is stacked above it. + expect(screen.getByText(/truncated/i)).toBeDefined(); + }); +}); diff --git a/web/src/shell/CodeViewer.tsx b/web/src/shell/CodeViewer.tsx index 7229004d394..e35e11be9cd 100644 --- a/web/src/shell/CodeViewer.tsx +++ b/web/src/shell/CodeViewer.tsx @@ -56,8 +56,10 @@ import { indexToLine, isBinaryPath, isImageFile, + isNotebookPath, lineOverlapsSelection, } from "./codeViewerHelpers"; +import { NotebookPreview } from "./NotebookPreview"; import { renderLineTokens } from "./codeViewerRendering"; import { HtmlCommentViewer } from "./HtmlCommentViewer"; import { TruncatedBanner } from "./TruncatedBanner"; @@ -586,8 +588,12 @@ export function CodeViewer({ ); } - if (viewMode === "preview" && lang === "markdown") { - const preview = ; + if (viewMode === "preview" && (lang === "markdown" || isNotebookPath(path))) { + const preview = isNotebookPath(path) ? ( + + ) : ( + + ); // A truncated preview renders incomplete content; warn the user (the editor // and source surfaces already do). No layout change when not truncated. if (!truncated) return preview; diff --git a/web/src/shell/FileViewer.tsx b/web/src/shell/FileViewer.tsx index 56dd5d3c1ac..477b8ad24aa 100644 --- a/web/src/shell/FileViewer.tsx +++ b/web/src/shell/FileViewer.tsx @@ -87,6 +87,7 @@ import { type SaveStatus, detectLang, isImageFile, + isNotebookPath, openHtmlArtifactInNewTab, } from "./codeViewerHelpers"; import { CommentsPanel, type ActiveSelection } from "./CommentsPanel"; @@ -600,10 +601,10 @@ function FileViewerBody({ return () => window.removeEventListener("keydown", handler); }, [open, onCloseTab, searchOpen, guardDirty]); - // View mode toggle — markdown defaults to the rich-text editor, HTML to its - // rendered preview, and everything else to source. + // View mode toggle — markdown defaults to the rich-text editor, HTML and + // notebooks to their rendered preview, and everything else to source. const lang = detectLang(path); - const isPreviewable = lang === "markdown" || lang === "html"; + const isPreviewable = lang === "markdown" || lang === "html" || isNotebookPath(path); // Images render through CodeViewer's regardless of view mode; // they have no source/diff representation, so diff is suppressed for them // (Monaco would otherwise render the base64 payload as garbage text). @@ -616,7 +617,8 @@ function FileViewerBody({ // Diff is a global toggle — turning it on/off on any file carries over as you // navigate to the next file. Source ↔ preview is also shared across previewable - // files (markdown/html), while non-previewable files always render as source. + // files (markdown/html/notebooks), while non-previewable files always render + // as source. // These are app-global *preferences*, persisted to localStorage so they also // survive a page refresh (and seed a brand-new conversation). Seed precedence: // 1. an explicit ?diff=1 link (shareable override, diff only), @@ -663,8 +665,8 @@ function FileViewerBody({ writeFileViewPreferences({ diffActive, diffLayout, previewableViewMode, hideWhitespace }); }, [diffActive, diffLayout, previewableViewMode, hideWhitespace]); // Markdown supports all three previewable modes (preview / editor / source). - // HTML has no rich-text editor, so its "editor" preference falls back to the - // rendered preview; "preview" / "source" pass through for both. The shared + // HTML and notebooks have no rich-text editor, so their "editor" preference + // falls back to the rendered preview; "preview" / "source" pass through. The shared // preference still carries across file types — opening markdown in source // then switching to an HTML file keeps you in source, etc. const fileViewMode: "editor" | "preview" | "source" = isPreviewable @@ -821,8 +823,9 @@ function FileViewerBody({ icon: activeMode.icon, options: modeOptions, }); - } else if (lang === "html" && viewMode !== "diff") { - // HTML has no rich-text editor — a single toggle flips preview ↔ source. + } else if ((lang === "html" || isNotebookPath(path)) && viewMode !== "diff") { + // HTML and notebooks have no rich-text editor — a single toggle flips + // preview ↔ source. toolbarActions.push({ key: "preview", label: viewMode === "preview" ? "View source" : "View preview", diff --git a/web/src/shell/NotebookPreview.test.tsx b/web/src/shell/NotebookPreview.test.tsx new file mode 100644 index 00000000000..3994bdc1c91 --- /dev/null +++ b/web/src/shell/NotebookPreview.test.tsx @@ -0,0 +1,166 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NotebookPreview } from "./NotebookPreview"; + +// Stub Shiki (same rationale as CodeViewer.test.tsx) — render code verbatim so +// assertions can target cell source without async highlighting. +vi.mock("@/components/ai-elements/code-block", () => ({ + CodeBlockContent: ({ code }: { code: string }) =>
{code}
, +})); + +import typicalRaw from "./__fixtures__/01_typical.ipynb?raw"; +import edgecasesRaw from "./__fixtures__/02_edgecases.ipynb?raw"; +import brokenRaw from "./__fixtures__/03_broken.ipynb?raw"; + +afterEach(cleanup); + +describe("NotebookPreview — typical notebook", () => { + it("renders markdown cells as formatted markdown", () => { + render(); + expect(screen.getByRole("heading", { name: "Sales Analysis" })).toBeInTheDocument(); + // **Q3 data** renders as , proving markdown is not shown raw. + expect(screen.getByText("Q3 data").tagName).toBe("STRONG"); + }); + + it("renders code cells with source and execution counts", () => { + render(); + expect(screen.getAllByTestId("code-cell")[0]).toHaveTextContent("import pandas as pd"); + expect(screen.getByText("In [1]:")).toBeInTheDocument(); + expect(screen.getByText("In [3]:")).toBeInTheDocument(); + }); + + it("renders stream output text", () => { + render(); + expect(screen.getByText(/rows: 1523/)).toBeInTheDocument(); + }); + + it("suppresses text/html output and falls back to text/plain with a note", () => { + const { container } = render(); + // The DataFrame html table must NOT be injected into the DOM … + expect(container.querySelector("table")).toBeNull(); + // … the plain-text repr is shown instead, with an explanatory note. + expect(screen.getByText(/count\s+1523\.0/)).toBeInTheDocument(); + expect(screen.getByText(/Rich HTML output hidden/)).toBeInTheDocument(); + }); + + it("renders image/png output as an inert data-URI img", () => { + const { container } = render(); + const img = container.querySelector("img"); + expect(img).not.toBeNull(); + expect(img!.getAttribute("src")).toMatch(/^data:image\/png;base64,/); + }); + + it("strips all whitespace from base64 images (CRLF-split payloads stay valid)", () => { + // A payload split across array lines with CRLF endings and a stray space — + // a data-URI containing any whitespace is rejected by the browser. + const nb = JSON.stringify({ + nbformat: 4, + cells: [ + { + cell_type: "code", + execution_count: 1, + source: [], + outputs: [ + { + output_type: "display_data", + data: { "image/png": ["iVBORw0K\r\n", "GgoA AAAN\n"] }, + }, + ], + }, + ], + }); + const { container } = render(); + const src = container.querySelector("img")!.getAttribute("src")!; + expect(src).toBe("data:image/png;base64,iVBORw0KGgoAAAAN"); + expect(src).not.toMatch(/\s/); + }); + + it("falls back to a note (not a broken img) for corrupt base64 images", () => { + // length % 4 === 1 is never valid base64 — a browser rejects the data-URI + // with ERR_INVALID_URL. Show the text/plain repr with a note instead. + const nb = JSON.stringify({ + nbformat: 4, + cells: [ + { + cell_type: "code", + execution_count: 1, + source: [], + outputs: [ + { + output_type: "display_data", + data: { + "image/png": "not@valid#base64!", + "text/plain": "
", + }, + }, + ], + }, + ], + }); + const { container } = render(); + expect(container.querySelector("img")).toBeNull(); + expect(screen.getByText(/could not be decoded/)).toBeInTheDocument(); + expect(screen.getByText(/Figure size 640x480/)).toBeInTheDocument(); + }); +}); + +describe("NotebookPreview — edge cases", () => { + it("renders error tracebacks (ANSI codes handled, not shown raw)", () => { + const { container } = render(); + expect(screen.getAllByText(/ZeroDivisionError/).length).toBeGreaterThan(0); + // ansi-to-react must consume the escape sequences, not print them. + expect(container.textContent).not.toContain("[0;31m"); + // Long unbroken traceback runs (separator rules, paths) must scroll within + // the cell, not widen the layout — the output
 owns the overflow.
+    const tb = screen.getAllByText(/ZeroDivisionError/)[0].closest("pre");
+    expect(tb!.className).toContain("overflow-x-auto");
+  });
+
+  it("never executes or injects script from hostile text/html outputs", () => {
+    const { container } = render();
+    expect(container.querySelector("script")).toBeNull();
+    expect(container.querySelector("b")).toBeNull();
+    expect(screen.getByText(/Rich HTML output hidden/)).toBeInTheDocument();
+  });
+
+  it("renders stderr streams distinctly from stdout", () => {
+    render();
+    const stderr = screen.getByText(/warning: deprecated/).closest("pre");
+    const stdout = screen.getByText("done").closest("pre");
+    expect(stderr!.className).toContain("bg-destructive");
+    expect(stdout!.className).not.toContain("bg-destructive");
+  });
+
+  it("renders raw cells verbatim and empty execution counts as In [ ]", () => {
+    render();
+    expect(screen.getByText(/raw cell content/)).toBeInTheDocument();
+    expect(screen.getByText(/In \[ \]:/)).toBeInTheDocument();
+  });
+});
+
+describe("NotebookPreview — invalid input", () => {
+  it("shows a parse error with a pointer to the source view", () => {
+    render();
+    expect(screen.getByText(/Cannot render notebook/)).toBeInTheDocument();
+    expect(screen.getByText(/source view/)).toBeInTheDocument();
+  });
+
+  it("rejects valid JSON that is not a notebook", () => {
+    render();
+    expect(screen.getByText(/missing cells array/)).toBeInTheDocument();
+  });
+
+  it("recovers from raw control characters (unescaped ANSI) in cell output", () => {
+    // A notebook whose stream output carries a bare ESC (0x1B) and newline —
+    // strict JSON.parse rejects these, but the preview escapes and recovers.
+    const nb = `{"nbformat": 4, "cells": [
+      {"cell_type": "code", "execution_count": 1, "source": ["print(x)"],
+       "outputs": [{"output_type": "stream", "name": "stdout",
+         "text": ["\x1b[31mred\x1b[0m line one\nline two"]}]}
+    ]}`;
+    render();
+    // Parse recovered (no error state) and the output text is rendered.
+    expect(screen.queryByText(/Cannot render notebook/)).toBeNull();
+    expect(screen.getByText(/line one/)).toBeInTheDocument();
+  });
+});
diff --git a/web/src/shell/NotebookPreview.tsx b/web/src/shell/NotebookPreview.tsx
new file mode 100644
index 00000000000..7d095a21196
--- /dev/null
+++ b/web/src/shell/NotebookPreview.tsx
@@ -0,0 +1,250 @@
+import type { BundledLanguage } from "shiki";
+import AnsiDefault from "ansi-to-react";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import { CodeBlockContent } from "@/components/ai-elements/code-block";
+import { cn } from "@/lib/utils";
+
+// ansi-to-react is CJS with a TS-compiled `exports.default`; depending on the
+// bundler interop (Vite dev prebundle vs vitest vs production build) the
+// default import is either the component or the whole exports object.
+const Ansi = ("default" in AnsiDefault ? AnsiDefault.default : AnsiDefault) as typeof AnsiDefault;
+
+// ---------------------------------------------------------------------------
+// NotebookPreview — read-only render of a Jupyter notebook (.ipynb, nbformat 4).
+//
+// Renders cells in order: markdown via react-markdown (same pipeline as
+// MarkdownPreview), code via the shared Shiki CodeBlockContent, and outputs
+// from each cell's mime bundle. Active content (text/html, javascript) is
+// never injected into the DOM — rich outputs fall back to their text/plain
+// representation with a note, so hostile notebooks can't run scripts here.
+// ---------------------------------------------------------------------------
+
+interface NotebookOutput {
+  output_type: string;
+  name?: string; // stream: stdout | stderr
+  text?: string | string[];
+  data?: Record;
+  ename?: string;
+  evalue?: string;
+  traceback?: string[];
+}
+
+interface NotebookCell {
+  cell_type: string;
+  source?: string | string[];
+  execution_count?: number | null;
+  outputs?: NotebookOutput[];
+}
+
+interface Notebook {
+  nbformat?: number;
+  cells?: NotebookCell[];
+  metadata?: { language_info?: { name?: string } };
+}
+
+// nbformat stores text as a list of lines (or a single string); normalize.
+function joinSource(src: string | string[] | undefined): string {
+  if (src === undefined) return "";
+  return Array.isArray(src) ? src.join("") : src;
+}
+
+// Raster images are inert; SVG and HTML can carry scripts, so they are not
+// rendered directly.
+const SAFE_IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
+
+// A data-URI built from malformed base64 (wrong length, stray chars, data after
+// padding) is rejected by the browser with ERR_INVALID_URL — showing a broken
+// image with no explanation. Validate before building the URI so we can fall
+// back to a note instead. Length must be a multiple of 4 with padding only at
+// the end; length % 4 === 1 is never valid base64.
+function isValidBase64(b64: string): boolean {
+  return /^[A-Za-z0-9+/]*={0,2}$/.test(b64) && b64.length % 4 === 0;
+}
+
+// Notebooks in the wild embed raw C0 control characters (most often ANSI escape
+// sequences in traceback/output text) directly inside JSON string literals,
+// which strict JSON.parse rejects ("Bad control character in string literal").
+// Escape any control char that appears *inside* a string to its \uXXXX form,
+// leaving structural whitespace between tokens untouched.
+function escapeControlCharsInStrings(content: string): string {
+  let out = "";
+  let inString = false;
+  let escaped = false;
+  for (let i = 0; i < content.length; i++) {
+    const ch = content[i];
+    if (!inString) {
+      out += ch;
+      if (ch === '"') inString = true;
+      continue;
+    }
+    if (escaped) {
+      out += ch;
+      escaped = false;
+    } else if (ch === "\\") {
+      out += ch;
+      escaped = true;
+    } else if (ch === '"') {
+      out += ch;
+      inString = false;
+    } else if (content.charCodeAt(i) < 0x20) {
+      out += `\\u${content.charCodeAt(i).toString(16).padStart(4, "0")}`;
+    } else {
+      out += ch;
+    }
+  }
+  return out;
+}
+
+function parseNotebook(content: string): { notebook?: Notebook; error?: string } {
+  let parsed: unknown;
+  try {
+    parsed = JSON.parse(content);
+  } catch {
+    // Retry once with stray control characters escaped — real notebooks often
+    // contain unescaped ANSI codes in cell output that strict JSON rejects.
+    try {
+      parsed = JSON.parse(escapeControlCharsInStrings(content));
+    } catch (e) {
+      return { error: e instanceof Error ? e.message : String(e) };
+    }
+  }
+  const nb = parsed as Notebook;
+  if (!nb || typeof nb !== "object" || !Array.isArray(nb.cells)) {
+    return { error: "not a notebook: missing cells array" };
+  }
+  return { notebook: nb };
+}
+
+function AnsiText({ text, className }: { text: string; className?: string }) {
+  // Outputs (tracebacks especially) contain long unbroken runs — separator
+  // rules, file paths — that word-wrapping can't split. Wrap where possible
+  // (overflow-wrap) but let anything unbreakable scroll horizontally within the
+  // cell rather than pushing the whole preview wide.
+  return (
+    
+      {text}
+    
+ ); +} + +function OutputView({ output }: { output: NotebookOutput }) { + if (output.output_type === "stream") { + return ( + + ); + } + + if (output.output_type === "error") { + return ; + } + + // execute_result / display_data carry a mime bundle; pick the richest safe + // representation. + const data = output.data ?? {}; + const imageMime = SAFE_IMAGE_MIMES.find((m) => data[m] !== undefined); + let imageError: string | undefined; + if (imageMime) { + // Strip *all* whitespace, not just newlines: base64 payloads split across + // JSON-array lines can carry CRLF or stray spaces, and a data-URI with any + // whitespace in it is rejected by the browser (renders as a broken image). + const b64 = joinSource(data[imageMime]).replace(/\s/g, ""); + if (isValidBase64(b64)) { + return ( + notebook output + ); + } + // Corrupt payload: don't emit a broken — note it and fall through to + // the text/plain repr below (matplotlib etc. usually include one). + imageError = `Image output (${imageMime}) could not be decoded.`; + } + + const plain = data["text/plain"] !== undefined ? joinSource(data["text/plain"]) : undefined; + const suppressedHtml = data["text/html"] !== undefined; + if (plain === undefined && !suppressedHtml && imageError === undefined) return null; + return ( +
+ {imageError && ( +
{imageError}
+ )} + {suppressedHtml && ( +
+ Rich HTML output hidden — showing plain text. +
+ )} + {plain !== undefined && } +
+ ); +} + +function CodeCell({ cell, language }: { cell: NotebookCell; language: BundledLanguage }) { + const count = cell.execution_count; + return ( +
+
+ In [{count ?? " "}]: +
+
+
+ +
+ {(cell.outputs ?? []).map((output, i) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} +
+
+ ); +} + +export function NotebookPreview({ content }: { content: string }) { + const { notebook, error } = parseNotebook(content); + + if (error || !notebook) { + return ( +
+
Cannot render notebook: {error}
+
+ Switch to the source view to inspect the raw file. +
+
+ ); + } + + const langName = notebook.metadata?.language_info?.name ?? "python"; + const language = langName as BundledLanguage; + + return ( +
+ {(notebook.cells ?? []).map((cell, i) => { + if (cell.cell_type === "markdown") { + return ( + // eslint-disable-next-line react/no-array-index-key +
+ {joinSource(cell.source)} +
+ ); + } + if (cell.cell_type === "code") { + // eslint-disable-next-line react/no-array-index-key + return ; + } + // raw (and any unknown cell type): show the source verbatim. + // eslint-disable-next-line react/no-array-index-key + return ; + })} +
+ ); +} diff --git a/web/src/shell/__fixtures__/01_typical.ipynb b/web/src/shell/__fixtures__/01_typical.ipynb new file mode 100644 index 00000000000..6adf5706570 --- /dev/null +++ b/web/src/shell/__fixtures__/01_typical.ipynb @@ -0,0 +1,90 @@ +{ + "nbformat": 4, + "nbformat_minor": 5, + "metadata": { + "kernelspec": { + "name": "python3", + "display_name": "Python 3" + }, + "language_info": { + "name": "python" + } + }, + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Sales Analysis\n", + "\n", + "Exploring **Q3 data** with a quick plot.\n", + "\n", + "- load\n", + "- clean\n", + "- plot\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "source": [ + "import pandas as pd\n", + "df = pd.read_csv('sales.csv')\n", + "print(f'rows: {len(df)}')\n" + ], + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "rows: 1523\n" + ] + } + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "source": [ + "df.describe()\n" + ], + "outputs": [ + { + "output_type": "execute_result", + "execution_count": 2, + "metadata": {}, + "data": { + "text/plain": [ + " amount\ncount 1523.0\nmean 47.3\nstd 12.1" + ], + "text/html": [ + "
amount
count1523.0
mean47.3
" + ] + } + } + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "source": [ + "df.plot()\n" + ], + "outputs": [ + { + "output_type": "display_data", + "metadata": {}, + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + } + } + ] + } + ] +} diff --git a/web/src/shell/__fixtures__/02_edgecases.ipynb b/web/src/shell/__fixtures__/02_edgecases.ipynb new file mode 100644 index 00000000000..3c1e0ae8b63 --- /dev/null +++ b/web/src/shell/__fixtures__/02_edgecases.ipynb @@ -0,0 +1,107 @@ +{ + "nbformat": 4, + "nbformat_minor": 5, + "metadata": { + "kernelspec": { + "name": "python3", + "display_name": "Python 3" + }, + "language_info": { + "name": "python" + } + }, + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Error and unsafe outputs\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "source": [ + "1/0\n" + ], + "outputs": [ + { + "output_type": "error", + "ename": "ZeroDivisionError", + "evalue": "division by zero", + "traceback": [ + "\u001b[0;31m---------------------------------------\u001b[0m", + "\u001b[0;31mZeroDivisionError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn[1], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m \u001b[38;5;241m1\u001b[39m\u001b[38;5;241m/\u001b[39m\u001b[38;5;241m0\u001b[39m", + "\u001b[0;31mZeroDivisionError\u001b[0m: division by zero" + ] + } + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "source": [ + "from IPython.display import HTML\n", + "HTML('bold')\n" + ], + "outputs": [ + { + "output_type": "execute_result", + "execution_count": 2, + "metadata": {}, + "data": { + "text/html": [ + "bold" + ], + "text/plain": [ + "" + ] + } + } + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "source": [ + "print('done')\n" + ], + "outputs": [ + { + "output_type": "stream", + "name": "stderr", + "text": [ + "warning: deprecated\n" + ] + }, + { + "output_type": "stream", + "name": "stdout", + "text": [ + "done\n" + ] + } + ] + }, + { + "cell_type": "raw", + "metadata": {}, + "source": [ + "raw cell content\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "source": [ + "# never executed\n" + ], + "outputs": [] + } + ] +} diff --git a/web/src/shell/__fixtures__/03_broken.ipynb b/web/src/shell/__fixtures__/03_broken.ipynb new file mode 100644 index 00000000000..d39658c9270 --- /dev/null +++ b/web/src/shell/__fixtures__/03_broken.ipynb @@ -0,0 +1 @@ +{"nbformat": 4, "cells": [{"cell_type": "cod diff --git a/web/src/shell/codeViewerHelpers.test.ts b/web/src/shell/codeViewerHelpers.test.ts index 57a4f69ad63..b37f7c3ea14 100644 --- a/web/src/shell/codeViewerHelpers.test.ts +++ b/web/src/shell/codeViewerHelpers.test.ts @@ -6,6 +6,7 @@ import { indexToLine, isBinaryPath, isImageFile, + isNotebookPath, lineOverlapsSelection, openHtmlArtifactInNewTab, prepareHtmlPreviewDoc, @@ -112,6 +113,22 @@ describe("isBinaryPath", () => { }); }); +describe("isNotebookPath", () => { + it.each(["analysis.ipynb", "dir/Report.IPYNB", "a.b.ipynb"])( + "classifies %s as a notebook", + (path) => { + expect(isNotebookPath(path)).toBe(true); + }, + ); + + it.each(["notes.md", "data.json", "ipynb", "nb.ipynb.bak"])( + "classifies %s as not a notebook", + (path) => { + expect(isNotebookPath(path)).toBe(false); + }, + ); +}); + // --------------------------------------------------------------------------- // isImageFile — image-preview detection (MIME-first, extension fallback) // --------------------------------------------------------------------------- diff --git a/web/src/shell/codeViewerHelpers.ts b/web/src/shell/codeViewerHelpers.ts index 7dc358b34b4..0aa3afaed4d 100644 --- a/web/src/shell/codeViewerHelpers.ts +++ b/web/src/shell/codeViewerHelpers.ts @@ -96,6 +96,12 @@ export function isBinaryPath(path: string): boolean { return BINARY_EXTENSIONS.has(ext); } +/** Jupyter notebooks get a read-only rendered preview (with raw-JSON source as + * the escape hatch), so they are previewable like markdown/html. */ +export function isNotebookPath(path: string): boolean { + return path.toLowerCase().endsWith(".ipynb"); +} + // Image formats the browser can render directly via an tag. SVG is // included but is only ever rendered through a blob URL (never inlined into // the DOM), so scripts embedded in it cannot execute. From 8452ce39d57602b899c8df7eb516f162c912afd2 Mon Sep 17 00:00:00 2001 From: Pat Sukprasert Date: Mon, 6 Jul 2026 15:46:35 +0700 Subject: [PATCH 020/546] fix(harness-caps): only declare streaming=False where live-verified (revert #1990 over-reach) (#2007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(harness-caps): only declare streaming=False where live-verified (revert #1990 over-reach) #1990 flipped 7 transcript-mirror natives to streaming=False from a static "forwarder posts no external_output_text_delta" grep. A live bench run disproved that for pi-native: it has no delta-posting forwarder yet streams 7 token deltas (its Pi extension emits them by another path), so it drifted !!✗>✓ (declared UNSUPPORTED, observed SUPPORTED). The static grep is not a sound basis for asserting a harness does NOT stream. Revert pi/cursor/goose/qwen/kimi/hermes to streaming=True (their pre-#1990 value, the honest default); keep streaming=False only for kiro-native, which is live-verified (0 deltas over a full SSE capture). The remaining five are unverified on this host (own-auth logins the bench can't provision); leaving them True means the bench will flag a real drift if any turns out not to stream, rather than asserting an unproven False that drifts the moment the harness does stream (as pi just showed). Offline suites: 60 passed / 14 skipped, ruff clean. * docs(harness-caps): don't claim an unverified emission path for pi-native The comment asserted pi-native "emits [deltas] by another path" — an inference that was never traced, the same unverified-assertion habit that caused the original wrong flip. Soften to the observed fact only: it streams 7 deltas live, by a path not traced. No behavior change. * fix(harness-bench): support lazy-chat natives (cursor); mark cursor/qwen non-streaming Two findings from an all-native bench run: 1. cursor-native could not provision — "native forwarder did not wire up within 90s (no external_session_id)". Root cause: cursor creates its chat id (external_session_id) lazily, only after the FIRST message lands (cursor_native_forwarder.py), but the driver hard-gated provisioning on that id BEFORE posting any turn — a deadlock. claude/codex stamp it at TUI launch, so the gate worked for them. Add a per-vendor `lazy_chat` flag (NativeVendor) and skip the pre-turn external_session_id gate for those vendors; the first probe turn triggers the chat and the forwarder discovers it then. cursor is the only known lazy-chat native today. Live-verified: cursor-native now provisions and runs (Basic/Model-override/Interrupt SUPPORTED). 2. With cursor now runnable, its Streaming observed 0 deltas — and qwen-native likewise (0 deltas) in the same run. Both were declaring streaming=True and drifting !!✓>✗. Set streaming=False for cursor-native and qwen-native, joining kiro-native — all three now LIVE-VERIFIED non-streaming (0 deltas observed), consistent with the "only declare False where observed" rule. Offline: 60 passed / 14 skipped, ruff clean. --- omnigent/harness_plugins.py | 24 +++++++++++--------- tests/harness_bench/native_tui_driver.py | 28 +++++++++++++++++++++--- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/omnigent/harness_plugins.py b/omnigent/harness_plugins.py index 83a0cc7eef7..6f1f4cfd240 100644 --- a/omnigent/harness_plugins.py +++ b/omnigent/harness_plugins.py @@ -239,12 +239,12 @@ class HarnessPluginState: interrupt=True, streaming=True, ), - # pi/cursor/kiro/goose/qwen/kimi/hermes are transcript-mirror natives: their - # forwarder posts each COMPLETE assistant message (external_conversation_item), - # never token-level external_output_text_delta, so the web UI sees the reply - # complete-only, not streamed. streaming=False reflects that (bench-verified - # for kiro-native; the others share the same forwarder shape — 0 delta posts). - # Contrast claude/codex/antigravity, whose forwarders do post deltas. + # streaming is declared True unless a live bench run proves a harness does + # NOT emit token-level deltas. Only kiro-native is so proven (0 deltas over + # a full SSE capture); a static "forwarder posts no external_output_text_delta" + # grep is NOT sufficient — pi-native has no such delta-posting forwarder yet + # streams 7 deltas live (by what path was not traced), so the grep-based + # flip was wrong for it. The rest stay True until live-verified. "pi-native": _C( _IM.NATIVE_TUI, _EL.NONE, @@ -254,8 +254,9 @@ class HarnessPluginState: _AU.SESSION_SCOPED_CONFIG, subagents=False, interrupt=True, - streaming=False, + streaming=True, ), + # streaming=False is LIVE-VERIFIED: a bench run observed 0 text deltas. "cursor-native": _C( _IM.NATIVE_TUI, _EL.APPROVAL_MIRROR, @@ -268,6 +269,8 @@ class HarnessPluginState: streaming=False, ), # kiro_native_permissions.py: "TUI ACP recorder -> web elicitation". + # streaming=False is LIVE-VERIFIED: a full SSE capture recorded 0 text + # deltas; the whole reply arrives as one response.output_item.done. "kiro-native": _C( _IM.NATIVE_TUI, _EL.APPROVAL_MIRROR, @@ -299,8 +302,9 @@ class HarnessPluginState: _AU.OWN_AUTH, subagents=False, interrupt=True, - streaming=False, + streaming=True, ), + # streaming=False is LIVE-VERIFIED: a bench run observed 0 text deltas. "qwen-native": _C( _IM.NATIVE_TUI, _EL.APPROVAL_MIRROR, @@ -321,7 +325,7 @@ class HarnessPluginState: _AU.SESSION_SCOPED_CONFIG, subagents=False, interrupt=True, - streaming=False, + streaming=True, ), "opencode-native": _C( _IM.NATIVE_SERVER, @@ -343,7 +347,7 @@ class HarnessPluginState: _AU.OWN_AUTH, subagents=False, interrupt=True, - streaming=False, + streaming=True, ), # SDK / subprocess harnesses (run the vendor model directly). The first four # are bench-verified interrupt=streaming=True. diff --git a/tests/harness_bench/native_tui_driver.py b/tests/harness_bench/native_tui_driver.py index ec08e001ce3..4d072cf817d 100644 --- a/tests/harness_bench/native_tui_driver.py +++ b/tests/harness_bench/native_tui_driver.py @@ -129,12 +129,28 @@ class NativeVendor: :param own_auth: ``True`` when the vendor logs in itself (auth is not ``OMNIGENT_CREDENTIAL``), so the bench cannot provision it — runnable only on a host where the vendor CLI is already logged in. + :param lazy_chat: ``True`` when the vendor's ``external_session_id`` (its + chat/thread id) is created by the FIRST message rather than at TUI + launch (cursor writes its chat store lazily on the first message). For + such a vendor the driver must NOT gate provisioning on + ``external_session_id`` — that id cannot exist until a turn is posted, + so waiting for it pre-turn deadlocks. Thread-at-launch vendors + (claude/codex) leave this ``False`` and are gated normally. """ harness: str agent_name: str terminal_name: str own_auth: bool = False + lazy_chat: bool = False + + +# Vendors whose external_session_id is created by the first message, not at TUI +# launch (see NativeVendor.lazy_chat). A delivery-mechanism fact not derivable +# from the capability model, so it is an explicit set. cursor is confirmed +# (its forwarder discovers the chat store written on the first message); others +# are added only once live-verified to behave this way. +_LAZY_CHAT_HARNESSES: frozenset[str] = frozenset({"cursor-native"}) def native_vendor(harness: str) -> NativeVendor | None: @@ -162,6 +178,7 @@ def native_vendor(harness: str) -> NativeVendor | None: agent_name=f"{harness}-ui", terminal_name=harness.removesuffix("-native"), own_auth=caps.auth is not AuthModel.OMNIGENT_CREDENTIAL, + lazy_chat=harness in _LAZY_CHAT_HARNESSES, ) @@ -323,9 +340,14 @@ def _wire_native_forwarder(self, host_id: str, workspace: Path) -> None: timeout=90.0, ) ensure.raise_for_status() - # Gate on the forwarder wiring up: it stamps external_session_id (the - # vendor thread id) on the session once the TUI creates its thread. - # Posting a turn before this races ahead of the forwarder subscription. + # A lazy-chat vendor (cursor) does not create its external_session_id + # until the first message lands, so it cannot be gated on here — waiting + # pre-turn would deadlock. The terminal is ensured; the first probe turn + # triggers the chat, and the forwarder discovers it then. Thread-at-launch + # vendors (claude/codex) stamp external_session_id at TUI launch, so gate + # on it to avoid racing a turn ahead of the forwarder subscription. + if self._vendor.lazy_chat: + return deadline = time.monotonic() + _FORWARDER_READY_TIMEOUT_S while time.monotonic() < deadline: snap = self._client.get(f"/v1/sessions/{session_id}") From 2d18ec2cd03111d365b37561fe8a26c868383db9 Mon Sep 17 00:00:00 2001 From: Serena Ruan <82044803+serena-ruan@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:55:00 +0800 Subject: [PATCH 021/546] feat(web): delete a queued message from the composer strip (#2010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): delete a queued message from the composer strip Each queued row gets a hover/focus-revealed remove button that drops it from the client-side queue via a new dequeueMessage(queueId) store action. Stacked on the client-side message queue foundation. Co-authored-by: Isaac * fix(web): make queued-message delete button always visible The remove button was hover-gated (opacity-0 → group-hover), so the delete affordance was undiscoverable — users couldn't tell a queued message could be removed. Show it persistently at reduced opacity; it brightens on hover/focus. Co-authored-by: Isaac * fix(web): use trash icon for queued-message delete Swap the ✕ for a trash icon so the delete affordance reads as delete, not dismiss. Co-authored-by: Isaac --- web/src/pages/ChatPage.tsx | 2 ++ web/src/pages/QueuedMessagesStrip.test.tsx | 28 ++++++++++++++++++---- web/src/pages/QueuedMessagesStrip.tsx | 23 ++++++++++++++---- web/src/store/chatStore.test.ts | 16 +++++++++++++ web/src/store/chatStore.ts | 8 +++++++ 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 8340477fddf..7fe26fba0ec 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -3652,6 +3652,7 @@ export function Composer({ const sessionStatus = useChatStore((s) => s.sessionStatus); const flushBoundAgentId = useChatStore((s) => s.boundAgentId); const maybeFlushQueuedHead = useChatStore((s) => s.maybeFlushQueuedHead); + const dequeueMessage = useChatStore((s) => s.dequeueMessage); // Drain the queue whenever idle with a waiting head — level-triggered so a // message queued right after the turn ended (or after an SSE reconnect that // carries no fresh idle transition) still sends instead of stranding. Hold @@ -4365,6 +4366,7 @@ export function Composer({ Scope to this conversation so a queue held elsewhere never leaks in. */} m.conversationId === conversationId)} + onDelete={dequeueMessage} widthClassName={CHAT_COLUMN_WIDTH} /> {/* Sub-agent context tray — peeks above the card; reserves its own diff --git a/web/src/pages/QueuedMessagesStrip.test.tsx b/web/src/pages/QueuedMessagesStrip.test.tsx index 549aa988dd0..4cda6750030 100644 --- a/web/src/pages/QueuedMessagesStrip.test.tsx +++ b/web/src/pages/QueuedMessagesStrip.test.tsx @@ -2,8 +2,8 @@ // listing messages queued while the agent is busy. It's a pure prop-driven // component (no store access), so we exercise it with plain props. -import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { QueuedMessage } from "@/store/chatStore"; import { QueuedMessagesStrip } from "./QueuedMessagesStrip"; @@ -18,15 +18,35 @@ afterEach(cleanup); describe("QueuedMessagesStrip", () => { it("renders nothing when the queue is empty", () => { - const { container } = render(); + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); it("renders one row per queued message, in order", () => { - render(); + render( + , + ); expect(screen.getByText("first")).toBeInTheDocument(); expect(screen.getByText("second")).toBeInTheDocument(); // Each row carries the "Queued" label. expect(screen.getAllByText("Queued")).toHaveLength(2); }); + + it("calls onDelete with the row's queueId when its remove button is clicked", () => { + const onDelete = vi.fn(); + render( + , + ); + const buttons = screen.getAllByRole("button", { name: "Remove queued message" }); + expect(buttons).toHaveLength(2); + fireEvent.click(buttons[1]!); + expect(onDelete).toHaveBeenCalledTimes(1); + expect(onDelete).toHaveBeenCalledWith("q_2"); + }); }); diff --git a/web/src/pages/QueuedMessagesStrip.tsx b/web/src/pages/QueuedMessagesStrip.tsx index f7e84e292d2..73de604b933 100644 --- a/web/src/pages/QueuedMessagesStrip.tsx +++ b/web/src/pages/QueuedMessagesStrip.tsx @@ -1,4 +1,4 @@ -import { ClockIcon } from "lucide-react"; +import { ClockIcon, Trash2Icon } from "lucide-react"; import type { QueuedMessage } from "@/store/chatStore"; import { cn } from "@/lib/utils"; @@ -6,6 +6,8 @@ import { cn } from "@/lib/utils"; interface QueuedMessagesStripProps { /** Messages waiting to be flushed, in FIFO order (head first). */ messages: QueuedMessage[]; + /** Remove a queued message by id (per-row delete). */ + onDelete: (queueId: string) => void; /** Column-width class so the strip lines up with the composer card. */ widthClassName?: string; } @@ -15,10 +17,13 @@ interface QueuedMessagesStripProps { * busy. Peeks above the composer card (`-mb-4` + bottom padding), mirroring * `SubagentComposerTray`. Renders nothing when the queue is empty. * - * Read-only in this iteration — per-row actions (delete / edit / steer / - * reorder) land in later changes. + * Each row can be deleted; edit / steer / reorder land in later changes. */ -export function QueuedMessagesStrip({ messages, widthClassName }: QueuedMessagesStripProps) { +export function QueuedMessagesStrip({ + messages, + onDelete, + widthClassName, +}: QueuedMessagesStripProps) { if (messages.length === 0) return null; return ( ))}
diff --git a/web/src/store/chatStore.test.ts b/web/src/store/chatStore.test.ts index aa21478fa10..7e1aaf186d9 100644 --- a/web/src/store/chatStore.test.ts +++ b/web/src/store/chatStore.test.ts @@ -7481,6 +7481,22 @@ describe("chatStore — client-side message queue", () => { expect(useChatStore.getState().queuedMessages).toEqual([]); }); + it("dequeueMessage removes the message with the given id, keeping order", () => { + useChatStore.setState({ + conversationId: "conv_abc", + queuedMessages: [ + { queueId: "q_1", text: "first", conversationId: "conv_abc" }, + { queueId: "q_2", text: "second", conversationId: "conv_abc" }, + { queueId: "q_3", text: "third", conversationId: "conv_abc" }, + ], + }); + useChatStore.getState().dequeueMessage("q_2"); + expect(useChatStore.getState().queuedMessages.map((m) => m.text)).toEqual(["first", "third"]); + // Removing a missing id is a no-op. + useChatStore.getState().dequeueMessage("q_missing"); + expect(useChatStore.getState().queuedMessages.map((m) => m.text)).toEqual(["first", "third"]); + }); + it("maybeFlushQueuedHead flushes the head FIFO, one per idle", async () => { // Spy on send so the flush's contract (which head, in what order) is // asserted without depending on the full bind→/events network path. diff --git a/web/src/store/chatStore.ts b/web/src/store/chatStore.ts index 8b454e9c276..f5160ace8ee 100644 --- a/web/src/store/chatStore.ts +++ b/web/src/store/chatStore.ts @@ -533,6 +533,8 @@ export interface ChatState { * turn) when the session next goes idle — see the `session_status` handler. */ enqueueMessage: (text: string, files?: File[]) => void; + /** Remove a queued message by id (the strip's per-row delete). */ + dequeueMessage: (queueId: string) => void; /** * Drop all queued messages for a conversation. Called when a conversation is * deleted so its queue can't linger in memory (it would never flush — you @@ -852,6 +854,12 @@ export const useChatStore = create((set, get) => ({ get().maybeFlushQueuedHead(); }, + dequeueMessage: (queueId) => { + set((s) => ({ + queuedMessages: s.queuedMessages.filter((m) => m.queueId !== queueId), + })); + }, + clearQueuedMessages: (conversationId) => { set((s) => { if (!s.queuedMessages.some((m) => m.conversationId === conversationId)) return {}; From 8552d68c7e3d9d8ecd0a04af5eda884ded0fa24b Mon Sep 17 00:00:00 2001 From: Pat Sukprasert Date: Mon, 6 Jul 2026 16:03:50 +0700 Subject: [PATCH 022/546] fix(server): seed goose-native-ui and hermes-native-ui default agents (#2018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _ensure_default_agents in server/app.py seeded 9 of the 11 native-ui agents declared in the harness registry (harness_plugins.native_agents) — goose and hermes were added to the registry but their startup seeders were never wired in. So `GET /v1/agents` never listed goose-native-ui / hermes-native-ui, and anything resolving a native agent by that name (the harness bench, and any head that relies on the built-in row) failed with "not auto-registered". Add the two missing seeder pairs (_build_*_native_bundle + _ensure_default_* _agent), mirroring the kiro pattern exactly, and call them from _ensure_default_agents. goose/hermes have the required _materialize_*_agent_spec functions already; only the app.py wiring was missing. Verified: with this change both goose-native and hermes-native get PAST agent registration in the harness bench (they now reach terminal provisioning, where each hits a separate downstream issue — hermes a lazy-chat/first-turn gate, goose a terminal-ensure 500 — tracked separately). test_native_coding_agents passes; ruff clean. Note: the per-harness hardcoded seeder list is itself the seam — a native plugin is invisible until hand-added here. Making _ensure_default_agents iterate native_agents() from the registry (which already includes plugins) is the follow-up that would close it. --- omnigent/server/app.py | 62 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/omnigent/server/app.py b/omnigent/server/app.py index 4c80e747470..8a879918487 100644 --- a/omnigent/server/app.py +++ b/omnigent/server/app.py @@ -28,6 +28,8 @@ CLAUDE_NATIVE_CODING_AGENT, CODEX_NATIVE_CODING_AGENT, CURSOR_NATIVE_CODING_AGENT, + GOOSE_NATIVE_CODING_AGENT, + HERMES_NATIVE_CODING_AGENT, KIMI_NATIVE_CODING_AGENT, KIRO_NATIVE_CODING_AGENT, OPENCODE_NATIVE_CODING_AGENT, @@ -148,6 +150,8 @@ def _register_web_mimetypes() -> None: _OPENCODE_NATIVE_AGENT_NAME = OPENCODE_NATIVE_CODING_AGENT.agent_name _CURSOR_NATIVE_AGENT_NAME = CURSOR_NATIVE_CODING_AGENT.agent_name _KIRO_NATIVE_AGENT_NAME = KIRO_NATIVE_CODING_AGENT.agent_name +_GOOSE_NATIVE_AGENT_NAME = GOOSE_NATIVE_CODING_AGENT.agent_name +_HERMES_NATIVE_AGENT_NAME = HERMES_NATIVE_CODING_AGENT.agent_name _ANTIGRAVITY_NATIVE_AGENT_NAME = ANTIGRAVITY_NATIVE_CODING_AGENT.agent_name _QWEN_NATIVE_AGENT_NAME = QWEN_NATIVE_CODING_AGENT.agent_name _KIMI_NATIVE_AGENT_NAME = KIMI_NATIVE_CODING_AGENT.agent_name @@ -431,6 +435,8 @@ def _ensure_default_agents( _ensure_default_opencode_agent(agent_store, artifact_store, agent_cache) _ensure_default_cursor_agent(agent_store, artifact_store, agent_cache) _ensure_default_kiro_agent(agent_store, artifact_store, agent_cache) + _ensure_default_goose_agent(agent_store, artifact_store, agent_cache) + _ensure_default_hermes_agent(agent_store, artifact_store, agent_cache) _ensure_default_antigravity_agent(agent_store, artifact_store, agent_cache) _ensure_default_qwen_agent(agent_store, artifact_store, agent_cache) _ensure_default_kimi_native_agent(agent_store, artifact_store, agent_cache) @@ -752,6 +758,62 @@ def _ensure_default_kiro_agent( ) +def _build_goose_native_bundle() -> bytes: + """Build a gzipped tarball of the goose-native-ui agent spec.""" + import tempfile + + from omnigent.goose_native import _materialize_goose_agent_spec + from omnigent.spec import materialize_bundle + + with tempfile.TemporaryDirectory() as tmpdir: + spec_path = _materialize_goose_agent_spec(Path(tmpdir)) + bundle_dir = materialize_bundle(spec_path, Path(tmpdir) / "bundle") + return _tar_gz_dir(bundle_dir) + + +def _ensure_default_goose_agent( + agent_store: AgentStore, + artifact_store: ArtifactStore, + agent_cache: Any, +) -> None: + """Register or refresh the goose-native-ui agent.""" + _ensure_builtin_agent( + agent_store, + artifact_store, + agent_cache, + name=_GOOSE_NATIVE_AGENT_NAME, + bundle_bytes=_build_goose_native_bundle(), + ) + + +def _build_hermes_native_bundle() -> bytes: + """Build a gzipped tarball of the hermes-native-ui agent spec.""" + import tempfile + + from omnigent.hermes_native import _materialize_hermes_agent_spec + from omnigent.spec import materialize_bundle + + with tempfile.TemporaryDirectory() as tmpdir: + spec_path = _materialize_hermes_agent_spec(Path(tmpdir)) + bundle_dir = materialize_bundle(spec_path, Path(tmpdir) / "bundle") + return _tar_gz_dir(bundle_dir) + + +def _ensure_default_hermes_agent( + agent_store: AgentStore, + artifact_store: ArtifactStore, + agent_cache: Any, +) -> None: + """Register or refresh the hermes-native-ui agent.""" + _ensure_builtin_agent( + agent_store, + artifact_store, + agent_cache, + name=_HERMES_NATIVE_AGENT_NAME, + bundle_bytes=_build_hermes_native_bundle(), + ) + + def _ensure_default_antigravity_agent( agent_store: AgentStore, artifact_store: ArtifactStore, From 31db1dcafe5cd27758d45a91df378e924aa45111 Mon Sep 17 00:00:00 2001 From: Serena Ruan <82044803+serena-ruan@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:16:23 +0800 Subject: [PATCH 023/546] feat(web): edit a queued message from the composer strip (#2019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): edit a queued message from the composer strip Each queued row gets a pencil button that pulls the message back into the composer for editing: its text and attachments load into the composer, the entry is removed from the queue, and the textarea is focused. Any in-progress draft is preserved (prepended). Re-sending re-queues it (busy) or sends it (idle). Stacked on the delete PR. Co-authored-by: Isaac * fix(web): edit replaces composer content instead of prepending Editing a queued message now replaces the composer's text and attachments with the queued message's, rather than prepending to an in-progress draft — prepending was surprising when the composer already held content. Co-authored-by: Isaac --- web/src/pages/ChatPage.tsx | 12 ++++++++++++ web/src/pages/QueuedMessagesStrip.test.tsx | 22 +++++++++++++++++++++- web/src/pages/QueuedMessagesStrip.tsx | 20 ++++++++++++++++---- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 7fe26fba0ec..fdb8c18adf2 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -4367,6 +4367,18 @@ export function Composer({ m.conversationId === conversationId)} onDelete={dequeueMessage} + onEdit={(queueId) => { + // Pull the queued message back into the composer for editing: + // replace the composer's text + attachments with the queued + // message's, remove it from the queue, and focus the textarea. + // Re-sending re-queues it (busy) or sends it (idle). + const target = queuedMessages.find((m) => m.queueId === queueId); + if (!target) return; + setValue(target.text); + setFiles(target.files ?? []); + dequeueMessage(queueId); + textareaRef.current?.focus(); + }} widthClassName={CHAT_COLUMN_WIDTH} /> {/* Sub-agent context tray — peeks above the card; reserves its own diff --git a/web/src/pages/QueuedMessagesStrip.test.tsx b/web/src/pages/QueuedMessagesStrip.test.tsx index 4cda6750030..4fdbb0fa33c 100644 --- a/web/src/pages/QueuedMessagesStrip.test.tsx +++ b/web/src/pages/QueuedMessagesStrip.test.tsx @@ -18,7 +18,9 @@ afterEach(cleanup); describe("QueuedMessagesStrip", () => { it("renders nothing when the queue is empty", () => { - const { container } = render(); + const { container } = render( + , + ); expect(container).toBeEmptyDOMElement(); }); @@ -27,6 +29,7 @@ describe("QueuedMessagesStrip", () => { , ); expect(screen.getByText("first")).toBeInTheDocument(); @@ -41,6 +44,7 @@ describe("QueuedMessagesStrip", () => { , ); const buttons = screen.getAllByRole("button", { name: "Remove queued message" }); @@ -49,4 +53,20 @@ describe("QueuedMessagesStrip", () => { expect(onDelete).toHaveBeenCalledTimes(1); expect(onDelete).toHaveBeenCalledWith("q_2"); }); + + it("calls onEdit with the row's queueId when its edit button is clicked", () => { + const onEdit = vi.fn(); + render( + , + ); + const buttons = screen.getAllByRole("button", { name: "Edit queued message" }); + expect(buttons).toHaveLength(2); + fireEvent.click(buttons[0]!); + expect(onEdit).toHaveBeenCalledTimes(1); + expect(onEdit).toHaveBeenCalledWith("q_1"); + }); }); diff --git a/web/src/pages/QueuedMessagesStrip.tsx b/web/src/pages/QueuedMessagesStrip.tsx index 73de604b933..d81c038667e 100644 --- a/web/src/pages/QueuedMessagesStrip.tsx +++ b/web/src/pages/QueuedMessagesStrip.tsx @@ -1,4 +1,4 @@ -import { ClockIcon, Trash2Icon } from "lucide-react"; +import { ClockIcon, PencilIcon, Trash2Icon } from "lucide-react"; import type { QueuedMessage } from "@/store/chatStore"; import { cn } from "@/lib/utils"; @@ -8,6 +8,8 @@ interface QueuedMessagesStripProps { messages: QueuedMessage[]; /** Remove a queued message by id (per-row delete). */ onDelete: (queueId: string) => void; + /** Pull a queued message back into the composer for editing. */ + onEdit: (queueId: string) => void; /** Column-width class so the strip lines up with the composer card. */ widthClassName?: string; } @@ -17,11 +19,13 @@ interface QueuedMessagesStripProps { * busy. Peeks above the composer card (`-mb-4` + bottom padding), mirroring * `SubagentComposerTray`. Renders nothing when the queue is empty. * - * Each row can be deleted; edit / steer / reorder land in later changes. + * Each row can be edited (pulled back into the composer) or deleted; steer / + * reorder land in later changes. */ export function QueuedMessagesStrip({ messages, onDelete, + onEdit, widthClassName, }: QueuedMessagesStripProps) { if (messages.length === 0) return null; @@ -44,8 +48,16 @@ export function QueuedMessagesStrip({
@@ -263,7 +293,7 @@ function UiFontSizeControl() { label="Increase font size" testId="ui-font-size-inc" disabled={atMax} - onClick={() => update(px + UI_FONT_SIZE_STEP)} + onClick={() => commit(px + UI_FONT_SIZE_STEP)} > From e1ee55aa4dd4023a5ff121ca1cddfc49591c3b24 Mon Sep 17 00:00:00 2001 From: Dhruv Gupta Date: Mon, 6 Jul 2026 17:47:24 -0700 Subject: [PATCH 039/546] feat(ci): enforce a 5-working-day reviewer SLA on PRs and issues (#2042) Scheduled weekday sweep (github-script, modeled on stale.yml + auto-assign-reviewer) that escalates open PRs/issues an assigned maintainer has sat on for >5 working days with no reply: - PRs: re-ping the requested reviewer + add a second reviewer (lowest-load owner of the touched area(s) in .github/areas.json, mirrored as an assignee). - Issues: re-ping the assignee + add a second assignee from the owners of the area(s) whose comp:* label the issue carries. - Escalate-once, guarded by BOTH a one-shot `review-sla-escalated` label and a hidden marker in the comment, so even a failed label write can't cause daily re-nudging. The second reviewer is added first (best-effort), so the comment only claims a reviewer that actually attached. - Cap escalations at 30 per sweep so an existing stale backlog drains gradually instead of firing all at once, and count each second reviewer against the in-sweep load so picks rotate across maintainers instead of concentrating on the current lowest-load one. Ownership is read from .github/areas.json -- the single source of truth shared with auto-assign-reviewer.js and issue triage. Runs from the trusted default branch (reads no PR code). Offline unit test (review-sla.test.js, 47 assertions, ownership pinned to a fixture) drives both paths through a mocked client; review-sla-test.yml runs it in CI. Co-authored-by: Isaac --- .github/workflows/review-sla-test.yml | 35 +++ .github/workflows/review-sla.js | 340 ++++++++++++++++++++++++++ .github/workflows/review-sla.test.js | 237 ++++++++++++++++++ .github/workflows/review-sla.yml | 49 ++++ 4 files changed, 661 insertions(+) create mode 100644 .github/workflows/review-sla-test.yml create mode 100644 .github/workflows/review-sla.js create mode 100644 .github/workflows/review-sla.test.js create mode 100644 .github/workflows/review-sla.yml diff --git a/.github/workflows/review-sla-test.yml b/.github/workflows/review-sla-test.yml new file mode 100644 index 00000000000..d014585400e --- /dev/null +++ b/.github/workflows/review-sla-test.yml @@ -0,0 +1,35 @@ +name: Reviewer SLA Test + +# Offline unit test for the SLA sweep logic: runs review-sla.test.js (mocked +# GitHub client, real .github/MAINTAINER; ownership pinned to a frozen fixture). +# Triggers only when the sweep, its test, or the pool files it reads change. Runs +# on `pull_request` (PR head checkout) so it tests the PR's own version. No +# secrets, no network. + +on: + pull_request: + paths: + - .github/workflows/review-sla.js + - .github/workflows/review-sla.test.js + - .github/workflows/review-sla.yml + - .github/MAINTAINER + - .github/areas.json + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: review-sla-test-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Run reviewer-SLA unit test + run: node .github/workflows/review-sla.test.js diff --git a/.github/workflows/review-sla.js b/.github/workflows/review-sla.js new file mode 100644 index 00000000000..d62b95ce3e6 --- /dev/null +++ b/.github/workflows/review-sla.js @@ -0,0 +1,340 @@ +// Reviewer SLA sweep: nudge + escalate open PRs and issues that a MAINTAINER has +// been sitting on for more than SLA_DAYS *working* days without replying. +// +// Runs on a schedule from the trusted default branch (see review-sla.yml), so it +// reads no PR-authored code and just talks to the issues/PRs API. For each open, +// non-draft item: +// - PRs: the "assigned person" is any maintainer in requested_reviewers (GitHub +// drops them from that list the moment they submit a review, so being in it +// means "still owes a review"). The clock starts at their latest +// `review_requested` event (fallback: PR opened). If >= SLA_DAYS working days +// have elapsed AND they've posted no comment or review since, the SLA is +// breached: re-ping them in one comment and add ONE second reviewer (lowest +// open-review load among the area owners in .github/areas.json, mirrored as +// an assignee like auto-assign-reviewer.js does). +// - Issues: the "assigned person" is any maintainer assignee; clock starts at +// their latest `assigned` event. Breach -> re-ping + add one second assignee +// from the owners of the area(s) whose comp:* label the issue carries. +// +// Ownership comes from .github/areas.json -- the single source of truth shared +// with auto-assign-reviewer.js and issue-triage.yml (it replaced the old +// .github/reviewers + .github/ISSUE_ASSIGNEES files). `owners_paused` is ignored. +// +// "Working days" = weekdays (Mon-Fri) in UTC. Reply = ANY comment or review by the +// assignee since the clock started. +// +// Escalate-once, two independent guards so the bot never spams: +// 1. a one-shot LABEL, and +// 2. the MARKER hidden in the reminder comment -- checked as a fallback so that +// even if the label write fails after the comment lands, the next sweep still +// sees the marker and skips. +// The second reviewer/assignee is added FIRST (best-effort); the comment is then +// worded to match what actually happened (so it can't claim "Adding @X" when the +// add 422'd), and the label is written last. If the comment itself fails nothing +// user-visible was posted, so we skip the label and let the next sweep retry. +// +// ponytail: one escalation per item. Per-reviewer re-escalation or a weekly +// re-ping would need per-nudge timestamp state instead of the label+marker pair -- +// add that only if a single nudge proves too weak. + +const fs = require("fs"); + +const SLA_DAYS = 5; // working days +const LABEL = "review-sla-escalated"; +const MARKER = ""; // idempotency fallback if the label write fails +const CANONICAL_REPO = "omnigent-ai/omnigent"; +// Max escalations per sweep. Bounds the day-one blast against an existing stale +// backlog (and any future surge): the backlog drains a chunk per weekday instead +// of nudging everything at once. PRs are processed before issues. +// ponytail: single global cap; split into per-kind caps if issue nudges starving +// behind a large PR backlog ever matters. +const MAX_ESCALATIONS_PER_RUN = 30; + +// --- Pure helpers (exported for the offline test; no network) -------------- + +// Weekdays strictly after `from`'s date, through `to`'s date, in UTC. So a review +// requested on a Monday first counts as 5 working days the following Monday. +// ponytail: weekends only, no holiday calendar -- add one if the SLA needs it. +function workingDaysBetween(from, to) { + const cur = new Date(from); + cur.setUTCHours(0, 0, 0, 0); + const end = new Date(to); + end.setUTCHours(0, 0, 0, 0); + let count = 0; + while (cur < end) { + cur.setUTCDate(cur.getUTCDate() + 1); + const d = cur.getUTCDay(); + if (d !== 0 && d !== 6) count++; + } + return count; +} + +// Latest ISO timestamp per (lowercased) login for a given timeline event type. +function latestByUser(timeline, eventName, getLogin) { + const out = {}; + for (const e of timeline || []) { + if (e.event !== eventName) continue; + const login = getLogin(e); + if (!login || !e.created_at) continue; + const lc = login.toLowerCase(); + if (!out[lc] || new Date(e.created_at) > new Date(out[lc])) out[lc] = e.created_at; + } + return out; +} + +// Did `login` post any comment/review after `sinceIso`? +function repliedSince(login, sinceIso, comments, reviews, reviewComments) { + const since = new Date(sinceIso).getTime(); + const lc = login.toLowerCase(); + const by = (u) => (u || "").toLowerCase() === lc; + const after = (t) => t && new Date(t).getTime() > since; + return ( + (comments || []).some((c) => by(c.user && c.user.login) && after(c.created_at)) || + (reviews || []).some((r) => by(r.user && r.user.login) && after(r.submitted_at)) || + (reviewComments || []).some((rc) => by(rc.user && rc.user.login) && after(rc.created_at)) + ); +} + +// Have we already posted a reminder here? (idempotency fallback for a failed label) +function alreadyNudged(comments) { + return (comments || []).some((c) => (c.body || "").includes(MARKER)); +} + +// Breached maintainer targets for one item, given the reply signals. Shared by the +// PR and issue paths (issues pass [] for reviews/reviewComments). +function breachedTargets({ targets, clockStartByUser, openedAt, now, comments, reviews, reviewComments }) { + const out = []; + for (const t of targets) { + // Fallback to openedAt when there's no explicit request/assign event for + // this login (e.g. a CODEOWNERS/team expansion, or a timeline pagination + // edge). That can over-count elapsed time slightly -- acceptable, and never + // fires for the normal auto-assigned path which always emits the event. + const since = clockStartByUser[t.toLowerCase()] || openedAt; + if (workingDaysBetween(since, now) < SLA_DAYS) continue; + if (repliedSince(t, since, comments, reviews, reviewComments)) continue; + out.push(t); + } + return out; +} + +// Parse .github/areas.json (same shape auto-assign-reviewer.js reads) into: +// rules - [{ prefix, owners }] in document order (last match wins per file) +// pool - Map lc->original of every owner (the full candidate set) +// labelOwners - Map "comp:x" -> Set of owners, for routing an issue by its label +// `owners_paused` is intentionally ignored. `text` is injectable for tests. +function parseAreas(text) { + const areas = JSON.parse(text).areas || []; + const rules = []; + const pool = new Map(); + const labelOwners = new Map(); + for (const area of areas) { + const owners = area.owners || []; + owners.forEach((o) => pool.set(o.toLowerCase(), o)); + for (const p of area.paths || []) rules.push({ prefix: p.replace(/^\//, ""), owners }); + if (area.label) { + const set = labelOwners.get(area.label) || new Set(); + owners.forEach((o) => set.add(o)); + labelOwners.set(area.label, set); + } + } + return { rules, pool, labelOwners }; +} + +// Count currently-open review requests per (lc) login -- the stateless fairness +// signal auto-assign-reviewer.js also uses. +function buildLoad(openPRs) { + const load = new Map(); + for (const p of openPRs) + for (const r of p.requested_reviewers || []) { + const l = (r.login || "").toLowerCase(); + load.set(l, (load.get(l) || 0) + 1); + } + return load; +} + +// Pick the lowest-load of a candidate list, random tie-break within a load tier. +function lowestLoad(candidates, load) { + if (!candidates.length) return null; + const loadOf = (u) => load.get(u.toLowerCase()) || 0; + const byTier = {}; + for (const u of candidates) (byTier[loadOf(u)] ||= []).push(u); + const lowest = byTier[Math.min(...Object.keys(byTier).map(Number))]; + return lowest[Math.floor(Math.random() * lowest.length)]; +} + +// One lowest-load area owner for the PR's files, else lowest from the full pool; +// never anyone already on the PR. +function pickSecondReviewer({ files, rules, pool, load, exclude }) { + const areaOwners = new Map(); + for (const f of files) { + let match = null; + for (const r of rules) if (f.startsWith(r.prefix)) match = r; // last wins + if (match) match.owners.forEach((o) => areaOwners.set(o.toLowerCase(), o)); + } + const base = areaOwners.size ? areaOwners : pool; + return lowestLoad([...base.values()].filter((u) => !exclude.has(u.toLowerCase())), load); +} + +// One second assignee from the owners of the issue's comp:* area(s), else the full +// pool; never anyone already assigned. +// ponytail: tie-break reuses the PR open-review `load` -- a proxy for issues (there +// is no per-assignee open-issue count), so this only approximates issue fairness. +// Tally open-issue assignee counts here if that starts to matter. +function pickSecondAssignee({ labels, labelOwners, pool, load, exclude }) { + const owners = new Set(); + for (const l of labels) for (const o of labelOwners.get(l) || []) owners.add(o); + const base = owners.size ? owners : new Set(pool.values()); + return lowestLoad([...base].filter((u) => !exclude.has(u.toLowerCase())), load); +} + +// --- Orchestrator ---------------------------------------------------------- + +async function run({ github, context, core }) { + const { owner, repo } = context.repo; + if (`${owner}/${repo}` !== CANONICAL_REPO) { + core.info(`Not ${CANONICAL_REPO}; skipping.`); + return; + } + const now = new Date(); + + const maintainers = new Set( + fs.readFileSync(".github/MAINTAINER", "utf8") + .split("\n").map((l) => l.replace(/#.*/, "").trim().toLowerCase()).filter(Boolean) + ); + // REVIEWER_AREAS_FILE lets the unit test pin a fixture; defaults to the real file. + const areasFile = process.env.REVIEWER_AREAS_FILE || ".github/areas.json"; + const { rules, pool, labelOwners } = parseAreas(fs.readFileSync(areasFile, "utf8")); + + const hasLabel = (item) => (item.labels || []).some((l) => (l.name || l) === LABEL); + const escalated = []; + const capReached = () => escalated.length >= MAX_ESCALATIONS_PER_RUN; + + // Escalate one item once. Add the second reviewer/assignee FIRST (best-effort, + // returns the login it actually added or null), so the comment states the true + // outcome; then post the marked comment; then lock the LABEL. If the comment + // fails, nothing was posted -> skip the label and retry next sweep. + const escalateOnce = async (number, breached, kind, addSecond, secondCandidate) => { + let added = null; + if (secondCandidate) { + try { + added = (await addSecond()) ? secondCandidate : null; + } catch (e) { + core.warning(`#${number}: could not add second ${kind} @${secondCandidate}: ${e.message}`); + } + } + const noun = kind === "reviewer" ? "review" : "a response"; + const body = + `${MARKER}\n⏰ **${kind === "reviewer" ? "Reviewer" : "Response"} SLA** — this ${kind === "reviewer" ? "PR" : "issue"} ` + + `has been awaiting ${noun} from ${breached.map((u) => "@" + u).join(", ")} for more than ${SLA_DAYS} working days.` + + (added ? ` Adding @${added} as a second ${kind}.` : ""); + try { + await github.rest.issues.createComment({ owner, repo, issue_number: number, body }); + } catch (e) { + core.warning(`#${number}: reminder comment failed, will retry next run: ${e.message}`); + return; + } + try { + await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels: [LABEL] }); + } catch (e) { + core.warning(`#${number}: could not add ${LABEL} label (marker still guards re-nudge): ${e.message}`); + } + escalated.push(`${kind === "reviewer" ? "PR" : "issue"} #${number} (re-pinged ${breached.join(", ")}${added ? `, +@${added}` : ""})`); + }; + + // ----- PRs: awaiting a maintainer's review ----- + const openPRs = await github.paginate(github.rest.pulls.list, { owner, repo, state: "open", per_page: 100 }); + const load = buildLoad(openPRs); + // Count each second reviewer/assignee we add during THIS sweep against the load + // map, so successive picks rotate instead of dogpiling the current lowest-load + // maintainer -- without it, one sweep hands nearly every escalation to one person. + const bumpLoad = (u) => load.set(u.toLowerCase(), (load.get(u.toLowerCase()) || 0) + 1); + + for (const pr of openPRs) { + if (capReached()) break; + if (pr.draft || hasLabel(pr)) continue; + const targets = (pr.requested_reviewers || []).map((r) => r.login).filter((l) => maintainers.has(l.toLowerCase())); + if (!targets.length) continue; + + const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, { owner, repo, issue_number: pr.number, per_page: 100 }); + const requestedAt = latestByUser(timeline, "review_requested", (e) => e.requested_reviewer && e.requested_reviewer.login); + + // Cheap staleness prefilter before fetching reply signals. + const stale = targets.filter((t) => workingDaysBetween(requestedAt[t.toLowerCase()] || pr.created_at, now) >= SLA_DAYS); + if (!stale.length) continue; + + const [comments, reviews, reviewComments] = await Promise.all([ + github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 }), + github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number: pr.number, per_page: 100 }), + github.paginate(github.rest.pulls.listReviewComments, { owner, repo, pull_number: pr.number, per_page: 100 }), + ]); + if (alreadyNudged(comments)) continue; // label may have failed to write; marker still guards + const breached = breachedTargets({ + targets: stale, clockStartByUser: requestedAt, openedAt: pr.created_at, now, comments, reviews, reviewComments, + }); + if (!breached.length) continue; + + const files = (await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pr.number, per_page: 100 })).map((f) => f.filename); + const onPr = new Set( + [pr.user && pr.user.login, ...targets, ...(pr.assignees || []).map((a) => a.login), ...(pr.requested_reviewers || []).map((r) => r.login)] + .filter(Boolean).map((s) => s.toLowerCase()) + ); + const second = pickSecondReviewer({ files, rules, pool, load, exclude: onPr }); + + await escalateOnce(pr.number, breached, "reviewer", async () => { + await github.rest.pulls.requestReviewers({ owner, repo, pull_number: pr.number, reviewers: [second] }); + // Mirror as assignee for UI filterability, matching auto-assign-reviewer.js. + await github.rest.issues.addAssignees({ owner, repo, issue_number: pr.number, assignees: [second] }); + bumpLoad(second); + return true; + }, second); + } + + // ----- Issues: awaiting a maintainer assignee ----- + const openIssues = await github.paginate(github.rest.issues.listForRepo, { owner, repo, state: "open", per_page: 100 }); + for (const issue of openIssues) { + if (capReached()) break; + if (issue.pull_request || hasLabel(issue)) continue; // listForRepo also returns PRs + const targets = (issue.assignees || []).map((a) => a.login).filter((l) => maintainers.has(l.toLowerCase())); + if (!targets.length) continue; + + const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, { owner, repo, issue_number: issue.number, per_page: 100 }); + const assignedAt = latestByUser(timeline, "assigned", (e) => e.assignee && e.assignee.login); + + const stale = targets.filter((t) => workingDaysBetween(assignedAt[t.toLowerCase()] || issue.created_at, now) >= SLA_DAYS); + if (!stale.length) continue; + + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: issue.number, per_page: 100 }); + if (alreadyNudged(comments)) continue; + const breached = breachedTargets({ + targets: stale, clockStartByUser: assignedAt, openedAt: issue.created_at, now, comments, reviews: [], reviewComments: [], + }); + if (!breached.length) continue; + + const labels = (issue.labels || []).map((l) => l.name || l).filter((n) => n.startsWith("comp:")); + const onIssue = new Set((issue.assignees || []).map((a) => a.login.toLowerCase())); + const second = pickSecondAssignee({ labels, labelOwners, pool, load, exclude: onIssue }); + + await escalateOnce(issue.number, breached, "assignee", async () => { + await github.rest.issues.addAssignees({ owner, repo, issue_number: issue.number, assignees: [second] }); + bumpLoad(second); + return true; + }, second); + } + + core.info(escalated.length ? `Escalated ${escalated.length}: ${escalated.join("; ")}.` : "No SLA breaches; nothing to escalate."); +} + +module.exports = run; +// Exported for the offline unit test. +module.exports.workingDaysBetween = workingDaysBetween; +module.exports.latestByUser = latestByUser; +module.exports.repliedSince = repliedSince; +module.exports.alreadyNudged = alreadyNudged; +module.exports.breachedTargets = breachedTargets; +module.exports.parseAreas = parseAreas; +module.exports.pickSecondReviewer = pickSecondReviewer; +module.exports.pickSecondAssignee = pickSecondAssignee; +module.exports.SLA_DAYS = SLA_DAYS; +module.exports.LABEL = LABEL; +module.exports.MARKER = MARKER; +module.exports.MAX_ESCALATIONS_PER_RUN = MAX_ESCALATIONS_PER_RUN; diff --git a/.github/workflows/review-sla.test.js b/.github/workflows/review-sla.test.js new file mode 100644 index 00000000000..448f7934af7 --- /dev/null +++ b/.github/workflows/review-sla.test.js @@ -0,0 +1,237 @@ +// Offline unit test for review-sla.js -- exercises the pure decision helpers and +// one end-to-end orchestration of each path against a mocked GitHub client. No +// network. cwd must be the repo root (the orchestrator reads the real +// .github/MAINTAINER; ownership is pinned to a frozen fixture via +// REVIEWER_AREAS_FILE so the test doesn't churn when .github/areas.json changes). +const path = require("path"); +const os = require("os"); +const fs = require("fs"); +const script = require(path.resolve(".github/workflows/review-sla.js")); + +// Frozen area fixture: stable owners the orchestration assertions can pin to. +const FIXTURE = { + areas: [ + { key: "inner", label: "comp:harnesses", paths: ["omnigent/inner/"], owners: ["ownerA", "ownerB", "ownerC"] }, + { key: "web", label: "comp:web-ui", paths: ["web/"], owners: ["webX", "webY"] }, + ], +}; +const FIXTURE_PATH = path.join(os.tmpdir(), "review-sla-areas.fixture.json"); +fs.writeFileSync(FIXTURE_PATH, JSON.stringify(FIXTURE)); +process.env.REVIEWER_AREAS_FILE = FIXTURE_PATH; + +function assert(name, cond, detail) { + console.log(`${cond ? "PASS" : "FAIL"} ${name}${detail ? " -- " + detail : ""}`); + if (!cond) process.exitCode = 1; +} + +const daysAgoIso = (n) => new Date(Date.now() - n * 86400000).toISOString(); + +// Mocked GitHub client. `canned` maps a list-endpoint tag -> the array it returns +// through github.paginate; writes are recorded in `sink`. `failRequestReviewers` +// makes pulls.requestReviewers throw, to exercise the partial-failure path. +function mkGithub(canned, sink, opts = {}) { + const list = (tag) => { const f = async () => {}; f._tag = tag; return f; }; + return { + paginate: async (fn) => canned[fn._tag] || [], + rest: { + pulls: { + list: list("openPRs"), + listReviews: list("reviews"), + listReviewComments: list("reviewComments"), + listFiles: list("files"), + requestReviewers: async (a) => { + if (opts.failRequestReviewers) throw new Error("HTTP 422: reviewer is not a collaborator"); + sink.requested.push(...a.reviewers); + }, + }, + issues: { + listForRepo: list("openIssues"), + listEventsForTimeline: list("timeline"), + listComments: list("comments"), + createComment: async (a) => sink.comments.push(a), + addAssignees: async (a) => sink.assigned.push(...a.assignees), + addLabels: async (a) => sink.labels.push(...a.labels), + }, + }, + }; +} + +async function runOrch(canned, opts) { + const sink = { comments: [], requested: [], assigned: [], labels: [], warnings: [] }; + const core = { info: () => {}, warning: (m) => sink.warnings.push(m) }; + const context = { repo: { owner: "omnigent-ai", repo: "omnigent" } }; + await script({ github: mkGithub(canned, sink, opts), context, core }); + return sink; +} + +(async () => { + // ---- workingDaysBetween (2026-01-05 is a Monday, 01-12 the next Monday) ---- + const wdb = script.workingDaysBetween; + assert("same day -> 0", wdb("2026-01-05", "2026-01-05") === 0); + assert("Mon -> next Mon (7 cal days) -> 5 working days", wdb("2026-01-05", "2026-01-12") === 5, String(wdb("2026-01-05", "2026-01-12"))); + assert("Fri -> Mon spans a weekend -> 1", wdb("2026-01-09", "2026-01-12") === 1, String(wdb("2026-01-09", "2026-01-12"))); + assert("Sat -> Sun -> 0", wdb("2026-01-10", "2026-01-11") === 0); + + // ---- latestByUser ---- + const tl = [ + { event: "review_requested", requested_reviewer: { login: "Alice" }, created_at: "2026-01-01T00:00:00Z" }, + { event: "review_requested", requested_reviewer: { login: "Alice" }, created_at: "2026-01-03T00:00:00Z" }, + { event: "assigned", assignee: { login: "Bob" }, created_at: "2026-01-02T00:00:00Z" }, + ]; + const rq = script.latestByUser(tl, "review_requested", (e) => e.requested_reviewer && e.requested_reviewer.login); + assert("latestByUser keeps the newer event", rq.alice === "2026-01-03T00:00:00Z", JSON.stringify(rq)); + assert("latestByUser ignores other event types", !("bob" in rq)); + + // ---- repliedSince ---- + const since = "2026-01-01T00:00:00Z"; + assert("comment after -> replied", + script.repliedSince("alice", since, [{ user: { login: "Alice" }, created_at: "2026-01-02T00:00:00Z" }], [], []) === true); + assert("comment before -> not replied", + script.repliedSince("alice", since, [{ user: { login: "Alice" }, created_at: "2025-12-31T00:00:00Z" }], [], []) === false); + assert("review after -> replied", + script.repliedSince("alice", since, [], [{ user: { login: "alice" }, submitted_at: "2026-01-05T00:00:00Z" }], []) === true); + assert("someone else's comment -> not replied", + script.repliedSince("alice", since, [{ user: { login: "Bob" }, created_at: "2026-01-09T00:00:00Z" }], [], []) === false); + + // ---- alreadyNudged (marker fallback) ---- + assert("alreadyNudged: marker present -> true", script.alreadyNudged([{ body: "hi " + script.MARKER }]) === true); + assert("alreadyNudged: no marker -> false", script.alreadyNudged([{ body: "just a normal comment" }]) === false); + + // ---- breachedTargets ---- + const now = new Date(); + const b1 = script.breachedTargets({ + targets: ["Alice"], clockStartByUser: { alice: daysAgoIso(14) }, openedAt: daysAgoIso(30), now, + comments: [], reviews: [], reviewComments: [], + }); + assert("stale + silent -> breached", JSON.stringify(b1) === JSON.stringify(["Alice"]), JSON.stringify(b1)); + const b2 = script.breachedTargets({ + targets: ["Alice"], clockStartByUser: { alice: daysAgoIso(1) }, openedAt: daysAgoIso(1), now, + comments: [], reviews: [], reviewComments: [], + }); + assert("within SLA -> not breached", b2.length === 0, JSON.stringify(b2)); + const b3 = script.breachedTargets({ + targets: ["Alice"], clockStartByUser: { alice: daysAgoIso(14) }, openedAt: daysAgoIso(30), now, + comments: [{ user: { login: "Alice" }, created_at: daysAgoIso(1) }], reviews: [], reviewComments: [], + }); + assert("stale but replied -> not breached", b3.length === 0, JSON.stringify(b3)); + + // ---- parseAreas ---- + const { rules, pool, labelOwners } = script.parseAreas(JSON.stringify(FIXTURE)); + assert("parseAreas: rules preserve prefixes", rules.some((r) => r.prefix === "omnigent/inner/") && rules.some((r) => r.prefix === "web/"), JSON.stringify(rules)); + assert("parseAreas: pool unions all owners", ["ownera", "ownerb", "ownerc", "webx", "weby"].every((o) => pool.has(o)), JSON.stringify([...pool.keys()])); + assert("parseAreas: labelOwners maps comp:* -> owners", [...(labelOwners.get("comp:web-ui") || [])].sort().join(",") === "webX,webY", JSON.stringify([...(labelOwners.get("comp:web-ui") || [])])); + + // ---- pickSecondReviewer ---- + const srMembers = script.pickSecondReviewer({ + files: ["omnigent/inner/foo.py"], rules, pool, load: new Map(), + exclude: new Set(["ownera"]), + }); + assert("second reviewer is an inner owner, excluding those on the PR", + ["ownerb", "ownerc"].includes((srMembers || "").toLowerCase()), String(srMembers)); + const srLoad = script.pickSecondReviewer({ + files: ["omnigent/inner/foo.py"], rules, pool, + load: new Map([["ownera", 5], ["ownerb", 5], ["ownerc", 0]]), + exclude: new Set(), + }); + assert("lowest-load owner wins the tie-break", (srLoad || "").toLowerCase() === "ownerc", String(srLoad)); + const srFallback = script.pickSecondReviewer({ + files: ["README.md"], rules, pool, load: new Map(), exclude: new Set(), + }); + assert("unowned path -> falls back to the full pool", pool.has((srFallback || "").toLowerCase()), String(srFallback)); + + // ---- pickSecondAssignee ---- + const saMatch = script.pickSecondAssignee({ + labels: ["comp:web-ui"], labelOwners, pool, load: new Map(), exclude: new Set(["webx"]), + }); + assert("second assignee comes from the label's owners, excluding the current one", + (saMatch || "").toLowerCase() === "weby", String(saMatch)); + const saFallback = script.pickSecondAssignee({ + labels: [], labelOwners, pool, load: new Map(), exclude: new Set(), + }); + assert("no comp label -> falls back to the full pool", pool.has((saFallback || "").toLowerCase()), String(saFallback)); + + // ---- orchestration: a stale, silent PR gets nudged + a 2nd reviewer + label -- + const stalePR = { + number: 7, draft: false, labels: [], user: { login: "someexternaldev" }, + created_at: daysAgoIso(14), requested_reviewers: [{ login: "dhruv0811" }], assignees: [{ login: "dhruv0811" }], + }; + let s = await runOrch({ + openPRs: [stalePR], openIssues: [], timeline: [], comments: [], reviews: [], reviewComments: [], + files: [{ filename: "omnigent/inner/foo.py" }], + }); + assert("stale PR: one reminder comment posted", s.comments.length === 1 && s.comments[0].issue_number === 7, JSON.stringify(s.comments)); + assert("stale PR: comment re-pings the assigned reviewer", /@dhruv0811/.test(s.comments[0].body), s.comments[0] && s.comments[0].body); + assert("stale PR: a second reviewer is requested from the area owners", + s.requested.length === 1 && ["ownera", "ownerb", "ownerc"].includes(s.requested[0].toLowerCase()), JSON.stringify(s.requested)); + assert("stale PR: second reviewer mirrored as assignee", JSON.stringify(s.assigned) === JSON.stringify(s.requested), JSON.stringify(s.assigned)); + assert("stale PR: comment names exactly the reviewer that was added", + new RegExp(`Adding @${s.requested[0]} as a second reviewer`).test(s.comments[0].body), s.comments[0] && s.comments[0].body); + assert("stale PR: comment carries the idempotency marker", s.comments[0].body.includes(script.MARKER), s.comments[0] && s.comments[0].body); + assert("stale PR: labelled once", JSON.stringify(s.labels) === JSON.stringify([script.LABEL]), JSON.stringify(s.labels)); + + // ---- orchestration: partial failure -- requestReviewers throws -- + // add-first ordering means the comment must NOT claim a 2nd reviewer that failed + // to attach, yet the item is still labelled so it won't be re-nudged tomorrow. + s = await runOrch({ + openPRs: [stalePR], openIssues: [], timeline: [], comments: [], reviews: [], reviewComments: [], + files: [{ filename: "omnigent/inner/foo.py" }], + }, { failRequestReviewers: true }); + assert("partial failure: reminder comment still posted", s.comments.length === 1, JSON.stringify(s.comments)); + assert("partial failure: comment does NOT over-claim a second reviewer", !/second reviewer/.test(s.comments[0].body), s.comments[0] && s.comments[0].body); + assert("partial failure: no reviewer was actually requested", s.requested.length === 0, JSON.stringify(s.requested)); + assert("partial failure: still labelled (won't re-nudge next run)", JSON.stringify(s.labels) === JSON.stringify([script.LABEL]), JSON.stringify(s.labels)); + assert("partial failure: the reviewer-add error is warned, not fatal", s.warnings.some((w) => /could not add second reviewer/.test(w)), JSON.stringify(s.warnings)); + + // ---- orchestration: marker fallback -- prior nudge exists but the label didn't -- + s = await runOrch({ + openPRs: [stalePR], openIssues: [], timeline: [], reviews: [], reviewComments: [], + files: [{ filename: "omnigent/inner/foo.py" }], + comments: [{ user: { login: "omnigent-ci" }, body: script.MARKER + "\nearlier nudge", created_at: daysAgoIso(2) }], + }); + assert("marker fallback: an already-nudged PR (marker present, no label) is skipped", + s.comments.length === 0 && s.labels.length === 0, JSON.stringify(s)); + + // ---- orchestration: already-labelled PR is left alone (one-shot) ---- + s = await runOrch({ openPRs: [{ ...stalePR, labels: [{ name: script.LABEL }] }], openIssues: [], files: [] }); + assert("already-escalated PR is skipped", s.comments.length === 0 && s.labels.length === 0, JSON.stringify(s)); + + // ---- orchestration: a fresh PR (within SLA) is left alone ---- + s = await runOrch({ openPRs: [{ ...stalePR, created_at: daysAgoIso(1) }], openIssues: [], timeline: [], files: [] }); + assert("fresh PR is not escalated", s.comments.length === 0, JSON.stringify(s)); + + // ---- orchestration: a PR whose reviewer already commented is left alone ---- + s = await runOrch({ + openPRs: [stalePR], openIssues: [], timeline: [], reviews: [], reviewComments: [], files: [], + comments: [{ user: { login: "dhruv0811" }, created_at: daysAgoIso(1) }], + }); + assert("PR with a recent reply is not escalated", s.comments.length === 0, JSON.stringify(s)); + + // ---- orchestration: a stale, silent issue gets nudged + a 2nd assignee + label -- + const staleIssue = { + number: 9, labels: [{ name: "comp:web-ui" }], created_at: daysAgoIso(14), assignees: [{ login: "hzub" }], + }; + s = await runOrch({ openPRs: [], openIssues: [staleIssue], timeline: [], comments: [] }); + assert("stale issue: one reminder comment posted", s.comments.length === 1 && s.comments[0].issue_number === 9, JSON.stringify(s.comments)); + assert("stale issue: re-pings the assignee", /@hzub/.test(s.comments[0].body), s.comments[0] && s.comments[0].body); + assert("stale issue: a second assignee from the label's owners", ["webx", "weby"].includes((s.assigned[0] || "").toLowerCase()), JSON.stringify(s.assigned)); + assert("stale issue: labelled once", JSON.stringify(s.labels) === JSON.stringify([script.LABEL]), JSON.stringify(s.labels)); + + // ---- orchestration: a real PR object (listForRepo) is not double-swept as an issue -- + s = await runOrch({ openPRs: [], openIssues: [{ ...staleIssue, pull_request: {} }], timeline: [], comments: [] }); + assert("PR returned by listForRepo is skipped in the issue sweep", s.comments.length === 0, JSON.stringify(s)); + + // ---- orchestration: per-run cap + in-sweep load spread ---- + // Feed more stale PRs than the cap. Expect exactly MAX escalations, and the + // second reviewer rotates across all 3 inner owners rather than dogpiling the + // one lowest-load maintainer (regression for the live-data concentration bug). + const MAX = script.MAX_ESCALATIONS_PER_RUN; + const manyStale = Array.from({ length: MAX + 5 }, (_, i) => ({ ...stalePR, number: 3000 + i })); + s = await runOrch({ + openPRs: manyStale, openIssues: [], timeline: [], comments: [], reviews: [], reviewComments: [], + files: [{ filename: "omnigent/inner/foo.py" }], + }); + assert("cap: escalations stop at MAX_ESCALATIONS_PER_RUN", s.comments.length === MAX, `${s.comments.length} vs ${MAX}`); + assert("cap: labels capped to match", s.labels.length === MAX, String(s.labels.length)); + assert("load spread: second reviewer rotates across all 3 inner owners (not dogpiled on one)", + new Set(s.requested.map((u) => u.toLowerCase())).size === 3, JSON.stringify([...new Set(s.requested)])); +})(); diff --git a/.github/workflows/review-sla.yml b/.github/workflows/review-sla.yml new file mode 100644 index 00000000000..2cbc4db96fa --- /dev/null +++ b/.github/workflows/review-sla.yml @@ -0,0 +1,49 @@ +name: Reviewer SLA + +# Daily (weekday) sweep that enforces a 5-working-day reviewer SLA: any open PR +# awaiting review from a maintainer -- or open issue awaiting a maintainer +# assignee -- with no reply in 5 working days gets the assignee re-pinged in a +# comment plus a second reviewer (PR) / second assignee (issue), then a one-shot +# `review-sla-escalated` label so it's never nudged twice. All logic + safety +# notes live in review-sla.js (offline unit test: review-sla.test.js). +# +# Scheduled -> runs on the trusted default branch with the repo GITHUB_TOKEN; it +# reads no PR-authored code, only .github/ config + the issues/PRs API. + +on: + schedule: + - cron: "0 8 * * 1-5" # 08:00 UTC, Mon-Fri (weekday SLA -> no weekend pings) + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: review-sla + cancel-in-progress: true + +jobs: + sweep: + if: github.repository == 'omnigent-ai/omnigent' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + # Job-level permissions REPLACE the workflow-level block, so restate read. + contents: read + pull-requests: write # comment + request the second reviewer + issues: write # comment + assign + label + steps: + # Trusted default branch, .github only (config the script reads). Never PR head. + - name: Check out .github + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.repository.default_branch }} + sparse-checkout: .github + persist-credentials: false + - name: Sweep open PRs + issues for SLA breaches + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + retries: 3 + script: | + const script = require('./.github/workflows/review-sla.js'); + await script({ github, context, core }); From a0e6f511ec5fb96f22beefa1970810a5c04c5192 Mon Sep 17 00:00:00 2001 From: Vadim Comanescu Date: Tue, 7 Jul 2026 03:06:14 +0200 Subject: [PATCH 040/546] fix(runtime): tolerate missing lsof in orphan sweep (#1266) Signed-off-by: Vadim Comanescu --- omnigent/runtime/harnesses/process_manager.py | 17 ++++++++++------- tests/runtime/harnesses/test_process_manager.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/omnigent/runtime/harnesses/process_manager.py b/omnigent/runtime/harnesses/process_manager.py index 21e207575b3..db104ba8b9c 100644 --- a/omnigent/runtime/harnesses/process_manager.py +++ b/omnigent/runtime/harnesses/process_manager.py @@ -1356,13 +1356,16 @@ async def _pids_holding_socket(socket_path: Path) -> list[int]: :returns: List of holding PIDs (often a single one — the bound runner). """ - proc = await asyncio.create_subprocess_exec( - "lsof", - "-t", - str(socket_path), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.DEVNULL, - ) + try: + proc = await asyncio.create_subprocess_exec( + "lsof", + "-t", + str(socket_path), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + except OSError: + return [] stdout, _ = await proc.communicate() if proc.returncode != 0: return [] diff --git a/tests/runtime/harnesses/test_process_manager.py b/tests/runtime/harnesses/test_process_manager.py index 266b2bdab31..091cfa2845f 100644 --- a/tests/runtime/harnesses/test_process_manager.py +++ b/tests/runtime/harnesses/test_process_manager.py @@ -794,6 +794,21 @@ async def test_pids_holding_socket_returns_empty_for_missing( assert pids == [] +async def test_pids_holding_socket_returns_empty_when_lsof_is_missing( + monkeypatch: pytest.MonkeyPatch, + short_tmp_parent: Path, +) -> None: + """Missing ``lsof`` is best-effort cleanup noise, not a boot failure.""" + + async def missing_lsof(*_args: object, **_kwargs: object) -> object: + raise FileNotFoundError(2, "No such file or directory", "lsof") + + monkeypatch.setattr(asyncio, "create_subprocess_exec", missing_lsof) + + pids = await _pids_holding_socket(short_tmp_parent / "conv-stale.sock") + assert pids == [] + + # ── Per-spawn env override ───────────────────────────────────── From 8236c7289066793585a5cf006af99498b3975ed5 Mon Sep 17 00:00:00 2001 From: ShiZai Date: Tue, 7 Jul 2026 09:10:44 +0800 Subject: [PATCH 041/546] fix(harnesses): re-check idleness before the reaper releases an entry (#1834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The idle reaper snapshots its stale list under the registry lock, then releases each entry outside it; a single teardown can hold the pass open for seconds (graceful-SIGTERM wait). A turn that starts on a later-listed conversation during that window refreshes last_used_at and marks itself in flight — but release() tore the entry down without re-checking, SIGTERMing the subprocess mid-turn. Users saw a turn on a long-idle session die seconds after it started with a harness stream connection error. release() now takes only_if_idle_cutoff (passed only by the reaper): under the registry lock, atomically with the unregister, it skips entries that were touched after the pass cutoff or have a turn in flight — they are reclaimed by a later pass once genuinely idle. Mirrors the pane reaper's busy re-check immediately before teardown. Signed-off-by: tomsen-ai <230283659+tomsen-ai@users.noreply.github.com> Co-authored-by: tomsen-ai <230283659+tomsen-ai@users.noreply.github.com> --- omnigent/runtime/harnesses/process_manager.py | 32 +++++- .../runtime/harnesses/test_process_manager.py | 100 +++++++++++++++++- 2 files changed, 128 insertions(+), 4 deletions(-) diff --git a/omnigent/runtime/harnesses/process_manager.py b/omnigent/runtime/harnesses/process_manager.py index db104ba8b9c..0f33d7cb131 100644 --- a/omnigent/runtime/harnesses/process_manager.py +++ b/omnigent/runtime/harnesses/process_manager.py @@ -895,13 +895,24 @@ def clear_in_flight(self, conversation_id: str) -> None: """ self._in_flight_response_ids.pop(conversation_id, None) - async def release(self, conversation_id: str) -> None: + async def release( + self, conversation_id: str, *, only_if_idle_cutoff: float | None = None + ) -> None: """ Terminate and unregister the subprocess for a conversation. Called when the conversation reaches a terminal state. No-op if no subprocess is registered for the id. + ``only_if_idle_cutoff`` (the idle reaper's pass cutoff) makes the + release conditional: the entry is torn down only if it is still + idle — untouched since the cutoff and with no turn in flight. + The check happens under the registry lock, atomically with the + unregister, so a turn that starts while an earlier entry in the + same reaper pass tears down can never be killed mid-flight + (mirrors ``pane_reaper``'s busy re-check immediately before + teardown). + Note: ``_spawn_locks[conversation_id]`` is intentionally NOT removed here. If we removed it, a concurrent caller already holding a reference to the lock could be racing a fresh @@ -917,6 +928,19 @@ async def release(self, conversation_id: str) -> None: :param conversation_id: AP-allocated conversation id. """ async with self._registry_lock: + if only_if_idle_cutoff is not None: + current = self._entries.get(conversation_id) + if ( + current is None + or current.last_used_at > only_if_idle_cutoff + or conversation_id in self._in_flight_response_ids + ): + _logger.info( + "skipping idle reap for conversation %s: entry became " + "active or was already released during the pass", + conversation_id, + ) + return entry = self._entries.pop(conversation_id, None) # NOTE: ``_spawn_locks[conversation_id]`` intentionally # NOT popped — see this method's docstring for the @@ -1192,7 +1216,11 @@ async def _idle_reaper_loop(self) -> None: conv_id, ) try: - await self.release(conv_id) + # Teardown of earlier entries in this pass yields the + # loop, so the snapshot above can be stale by the time + # this entry's turn comes — release re-checks idleness + # atomically with the unregister. + await self.release(conv_id, only_if_idle_cutoff=cutoff) except Exception: # A release failure (e.g. ``client.aclose()`` on a broken # transport, or ``process.wait()`` raising) must not escape diff --git a/tests/runtime/harnesses/test_process_manager.py b/tests/runtime/harnesses/test_process_manager.py index 091cfa2845f..84c507dc9f8 100644 --- a/tests/runtime/harnesses/test_process_manager.py +++ b/tests/runtime/harnesses/test_process_manager.py @@ -29,6 +29,7 @@ import signal import sys import tempfile +import time import uuid from collections.abc import Iterator from pathlib import Path @@ -43,6 +44,7 @@ NoLiveHarnessError, _pid_alive, _pids_holding_socket, + _SubprocessEntry, ) _TEST_HARNESS_NAME = "test" @@ -594,11 +596,11 @@ async def test_idle_reaper_survives_release_error( real_release = fast.release calls = {"n": 0} - async def flaky_release(conversation_id: str) -> None: + async def flaky_release(conversation_id: str, **kw: object) -> None: calls["n"] += 1 if calls["n"] == 1: raise RuntimeError("simulated close failure") - await real_release(conversation_id) + await real_release(conversation_id, **kw) # type: ignore[arg-type] monkeypatch.setattr(fast, "release", flaky_release) @@ -668,6 +670,100 @@ async def test_idle_reaper_skips_in_flight_turn( await fast.shutdown() +class _FakeReapProc: + """Minimal process stand-in recording whether the reaper killed it.""" + + def __init__(self) -> None: + self.returncode: int | None = None + self.killed = False + self._done = asyncio.Event() + + def send_signal(self, sig: int) -> None: + self.killed = True + self.returncode = -15 + self._done.set() + + def kill(self) -> None: + self.killed = True + self.returncode = -9 + self._done.set() + + async def wait(self) -> int | None: + await self._done.wait() + return self.returncode + + +class _SlowCloseClient: + """httpx-client stand-in whose aclose() stalls, holding the reaper pass open.""" + + def __init__(self, delay_s: float) -> None: + self._delay_s = delay_s + + async def aclose(self) -> None: + await asyncio.sleep(self._delay_s) + + +class _FakeEndpoint: + def cleanup(self) -> None: + pass + + +async def test_idle_reaper_spares_turn_started_during_pass(tmp_path: Path) -> None: + """A turn that starts while an earlier stale entry tears down is not reaped. + + The reaper snapshots its stale list under the registry lock, then releases + each entry outside it; a single teardown can hold the pass open for seconds + (graceful-SIGTERM wait). A turn that starts on a later-listed conversation + during that window refreshes ``last_used_at`` and marks itself in flight — + but the snapshot has already been taken, and ``release`` used to tear the + entry down without re-checking, SIGTERMing the subprocess mid-turn + ("Harness stream connection error" seconds after messaging an idle + session). ``only_if_idle_cutoff`` re-checks idleness atomically with the + unregister, so the now-active entry is spared; the genuinely idle entry in + the same pass is still reaped, and the spared one is reclaimed by a later + pass once it goes idle again. + """ + mgr = HarnessProcessManager(idle_timeout_s=0.5, reaper_interval_s=0.2, tmp_parent=tmp_path) + e1 = _SubprocessEntry(_FakeReapProc(), _SlowCloseClient(0.6), _FakeEndpoint(), "h") # type: ignore[arg-type] + e2 = _SubprocessEntry(_FakeReapProc(), _SlowCloseClient(0.0), _FakeEndpoint(), "h") # type: ignore[arg-type] + e1.last_used_at = time.monotonic() - 100.0 + e2.last_used_at = time.monotonic() - 100.0 + mgr._entries = {"conv1": e1, "conv2": e2} + + reaper = asyncio.create_task(mgr._idle_reaper_loop()) + try: + # Wait for the pass to claim conv1 and enter its slow teardown. + deadline = time.monotonic() + 3.0 + while "conv1" in mgr._entries: + assert time.monotonic() < deadline, "reaper never started a pass" + await asyncio.sleep(0.01) + + # While conv1 tears down, a new turn arrives for conv2: get_client + # refreshes last_used_at and the runner marks the response in flight. + e2.last_used_at = time.monotonic() + mgr.mark_in_flight("conv2", "resp_live") + + await asyncio.sleep(1.0) + assert e1.process.killed, "the genuinely idle entry must still be reaped" + assert not e2.process.killed, ( + "reaper SIGTERMed a subprocess whose turn started during the pass" + ) + assert "conv2" in mgr._entries + + # Once the turn ends and the entry goes idle again, a later pass + # reclaims it — sparing is a deferral, not an exemption. + mgr.clear_in_flight("conv2") + e2.last_used_at = time.monotonic() - 100.0 + deadline = time.monotonic() + 3.0 + while not e2.process.killed: + assert time.monotonic() < deadline, "spared entry never reaped later" + await asyncio.sleep(0.05) + finally: + reaper.cancel() + with contextlib.suppress(asyncio.CancelledError): + await reaper + + async def test_idle_reaper_disabled_when_timeout_zero( register_test_harness: None, short_tmp_parent: Path, From e83b11ea1afabaffe79f1d25612e103a2c9e873b Mon Sep 17 00:00:00 2001 From: ShiZai Date: Tue, 7 Jul 2026 09:41:31 +0800 Subject: [PATCH 042/546] fix(kimi-native): mirror reasoning (think blocks) to the web transcript (#1677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kimi-native forwarder only mirrored `content.part` of type `text`, so Kimi's reasoning (the `think` block shown in the TUI) never reached the web conversation — the forwarder's own docstring acknowledged it as "skipped for v1". The reasoning text lives in `part["think"]`, not `part["text"]`. Mirror a `think` part as a one-shot transient `external_output_reasoning_delta` (`started: true`) so the web UI paints a reasoning block — the kimi analogue of the codex-native fix in #1254, where the project settled this as a required native-harness capability. `tool.call` / `tool.result` mirroring is left as a separate follow-up. Update the existing `_row_to_item` test that asserted think parts are skipped to assert they now produce a reasoning item. Closes #1676 Signed-off-by: tomsen-ai <230283659+tomsen-ai@users.noreply.github.com> Co-authored-by: tomsen-ai <230283659+tomsen-ai@users.noreply.github.com> --- omnigent/kimi_native_forwarder.py | 85 ++++++++++++++++++++++++----- tests/test_kimi_native_forwarder.py | 17 +++++- 2 files changed, 84 insertions(+), 18 deletions(-) diff --git a/omnigent/kimi_native_forwarder.py b/omnigent/kimi_native_forwarder.py index 92b3a4b811c..78ce763ed19 100644 --- a/omnigent/kimi_native_forwarder.py +++ b/omnigent/kimi_native_forwarder.py @@ -18,8 +18,9 @@ → a user message. - ``{"type": "context.append_loop_event", "event": {"type": "content.part", "part": {"type": "text", "text": …}, "uuid": …}}`` → an assistant message. - (``part.type == "think"`` is reasoning and is skipped for v1; ``tool.call`` / - ``tool.result`` events are likewise skipped — the embedded terminal shows them.) + (``part.type == "think"`` is reasoning, mirrored as a transient + ``external_output_reasoning_delta`` from ``part["think"]``; ``tool.call`` / + ``tool.result`` events are still skipped — the embedded terminal shows them.) Each mirrored turn is POSTed as an ``external_conversation_item`` to ``/v1/sessions/{id}/events`` (the same shape :mod:`omnigent.kimi_native_hook` @@ -67,6 +68,9 @@ class _MirrorItem: role: str text: str response_id: str + # "message" (a user/assistant turn → external_conversation_item) or + # "reasoning" (a think block → external_output_reasoning_delta). + kind: str = "message" def clear_kimi_bridge_state(bridge_dir: Path) -> None: @@ -202,14 +206,33 @@ def _row_to_item(line_no: int, row: dict[str, object]) -> _MirrorItem | None: if not isinstance(event, dict) or event.get("type") != "content.part": return None part = event.get("part") - if not isinstance(part, dict) or part.get("type") != "text": - return None - text = part.get("text") - if not isinstance(text, str) or not text: + if not isinstance(part, dict): return None uuid = event.get("uuid") response_id = f"kimi:{uuid}" if isinstance(uuid, str) and uuid else f"kimi:line:{line_no}" - return _MirrorItem(line_no=line_no, role="assistant", text=text, response_id=response_id) + part_type = part.get("type") + if part_type == "text": + text = part.get("text") + if not isinstance(text, str) or not text: + return None + return _MirrorItem( + line_no=line_no, role="assistant", text=text, response_id=response_id + ) + if part_type == "think": + # Reasoning lives in ``part["think"]`` (not ``part["text"]``). Mirror it + # as a transient reasoning event so the web UI paints a thinking block — + # the kimi analogue of codex-native's #1254 reasoning fix. + think = part.get("think") + if not isinstance(think, str) or not think: + return None + return _MirrorItem( + line_no=line_no, + role="assistant", + text=think, + response_id=response_id, + kind="reasoning", + ) + return None return None @@ -270,6 +293,29 @@ async def _post_conversation_item( resp.raise_for_status() +async def _post_reasoning_item( + client: httpx.AsyncClient, + *, + base_url: str, + headers: dict[str, str], + session_id: str, + item: _MirrorItem, +) -> None: + """POST one mirrored think block as a transient reasoning event. + + Mirrors codex-native (#1254): a one-shot ``external_output_reasoning_delta`` + with ``started: true`` opens a reasoning block in the web UI. Kimi persists + completed think parts (not streamed deltas), so one delta per part is correct. + """ + body = { + "type": "external_output_reasoning_delta", + "data": {"delta": item.text, "started": True}, + } + url = f"{base_url.rstrip('/')}/v1/sessions/{session_id}/events" + resp = await client.post(url, headers=headers, json=body) + resp.raise_for_status() + + async def forward_kimi_wire_to_session( *, base_url: str, @@ -304,14 +350,23 @@ async def forward_kimi_wire_to_session( items = await asyncio.to_thread(_read_new_items, wire_path, last_line) for item in items: try: - await _post_conversation_item( - client, - base_url=base_url, - headers=headers, - session_id=session_id, - item=item, - agent_name=agent_name, - ) + if item.kind == "reasoning": + await _post_reasoning_item( + client, + base_url=base_url, + headers=headers, + session_id=session_id, + item=item, + ) + else: + await _post_conversation_item( + client, + base_url=base_url, + headers=headers, + session_id=session_id, + item=item, + agent_name=agent_name, + ) except httpx.HTTPError as exc: _logger.warning("kimi forwarder: POST failed (will retry): %s", exc) break diff --git a/tests/test_kimi_native_forwarder.py b/tests/test_kimi_native_forwarder.py index d0de77f3b44..602dc62f20f 100644 --- a/tests/test_kimi_native_forwarder.py +++ b/tests/test_kimi_native_forwarder.py @@ -50,12 +50,23 @@ def test_content_part_text_is_assistant(self) -> None: assert item.text == "This is **Omnigent**." assert item.response_id == "kimi:67ce67f7" - def test_think_part_is_skipped(self) -> None: + def test_think_part_is_reasoning(self) -> None: + # Reasoning lives in part["think"] (not part["text"]) and is mirrored as a + # reasoning item, not skipped — the kimi analogue of codex-native #1254. row = { "type": "context.append_loop_event", - "event": {"type": "content.part", "part": {"type": "think", "think": "reasoning"}}, + "event": { + "type": "content.part", + "uuid": "abc123", + "part": {"type": "think", "think": "Let me reason about this."}, + }, } - assert _row_to_item(5, row) is None + item = _row_to_item(5, row) + assert item is not None + assert item.kind == "reasoning" + assert item.role == "assistant" + assert item.text == "Let me reason about this." + assert item.response_id == "kimi:abc123" def test_tool_call_and_metadata_skipped(self) -> None: for row in ( From 52ec40109d7105d6ef250c033b4463cb2620ebed Mon Sep 17 00:00:00 2001 From: Dimitar Dimitrov <41156947+dimaldim@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:40:59 +0300 Subject: [PATCH 043/546] feat(web-ui): global command palette (Cmd/Ctrl+K) (#1386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web-ui): global command palette (⌘K) Add a cross-platform command palette opened with ⌘K (Ctrl+K on Windows/Linux), with two groups: - Actions: New chat, Go to Inbox/Settings, toggle the conversations and workspace sidebars, and open the keyboard-shortcuts dialog. Filtered client-side against the query. - Sessions: fuzzy session switching from the same server-search source the sidebar uses (useConversations → GET /v1/sessions?search_query=), debounced, so the palette finds sessions beyond the first page rather than client-filtering one page. Archived excluded, matching the sidebar default. The hotkey is bound once in AppShell and bails when focus is inside an xterm terminal or the Monaco editor (both own ⌘K), and is disabled in embedded mode where ⌘K belongs to the host page. The desktop (Electron) app loads the same SPA and binds only ⌘N/⌘F natively, so ⌘K reaches the renderer unchanged. Adds an 'Open command palette · ⌘K' row to the keyboard-shortcuts dialog, a ResizeObserver test polyfill cmdk needs under jsdom, colocated Vitest coverage, and a Playwright e2e (tests/e2e_ui/sessions/test_command_palette.py). Signed-off-by: Dimitar Dimitrov * feat(web-ui): reuse UI icons in command palette, drop shortcuts action Give each palette Action the same icon as its equivalent button elsewhere in the UI (new chat, inbox, settings, sidebar toggles) so the palette reads as a shortcut to those surfaces. Icons inherit the item's foreground color rather than the muted tone, matching the label text. Remove the "Keyboard shortcuts" action — the palette is for imperative commands, not opening an informational dialog. Widen the palette so the two columns of longer session labels aren't cramped. Co-authored-by: Isaac --------- Signed-off-by: Dimitar Dimitrov Co-authored-by: Dimitar Dimitrov Co-authored-by: Daniel Lok --- tests/e2e_ui/sessions/test_command_palette.py | 77 +++++++ .../KeyboardShortcutsDialog.test.tsx | 1 + .../components/KeyboardShortcutsDialog.tsx | 5 +- .../hooks/useCommandPaletteHotkey.test.tsx | 108 +++++++++ web/src/hooks/useCommandPaletteHotkey.ts | 66 ++++++ web/src/shell/AppShell.tsx | 19 ++ web/src/shell/CommandPalette.test.tsx | 163 +++++++++++++ web/src/shell/CommandPalette.tsx | 215 ++++++++++++++++++ web/src/test-setup.ts | 11 + 9 files changed, 664 insertions(+), 1 deletion(-) create mode 100644 tests/e2e_ui/sessions/test_command_palette.py create mode 100644 web/src/hooks/useCommandPaletteHotkey.test.tsx create mode 100644 web/src/hooks/useCommandPaletteHotkey.ts create mode 100644 web/src/shell/CommandPalette.test.tsx create mode 100644 web/src/shell/CommandPalette.tsx diff --git a/tests/e2e_ui/sessions/test_command_palette.py b/tests/e2e_ui/sessions/test_command_palette.py new file mode 100644 index 00000000000..935fdf91548 --- /dev/null +++ b/tests/e2e_ui/sessions/test_command_palette.py @@ -0,0 +1,77 @@ +"""E2E: ⌘/Ctrl+K opens the command palette and jumps to a session. + +Covers the command palette added in ``ap-web/src/shell/CommandPalette.tsx`` and +its global hotkey (``useCommandPaletteHotkey``, ⌘/Ctrl+K, bound in +``AppShell``). The palette lists sessions from the same server-search source as +the sidebar and navigates to the picked one. + +The flow: open the palette from a focused composer (proving the window-level +hotkey fires regardless of focus, like the session-switch hotkey), then select +the *other* seeded session from the palette's list and assert the route changes +to it. + +No LLM turn is needed — this is pure client-side keyboard + routing — so it +skips the nightly/real-agent markers the approval suites carry. Two runner-bound +sessions come from the ``seeded_session_pair`` fixture; both are recent and +non-archived, so both appear in the palette's default (empty-query) list. + +Server-side search-query *filtering* is left to the Vitest unit tests +(``CommandPalette.test.tsx``): the server's search reindex is asynchronous (see +``useConversations.ts``), which would make a "type then expect filtered" e2e +assertion timing-dependent. Selecting from the listed sessions exercises the +same open → select → navigate path deterministically. +""" + +from __future__ import annotations + +import httpx +from playwright.sync_api import Page, expect + +_COMPOSER = "Ask the agent anything…" + + +def _set_title(base_url: str, session_id: str, title: str) -> None: + """Title a session via ``PATCH /v1/sessions/{id}`` so its row is legible.""" + resp = httpx.patch( + f"{base_url}/v1/sessions/{session_id}", + json={"title": title}, + timeout=10.0, + ) + resp.raise_for_status() + + +def test_command_palette_opens_and_switches_session( + page: Page, + seeded_session_pair: tuple[str, str, str], +) -> None: + """⌘/Ctrl+K opens the palette; picking session B navigates to it.""" + base_url, session_a, session_b = seeded_session_pair + _set_title(base_url, session_a, "e2e-palette-a") + _set_title(base_url, session_b, "e2e-palette-b") + + page.goto(f"{base_url}/c/{session_a}") + + # Both sessions must be loaded so the palette's session list holds them. + expect(page.locator(f'a[href="/c/{session_a}"]')).to_be_visible(timeout=30_000) + expect(page.locator(f'a[href="/c/{session_b}"]')).to_be_visible() + + # Focus the composer first — the hotkey is window-level and must fire even + # from a focused text field (same contract as the session-switch hotkey). + composer = page.get_by_placeholder(_COMPOSER) + expect(composer).to_be_visible() + composer.click() + + # Open the palette. CI runs Linux chromium → Control; the hook also accepts + # Cmd via metaKey on macOS. + page.keyboard.press("Control+k") + + dialog = page.get_by_role("dialog") + expect(dialog).to_be_visible(timeout=10_000) + expect(page.get_by_test_id("command-palette-input")).to_be_focused() + + # Pick the other session from inside the palette and assert we navigate to it. + dialog.get_by_text("e2e-palette-b").click() + + expect(page).to_have_url(f"{base_url}/c/{session_b}", timeout=10_000) + # The palette closes on select. + expect(page.get_by_test_id("command-palette-input")).to_have_count(0) diff --git a/web/src/components/KeyboardShortcutsDialog.test.tsx b/web/src/components/KeyboardShortcutsDialog.test.tsx index 76f545fdc6d..f541ff2a4d3 100644 --- a/web/src/components/KeyboardShortcutsDialog.test.tsx +++ b/web/src/components/KeyboardShortcutsDialog.test.tsx @@ -32,6 +32,7 @@ describe("KeyboardShortcutsDialog", () => { expect(screen.getByText("Keyboard shortcuts")).toBeTruthy(); // General / In chats / Navigation / View / Slash commands — one each. + expect(screen.getByText("Open command palette")).toBeTruthy(); expect(screen.getByText("Show keyboard shortcuts")).toBeTruthy(); expect(screen.getByText("Send message")).toBeTruthy(); expect(screen.getByText("Recall previous prompt")).toBeTruthy(); diff --git a/web/src/components/KeyboardShortcutsDialog.tsx b/web/src/components/KeyboardShortcutsDialog.tsx index 7116d67d7df..fbe96788e4a 100644 --- a/web/src/components/KeyboardShortcutsDialog.tsx +++ b/web/src/components/KeyboardShortcutsDialog.tsx @@ -65,7 +65,10 @@ interface ShortcutGroup { const SHORTCUT_GROUPS: ShortcutGroup[] = [ { title: "General", - items: [{ label: "Show keyboard shortcuts", keys: [MOD_KEY, "/"] }], + items: [ + { label: "Open command palette", keys: [MOD_KEY, "K"] }, + { label: "Show keyboard shortcuts", keys: [MOD_KEY, "/"] }, + ], }, { title: "In chats", diff --git a/web/src/hooks/useCommandPaletteHotkey.test.tsx b/web/src/hooks/useCommandPaletteHotkey.test.tsx new file mode 100644 index 00000000000..1c0bb13462d --- /dev/null +++ b/web/src/hooks/useCommandPaletteHotkey.test.tsx @@ -0,0 +1,108 @@ +import { cleanup, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { isCommandPaletteHotkey, useCommandPaletteHotkey } from "./useCommandPaletteHotkey"; + +afterEach(() => { + cleanup(); + document.body.innerHTML = ""; +}); + +function press(init: KeyboardEventInit): KeyboardEvent { + const e = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }); + window.dispatchEvent(e); + return e; +} + +describe("isCommandPaletteHotkey", () => { + it("matches Cmd+K and Ctrl+K", () => { + expect(isCommandPaletteHotkey(new KeyboardEvent("keydown", { key: "k", metaKey: true }))).toBe( + true, + ); + expect(isCommandPaletteHotkey(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }))).toBe( + true, + ); + // Uppercase (some layouts report "K" with the modifier). + expect(isCommandPaletteHotkey(new KeyboardEvent("keydown", { key: "K", metaKey: true }))).toBe( + true, + ); + }); + + it("rejects plain k, and k with Alt or Shift held", () => { + expect(isCommandPaletteHotkey(new KeyboardEvent("keydown", { key: "k" }))).toBe(false); + expect( + isCommandPaletteHotkey( + new KeyboardEvent("keydown", { key: "k", metaKey: true, altKey: true }), + ), + ).toBe(false); + expect( + isCommandPaletteHotkey( + new KeyboardEvent("keydown", { key: "k", ctrlKey: true, shiftKey: true }), + ), + ).toBe(false); + }); + + it("rejects other keys with the modifier", () => { + expect(isCommandPaletteHotkey(new KeyboardEvent("keydown", { key: "j", metaKey: true }))).toBe( + false, + ); + }); +}); + +describe("useCommandPaletteHotkey", () => { + it("toggles on Cmd+K and prevents the browser default", () => { + const onToggle = vi.fn(); + renderHook(() => useCommandPaletteHotkey(onToggle)); + + const e = press({ key: "k", metaKey: true }); + + expect(onToggle).toHaveBeenCalledTimes(1); + expect(e.defaultPrevented).toBe(true); + }); + + it("ignores auto-repeat", () => { + const onToggle = vi.fn(); + renderHook(() => useCommandPaletteHotkey(onToggle)); + + press({ key: "k", metaKey: true, repeat: true }); + + expect(onToggle).not.toHaveBeenCalled(); + }); + + it("does nothing when disabled", () => { + const onToggle = vi.fn(); + renderHook(() => useCommandPaletteHotkey(onToggle, false)); + + const e = press({ key: "k", metaKey: true }); + + expect(onToggle).not.toHaveBeenCalled(); + expect(e.defaultPrevented).toBe(false); + }); + + it("bails when focus sits inside a terminal or code editor", () => { + const onToggle = vi.fn(); + renderHook(() => useCommandPaletteHotkey(onToggle)); + + const term = document.createElement("div"); + term.className = "xterm"; + const input = document.createElement("input"); + term.appendChild(input); + document.body.appendChild(term); + input.focus(); + expect(document.activeElement).toBe(input); + + press({ key: "k", metaKey: true }); + + expect(onToggle).not.toHaveBeenCalled(); + }); + + it("unbinds on unmount", () => { + const onToggle = vi.fn(); + const { unmount } = renderHook(() => useCommandPaletteHotkey(onToggle)); + unmount(); + + press({ key: "k", metaKey: true }); + + expect(onToggle).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/hooks/useCommandPaletteHotkey.ts b/web/src/hooks/useCommandPaletteHotkey.ts new file mode 100644 index 00000000000..4a08415c4e1 --- /dev/null +++ b/web/src/hooks/useCommandPaletteHotkey.ts @@ -0,0 +1,66 @@ +// ⌘K (Ctrl+K on Win/Linux) toggles the global command palette. Sibling to the +// session-switch (⌘↑/↓) and sidebar-toggle (⌘⌥[ / ⌘⌥]) hotkeys; like them it's +// bound ONCE at the app shell, where the palette's open-state lives. +// +// Why ⌘K: it's the de-facto command-palette key across developer tools, and +// issue #1059 / PR #1064 deliberately reserved it for this (PR #1064 took ⌘⇧F +// for sidebar search precisely to leave ⌘K free). The browser binds Ctrl+K to +// the address bar, so we preventDefault to claim it. +// +// Two surfaces own ⌘K themselves and must keep it: xterm terminals (forward it +// to the PTY) and the Monaco editor (⌘K is a chord prefix). When focus sits in +// one of those, we bail and let the keystroke through. + +import { useEffect, useRef } from "react"; + +/** Selector for surfaces that own ⌘K and must keep it (terminals, code editor). */ +const HOTKEY_OWNING_SURFACES = ".xterm, .monaco-editor"; + +/** True when the event is the command-palette chord: Cmd/Ctrl+K, no Alt/Shift. */ +export function isCommandPaletteHotkey(e: globalThis.KeyboardEvent): boolean { + if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return false; + // AltGr reports as Ctrl+Alt on some layouts; the altKey check above already + // rejects it, but guard explicitly so intl typing never triggers the palette. + if (e.getModifierState("AltGraph")) return false; + // Match the letter, not a physical code — ⌘ doesn't remap "k" across layouts. + return e.key === "k" || e.key === "K"; +} + +/** Does focus sit inside a surface that owns ⌘K (xterm / Monaco)? */ +function focusOwnsHotkey(): boolean { + const el = document.activeElement; + return el instanceof Element && el.closest(HOTKEY_OWNING_SURFACES) !== null; +} + +/** + * Bind ⌘/Ctrl+K to toggle the command palette. Bind ONCE. + * + * @param onToggle Flip the palette open/closed. + * @param enabled Pass `false` to disable the hotkey (e.g. embedded mode, where + * ⌘K belongs to the host page). Defaults to enabled. + */ +export function useCommandPaletteHotkey(onToggle: () => void, enabled: boolean = true): void { + // Held in a ref so the bound handler always calls the latest closure without + // re-registering on every render. + const latest = useRef(onToggle); + latest.current = onToggle; + + useEffect(() => { + if (!enabled) return; + const handler = (e: globalThis.KeyboardEvent): void => { + // Ignore auto-repeat: holding the chord would flap the palette. + if (e.repeat) return; + if (!isCommandPaletteHotkey(e)) return; + // Leave ⌘K to terminals/editors that bind it themselves. + if (focusOwnsHotkey()) return; + // Claim the chord: preventDefault drops the browser default (Ctrl+K + // focuses the address bar). stopPropagation mirrors the sibling hotkey + // hooks; no other listener binds ⌘K, so it's belt-and-suspenders. + e.preventDefault(); + e.stopPropagation(); + latest.current(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [enabled]); +} diff --git a/web/src/shell/AppShell.tsx b/web/src/shell/AppShell.tsx index 4dc310e160d..37ef0be1e11 100644 --- a/web/src/shell/AppShell.tsx +++ b/web/src/shell/AppShell.tsx @@ -5,6 +5,8 @@ import { useConversations } from "@/hooks/useConversations"; import { useSessionAgent } from "@/hooks/useAgents"; import { useApproveHotkey } from "@/hooks/useApproveHotkey"; import { useSidebarToggleHotkeys } from "@/hooks/useSidebarToggleHotkeys"; +import { useCommandPaletteHotkey } from "@/hooks/useCommandPaletteHotkey"; +import { useIsEmbedded } from "@/lib/embedded"; import { AgentInfoContent, agentHasInfo } from "@/components/AgentInfo"; import { useIdleNotifications } from "@/hooks/useIdleNotifications"; import { useSeedReadState } from "@/hooks/useUnseenConversations"; @@ -70,6 +72,7 @@ import { TerminalsPanel } from "./TerminalsPanel"; import { TodoPanel } from "./TodoPanel"; import { PermissionsModal } from "@/components/PermissionsModal"; import { KeyboardShortcutsDialog } from "@/components/KeyboardShortcutsDialog"; +import { CommandPalette } from "./CommandPalette"; import { Toaster } from "@/components/ui/toast"; import { ForkSessionDialog } from "./ForkSessionDialog"; import { ForkDialogContextProvider, type ForkDialogContextValue } from "./ForkDialogContext"; @@ -777,6 +780,12 @@ export function AppShell() { onToggleRight: toggleRightPanel, }); + // ⌘K (Ctrl+K) toggles the command palette. Disabled embedded, where ⌘K is the + // host page's. Bound here where the palette's open-state lives. + const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); + const isEmbedded = useIsEmbedded(); + useCommandPaletteHotkey(() => setCommandPaletteOpen((prev) => !prev), !isEmbedded); + // Mobile back button: close the open file and return to the files/changes // list. On mobile the tab strip is hidden, so a "back" should fully drop the // file (remove it from openFiles) rather than leaving an orphan tab the user @@ -1334,6 +1343,16 @@ export function AppShell() { {/* Keyboard-shortcuts reference. Self-contained (owns its open state + ⌘/Ctrl+/ opener); ungated so it works on every route. */} + {/* Global command palette (⌘K). Ungated so it works on every route; + the hotkey itself is disabled in embedded mode. */} + {!isEmbedded && ( + setSidebarOpen((prev) => !prev)} + onToggleRightSidebar={toggleRightPanel} + /> + )} {/* Transient toasts (e.g. "session archived"). Mounted once here so any surface can fire one via showToast(). */} diff --git a/web/src/shell/CommandPalette.test.tsx b/web/src/shell/CommandPalette.test.tsx new file mode 100644 index 00000000000..32e1b395760 --- /dev/null +++ b/web/src/shell/CommandPalette.test.tsx @@ -0,0 +1,163 @@ +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ComponentProps } from "react"; + +import { CommandPalette } from "./CommandPalette"; + +const navigate = vi.fn(); +vi.mock("@/lib/routing", () => ({ + useNavigate: () => navigate, +})); + +const useConversations = vi.fn(); +vi.mock("@/hooks/useConversations", () => ({ + useConversations: (...args: unknown[]) => useConversations(...args), +})); + +function conv(id: string, title: string | null, agent_name: string | null = null) { + return { id, title, agent_name, archived: false }; +} + +function setSessions(sessions: ReturnType[], isFetching = false) { + useConversations.mockReturnValue({ data: { pages: [{ data: sessions }] }, isFetching }); +} + +function renderPalette(overrides: Partial> = {}) { + const props = { + open: true, + onOpenChange: vi.fn(), + onToggleLeftSidebar: vi.fn(), + onToggleRightSidebar: vi.fn(), + ...overrides, + }; + render(); + return props; +} + +beforeEach(() => { + navigate.mockClear(); + useConversations.mockReset(); + setSessions([]); +}); +afterEach(cleanup); + +describe("CommandPalette — sessions", () => { + it("lists sessions by display label with their agent type", () => { + setSessions([conv("c1", "Fix the parser", "research-agent"), conv("c2", null)]); + renderPalette(); + + expect(screen.getByText("Fix the parser")).toBeTruthy(); + expect(screen.getByText("research-agent")).toBeTruthy(); + // Null title → conversationDisplayLabel's "New session" fallback. + expect(screen.getByText("New session")).toBeTruthy(); + }); + + it("navigates to the session and closes when an item is selected", () => { + setSessions([conv("c1", "Fix the parser")]); + const onOpenChange = vi.fn(); + renderPalette({ onOpenChange }); + + fireEvent.click(screen.getByText("Fix the parser")); + + expect(navigate).toHaveBeenCalledWith("/c/c1"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("debounces the typed query into a server search (archived excluded)", () => { + vi.useFakeTimers(); + try { + setSessions([conv("c1", "Fix the parser")]); + renderPalette(); + + // Empty query on mount → shares AppShell's `["conversations","",false]` entry. + expect(useConversations).toHaveBeenCalledWith("", false); + + fireEvent.change(screen.getByTestId("command-palette-input"), { + target: { value: "deploy" }, + }); + // Before the debounce elapses the query has NOT yet reached the hook. + expect(useConversations).not.toHaveBeenCalledWith("deploy", false); + + act(() => { + vi.advanceTimersByTime(300); + }); + // After the 300ms debounce, the typed query drives a server search with + // archived excluded — proving the palette searches the server, not a page. + expect(useConversations).toHaveBeenCalledWith("deploy", false); + } finally { + vi.useRealTimers(); + } + }); + + it("dedupes sessions that appear on overlapping pages", () => { + useConversations.mockReturnValue({ + data: { + pages: [{ data: [conv("c1", "One")] }, { data: [conv("c1", "One"), conv("c2", "Two")] }], + }, + isFetching: false, + }); + renderPalette(); + + expect(screen.getAllByText("One")).toHaveLength(1); + expect(screen.getByText("Two")).toBeTruthy(); + }); +}); + +describe("CommandPalette — actions", () => { + it("lists the built-in action commands", () => { + renderPalette(); + + expect(screen.getByText("New chat")).toBeTruthy(); + expect(screen.getByText("Go to Inbox")).toBeTruthy(); + expect(screen.getByText("Go to Settings")).toBeTruthy(); + expect(screen.getByText("Toggle conversations sidebar")).toBeTruthy(); + expect(screen.getByText("Toggle workspace sidebar")).toBeTruthy(); + }); + + it("runs a navigation action and closes the palette", () => { + const onOpenChange = vi.fn(); + renderPalette({ onOpenChange }); + + fireEvent.click(screen.getByText("Go to Settings")); + + expect(navigate).toHaveBeenCalledWith("/settings"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("invokes the sidebar-toggle callbacks", () => { + const onToggleLeftSidebar = vi.fn(); + const onToggleRightSidebar = vi.fn(); + renderPalette({ onToggleLeftSidebar, onToggleRightSidebar }); + + fireEvent.click(screen.getByText("Toggle conversations sidebar")); + expect(onToggleLeftSidebar).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByText("Toggle workspace sidebar")); + expect(onToggleRightSidebar).toHaveBeenCalledTimes(1); + }); + + it("filters actions client-side against the query", () => { + renderPalette(); + + fireEvent.change(screen.getByTestId("command-palette-input"), { + target: { value: "settings" }, + }); + + expect(screen.getByText("Go to Settings")).toBeTruthy(); + expect(screen.queryByText("New chat")).toBeNull(); + }); +}); + +describe("CommandPalette — empty state", () => { + it("shows an empty state when nothing matches", () => { + setSessions([]); + renderPalette(); + + // A query that matches no action and no session. + fireEvent.change(screen.getByTestId("command-palette-input"), { + target: { value: "zzzznomatch" }, + }); + + expect(screen.getByText("No results found")).toBeTruthy(); + }); +}); diff --git a/web/src/shell/CommandPalette.tsx b/web/src/shell/CommandPalette.tsx new file mode 100644 index 00000000000..c65f862d103 --- /dev/null +++ b/web/src/shell/CommandPalette.tsx @@ -0,0 +1,215 @@ +// Global command palette (⌘K). Two command groups: +// +// • Actions — static app commands (new chat, navigate, toggle panels). +// Filtered client-side against the live query. +// • Sessions — fuzzy session switching from the SAME server-search source the +// sidebar uses (`useConversations(query)` → `GET /v1/sessions?search_query=`), +// debounced. Not a static first page: a user with hundreds of sessions must +// find any of them, which client-side filtering over one page cannot do. +// +// cmdk's own filtering is disabled (`shouldFilter={false}`): the server filters +// sessions, and we filter the (tiny, static) action list ourselves so both +// groups react to the same input. + +import { useEffect, useMemo, useState } from "react"; +import { + InboxIcon, + type LucideIcon, + PanelLeftIcon, + PanelRightIcon, + SettingsIcon, + SquarePenIcon, +} from "lucide-react"; +import { useNavigate } from "@/lib/routing"; +import { useConversations } from "@/hooks/useConversations"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { conversationDisplayLabel, getConversationAgentType } from "./sidebarNav"; + +export interface CommandPaletteProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Flip the left (Conversations) sidebar — owned by AppShell. */ + onToggleLeftSidebar: () => void; + /** Flip the right (Workspace) sidebar — owned by AppShell. */ + onToggleRightSidebar: () => void; +} + +interface ActionCommand { + id: string; + label: string; + /** Mirrors the icon on the equivalent button elsewhere in the UI. */ + icon: LucideIcon; + /** Extra terms the client-side filter matches against (beyond the label). */ + keywords: string[]; + run: () => void; +} + +/** Debounce matches the sidebar search (300ms) so keystrokes don't each fetch. */ +const SEARCH_DEBOUNCE_MS = 300; + +export function CommandPalette({ + open, + onOpenChange, + onToggleLeftSidebar, + onToggleRightSidebar, +}: CommandPaletteProps) { + const navigate = useNavigate(); + const [query, setQuery] = useState(""); + const [debouncedQuery, setDebouncedQuery] = useState(""); + + // Reset the query when the palette closes so it reopens clean. + useEffect(() => { + if (!open) { + setQuery(""); + setDebouncedQuery(""); + } + }, [open]); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(query), SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [query]); + + const close = (): void => onOpenChange(false); + + const actions = useMemo( + () => [ + { + id: "new-chat", + label: "New chat", + icon: SquarePenIcon, + keywords: ["compose", "start", "new session"], + run: () => navigate("/"), + }, + { + id: "go-inbox", + label: "Go to Inbox", + icon: InboxIcon, + keywords: ["notifications", "comments", "needs response"], + run: () => navigate("/inbox"), + }, + { + id: "go-settings", + label: "Go to Settings", + icon: SettingsIcon, + keywords: ["preferences", "configuration", "account"], + run: () => navigate("/settings"), + }, + { + id: "toggle-left-sidebar", + label: "Toggle conversations sidebar", + icon: PanelLeftIcon, + keywords: ["panel", "left", "sessions list"], + run: onToggleLeftSidebar, + }, + { + id: "toggle-right-sidebar", + label: "Toggle workspace sidebar", + icon: PanelRightIcon, + keywords: ["panel", "right", "files", "terminal"], + run: onToggleRightSidebar, + }, + ], + [navigate, onToggleLeftSidebar, onToggleRightSidebar], + ); + + const filteredActions = useMemo(() => { + const q = query.trim().toLowerCase(); + if (q === "") return actions; + return actions.filter( + (a) => + a.label.toLowerCase().includes(q) || a.keywords.some((k) => k.toLowerCase().includes(q)), + ); + }, [actions, query]); + + // Archived excluded (matches the sidebar default). With an empty query this + // shares AppShell's existing `useConversations()` cache entry, so an idle + // palette costs no extra fetch; a search keys its own entry. + const { data, isFetching } = useConversations(debouncedQuery, false); + + const sessions = useMemo(() => { + const seen = new Set(); + const out: { id: string; label: string; agent: string }[] = []; + for (const page of data?.pages ?? []) { + for (const c of page.data) { + if (seen.has(c.id)) continue; + seen.add(c.id); + out.push({ + id: c.id, + label: conversationDisplayLabel(c), + agent: getConversationAgentType(c), + }); + } + } + return out; + }, [data]); + + const runAction = (action: ActionCommand): void => { + close(); + action.run(); + }; + + const goToSession = (id: string): void => { + close(); + navigate(`/c/${id}`); + }; + + return ( + + + Command palette + {/* shouldFilter=false: the server filters sessions and we filter actions + (see file header). vimBindings=false: keep Ctrl+K/J from doubling as + list-nav on Win/Linux, where Ctrl+K is also the opener. */} + + + + + {isFetching && debouncedQuery ? "Searching…" : "No results found"} + + {filteredActions.length > 0 && ( + + {filteredActions.map((a) => { + const Icon = a.icon; + return ( + runAction(a)}> + + {a.label} + + ); + })} + + )} + {sessions.length > 0 && ( + + {sessions.map((s) => ( + goToSession(s.id)}> + {s.label} + {s.agent} + + ))} + + )} + + + + + ); +} diff --git a/web/src/test-setup.ts b/web/src/test-setup.ts index 7bf3eff0c90..c7c9b2cbc0a 100644 --- a/web/src/test-setup.ts +++ b/web/src/test-setup.ts @@ -67,6 +67,17 @@ if (!("IntersectionObserver" in globalThis)) { }); } +// cmdk (the command-palette primitive) constructs a ResizeObserver on mount, +// which jsdom doesn't implement. A no-op stub lets command-palette/selector +// component tests render without throwing. +if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }; +} + Object.defineProperty(window, "matchMedia", { writable: true, value: (query: string) => ({ From 53883864af781adc77b5bfd00bacb4b1ef094f5c Mon Sep 17 00:00:00 2001 From: Sabhya Chhabria Date: Mon, 6 Jul 2026 20:13:37 -0700 Subject: [PATCH 044/546] feat(web): add UI font family setting to Appearance (#2047) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): add UI font family setting to Appearance Add a font-family control to Settings → Appearance, beside the font-size stepper. It's a free-text field (Cursor-style): type any font installed on this device; leave it blank for the system default. The choice re-fonts the whole UI chrome, is persisted per-device in localStorage, and is applied before first paint so a reload doesn't flash the default. Implementation mirrors the just-merged font-size setting (#2040). It can't reuse --font-sans: Tailwind v4's @theme inline block inlines the literal stack into the font-sans utility rather than a var() reference, so a runtime --font-sans override is a no-op. Instead the html rule reads font-family: var(--ui-font-family, var(--font-sans)), and the preference module sets --ui-font-family on documentElement — unset falls back to the existing system stack. The theme picker and font-size stepper are unchanged. The two .font-heading elements (dialog/card titles) resolve font-family: var(--font-sans) directly, so they keep the system stack rather than the custom family — acceptable for this UI-chrome-only change. Co-authored-by: Isaac * fix(web): keep font-family input inline; ruff-format e2e test - The Font family row's longer description pushed the input onto its own line under flex-wrap. Give the text column min-w-0 flex-1 and the control shrink-0 so the input stays flush-right on the same row as the label, matching the font-size stepper above it. - Apply ruff format to the new e2e test (one-line test signature) so the Pre-commit CI check passes. Co-authored-by: Isaac * fix(web): right-align font-family input with the font-size stepper Move the Reset button to the left of the input so the input is the rightmost element in its group; its right edge now lines up flush with the font-size stepper above it (both at the row's right edge). Reset stays `invisible` (not removed) at the default so the row doesn't shift. Co-authored-by: Isaac * fix(web): keep code surfaces on the mono font, immune to the UI font setting The UI font-family setting is UI chrome only. Pin the Monaco editor and xterm terminal roots (.monaco-editor, .xterm) to var(--font-mono) so the --ui-font-family override can't leak into code surfaces through an unpinned descendant. Editor/terminal code fonts are intended for a separate, future code-font setting. Both surfaces already pin their own font (xterm via its JS fontFamily option, Monaco via its inline default), so this is a defensive guard; verified live that with a UI font override active, .xterm/.xterm-screen and the Shiki code viewer all stay on the mono stack. Co-authored-by: Isaac * fix(web): fall back to the default sans for unknown/partial font names Applying a bare `--ui-font-family: ` meant that a font that isn't installed — or a partial name while the user is still typing — left the browser with an unresolvable family and no fallback, so the UI dropped to the browser's default serif (Times) instead of the app's sans. Append the system stack to the applied value (`, var(--font-sans)`) so an unusable name degrades to the default sans. The CSS-level `var(--ui-font-family, …)` fallback only fires when the property is unset, not when it holds an unusable value, so the fallback must live in the value too. localStorage still stores just the raw name (the input shows it verbatim). Verified live: partial/uninstalled names now render as the default sans, not serif. Co-authored-by: Isaac * test(e2e): assert font-family starts with the chosen name The applied --ui-font-family now leads the chosen family and appends the system stack as a fallback, so getComputedStyle resolves the custom property to the full stack (e.g. "Georgia, ui-sans-serif, ..."). Assert the resolved value startswith the typed name rather than equals it. The reset/empty assertions are unchanged (property removed → empty). Co-authored-by: Isaac --- tests/e2e_ui/sessions/test_ui_font_family.py | 101 +++++++++++++++++++ web/src/index.css | 21 +++- web/src/lib/uiFontPreferences.test.ts | 83 +++++++++++++++ web/src/lib/uiFontPreferences.ts | 97 +++++++++++++++++- web/src/main.tsx | 10 +- web/src/pages/SettingsPage.test.tsx | 38 +++++++ web/src/pages/SettingsPage.tsx | 68 +++++++++++++ 7 files changed, 414 insertions(+), 4 deletions(-) create mode 100644 tests/e2e_ui/sessions/test_ui_font_family.py diff --git a/tests/e2e_ui/sessions/test_ui_font_family.py b/tests/e2e_ui/sessions/test_ui_font_family.py new file mode 100644 index 00000000000..434a6f9e3bf --- /dev/null +++ b/tests/e2e_ui/sessions/test_ui_font_family.py @@ -0,0 +1,101 @@ +"""E2E: the Settings → Appearance font-family field re-fonts the UI and persists. + +The font-family control lives on the Settings page (``pages/SettingsPage.tsx``, +``UiFontFamilyControl``): a free-text input (Cursor-style) plus a ``Reset`` +button under a ``role="group"`` labelled "Font family". Typing a name writes the +choice to ``localStorage["omnigent:ui-font-family"]`` and applies it as the +``--ui-font-family`` custom property on ```` (see +``lib/uiFontPreferences.ts``). A blank field is "System default": the property is +removed and the ``html`` rule falls back to ``var(--font-sans)``. + +Because the whole rem-based UI inherits its font from the root ``html`` rule +(``font-family: var(--ui-font-family, var(--font-sans))``), setting that one +variable re-fonts the entire chrome. The value is applied before first paint in +``main.tsx`` so a reload doesn't flash the default first. + +No LLM turn is involved. +""" + +from __future__ import annotations + +from playwright.sync_api import Page, expect + +STORAGE_KEY = "omnigent:ui-font-family" + + +def _ui_font_family(page: Page) -> str: + """The ``--ui-font-family`` custom property applied to ````.""" + return page.evaluate( + "() => getComputedStyle(document.documentElement)" + ".getPropertyValue('--ui-font-family').trim()" + ) + + +def _stored_family(page: Page) -> str | None: + """The persisted font-family preference, or None when unset (default).""" + return page.evaluate(f"() => window.localStorage.getItem('{STORAGE_KEY}')") + + +def _open_appearance(page: Page, base_url: str) -> None: + """Navigate to the Settings Appearance section and wait for the control.""" + page.goto(f"{base_url}/settings/appearance") + expect(page.get_by_role("group", name="Font family")).to_be_visible(timeout=30_000) + + +def test_ui_font_family_applies_and_persists(page: Page, seeded_session: tuple[str, str]) -> None: + """Typing a family updates the applied property + value live and survives reload. + + A fresh context has no stored preference → empty field, no ``--ui-font-family`` + override (the UI uses the system stack). Typing a name applies the property and + persists the choice; a page reload restores it (no reset, no flash to default). + """ + base_url, _session_id = seeded_session + _open_appearance(page, base_url) + + value = page.get_by_test_id("ui-font-family-input") + + # Fresh context → empty field, nothing stored, no override applied. + expect(value).to_have_value("") + assert _stored_family(page) is None, "expected no persisted family on a fresh load" + assert _ui_font_family(page) == "", "fresh load should apply no family override" + + # → "Georgia": the field, the applied property, and storage all move together. + # The applied value leads with the chosen family and appends the system stack + # (so an uninstalled/partial name degrades to the default sans, not serif), so + # the resolved custom property starts with — rather than equals — "Georgia". + value.fill("Georgia") + expect(value).to_have_value("Georgia") + assert _stored_family(page) == '"Georgia"', "the typed family was not persisted" + assert _ui_font_family(page).startswith("Georgia"), "root family did not track the typed name" + + # The choice survives a full reload (persisted + re-applied before paint). + page.reload() + expect(page.get_by_role("group", name="Font family")).to_be_visible(timeout=30_000) + expect(page.get_by_test_id("ui-font-family-input")).to_have_value("Georgia") + assert _ui_font_family(page).startswith("Georgia"), "family was not restored after reload" + + +def test_ui_font_family_reset_restores_system_default( + page: Page, seeded_session: tuple[str, str] +) -> None: + """The Reset button clears the override and returns to the system default.""" + base_url, _session_id = seeded_session + + # Seed a family before the app boots so the override is applied on load. + page.goto(base_url) + page.evaluate(f"() => window.localStorage.setItem('{STORAGE_KEY}', '\"Georgia\"')") + _open_appearance(page, base_url) + + value = page.get_by_test_id("ui-font-family-input") + reset = page.get_by_test_id("ui-font-family-reset") + + # The seeded family renders and is applied to the root (leading the appended + # system-stack fallback, so the resolved value starts with "Georgia"). + expect(value).to_have_value("Georgia") + assert _ui_font_family(page).startswith("Georgia") + + # → Reset: the field clears, the override is removed, and the key is cleared. + reset.click() + expect(value).to_have_value("") + assert _ui_font_family(page) == "", "the family override was not removed on reset" + assert _stored_family(page) is None, "reset did not clear the persisted family" diff --git a/web/src/index.css b/web/src/index.css index 03936462c33..734323db12a 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -109,6 +109,11 @@ * the whole rem-based UI scales. Overridden at runtime on documentElement. */ --ui-font-scale: 1; + /* User-controlled UI font family (see Appearance settings / + * lib/uiFontPreferences.ts). Deliberately left unset here so the `html` rule's + * `var(--ui-font-family, var(--font-sans))` falls back to the system stack; + * the Appearance setting sets it at runtime on documentElement. */ + /* ---- Inset system ------------------------------------------------------- * Single source of truth for how far content must stay clear of screen * chrome. The SAME bundle runs in a browser, in Electron, and inside the @@ -722,12 +727,26 @@ overflow: hidden; } html { - @apply font-sans; + /* Font family for the whole inherited UI. Can't `@apply font-sans`: Tailwind + * v4's `@theme inline` inlines the literal stack, so a runtime `--font-sans` + * override is a no-op. Read a dedicated `--ui-font-family` (set on + * documentElement by the Appearance setting / lib/uiFontPreferences.ts) + * with the system stack as the fallback when it's unset. */ + font-family: var(--ui-font-family, var(--font-sans)); /* Root size for the whole rem-based UI. `1em` resolves against the * browser default so a customized default is preserved; --ui-font-scale * layers the user's Appearance choice on top. */ font-size: calc(1em * var(--ui-font-scale)); } + /* Code surfaces stay on the mono stack, immune to the UI --ui-font-family + * override above. The Appearance font-family setting is UI chrome only; the + * Monaco editor and xterm terminals are code fonts, owned by a separate + * (future) code-font setting. Monaco/xterm already pin their own font, so this + * is a guard against inheritance leaking in through any unpinned descendant. */ + .monaco-editor, + .xterm { + font-family: var(--font-mono); + } /* Text selection — brand pink highlight. Light: gentle wash + dark pink * text. Dark: solid brand pink background + white text. */ ::selection { diff --git a/web/src/lib/uiFontPreferences.test.ts b/web/src/lib/uiFontPreferences.test.ts index 30156ae442f..cf691f1dbb8 100644 --- a/web/src/lib/uiFontPreferences.test.ts +++ b/web/src/lib/uiFontPreferences.test.ts @@ -1,18 +1,24 @@ import { afterEach, describe, expect, it } from "vitest"; import { + applyUiFontFamily, applyUiFontScale, + readUiFontFamily, readUiFontSizePx, + UI_FONT_FAMILY_DEFAULT, UI_FONT_SIZE_DEFAULT, UI_FONT_SIZE_MAX, UI_FONT_SIZE_MIN, + writeUiFontFamily, writeUiFontSizePx, } from "./uiFontPreferences"; const STORAGE_KEY = "omnigent:ui-font-size"; +const FAMILY_STORAGE_KEY = "omnigent:ui-font-family"; afterEach(() => { localStorage.clear(); document.documentElement.style.removeProperty("--ui-font-scale"); + document.documentElement.style.removeProperty("--ui-font-family"); }); describe("uiFontPreferences", () => { @@ -65,3 +71,80 @@ describe("uiFontPreferences", () => { expect(document.documentElement.style.getPropertyValue("--ui-font-scale")).toBe("1.25"); }); }); + +describe("uiFontPreferences — family", () => { + it("returns the empty default when nothing is stored", () => { + expect(readUiFontFamily()).toBe(UI_FONT_FAMILY_DEFAULT); + expect(readUiFontFamily()).toBe(""); + }); + + it("round-trips a valid family name", () => { + writeUiFontFamily("Inter"); + expect(readUiFontFamily()).toBe("Inter"); + expect(localStorage.getItem(FAMILY_STORAGE_KEY)).toBe(JSON.stringify("Inter")); + }); + + it("preserves spaces, commas and quotes in a font stack", () => { + // A multi-family stack must survive normalization intact (the guard only + // strips declaration-breaking chars, not the punctuation stacks rely on). + writeUiFontFamily('"Times New Roman", serif'); + expect(readUiFontFamily()).toBe('"Times New Roman", serif'); + }); + + it("trims surrounding whitespace", () => { + writeUiFontFamily(" Georgia "); + expect(readUiFontFamily()).toBe("Georgia"); + }); + + it("clears the preference when written empty or whitespace-only", () => { + writeUiFontFamily("Inter"); + expect(localStorage.getItem(FAMILY_STORAGE_KEY)).not.toBeNull(); + writeUiFontFamily(" "); + // Empty input removes the key rather than storing a blank string. + expect(localStorage.getItem(FAMILY_STORAGE_KEY)).toBeNull(); + expect(readUiFontFamily()).toBe(""); + }); + + it("strips characters that could break the CSS declaration", () => { + // `;{}` and control chars can't be allowed to escape the custom-property + // value; everything else about the name (here the leading font) is kept. + writeUiFontFamily("Arial;}body{"); + expect(readUiFontFamily()).toBe("Arialbody"); + }); + + it("falls back to the default on a value longer than the cap", () => { + writeUiFontFamily("x".repeat(200)); + expect(readUiFontFamily()).toBe(UI_FONT_FAMILY_DEFAULT); + expect(localStorage.getItem(FAMILY_STORAGE_KEY)).toBeNull(); + }); + + it("falls back to the default on malformed JSON", () => { + // Corrupt localStorage should not break app boot. + localStorage.setItem(FAMILY_STORAGE_KEY, "}{not json"); + expect(readUiFontFamily()).toBe(UI_FONT_FAMILY_DEFAULT); + }); + + it("falls back to the default on a non-string value", () => { + localStorage.setItem(FAMILY_STORAGE_KEY, JSON.stringify(42)); + expect(readUiFontFamily()).toBe(UI_FONT_FAMILY_DEFAULT); + }); + + it("applies the family with the system stack appended as a fallback", () => { + // The system stack is appended so an uninstalled/partial name degrades to + // the app's default sans, not the browser's default serif. + applyUiFontFamily("Inter"); + expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe( + "Inter, var(--font-sans)", + ); + }); + + it("removes the custom property when applied empty (System default)", () => { + applyUiFontFamily("Inter"); + expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe( + "Inter, var(--font-sans)", + ); + applyUiFontFamily(""); + // Removing the property lets the html rule fall back to var(--font-sans). + expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(""); + }); +}); diff --git a/web/src/lib/uiFontPreferences.ts b/web/src/lib/uiFontPreferences.ts index 573b2f5fbab..c1c9b32fad4 100644 --- a/web/src/lib/uiFontPreferences.ts +++ b/web/src/lib/uiFontPreferences.ts @@ -1,4 +1,4 @@ -// Persisted, app-global preference for the UI font size. +// Persisted, app-global preferences for the UI font — size and family. // // The web UI is Tailwind v4, which sizes typography AND spacing in `rem`, so // scaling the root `` font-size reflows the entire UI uniformly. Rather @@ -8,6 +8,13 @@ // multiply into. The base rule uses `calc(1em * var(--ui-font-scale))`, so the // user's browser-default size is preserved and the displayed px maps 1:1 for // the default-16px case. +// +// Font family works the analogous way with `--ui-font-family`. Note it can't +// reuse `--font-sans`: Tailwind v4's `@theme inline` block inlines the literal +// stack into the `font-sans` utility instead of a `var()` reference, so setting +// `--font-sans` at runtime is a no-op. The `html` rule reads +// `var(--ui-font-family, var(--font-sans))`, so an unset family falls back to +// the system stack and any value we set on documentElement wins. const STORAGE_KEY = "omnigent:ui-font-size"; @@ -73,3 +80,91 @@ export function applyUiFontScale(px: number): void { const scale = clampUiFontSizePx(px) / BASE_FONT_SIZE_PX; document.documentElement.style.setProperty("--ui-font-scale", String(scale)); } + +// ---- Font family --------------------------------------------------------- + +const FONT_FAMILY_STORAGE_KEY = "omnigent:ui-font-family"; + +/** Empty string = "System default": no override, falls back to `--font-sans`. */ +export const UI_FONT_FAMILY_DEFAULT = ""; + +/** Longest family name we'll accept — a guard against a corrupt/oversized entry. */ +const UI_FONT_FAMILY_MAX_LENGTH = 100; + +/** + * Normalize a raw family name into a value safe to persist and to set as a CSS + * custom property: trimmed, with characters that could terminate the + * declaration or open a new one (`;{}` and control chars) stripped. Over-long + * input collapses to the default. Returns "" for anything that isn't a usable + * family, so callers treat empty as "System default". + */ +function normalizeUiFontFamily(value: unknown): string { + if (typeof value !== "string") return UI_FONT_FAMILY_DEFAULT; + // eslint-disable-next-line no-control-regex -- intentionally stripping control chars + const cleaned = value.replace(/[;{}\x00-\x1f\x7f]/g, "").trim(); + if (!cleaned || cleaned.length > UI_FONT_FAMILY_MAX_LENGTH) { + return UI_FONT_FAMILY_DEFAULT; + } + return cleaned; +} + +/** + * Read the persisted UI font family. + * + * Returns "" (System default) when nothing is stored, on a server render (no + * `window`), or when the stored value is missing/malformed — never throws, so a + * corrupt entry can't break app boot. + */ +export function readUiFontFamily(): string { + if (typeof window === "undefined") return UI_FONT_FAMILY_DEFAULT; + try { + const raw = window.localStorage.getItem(FONT_FAMILY_STORAGE_KEY); + if (!raw) return UI_FONT_FAMILY_DEFAULT; + const parsed: unknown = JSON.parse(raw); + return normalizeUiFontFamily(parsed); + } catch { + return UI_FONT_FAMILY_DEFAULT; + } +} + +/** + * Persist the UI font family. An empty (or all-stripped) name clears the + * preference — reverting to System default — rather than storing a blank. Swallows + * quota/access errors so a failed write can't break the app. + */ +export function writeUiFontFamily(name: string): void { + if (typeof window === "undefined") return; + try { + const normalized = normalizeUiFontFamily(name); + if (!normalized) { + window.localStorage.removeItem(FONT_FAMILY_STORAGE_KEY); + return; + } + window.localStorage.setItem(FONT_FAMILY_STORAGE_KEY, JSON.stringify(normalized)); + } catch { + // localStorage quota or access errors shouldn't break the app. + } +} + +/** + * Apply the given family to the DOM by setting the `--ui-font-family` variable + * on the document root; the `html` rule in index.css reads it as the whole UI's + * font. An empty name removes the property, restoring the system stack. + * + * The chosen family is applied WITH the system stack appended + * (`, var(--font-sans)`) so a name that isn't installed — or a partial one + * typed so far — degrades to the app's default sans rather than the browser's + * default serif. (The `var(--ui-font-family, …)` fallback in the CSS only fires + * when the property is unset, not when it holds an unusable name, so the + * fallback has to live inside the value too.) This is the single source of the + * DOM side-effect. + */ +export function applyUiFontFamily(name: string): void { + if (typeof document === "undefined") return; + const normalized = normalizeUiFontFamily(name); + if (!normalized) { + document.documentElement.style.removeProperty("--ui-font-family"); + return; + } + document.documentElement.style.setProperty("--ui-font-family", `${normalized}, var(--font-sans)`); +} diff --git a/web/src/main.tsx b/web/src/main.tsx index dca001edb69..1bd6aea95e3 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -14,7 +14,12 @@ import { CapabilitiesProvider } from "./lib/CapabilitiesContext"; import { resolveIdentity } from "./lib/identity"; import { initNativeInsets } from "./lib/nativeInsets"; import { initBrowserTelemetry } from "./lib/telemetry"; -import { applyUiFontScale, readUiFontSizePx } from "./lib/uiFontPreferences"; +import { + applyUiFontFamily, + applyUiFontScale, + readUiFontFamily, + readUiFontSizePx, +} from "./lib/uiFontPreferences"; import { initChatStore } from "./store/chatStore"; import "./index.css"; @@ -49,8 +54,9 @@ void resolveIdentity(); // No-op off the iOS shell (the inset vars stay at their env()-only defaults). initNativeInsets(); -// Apply the saved UI font size before first paint so there's no size flash. +// Apply the saved UI font size and family before first paint so there's no flash. applyUiFontScale(readUiFontSizePx()); +applyUiFontFamily(readUiFontFamily()); // Probe /v1/info BEFORE the first render so the route table knows // whether to mount accounts routes. The probe is unauthed and the diff --git a/web/src/pages/SettingsPage.test.tsx b/web/src/pages/SettingsPage.test.tsx index 2f27da360d0..013c346e6d1 100644 --- a/web/src/pages/SettingsPage.test.tsx +++ b/web/src/pages/SettingsPage.test.tsx @@ -143,6 +143,44 @@ describe("SettingsPage", () => { expect(screen.getByTestId("ui-font-size-inc")).not.toBeDisabled(); }); + it("shows the empty font family default and applies + persists a typed name", () => { + localStorage.clear(); + document.documentElement.style.removeProperty("--ui-font-family"); + renderPage("/settings/appearance"); + const input = screen.getByTestId("ui-font-family-input") as HTMLInputElement; + // No stored preference → empty input, System-default placeholder, no override. + expect(input.value).toBe(""); + expect(input.placeholder).toBe("System default"); + expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(""); + // Reset has nothing to do at the default. + expect(screen.getByTestId("ui-font-family-reset")).toBeDisabled(); + + fireEvent.change(input, { target: { value: "Inter" } }); + expect(input.value).toBe("Inter"); + // The choice is persisted so it survives a refresh... + expect(localStorage.getItem("omnigent:ui-font-family")).toBe(JSON.stringify("Inter")); + // ...and applied live to the document root, with the system stack appended + // so an uninstalled/partial name degrades to the default sans, not serif. + expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe( + "Inter, var(--font-sans)", + ); + expect(screen.getByTestId("ui-font-family-reset")).not.toBeDisabled(); + }); + + it("reset restores the system default font family", () => { + localStorage.setItem("omnigent:ui-font-family", JSON.stringify("Georgia")); + renderPage("/settings/appearance"); + const input = screen.getByTestId("ui-font-family-input") as HTMLInputElement; + // The control reflects the stored preference on mount. + expect(input.value).toBe("Georgia"); + + fireEvent.click(screen.getByTestId("ui-font-family-reset")); + // Reset clears the field, the applied property, and the stored key. + expect(input.value).toBe(""); + expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(""); + expect(localStorage.getItem("omnigent:ui-font-family")).toBeNull(); + }); + it("lets you clear and retype the font size without clamping mid-edit", () => { localStorage.setItem("omnigent:ui-font-size", "13"); renderPage("/settings/appearance"); diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 39a767adcc3..e7f15d27bd8 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -60,12 +60,16 @@ import { absoluteTime } from "@/lib/relativeTime"; import { useSettingsRoute } from "@/shell/settingsNav"; import { type ThemeMode, normalizeThemeMode } from "@/components/theme/themeMode"; import { + applyUiFontFamily, applyUiFontScale, clampUiFontSizePx, + readUiFontFamily, readUiFontSizePx, + UI_FONT_FAMILY_DEFAULT, UI_FONT_SIZE_MAX, UI_FONT_SIZE_MIN, UI_FONT_SIZE_STEP, + writeUiFontFamily, writeUiFontSizePx, } from "@/lib/uiFontPreferences"; import { useIsEmbedded } from "@/lib/embedded"; @@ -192,6 +196,8 @@ function AppearanceSection() { + + ); @@ -302,6 +308,68 @@ function UiFontSizeControl() { ); } +/** + * UI font family picker. Free-text (Cursor-style): type any font installed on + * this device; blank means "System default", which falls back to the existing + * --font-sans stack. Applies live and persists on every change via the + * --ui-font-family variable (see lib/uiFontPreferences.ts). Like the size + * control it stays visible when embedded — a per-device readability pref that + * doesn't conflict with host theming. + */ +function UiFontFamilyControl() { + const [family, setFamily] = useState(() => readUiFontFamily()); + + const update = useCallback((next: string) => { + setFamily(next); + writeUiFontFamily(next); + applyUiFontFamily(next); + }, []); + + const isDefault = family.trim() === UI_FONT_FAMILY_DEFAULT; + + return ( +
+ {/* Take the remaining width (and let the longer description wrap within + this column) so the input stays inline instead of dropping to its own + row — matches the font-size row's alignment. */} +
+ Font family + + Use any font installed on this device. Leave blank for the system default. + +
+ {/* Reset sits left of the input so the input is the rightmost element and + its right edge lines up flush with the font-size stepper above. + `invisible` (not removed) at the default keeps the row from shifting. */} +
+ + update(e.target.value)} + /> +
+
+ ); +} + /** Flanking +/- segment of the font-size pill: square, ghost-hover, no border. */ function StepperButton({ label, From e6cbd35410039ee4751b52058f1465098f2ba756 Mon Sep 17 00:00:00 2001 From: Serena Ruan <82044803+serena-ruan@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:22:03 +0800 Subject: [PATCH 045/546] feat(web): background cross-session flush of queued messages (#2029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): background cross-session flush of queued messages A message queued in conversation B now flushes when B goes idle, even while the user is viewing a different conversation A — previously it sat until the user returned to B (navigating away aborts B's SSE stream, so the foreground flush couldn't see B's status). New flushBackgroundQueues store action: for each conversation with queued messages that isn't the active one, read its status from the live ["conversations"] cache (kept fresh by the WS session-updates overlay + poll) and, if idle, POST the head via postEvent — a stateless primitive that touches no active-session state (no optimistic bubble; it re-hydrates on return). One message per idle conversation per call (FIFO); re-queues on POST failure to retry. Text-only for now — attachments are left to the foreground flush (tracked in the code comment). A new app-wide QueueFlushProvider triggers it on queue changes and on any ["conversations"] cache change (the signal a navigated-away conversation went idle). The foreground maybeFlushQueuedHead still owns the active conversation; the two are complementary. Updates the cross-session routing e2e: it now asserts the queued message is delivered to its origin B via background flush (never leaking to the active A) — closing the loop the pre-queue test guarded. Co-authored-by: Isaac * fix(web): bound background-flush retries on persistent POST failure Polly review flagged an unbounded retry storm: on a persistent POST failure the head is re-queued, which mutates queuedMessages and re-fires QueueFlushProvider's effect; the failed POST leaves the conversation idle in the cache, so it flushes → POSTs → fails → re-queues → … with no backoff, hammering /v1/sessions/{id}/events. Add a module-level throttle (kept out of store state so it can't re-trigger the effect): skip a conversation that is mid-POST or within a 5s post-failure cooldown. Also re-queue a failed head ahead of its own successors instead of at the tail, preserving per-conversation FIFO. Tests: cooldown blocks an immediate re-POST of a just-failed conversation; a failed head lands back in front of its successor. Co-authored-by: Isaac --- .../sessions/test_cross_session_routing.py | 44 +++-- web/src/embed.tsx | 5 +- web/src/hooks/QueueFlushProvider.tsx | 48 +++++ web/src/main.tsx | 5 +- web/src/store/chatStore.test.ts | 170 ++++++++++++++++++ web/src/store/chatStore.ts | 104 +++++++++++ 6 files changed, 355 insertions(+), 21 deletions(-) create mode 100644 web/src/hooks/QueueFlushProvider.tsx diff --git a/tests/e2e_ui/sessions/test_cross_session_routing.py b/tests/e2e_ui/sessions/test_cross_session_routing.py index 5b732147b15..8777c10ab95 100644 --- a/tests/e2e_ui/sessions/test_cross_session_routing.py +++ b/tests/e2e_ui/sessions/test_cross_session_routing.py @@ -1,19 +1,20 @@ -"""E2E: a message queued in one session never leaks into another. +"""E2E: a queued message is delivered to its origin session, never another. -Guards the cross-session message-routing regression under the client-side -queue model: +Guards cross-session message routing under the client-side queue + +background-flush model: Session B is busy (its first message's POST is held open, so B stays "streaming"), so a follow-up typed into B is held in B's client-side - queue — NOT POSTed. While it waits there, the user switches to a - different, idle session A. The queued message MUST stay bound to B: it - must never be POSTed to A (the now-active session). + queue — NOT POSTed. The user switches to a different, idle session A. + Once B's turn settles and B reads idle, **background flush** delivers + the queued message to B — its origin — even though A is now active. It + must go to B and never leak into A. -The queue is a per-conversation client-side buffer: ``maybeFlushQueuedHead`` -only flushes the head whose ``conversationId`` matches the bound session, so a -message composed in B cannot be addressed to A. This test pins that no-leak -guarantee. (The positive path — a queued head flushing to its own session on -idle, in FIFO order — is covered by the ``chatStore`` unit tests.) +The queue is a per-conversation client-side buffer keyed by ``conversationId``; +``flushBackgroundQueues`` POSTs a queued message to its own conversation when +that conversation is idle in the ``["conversations"]`` cache, regardless of +which session is being viewed. This test pins both halves: delivered-to-B and +never-to-A. (The FIFO/idle unit behavior is covered by the ``chatStore`` tests.) Why async Playwright (not the sync ``page`` fixture): the test inspects the body of every ``/events`` POST via a route handler and asserts on which @@ -174,17 +175,22 @@ async def handle_events(route: Route) -> None: # Switch to the idle session A via the sidebar link — a client-side # navigation that preserves the store (a full reload would drop the - # queue). msg2 must NOT flush into A. + # queue). await page.locator(f'a[href="/c/{session_a}"]').click() await page.wait_for_url(re.compile(rf"/c/{re.escape(session_a)}")) - # Release B's first POST so the send lifecycle can settle; the queued - # msg2 is bound to B, so switching to A must not flush it there. + # Release B's first POST so its turn settles and B reads idle in the + # conversations cache — the trigger for background flush. release_first.set() - # Give any errant flush a chance to fire before asserting the - # negative (the queue head is bound to B, so nothing should POST). - await asyncio.sleep(1.0) - assert all(text != _MSG2 for _, text in event_posts), ( - f"msg2 leaked out of B while A was active: {event_posts}" + + # Background flush now delivers the queued msg2 to B (its origin), + # even though A is the active session — the key guarantee. It must + # go to B, never to A. + await _wait_until(lambda: any(text == _MSG2 for _, text in event_posts)) + msg2_targets = [sid for sid, text in event_posts if text == _MSG2] + assert msg2_targets == [session_b], ( + f"msg2 was composed in session B ({session_b}) and must be " + f"delivered there via background flush, but POST targets were " + f"{msg2_targets}." ) assert all(sid != session_a for sid, _ in event_posts), ( f"a message leaked into the active session A: {event_posts}" diff --git a/web/src/embed.tsx b/web/src/embed.tsx index 4de6713063a..709a5482d24 100644 --- a/web/src/embed.tsx +++ b/web/src/embed.tsx @@ -43,6 +43,7 @@ import { } from "./lib/routing"; import { initChatStore } from "./store/chatStore"; import "./index.css"; +import { QueueFlushProvider } from "./hooks/QueueFlushProvider"; import { SessionUpdatesProvider } from "./hooks/SessionUpdatesProvider"; export type { OmnigentHostConfig } from "./lib/host"; @@ -205,7 +206,9 @@ function OmnigentProviders({ - + + + diff --git a/web/src/hooks/QueueFlushProvider.tsx b/web/src/hooks/QueueFlushProvider.tsx new file mode 100644 index 00000000000..e01266d1c40 --- /dev/null +++ b/web/src/hooks/QueueFlushProvider.tsx @@ -0,0 +1,48 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import type { ReactNode } from "react"; + +import { useChatStore } from "@/store/chatStore"; + +/** + * Drives the background flush of the client-side message queue. + * + * The composer's own effect flushes the queue for the conversation the user is + * *viewing*. This provider covers the rest: a message queued in a conversation + * the user has navigated away from (whose SSE stream is gone) still needs to + * send when that conversation next goes idle. + * + * It calls `flushBackgroundQueues` — which reads each queued conversation's + * status from the live `["conversations"]` cache and POSTs the head of any that + * are idle — whenever either the queue or that cache changes. Mounted app-wide + * so it fires regardless of the current route. Level-triggered and idempotent + * (the action skips the active conversation and no-ops when nothing is ready), + * so over-firing is harmless. + */ +export function QueueFlushProvider({ children }: { children: ReactNode }) { + const queryClient = useQueryClient(); + const queuedMessages = useChatStore((s) => s.queuedMessages); + const flushBackgroundQueues = useChatStore((s) => s.flushBackgroundQueues); + + // Re-evaluate whenever the queue itself changes (e.g. a message enqueued in + // another conversation, or one drained here). + useEffect(() => { + flushBackgroundQueues(); + }, [queuedMessages, flushBackgroundQueues]); + + // Re-evaluate whenever the sidebar/conversations cache changes — this is how + // a navigated-away conversation's idle transition (WS overlay or poll) + // reaches us without its live SSE stream. + useEffect(() => { + const cache = queryClient.getQueryCache(); + const unsubscribe = cache.subscribe((event) => { + const key = event.query.queryKey; + if (Array.isArray(key) && key[0] === "conversations") { + flushBackgroundQueues(); + } + }); + return unsubscribe; + }, [queryClient, flushBackgroundQueues]); + + return <>{children}; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 1bd6aea95e3..81266d15ac7 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -8,6 +8,7 @@ import { ThemeProvider } from "./components/theme/ThemeProvider"; import { TooltipProvider } from "./components/ui/tooltip"; import { ImageLightboxProvider } from "./components/ImageLightbox"; import { RunnerHealthProvider } from "./hooks/RunnerHealthProvider"; +import { QueueFlushProvider } from "./hooks/QueueFlushProvider"; import { SessionUpdatesProvider } from "./hooks/SessionUpdatesProvider"; import { resolveServerInfo, type ServerInfo } from "./lib/capabilities"; import { CapabilitiesProvider } from "./lib/CapabilitiesContext"; @@ -96,7 +97,9 @@ void _bootProbe.then((info) => { - + + + diff --git a/web/src/store/chatStore.test.ts b/web/src/store/chatStore.test.ts index e73d4eb7e82..24e1cc68f39 100644 --- a/web/src/store/chatStore.test.ts +++ b/web/src/store/chatStore.test.ts @@ -7705,3 +7705,173 @@ describe("chatStore — client-side message queue", () => { expect(sendSpy.mock.calls[0]!.slice(0, 2)).toEqual(["stranded?", "agent_xyz"]); }); }); + +describe("chatStore — background cross-session flush", () => { + /** /events POSTs the flush fired, as (conversationId, text) pairs. */ + const eventPosts = (): Array<{ id: string; text: string }> => + fetchMock.mock.calls + .filter( + ([u, init]) => + typeof u === "string" && + /\/v1\/sessions\/([^/]+)\/events$/.test(u) && + (init as RequestInit | undefined)?.method === "POST", + ) + .map(([u, init]) => { + const id = /\/v1\/sessions\/([^/]+)\/events$/.exec(u as string)![1]!; + const body = JSON.parse((init as RequestInit).body as string); + const text = (body.data?.content ?? []).find( + (b: { type: string }) => b.type === "input_text", + )?.text; + return { id, text }; + }); + + it("flushes an idle non-active conversation's head via postEvent", async () => { + // Viewing conv_active; conv_bg is idle in the sidebar cache with a queue. + seedConversationsCache([conv("conv_active", "running"), conv("conv_bg", "idle")]); + useChatStore.setState({ + conversationId: "conv_active", + queuedMessages: [ + { queueId: "q_1", text: "bg-first", conversationId: "conv_bg" }, + { queueId: "q_2", text: "bg-second", conversationId: "conv_bg" }, + ], + }); + + useChatStore.getState().flushBackgroundQueues(); + await tick(); + + // One POST to conv_bg (FIFO head only); its head left the queue, tail stays. + expect(eventPosts()).toEqual([{ id: "conv_bg", text: "bg-first" }]); + expect(useChatStore.getState().queuedMessages.map((m) => m.text)).toEqual(["bg-second"]); + }); + + it("does not flush a non-active conversation that is not idle", async () => { + seedConversationsCache([conv("conv_active", "idle"), conv("conv_bg", "running")]); + useChatStore.setState({ + conversationId: "conv_active", + queuedMessages: [{ queueId: "q_1", text: "wait", conversationId: "conv_bg" }], + }); + + useChatStore.getState().flushBackgroundQueues(); + await tick(); + + expect(eventPosts()).toEqual([]); + expect(useChatStore.getState().queuedMessages.map((m) => m.text)).toEqual(["wait"]); + }); + + it("skips the active conversation (owned by the foreground flush)", async () => { + // conv_active is idle with a queue, but background flush must leave it to + // maybeFlushQueuedHead — otherwise both paths would race the same message. + seedConversationsCache([conv("conv_active", "idle")]); + useChatStore.setState({ + conversationId: "conv_active", + queuedMessages: [{ queueId: "q_1", text: "mine", conversationId: "conv_active" }], + }); + + useChatStore.getState().flushBackgroundQueues(); + await tick(); + + expect(eventPosts()).toEqual([]); + expect(useChatStore.getState().queuedMessages.map((m) => m.text)).toEqual(["mine"]); + }); + + it("leaves a message queued when its background POST fails", async () => { + seedConversationsCache([conv("conv_active", "running"), conv("conv_bg", "idle")]); + useChatStore.setState({ + conversationId: "conv_active", + queuedMessages: [{ queueId: "q_1", text: "retry-me", conversationId: "conv_bg" }], + }); + fetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (/\/v1\/sessions\/conv_bg\/events$/.test(url) && init?.method === "POST") { + return mockResponse({}, { ok: false, status: 500 }); + } + return defaultFetchHandler(input, init); + }); + + useChatStore.getState().flushBackgroundQueues(); + await tick(); + await tick(); + + // POST failed → the message is re-queued for the next trigger to retry. + expect(useChatStore.getState().queuedMessages.map((m) => m.text)).toEqual(["retry-me"]); + }); + + it("does not re-POST a just-failed conversation within its cooldown", async () => { + // Guards the retry-storm case: a persistently-failing idle conversation + // must not be hammered when the queue-change effect re-fires immediately. + seedConversationsCache([conv("conv_active", "running"), conv("conv_bg", "idle")]); + useChatStore.setState({ + conversationId: "conv_active", + queuedMessages: [{ queueId: "q_1", text: "flaky", conversationId: "conv_bg" }], + }); + let posts = 0; + fetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (/\/v1\/sessions\/conv_bg\/events$/.test(url) && init?.method === "POST") { + posts += 1; + return mockResponse({}, { ok: false, status: 503 }); + } + return defaultFetchHandler(input, init); + }); + + // First flush POSTs once and fails → re-queued + cooldown set. + useChatStore.getState().flushBackgroundQueues(); + await tick(); + await tick(); + expect(posts).toBe(1); + + // Immediate re-triggers (mirroring the effect firing on every re-queue) + // must NOT POST again while the conversation is in cooldown. + useChatStore.getState().flushBackgroundQueues(); + useChatStore.getState().flushBackgroundQueues(); + await tick(); + expect(posts).toBe(1); + expect(useChatStore.getState().queuedMessages.map((m) => m.text)).toEqual(["flaky"]); + }); + + it("re-queues a failed head ahead of its own successors (FIFO preserved)", async () => { + // conv_bg has two queued messages; only the head fails. It must land back + // in front of its successor, not behind it. + seedConversationsCache([conv("conv_active", "running"), conv("conv_bg", "idle")]); + useChatStore.setState({ + conversationId: "conv_active", + queuedMessages: [ + { queueId: "q_1", text: "bg-first", conversationId: "conv_bg" }, + { queueId: "q_2", text: "bg-second", conversationId: "conv_bg" }, + ], + }); + fetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (/\/v1\/sessions\/conv_bg\/events$/.test(url) && init?.method === "POST") { + return mockResponse({}, { ok: false, status: 500 }); + } + return defaultFetchHandler(input, init); + }); + + useChatStore.getState().flushBackgroundQueues(); + await tick(); + await tick(); + + expect(useChatStore.getState().queuedMessages.map((m) => m.text)).toEqual([ + "bg-first", + "bg-second", + ]); + }); + + it("skips a message with attachments (foreground flush owns those)", async () => { + seedConversationsCache([conv("conv_active", "running"), conv("conv_bg", "idle")]); + const file = new File(["x"], "a.txt", { type: "text/plain" }); + useChatStore.setState({ + conversationId: "conv_active", + queuedMessages: [ + { queueId: "q_1", text: "has-file", conversationId: "conv_bg", files: [file] }, + ], + }); + + useChatStore.getState().flushBackgroundQueues(); + await tick(); + + expect(eventPosts()).toEqual([]); + expect(useChatStore.getState().queuedMessages.map((m) => m.text)).toEqual(["has-file"]); + }); +}); diff --git a/web/src/store/chatStore.ts b/web/src/store/chatStore.ts index 12a5f8cd539..3113f581dca 100644 --- a/web/src/store/chatStore.ts +++ b/web/src/store/chatStore.ts @@ -73,6 +73,7 @@ import { createPresenceIdleTracker } from "@/lib/presenceIdle"; import { parseEvent, parseSseStream, type SseStreamResult } from "@/lib/sse"; import { childSessionsQueryKey, type ChildSessionInfo } from "@/hooks/useChildSessions"; import type { Conversation, ConversationsPage } from "@/hooks/useConversations"; +import type { ConversationsInfiniteData } from "@/lib/sessionListCache"; import { useTerminalActivityStore } from "./terminalActivity"; import { terminalInfoFromResource, @@ -556,6 +557,16 @@ export interface ChatState { * idle, so the queue drains FIFO one per turn. */ maybeFlushQueuedHead: () => void; + /** + * Flush queued messages for conversations OTHER than the active one, whose + * status in the `["conversations"]` cache is idle. The active conversation is + * owned by {@link maybeFlushQueuedHead}; this covers a queue whose session the + * user has navigated away from (its SSE stream is gone, so it can't drain + * itself). POSTs one message per idle conversation per call, via `postEvent` + * (no active-session state touched, no optimistic bubble — it re-hydrates on + * return). Level-triggered + idempotent; safe to over-fire. + */ + flushBackgroundQueues: () => void; /** * Invoke a skill by posting a ``slash_command`` event — the same wire * shape the REPL sends. The server resolves the skill, persists the @@ -645,6 +656,16 @@ let sendChain: Promise = Promise.resolve(); let flashTimer: ReturnType | null = null; const workspaceInvalidationTimers = new Map>(); +// Background-flush throttle, kept OUT of store state so it can't re-trigger the +// queue effect. A conversation currently mid-POST (inFlight) or in its +// post-failure cooldown is skipped, so `flushBackgroundQueues` can't spin into +// a tight retry loop against a persistently-failing idle conversation — a +// failed POST leaves it idle in the cache, which would otherwise re-fire on +// every re-queue. Cooldown paces retries to roughly the sidebar poll cadence. +const BACKGROUND_FLUSH_COOLDOWN_MS = 5_000; +const backgroundFlushInFlight = new Set(); +const backgroundFlushCooldownUntil = new Map(); + // Must match the @keyframes user-msg-flash duration in index.css. const FLASH_DURATION_MS = 800; const WORKSPACE_INVALIDATION_DEBOUNCE_MS = 750; @@ -690,6 +711,8 @@ export function initChatStore(client: QueryClient): void { clearTimeout(timer); } workspaceInvalidationTimers.clear(); + backgroundFlushInFlight.clear(); + backgroundFlushCooldownUntil.clear(); queryClient = client; } @@ -910,6 +933,87 @@ export const useChatStore = create((set, get) => ({ void s.send(head.text, head.agentId ?? s.boundAgentId, head.files); }, + flushBackgroundQueues: () => { + const s = get(); + if (queryClient === null || s.queuedMessages.length === 0) return; + + // Conversations (other than the active one) that have a queued message. + // The active conversation is owned by maybeFlushQueuedHead. + const candidateIds = new Set( + s.queuedMessages.map((m) => m.conversationId).filter((id) => id !== s.conversationId), + ); + if (candidateIds.size === 0) return; + + // Per-conversation status from the sidebar cache (kept live by the WS + // /v1/sessions/updates overlay + poll), so we can tell whether a + // navigated-away conversation is idle without its SSE stream. A conversation + // scrolled past the loaded pages has no row here → treated as not-idle and + // left for the foreground flush when the user navigates back to it. + const statusById = new Map(); + for (const [, data] of queryClient.getQueriesData({ + queryKey: ["conversations"], + })) { + for (const page of data?.pages ?? []) { + for (const row of page.data) { + if (candidateIds.has(row.id) && !statusById.has(row.id)) { + statusById.set(row.id, row.status); + } + } + } + } + + // One message per idle conversation per call: POSTing makes it busy, so the + // next idle (via WS/poll) triggers this again for the next message (FIFO). + const now = Date.now(); + for (const conversationId of candidateIds) { + if (statusById.get(conversationId) !== "idle") continue; + // Skip a conversation mid-POST or in its post-failure cooldown so a + // persistent failure can't spin this into a tight retry loop (the effect + // re-fires on every re-queue, and a failed POST leaves the row idle). + if (backgroundFlushInFlight.has(conversationId)) continue; + const cooldownUntil = backgroundFlushCooldownUntil.get(conversationId); + if (cooldownUntil !== undefined && cooldownUntil > now) continue; + const head = get().queuedMessages.find((m) => m.conversationId === conversationId); + // Files are left for the foreground flush — background covers text only + // (attachments are in-memory and the user is likely still near that chat). + if (head === undefined || (head.files && head.files.length > 0)) continue; + + // Remove BEFORE the POST so a re-entrant trigger can't double-send. + backgroundFlushInFlight.add(conversationId); + set((st) => ({ + queuedMessages: st.queuedMessages.filter((m) => m.queueId !== head.queueId), + })); + // No optimistic bubble — we're not viewing this conversation; it + // re-hydrates from the snapshot on return. On failure re-queue at the + // head (preserving this conversation's FIFO order) and set a cooldown so + // the next trigger backs off instead of hammering a failing runner. + void postEvent(conversationId, { + type: "message", + data: { role: "user", content: [{ type: "input_text", text: head.text }] }, + }) + .catch(() => { + backgroundFlushCooldownUntil.set( + conversationId, + Date.now() + BACKGROUND_FLUSH_COOLDOWN_MS, + ); + set((st) => { + const idx = st.queuedMessages.findIndex((m) => m.conversationId === conversationId); + const at = idx === -1 ? st.queuedMessages.length : idx; + return { + queuedMessages: [ + ...st.queuedMessages.slice(0, at), + head, + ...st.queuedMessages.slice(at), + ], + }; + }); + }) + .finally(() => { + backgroundFlushInFlight.delete(conversationId); + }); + } + }, + send: async (text, agentId, files, opts) => { if (!agentId) { throw new Error("chatStore.send: no agentId"); From 62b4254aff4fe036fce8e93b1efb2dbf7c8abf04 Mon Sep 17 00:00:00 2001 From: Serena Ruan <82044803+serena-ruan@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:54:50 +0800 Subject: [PATCH 046/546] ci(e2e-ui): cache the sidecar binary and skip recompiles (#2028) The `build codex-parity sidecar` job recompiles the Rust sidecar (~1100 crates, ~7 min cold) on nearly every PR run. The old `Cache Rust build` step cached the whole 1.6 GB `--target-dir` keyed on `Cargo.lock`, but: - The job triggers only on `pull_request`, so every cache is scoped to `refs/pull/NNNN/merge`. GitHub only lets a PR restore caches from its own ref or the base branch (main), and this workflow never writes a main-scoped cache -- so no PR can ever restore another's. Every first run is a guaranteed cold miss. - Each 1.6 GB entry churns out of the 10 GB repo cache under LRU, so even same-PR re-runs frequently miss. - Even on a target-dir hit, Cargo re-fingerprints and rebuilds anyway. Mirror the fix #2016 applied to ci.yml's codex-parity job: cache just the ~10 MB binary, keyed on `sidecar/**` + the rustc version, and skip `cargo build` on a hit. This uses the SAME key as ci.yml, which runs on push to main -- so the main-scoped `codex-parity-bin` cache ci.yml produces is now restorable by this PR-only workflow. Warm runs drop from ~7 min to the artifact download/upload (~15-25s). The key self- invalidates when the source, Cargo.lock, or toolchain changes. Co-authored-by: Isaac --- .github/workflows/e2e-ui.yml | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/e2e-ui.yml b/.github/workflows/e2e-ui.yml index 208db3e9713..685ab733613 100644 --- a/.github/workflows/e2e-ui.yml +++ b/.github/workflows/e2e-ui.yml @@ -111,16 +111,23 @@ jobs: uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: toolchain: stable - # Pin the toolchain for a stable cache fingerprint, key on the sidecar - # Cargo.lock. A warm hit reuses every dep and only relinks the workspace - # crate (~40s); a cold miss is the full ~7min compile (rare -- the lock - # is near-static). Same key as ci.yml's codex-parity job, so they share. - - name: Cache Rust build + - name: Capture Rust version + id: rustc + run: echo "version=$(rustc --version | tr ' ' '-')" >> "$GITHUB_OUTPUT" + # The sidecar source is frozen and its deps are rev-pinned, so the binary + # is a pure function of sidecar/** + the toolchain. Cache the built binary + # (not the 1.6 GB target dir) and skip the ~7 min compile below on a hit; + # the key self-invalidates when the source, Cargo.lock, or rustc changes. + # Same key as ci.yml's codex-parity job -- ci.yml runs on push to main and + # populates the main-scoped cache that this PR-only workflow restores from. + - name: Cache parity sidecar binary + id: sidecar-cache uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4 with: - path: .tmp-codex-parity-target - key: codex-parity-sidecar-${{ runner.os }}-${{ hashFiles('tests/codex_parity/sidecar/Cargo.lock') }} + path: .tmp-codex-parity-target/debug/codex-parity-sidecar + key: codex-parity-bin-${{ runner.os }}-${{ steps.rustc.outputs.version }}-${{ hashFiles('tests/codex_parity/sidecar/**') }} - name: Build parity sidecar + if: steps.sidecar-cache.outputs.cache-hit != 'true' run: | cargo build \ --manifest-path tests/codex_parity/sidecar/Cargo.toml \ From 75a5ec58dba9e73a06c3a2359baaec31c67ae862 Mon Sep 17 00:00:00 2001 From: Tomu Hirata Date: Tue, 7 Jul 2026 14:35:22 +0900 Subject: [PATCH 047/546] feat(cli): add omni session export --id (#2021) * feat(cli): add omni session export --id command Closes #1623 * test(cli): add unit tests for omni session export * fix(test): rename l -> line to fix E741 ambiguous variable name * feat(cli): switch session export to use server API via --server * fix(cli): pass auth headers to session export HTTP client --- omnigent/cli.py | 107 ++++++++++++ tests/host/test_cli_session_export.py | 225 ++++++++++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 tests/host/test_cli_session_export.py diff --git a/omnigent/cli.py b/omnigent/cli.py index 3fe97ecc8cd..07ec41d7f50 100644 --- a/omnigent/cli.py +++ b/omnigent/cli.py @@ -1199,6 +1199,7 @@ def cli() -> None: "qwen", "resume", "run", + "session", "sandbox", "server", "setup", @@ -5542,6 +5543,112 @@ def resume( ) +@cli.group("session", invoke_without_command=True) +@click.pass_context +def session(ctx: click.Context) -> None: + """Manage Omnigent sessions. + + \b + Examples: + omnigent session export --id conv_abc123 + omnigent session export --id conv_abc123 --output transcript.jsonl + omnigent session export --id conv_abc123 --server https://myserver.com + """ + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +@session.command("export") +@click.option( + "--id", + "session_id", + required=True, + metavar="SESSION_ID", + help="Session ID to export, e.g. conv_abc123.", +) +@click.option( + "--output", + "-o", + "output", + default=None, + metavar="FILE", + help="Output file path. Defaults to .jsonl in the current directory.", +) +@click.option( + "--server", + default=None, + help=( + "Omnigent server URL. " + "Defaults to the configured server, or a local server already running." + ), +) +def session_export(session_id: str, output: str | None, server: str | None) -> None: + """Export a session transcript to a portable JSONL file. + + Each line of the output is a JSON object. The first line carries + the session metadata (``"record_type": "session_meta"``); every + subsequent line is one conversation item + (``"record_type": "item"``). The file preserves full turn order + and can be re-imported with a future ``omnigent session import``. + + \b + Examples: + omnigent session export --id conv_abc123 + omnigent session export --id conv_abc123 --output my_session.jsonl + omnigent session export --id conv_abc123 --server https://myserver.com + """ + import httpx + + from omnigent.chat import _remote_headers + + cfg = _load_effective_config() + base_url = _resolve_attach_server(server, cfg.get("server")) + if base_url is None: + startup = ensure_local_omnigent_server() + base_url = startup.url + + base_url = base_url.rstrip("/") + out_path = Path(output) if output else Path(f"{session_id}.jsonl") + + with httpx.Client( + base_url=base_url, headers=_remote_headers(server_url=base_url), timeout=30.0 + ) as client: + # Fetch session metadata (items fetched separately via pagination). + resp = client.get( + f"/v1/sessions/{session_id}", + params={"include_items": "false", "include_liveness": "false"}, + ) + if resp.status_code == 404: + raise click.ClickException(f"Session {session_id!r} not found.") + resp.raise_for_status() + session_data = resp.json() + + n_items = 0 + with out_path.open("w", encoding="utf-8") as fh: + # First line: session metadata. + meta_record = {"record_type": "session_meta", **session_data} + fh.write(json.dumps(meta_record) + "\n") + + # Remaining lines: items in ascending order, paginated. + after: str | None = None + while True: + params: dict[str, str | int] = {"limit": 500, "order": "asc"} + if after: + params["after"] = after + items_resp = client.get(f"/v1/sessions/{session_id}/items", params=params) + items_resp.raise_for_status() + page = items_resp.json() + for item in page["data"]: + item_record = {"record_type": "item", **item} + fh.write(json.dumps(item_record) + "\n") + n_items += 1 + if not page.get("has_more"): + break + after = page.get("last_id") + + click.echo(f"Exported {n_items} item(s) from {session_id} to {out_path}") + + # Shared option help for ``run`` and the harness commands. These are the same # flags the legacy argparse CLI exposed — keeping them on the unified # click CLI so users don't regress when a YAML declares no executor diff --git a/tests/host/test_cli_session_export.py b/tests/host/test_cli_session_export.py new file mode 100644 index 00000000000..d96f74cdde3 --- /dev/null +++ b/tests/host/test_cli_session_export.py @@ -0,0 +1,225 @@ +"""Unit tests for ``omnigent session export``.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import httpx +import respx +from click.testing import CliRunner + +from omnigent.cli import cli + +_BASE = "http://localhost:6767" + +_SESSION_META = { + "id": "conv_abc123", + "title": "test session", + "status": "idle", + "created_at": 1700000000, + "updated_at": 1700000001, + "agent_id": None, + "agent_name": None, + "items": [], +} + +_ITEMS_PAGE = { + "data": [ + { + "id": "msg_1", + "type": "message", + "status": "completed", + "response_id": "resp_1", + "role": "user", + "content": [{"type": "input_text", "text": "hello"}], + }, + { + "id": "msg_2", + "type": "message", + "status": "completed", + "response_id": "resp_1", + "role": "assistant", + "content": [{"type": "output_text", "text": "hi there"}], + "model": "my-agent", + }, + ], + "first_id": "msg_1", + "last_id": "msg_2", + "has_more": False, +} + + +def _patch_server(base_url: str = _BASE) -> Any: + """Patch the CLI so it uses *base_url* without spawning a real server.""" + return patch("omnigent.cli._resolve_attach_server", return_value=base_url) + + +@respx.mock +def test_session_export_writes_jsonl(tmp_path: Path) -> None: + """Export writes one session_meta line then one item line per item.""" + respx.get(f"{_BASE}/v1/sessions/conv_abc123").mock( + return_value=httpx.Response(200, json=_SESSION_META) + ) + respx.get(f"{_BASE}/v1/sessions/conv_abc123/items").mock( + return_value=httpx.Response(200, json=_ITEMS_PAGE) + ) + + out_file = tmp_path / "out.jsonl" + runner = CliRunner() + with _patch_server(): + result = runner.invoke( + cli, + ["session", "export", "--id", "conv_abc123", "--output", str(out_file)], + ) + + assert result.exit_code == 0, result.output + assert out_file.exists() + + lines = [json.loads(line) for line in out_file.read_text().splitlines() if line] + assert len(lines) == 3 # 1 meta + 2 items + + meta = lines[0] + assert meta["record_type"] == "session_meta" + assert meta["id"] == "conv_abc123" + assert meta["title"] == "test session" + + item_lines = lines[1:] + assert all(r["record_type"] == "item" for r in item_lines) + assert [r["role"] for r in item_lines] == ["user", "assistant"] + assert item_lines[1]["content"] == [{"type": "output_text", "text": "hi there"}] + + +@respx.mock +def test_session_export_default_filename(tmp_path: Path) -> None: + """Without --output, the file is named .jsonl in cwd.""" + respx.get(f"{_BASE}/v1/sessions/conv_abc123").mock( + return_value=httpx.Response(200, json=_SESSION_META) + ) + respx.get(f"{_BASE}/v1/sessions/conv_abc123/items").mock( + return_value=httpx.Response(200, json={**_ITEMS_PAGE, "data": [], "has_more": False}) + ) + + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=tmp_path), _patch_server(): + result = runner.invoke(cli, ["session", "export", "--id", "conv_abc123"]) + assert result.exit_code == 0, result.output + default_path = Path("conv_abc123.jsonl") + assert default_path.exists() + lines = [json.loads(line) for line in default_path.read_text().splitlines() if line] + + assert len(lines) == 1 + assert lines[0]["record_type"] == "session_meta" + assert lines[0]["id"] == "conv_abc123" + + +@respx.mock +def test_session_export_missing_session_errors(tmp_path: Path) -> None: + """Export of an unknown session id exits non-zero with a clear message.""" + respx.get(f"{_BASE}/v1/sessions/conv_doesnotexist").mock( + return_value=httpx.Response(404, json={"error": "not found"}) + ) + + runner = CliRunner() + with _patch_server(): + result = runner.invoke( + cli, + [ + "session", + "export", + "--id", + "conv_doesnotexist", + "--output", + str(tmp_path / "out.jsonl"), + ], + ) + + assert result.exit_code != 0 + assert "conv_doesnotexist" in result.output + + +@respx.mock +def test_session_export_items_ordered_ascending(tmp_path: Path) -> None: + """Items in the JSONL appear in ascending position order (user then assistant).""" + respx.get(f"{_BASE}/v1/sessions/conv_abc123").mock( + return_value=httpx.Response(200, json=_SESSION_META) + ) + respx.get(f"{_BASE}/v1/sessions/conv_abc123/items").mock( + return_value=httpx.Response(200, json=_ITEMS_PAGE) + ) + + out_file = tmp_path / "ordered.jsonl" + runner = CliRunner() + with _patch_server(): + result = runner.invoke( + cli, + ["session", "export", "--id", "conv_abc123", "--output", str(out_file)], + ) + assert result.exit_code == 0, result.output + + records = [json.loads(line) for line in out_file.read_text().splitlines() if line] + item_records = [r for r in records if r["record_type"] == "item"] + assert len(item_records) == 2 + assert item_records[0]["role"] == "user" + assert item_records[1]["role"] == "assistant" + + +@respx.mock +def test_session_export_pagination(tmp_path: Path) -> None: + """Export follows has_more cursors to fetch all pages.""" + page1 = { + "data": [ + { + "id": "msg_1", + "type": "message", + "status": "completed", + "response_id": "r1", + "role": "user", + "content": [], + } + ], + "first_id": "msg_1", + "last_id": "msg_1", + "has_more": True, + } + page2 = { + "data": [ + { + "id": "msg_2", + "type": "message", + "status": "completed", + "response_id": "r1", + "role": "assistant", + "content": [], + "model": "ag", + } + ], + "first_id": "msg_2", + "last_id": "msg_2", + "has_more": False, + } + respx.get(f"{_BASE}/v1/sessions/conv_abc123").mock( + return_value=httpx.Response(200, json=_SESSION_META) + ) + # First call (no after param) → page1; second call (after=msg_1) → page2. + items_route = respx.get(f"{_BASE}/v1/sessions/conv_abc123/items") + items_route.side_effect = [ + httpx.Response(200, json=page1), + httpx.Response(200, json=page2), + ] + + out_file = tmp_path / "paged.jsonl" + runner = CliRunner() + with _patch_server(): + result = runner.invoke( + cli, + ["session", "export", "--id", "conv_abc123", "--output", str(out_file)], + ) + assert result.exit_code == 0, result.output + + records = [json.loads(line) for line in out_file.read_text().splitlines() if line] + item_records = [r for r in records if r["record_type"] == "item"] + assert len(item_records) == 2 + assert [r["id"] for r in item_records] == ["msg_1", "msg_2"] From 7a8fcf931b5a2de870eae7b83ea3ce887cecd8dc Mon Sep 17 00:00:00 2001 From: Anthony Ivan <21217602+anthonyivn2@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:54:33 +0800 Subject: [PATCH 048/546] feat(web): keep the working indicator lit for the whole turn, rotate its label (#2006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): keep the working indicator lit for the whole turn, rotate its label The Otto + shimmer "Working…" indicator was hidden the moment an assistant bubble began streaming, so long tool runs and reasoning gaps looked stalled. Keep it lit for the entire busy turn (only a trailing compaction spinner still suppresses it), and rotate its label through a short pool for variety. - shouldShowWorkingIndicator no longer hides on a streaming bubble; drop the now-unused hasInProgressAssistantBubble helper. - Add useWorkingLabelTick: one shared wall-clock timer (useSyncExternalStore) so both render sites rotate in lockstep. ROTATE_MS = 1 minute. - workingIndicatorLabel(bgCount, tick) cycles WORKING_MESSAGES (7 labels, index 0 = "Working…"); background-task counts still take priority. - Keep the pinned pill's aria-live announcement stable at "Working…" while only the visible tab text rotates, so screen readers aren't re-announced. Reduced motion needs no change: the shimmer sweep and Otto bob already freeze via CSS, and the label is a JS text swap so it keeps rotating. Co-authored-by: Isaac * fix(web): address PR review — drop "Thinking…" label, fix e2e assert Review follow-ups on #2006: - Remove "Thinking…" from WORKING_MESSAGES — it carries a specific reasoning/thinking meaning in the LLM context (per @daniellok-db). - Update the background-task e2e (test_background_task_indicator_label_lifecycle) now that the running-turn label rotates: assert on the trailing ellipsis every rotating label shares (the background-task text has none) instead of the literal "Working", so it's robust to which pool entry the wall-clock bucket lands on. Co-authored-by: Isaac * test(e2e): match working label against the pool, not the ellipsis Per review follow-up: assert the running-turn indicator shows one of the actual rotating labels (regex alternation over the WORKING_MESSAGES mirror) rather than the trailing ellipsis. A commented _WORKING_LABELS constant mirrors the web pool and must stay in sync if it changes. Co-authored-by: Isaac --------- Co-authored-by: Anthony Ivan --- ...test_working_indicator_background_tasks.py | 22 +++- web/src/hooks/useWorkingLabelTick.ts | 54 +++++++++ web/src/pages/ChatPage.test.ts | 55 ++++++--- web/src/pages/ChatPage.tsx | 114 ++++++++++-------- 4 files changed, 181 insertions(+), 64 deletions(-) create mode 100644 web/src/hooks/useWorkingLabelTick.ts diff --git a/tests/e2e_ui/chat/test_working_indicator_background_tasks.py b/tests/e2e_ui/chat/test_working_indicator_background_tasks.py index aeb4052fb75..5bdaa6aee89 100644 --- a/tests/e2e_ui/chat/test_working_indicator_background_tasks.py +++ b/tests/e2e_ui/chat/test_working_indicator_background_tasks.py @@ -18,11 +18,27 @@ from __future__ import annotations +import re + import httpx from playwright.sync_api import Page, expect _WORKING = '[data-testid="working-indicator"]' +# Rotating labels the working indicator cycles through — mirror of +# WORKING_MESSAGES in web/src/pages/ChatPage.tsx. The running-turn label is +# whichever entry the wall-clock bucket lands on, so the test accepts any of +# them. Keep this list in sync if that pool changes. +_WORKING_LABELS = ( + "Working…", + "Cooking…", + "Crunching…", + "Tinkering…", + "Pondering…", + "Brewing…", +) +_WORKING_LABEL_RE = re.compile("|".join(re.escape(label) for label in _WORKING_LABELS)) + def _publish_status( base_url: str, @@ -75,9 +91,11 @@ def test_background_task_indicator_label_lifecycle( # 2. A new turn starts (the `running` edge a composer send produces): the # fresh turn supersedes the tally, so the label flips from the - # background-task count to the plain "Working…". + # background-task count to a rotating working label (e.g. "Working…", + # "Cooking…"). Accept any label in the pool — which one shows depends on + # the wall-clock bucket the turn lands on. _publish_status(base_url, session_id, "running") - expect(working).to_contain_text("Working", timeout=15_000) + expect(working).to_contain_text(_WORKING_LABEL_RE, timeout=15_000) expect(working).not_to_contain_text("background task", timeout=15_000) # 3. The turn ends with the background shell finished: an authoritative diff --git a/web/src/hooks/useWorkingLabelTick.ts b/web/src/hooks/useWorkingLabelTick.ts new file mode 100644 index 00000000000..8c3d608276c --- /dev/null +++ b/web/src/hooks/useWorkingLabelTick.ts @@ -0,0 +1,54 @@ +// Shared wall-clock tick for the rotating "Working…" label. +// +// The busy indicator renders in two places (the inline shimmer and the +// scroll-pinned pill). Both cycle the same label pool, so they derive their +// index from ONE module-level timer: a single `setInterval` shared via +// `useSyncExternalStore` (same pattern as `useIsMobileViewport`). Reading +// `Date.now()` in `getSnapshot` means every subscriber lands on the same +// bucket, so the two sites stay in lockstep with zero drift. +// +// Deliberately NOT gated on `prefers-reduced-motion`: a text swap isn't CSS +// motion, so the label keeps rotating while the shimmer sweep and Otto's bob +// freeze (that gate lives in index.css). + +import { useSyncExternalStore } from "react"; + +// How long each label stays on screen before rotating. Deliberately slow: at +// this cadence the label is effectively stable within a single turn and only +// varies across turns (the bucket is wall-clock aligned), so the indicator +// reads as a calm "still working" cue rather than a ticker. +export const ROTATE_MS = 60 * 1000; // 1 minute + +let intervalId: ReturnType | null = null; +const listeners = new Set<() => void>(); + +function subscribe(callback: () => void): () => void { + listeners.add(callback); + // Lazily start the one shared timer on the first subscriber. + if (intervalId === null) { + intervalId = setInterval(() => { + for (const listener of listeners) listener(); + }, ROTATE_MS); + } + return () => { + listeners.delete(callback); + // Tear the timer down once nothing is listening. + if (listeners.size === 0 && intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + } + }; +} + +function getSnapshot(): number { + return Math.floor(Date.now() / ROTATE_MS); +} + +/** + * Monotonic wall-clock bucket that advances once every `ROTATE_MS`. Feed it + * into `workingIndicatorLabel(bgCount, tick)` to rotate the label. SSR-safe + * (returns 0 on the server, matching `useIsMobileViewport`). + */ +export function useWorkingLabelTick(): number { + return useSyncExternalStore(subscribe, getSnapshot, () => 0); +} diff --git a/web/src/pages/ChatPage.test.ts b/web/src/pages/ChatPage.test.ts index 10360578226..6309b19504c 100644 --- a/web/src/pages/ChatPage.test.ts +++ b/web/src/pages/ChatPage.test.ts @@ -25,6 +25,7 @@ import { splitSlashCommand, stripPendingElicitations, subAgentComposerLabel, + WORKING_MESSAGES, workingIndicatorLabel, } from "./ChatPage"; @@ -677,7 +678,10 @@ describe("shouldShowWorkingIndicator", () => { expect(shouldShowWorkingIndicator(false, [])).toBe(false); }); - it("suppresses Working once a streaming assistant bubble is rendering content", () => { + it("keeps Working visible while a streaming assistant bubble renders (always-on)", () => { + // Always-on: the indicator no longer hides when content starts arriving. + // It stays lit for the whole turn so a long tool run or reasoning gap + // never looks stalled. const bubbles: Bubble[] = [ { kind: "assistant", @@ -689,33 +693,34 @@ describe("shouldShowWorkingIndicator", () => { }, ]; - expect(shouldShowWorkingIndicator(true, bubbles)).toBe(false); + expect(shouldShowWorkingIndicator(true, bubbles)).toBe(true); }); - it("lets an empty streaming assistant bubble keep the Working indicator visible", () => { + it("suppresses Working when a compaction loading bubble owns the active slot", () => { + // Compaction loading already renders the busy state, so the standalone + // Working indicator would be duplicate progress UI. + expect( + shouldShowWorkingIndicator(true, [{ kind: "compaction_loading", itemId: "cmp_1" }]), + ).toBe(false); + }); + + it("suppresses Working only when compaction is the LAST bubble", () => { + // The compaction guard is trailing-bubble-only: a streaming assistant + // after an earlier compaction spinner keeps Working lit. const bubbles: Bubble[] = [ + { kind: "compaction_loading", itemId: "cmp_1" }, { kind: "assistant", responseId: "resp_live", stableId: "resp_live", lifecycle: "streaming", error: null, - items: [], + items: [{ kind: "text", itemId: null, text: "partial", final: false }], }, ]; - // Empty assistant shells do not yet prove content is rendering; hiding - // Working here would recreate the blank gap this helper avoids. expect(shouldShowWorkingIndicator(true, bubbles)).toBe(true); }); - - it("suppresses Working when a compaction loading bubble owns the active slot", () => { - // Compaction loading already renders the busy state, so the standalone - // Working indicator would be duplicate progress UI. - expect( - shouldShowWorkingIndicator(true, [{ kind: "compaction_loading", itemId: "cmp_1" }]), - ).toBe(false); - }); }); // ── workingIndicatorLabel ─────────────────────────────────────────────────── @@ -738,6 +743,28 @@ describe("workingIndicatorLabel", () => { it("pluralizes the noun for more than one background task", () => { expect(workingIndicatorLabel(3)).toBe("3 background tasks still running"); }); + + it("pins the first rotation message to 'Working…'", () => { + // A fresh tick and the default arg both land on index 0, so this label + // must stay "Working…" — the invariant the (0) / (-1) cases rely on. + expect(WORKING_MESSAGES[0]).toBe("Working…"); + expect(workingIndicatorLabel(0, 0)).toBe("Working…"); + }); + + it("rotates through the message pool by tick", () => { + expect(workingIndicatorLabel(0, 1)).toBe(WORKING_MESSAGES[1]); + expect(workingIndicatorLabel(0, 2)).toBe(WORKING_MESSAGES[2]); + }); + + it("wraps the rotation modulo the pool length", () => { + expect(workingIndicatorLabel(0, WORKING_MESSAGES.length)).toBe("Working…"); + expect(workingIndicatorLabel(0, WORKING_MESSAGES.length + 1)).toBe(WORKING_MESSAGES[1]); + }); + + it("ignores the tick while background tasks remain", () => { + // The count is information, not decoration — it must not rotate away. + expect(workingIndicatorLabel(2, 5)).toBe("2 background tasks still running"); + }); }); // ── subAgentComposerLabel ─────────────────────────────────────────────────── diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 1b9244048ca..f45a2b61b9c 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -137,6 +137,7 @@ import { } from "@/hooks/useSessionLiveness"; import { useMarkConversationSeen } from "@/hooks/useUnseenConversations"; import { useUserMessageNav } from "@/hooks/useUserMessageNav"; +import { useWorkingLabelTick } from "@/hooks/useWorkingLabelTick"; import { UserMessageNav } from "@/components/UserMessageNav"; import { HostBadge } from "@/components/HostBadge"; import { @@ -1568,10 +1569,10 @@ function MainAgentSurface({ [onSendSlashCommand, isNativeWrapper], ); - // "Working…" is shown when the main session is busy, including after a - // reload that hydrates `running` before any bubbles exist locally. Streaming - // assistant content and compaction spinners own the in-progress slot once - // they have rendered. + // "Working…" stays lit for the whole busy turn — through streaming text, + // tool runs, and reasoning gaps — including after a reload that hydrates + // `running` before any bubbles exist locally. Only a trailing compaction + // spinner suppresses it (that bubble owns the slot with its own animation). const showWorkingIndicator = shouldShowWorkingIndicator(showsWorking, bubbles); if (showTerminal && conversationId) { @@ -1670,10 +1671,11 @@ function MainAgentSurface({ ))} - {/* Working… shimmer between send and first rendered block. - Suppressed when the last bubble is a compaction spinner — - that bubble already owns the "in-progress" slot. aria-hidden: - the pinned pill owns the single aria-live region (see WorkingStatusPin). */} + {/* Working… shimmer, lit for the whole busy turn so the user + always sees the session is still going. Suppressed when the + last bubble is a compaction spinner — that bubble already + owns the "in-progress" slot. aria-hidden: the pinned pill + owns the single aria-live region (see WorkingStatusPin). */} {showWorkingIndicator && } {/* Terminal-first spin-up cue beneath the just-sent first message: the prompt bubble renders immediately (no @@ -1830,6 +1832,8 @@ function UserMessageNavConnected(props: React.ComponentProps s.backgroundTaskCount); + const tick = useWorkingLabelTick(); const visible = show && !isAtBottom && !suppress; return (
+ {/* The single announced string. Held stable at "Working…" so the rotating + tab text below never re-announces every few seconds. Present whenever + the agent is working, so it announces whether the tab is painted + (scrolled up) or collapsed (at the bottom, where the inline shimmer + owns the visuals). */} + {show && Working…} {/* Mirror the conversation content column (mx-auto + px-6 + width) so the tab's left edge lines up with the inline shimmer's. */}
- {/* Gated on `show` (not `visible`) so the aria-live region always holds - the "Working…" text while the agent is working — that's what gets - announced. When at the bottom (`!visible`) the inline shimmer owns - the visuals, so the pill collapses to sr-only: still announced, but - not painted. Scrolled up, it renders as the visible tab. */} {show && ( // Tab shape (rounded top, no bottom border, composer-matching bg) so - // its flat bottom edge merges into the chat box. + // its flat bottom edge merges into the chat box. aria-hidden: the + // sr-only span above owns the announcement, so the rotating label + // here stays silent to screen readers. Collapses to sr-only when at + // the bottom (`!visible`) — the inline shimmer paints there instead. )} @@ -2301,47 +2310,40 @@ function bubbleKey(bubble: Bubble): string { } /** - * True when there's an assistant bubble whose stream is still in - * progress (lifecycle "streaming", at least one item rendered). Used - * to suppress the "Working…" shimmer once content starts arriving. + * Playful labels the idle-but-busy indicator rotates through (one per + * `ROTATE_MS`). Index 0 MUST stay "Working…": it's the label a fresh tick + * (and every unit test) lands on. Keep each entry a single short word + + * ellipsis — `Shimmer` scales its sweep width to the text length, so uniform + * lengths keep the animation steady across rotations. */ -function hasInProgressAssistantBubble(bubbles: Bubble[]): boolean { - return bubbles.some( - (b) => b.kind === "assistant" && b.lifecycle === "streaming" && b.items.length > 0, - ); -} +export const WORKING_MESSAGES = [ + "Working…", + "Cooking…", + "Crunching…", + "Tinkering…", + "Pondering…", + "Brewing…", +] as const; -/** - * Decide whether to render the main chat's "Working…" indicator. - * - * A reload can hydrate a custom-agent session as ``running`` before any - * committed or pending bubble is available locally — keep the indicator - * visible in that empty-but-busy state. - * - * @param showsWorking - True when the session snapshot or local response - * state says the main session is still working. - * @param bubbles - Rendered chat bubbles currently hydrated in the main - * session, e.g. assistant, user, or compaction-loading bubbles. - * @returns True when the standalone working indicator should render; false - * when the session is idle, a streaming assistant bubble has rendered at - * least one item, or a compaction-loading bubble already represents the - * busy state. - */ /** * The label shown next to the working spinner. When background shells outlive - * the turn (`bgCount > 0`) it names how many are still running; otherwise it's - * the plain "Working…" string. + * the turn (`bgCount > 0`) it names how many are still running (the tick is + * ignored — that count is information, not decoration). Otherwise it rotates + * through `WORKING_MESSAGES` by wall-clock `tick`. */ -export function workingIndicatorLabel(bgCount: number): string { - if (bgCount <= 0) return "Working…"; - return bgCount === 1 - ? "1 background task still running" - : `${bgCount} background tasks still running`; +export function workingIndicatorLabel(bgCount: number, tick = 0): string { + if (bgCount > 0) { + return bgCount === 1 + ? "1 background task still running" + : `${bgCount} background tasks still running`; + } + return WORKING_MESSAGES[tick % WORKING_MESSAGES.length]!; } function WorkingIndicator() { const bgCount = useChatStore((s) => s.backgroundTaskCount); - const label = workingIndicatorLabel(bgCount); + const tick = useWorkingLabelTick(); + const label = workingIndicatorLabel(bgCount, tick); return (
); diff --git a/web/src/store/chatStore.test.ts b/web/src/store/chatStore.test.ts index 7b1f0e0c0fe..73d4dc832bf 100644 --- a/web/src/store/chatStore.test.ts +++ b/web/src/store/chatStore.test.ts @@ -7533,6 +7533,89 @@ describe("chatStore — client-side message queue", () => { expect(useChatStore.getState().queuedMessages.map((m) => m.text)).toEqual(["first", "third"]); }); + it("reorderQueuedMessage moves a message before another within its conversation", () => { + useChatStore.setState({ + conversationId: "conv_abc", + queuedMessages: [ + { queueId: "q_1", text: "first", conversationId: "conv_abc" }, + { queueId: "q_2", text: "second", conversationId: "conv_abc" }, + { queueId: "q_3", text: "third", conversationId: "conv_abc" }, + ], + }); + + // Move the last message ahead of the first. + useChatStore.getState().reorderQueuedMessage("q_3", "q_1"); + expect(useChatStore.getState().queuedMessages.map((m) => m.text)).toEqual([ + "third", + "first", + "second", + ]); + + // beforeQueueId=null moves it to the end. + useChatStore.getState().reorderQueuedMessage("third", "q_missing"); // (no such row) + useChatStore.getState().reorderQueuedMessage("q_3", null); + expect(useChatStore.getState().queuedMessages.map((m) => m.text)).toEqual([ + "first", + "second", + "third", + ]); + }); + + it("reorderQueuedMessage no-ops for a missing id or a self move", () => { + const initial = [ + { queueId: "q_1", text: "first", conversationId: "conv_abc" }, + { queueId: "q_2", text: "second", conversationId: "conv_abc" }, + ]; + useChatStore.setState({ conversationId: "conv_abc", queuedMessages: initial }); + + useChatStore.getState().reorderQueuedMessage("q_missing", "q_1"); + useChatStore.getState().reorderQueuedMessage("q_1", "q_1"); // before itself + // Reference identity preserved — no state churn on a no-op. + expect(useChatStore.getState().queuedMessages).toBe(initial); + }); + + it("reorderQueuedMessage only touches its own conversation's slots (interleaved queue)", () => { + // The flat queue interleaves conversations; reordering conv_abc must leave + // conv_other's entries at their absolute positions. + useChatStore.setState({ + conversationId: "conv_abc", + queuedMessages: [ + { queueId: "a1", text: "a-first", conversationId: "conv_abc" }, + { queueId: "o1", text: "other-1", conversationId: "conv_other" }, + { queueId: "a2", text: "a-second", conversationId: "conv_abc" }, + { queueId: "o2", text: "other-2", conversationId: "conv_other" }, + { queueId: "a3", text: "a-third", conversationId: "conv_abc" }, + ], + }); + + // Move a-third to the front of conv_abc's run. + useChatStore.getState().reorderQueuedMessage("a3", "a1"); + + // conv_abc reordered (a3, a1, a2); conv_other's o1/o2 keep their slots + // (indices 1 and 3), so the flat array interleaves as below. + expect(useChatStore.getState().queuedMessages.map((m) => m.queueId)).toEqual([ + "a3", + "o1", + "a1", + "o2", + "a2", + ]); + }); + + it("reorderQueuedMessage won't move a message across conversations", () => { + useChatStore.setState({ + conversationId: "conv_abc", + queuedMessages: [ + { queueId: "a1", text: "a-first", conversationId: "conv_abc" }, + { queueId: "o1", text: "other-1", conversationId: "conv_other" }, + ], + }); + + // Target belongs to a different conversation → no-op. + useChatStore.getState().reorderQueuedMessage("a1", "o1"); + expect(useChatStore.getState().queuedMessages.map((m) => m.queueId)).toEqual(["a1", "o1"]); + }); + it("steerMessage sends the chosen message now and removes it from the queue", () => { const sendSpy = vi.fn().mockResolvedValue(undefined); useChatStore.setState({ diff --git a/web/src/store/chatStore.ts b/web/src/store/chatStore.ts index 892342626b3..d48791baa10 100644 --- a/web/src/store/chatStore.ts +++ b/web/src/store/chatStore.ts @@ -536,6 +536,15 @@ export interface ChatState { enqueueMessage: (text: string, files?: File[]) => void; /** Remove a queued message by id (the strip's per-row delete). */ dequeueMessage: (queueId: string) => void; + /** + * Reorder a queued message within its own conversation (the strip's + * drag-to-reorder). Moves `queueId` so it sits before `beforeQueueId`, or to + * the end of its conversation's run when `beforeQueueId` is null. Only + * reorders among the same conversation's messages — the flat `queuedMessages` + * array interleaves conversations, so other conversations' entries keep their + * absolute positions. No-op if the id isn't queued or the move is a no-op. + */ + reorderQueuedMessage: (queueId: string, beforeQueueId: string | null) => void; /** * Send a queued message NOW instead of waiting for the idle flush (the * strip's per-row steer). Removes it from the queue and POSTs it: on an @@ -922,6 +931,35 @@ export const useChatStore = create((set, get) => ({ })); }, + reorderQueuedMessage: (queueId, beforeQueueId) => { + set((s) => { + const moved = s.queuedMessages.find((m) => m.queueId === queueId); + if (moved === undefined || queueId === beforeQueueId) return {}; + const conversationId = moved.conversationId; + + // Reorder only within this conversation's messages, in their current + // relative order, then drop `moved` before its target (or at the end). + const own = s.queuedMessages.filter((m) => m.conversationId === conversationId); + const without = own.filter((m) => m.queueId !== queueId); + const at = + beforeQueueId === null + ? without.length + : without.findIndex((m) => m.queueId === beforeQueueId); + if (at === -1) return {}; // target isn't in this conversation — no-op + const reordered = [...without.slice(0, at), moved, ...without.slice(at)]; + if (reordered.every((m, i) => m.queueId === own[i]?.queueId)) return {}; // unchanged + + // Refill this conversation's slots (their absolute positions in the flat + // array) with the reordered run; other conversations' entries stay put. + let next = 0; + return { + queuedMessages: s.queuedMessages.map((m) => + m.conversationId === conversationId ? reordered[next++]! : m, + ), + }; + }); + }, + steerMessage: (queueId) => { const s = get(); const target = s.queuedMessages.find((m) => m.queueId === queueId); From 279b7e0c1315bcbc086480eaa841cc85bb0cdf99 Mon Sep 17 00:00:00 2001 From: Tomu Hirata Date: Tue, 7 Jul 2026 17:03:07 +0900 Subject: [PATCH 052/546] refactor(agents): remove Agent<->Conversations double reference (#2069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the back-pointer `agents.session_id` column (FK to `conversations.id`) in favour of the forward pointer `conversations.agent_id`, which was already the canonical source of truth. An agent is now classified as session-scoped if any conversation row references it via `conversations.agent_id`, discovered at query time with a NOT EXISTS subquery rather than a nullable FK column. - Remove `session_id` from `SqlAgent`, `Agent` entity, and the `sql_agent_to_entity` converter. - Rewrite `get_by_name` and `list` template-agent filters from `session_id IS NULL` to `NOT EXISTS (SELECT … FROM conversations …)`. - Drop the partial unique index `ix_agents_template_name` (was scoped to `session_id IS NULL`) and recreate it as a plain unique index; drop `ix_agents_session_id`. - Add Alembic migration `o1a2b3c4d5e6` with upgrade/downgrade paths. --- omnigent/db/converters.py | 11 +- omnigent/db/db_models.py | 25 +- ...1a2b3c4d5e6_drop_session_id_from_agents.py | 159 +++++++ omnigent/entities/agent.py | 4 +- omnigent/server/API.md | 2 +- .../stores/agent_store/sqlalchemy_store.py | 46 +- .../conversation_store/sqlalchemy_store.py | 40 +- tests/db/test_converters.py | 30 +- tests/db/test_db_models.py | 35 +- tests/db/test_migration_agents_session_id.py | 429 ++++++++---------- tests/stores/test_agent_store.py | 5 +- tests/stores/test_conversation_store.py | 4 +- 12 files changed, 465 insertions(+), 325 deletions(-) create mode 100644 omnigent/db/migrations/versions/o1a2b3c4d5e6_drop_session_id_from_agents.py diff --git a/omnigent/db/converters.py b/omnigent/db/converters.py index 74ab76f47b0..e48e0c01b92 100644 --- a/omnigent/db/converters.py +++ b/omnigent/db/converters.py @@ -2,15 +2,20 @@ from __future__ import annotations -from omnigent.db.db_models import SqlAgent +from omnigent.db.db_models import AGENT_KIND_TEMPLATE, SqlAgent from omnigent.entities import Agent -def sql_agent_to_entity(row: SqlAgent) -> Agent: +def sql_agent_to_entity(row: SqlAgent, session_id: str | None = None) -> Agent: """ Convert a :class:`SqlAgent` ORM row to an :class:`Agent` entity. :param row: The SQLAlchemy ORM row to convert. + :param session_id: Owning conversation id when this agent is + session-scoped; ``None`` for template agents. Callers that know + the owning conversation id (e.g. the conversation store) pass it + directly; the agent store leaves it ``None`` for templates (where + ``row.kind == AGENT_KIND_TEMPLATE``). :returns: An :class:`Agent` dataclass instance. """ return Agent( @@ -21,5 +26,5 @@ def sql_agent_to_entity(row: SqlAgent) -> Agent: version=row.version, description=row.description, updated_at=row.updated_at, - session_id=row.session_id, + session_id=None if row.kind == AGENT_KIND_TEMPLATE else session_id, ) diff --git a/omnigent/db/db_models.py b/omnigent/db/db_models.py index 1025b1b5815..be1b03d620b 100644 --- a/omnigent/db/db_models.py +++ b/omnigent/db/db_models.py @@ -24,6 +24,10 @@ class Base(DeclarativeBase): """Shared declarative base for all omnigent tables.""" +AGENT_KIND_TEMPLATE = "template" +AGENT_KIND_SESSION = "session" + + class SqlAgent(Base): """ SQLAlchemy model for the ``agents`` table. @@ -40,13 +44,12 @@ class SqlAgent(Base): ``"ag_abc123/a1b2c3d4e5f6..."``. :param version: Monotonic version counter. Starts at 1, incremented on each update via ``PUT /api/agents/{id}``. + :param kind: ``"template"`` for server-wide registered agents; + ``"session"`` for per-conversation copies. :param description: Optional free-text description of the agent's purpose. ``None`` when not provided. :param updated_at: Unix epoch seconds of the last update, or ``None`` if the agent has never been updated. - :param session_id: Owning conversation/session id for a - session-scoped agent. ``None`` for template agents uploaded - through ``POST /api/agents``. """ __tablename__ = "agents" @@ -56,24 +59,22 @@ class SqlAgent(Base): name: Mapped[str] = mapped_column(String(256)) bundle_location: Mapped[str] = mapped_column(String(512)) version: Mapped[int] = mapped_column(Integer, default=1) + kind: Mapped[str] = mapped_column(String(16)) description: Mapped[str | None] = mapped_column(Text, nullable=True) updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True) - session_id: Mapped[str | None] = mapped_column( - String(64), - ForeignKey("conversations.id", ondelete="CASCADE"), - nullable=True, - ) __table_args__ = ( Index("ix_agents_created_at", "created_at"), + # Template agents have unique names; session-scoped agents (kind='session') + # may reuse the same name across conversations. The partial index enforces + # uniqueness only within the template set. Index( "ix_agents_template_name", "name", unique=True, - sqlite_where=text("session_id IS NULL"), - postgresql_where=text("session_id IS NULL"), + sqlite_where=text("kind = 'template'"), + postgresql_where=text("kind = 'template'"), ), - Index("ix_agents_session_id", "session_id", unique=True), ) @@ -421,6 +422,8 @@ class SqlConversation(Base): # Reconnect reconciliation queries conversations by host_id on # every host reconnect; index it to avoid a full scan. Index("ix_conversations_host_id", "host_id"), + # Agent lookups: find the conversation(s) that own a given agent. + Index("ix_conversations_agent_id", "agent_id"), Index("ix_conversations_root_conversation_id", "root_conversation_id"), # Phase 4: partial unique index on (parent_conversation_id, # title) prevents two same-named children under the same diff --git a/omnigent/db/migrations/versions/o1a2b3c4d5e6_drop_session_id_from_agents.py b/omnigent/db/migrations/versions/o1a2b3c4d5e6_drop_session_id_from_agents.py new file mode 100644 index 00000000000..0914833e7dc --- /dev/null +++ b/omnigent/db/migrations/versions/o1a2b3c4d5e6_drop_session_id_from_agents.py @@ -0,0 +1,159 @@ +"""drop agents.session_id; add agents.kind and ix_conversations_agent_id + +Revision ID: o1a2b3c4d5e6 +Revises: n1a2b3c4d5e6 +Create Date: 2026-07-07 00:00:00.000000 + +Removes the back-pointer ``agents.session_id`` (FK to ``conversations.id``) +in favour of an explicit ``agents.kind`` column (``'template'`` | +``'session'``) that carries the same distinction without a circular +reference. The upgrade reads ``session_id`` before dropping it to back-fill +``kind`` correctly. The forward pointer ``conversations.agent_id`` remains +the authoritative runtime link; ``kind`` is set at row-creation time and +never changes. + +Also adds ``ix_conversations_agent_id`` to speed up "find the conversation +that owns this agent" lookups (used in ``replace_agent`` and +``fork_conversation``). + +SQLite note: ``conversations.agent_id`` is a FK to ``agents.id`` with +``ON DELETE CASCADE``. SQLite runs migrations with ``PRAGMA foreign_keys = ON`` +so any ``batch_alter_table`` that drops and recreates ``agents`` would +cascade-delete bound conversations. Both upgrade and downgrade issue +``PRAGMA foreign_keys = OFF`` (SQLite-only, guarded by dialect) before the +batch operations and ``PRAGMA foreign_keys = ON`` after. ``recreate="always"`` +is also set on SQLite and ``"auto"`` on other dialects. +""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "o1a2b3c4d5e6" +down_revision: str | None = "n1a2b3c4d5e6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +# Naming convention used by the prior migration (d7a6b3c91f48) when it +# created fk_agents_session_id and ix_agents_session_id. Passing the same +# convention here lets Alembic locate the constraints by name even on SQLite, +# which may not reflect constraint names reliably without it. +_AGENTS_NAMING_CONVENTION = { + "fk": "fk_%(table_name)s_%(column_0_name)s", + "ix": "ix_%(table_name)s_%(column_0_name)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", +} + +_logger = logging.getLogger(__name__) + + +def _is_sqlite() -> bool: + return op.get_bind().dialect.name == "sqlite" + + +def upgrade() -> None: + """ + 1. Add ``agents.kind`` (nullable, ``recreate="always"`` on SQLite to avoid + cascade-deleting conversations during the table rebuild). + 2. Back-fill ``kind`` from ``session_id``. + 3. Drop ``session_id`` and its FK/indexes; make ``kind`` NOT NULL; recreate + ``ix_agents_template_name`` scoped to ``kind = 'template'``. + 4. Add ``ix_conversations_agent_id`` on ``conversations.agent_id``. + """ + sqlite = _is_sqlite() + # On SQLite, disable FK enforcement so batch table-rebuilds do not + # cascade-delete conversations via conversations.agent_id → agents.id. + # PRAGMA is SQLite-only and must be guarded by dialect. + if sqlite: + op.execute(sa.text("PRAGMA foreign_keys = OFF")) + + # Step 2: add kind as nullable so we can back-fill before making it NOT NULL. + with op.batch_alter_table("agents", recreate="always" if sqlite else "auto") as batch_op: + batch_op.add_column(sa.Column("kind", sa.String(length=16), nullable=True)) + + # Step 3: back-fill from session_id while it still exists. + op.execute(sa.text("UPDATE agents SET kind = 'session' WHERE session_id IS NOT NULL")) + op.execute(sa.text("UPDATE agents SET kind = 'template' WHERE session_id IS NULL")) + _logger.info("Upgrade: back-filled agents.kind from session_id") + + # Step 4: drop session_id, make kind NOT NULL, recreate the name index. + with op.batch_alter_table( + "agents", + recreate="always" if sqlite else "auto", + naming_convention=_AGENTS_NAMING_CONVENTION, + ) as batch_op: + batch_op.drop_index("ix_agents_template_name") + batch_op.drop_index("ix_agents_session_id") + batch_op.drop_constraint("fk_agents_session_id", type_="foreignkey") + batch_op.drop_column("session_id") + batch_op.alter_column("kind", existing_type=sa.String(16), nullable=False) + batch_op.create_index( + "ix_agents_template_name", + ["name"], + unique=True, + sqlite_where=sa.text("kind = 'template'"), + postgresql_where=sa.text("kind = 'template'"), + ) + + # Step 5: index for agent-ownership lookups via the forward pointer. + op.create_index("ix_conversations_agent_id", "conversations", ["agent_id"]) + + if sqlite: + op.execute(sa.text("PRAGMA foreign_keys = ON")) + + +def downgrade() -> None: + """ + Reverse: drop ``kind``, re-add ``session_id`` back-populated from + ``conversations.agent_id``, and drop ``ix_conversations_agent_id``. + """ + op.drop_index("ix_conversations_agent_id", table_name="conversations") + + sqlite = _is_sqlite() + if sqlite: + op.execute(sa.text("PRAGMA foreign_keys = OFF")) + + # Step 1: add session_id as nullable (no FK yet) so we can back-fill. + with op.batch_alter_table("agents", recreate="always" if sqlite else "auto") as batch_op: + batch_op.add_column(sa.Column("session_id", sa.String(length=64), nullable=True)) + + # Step 2: back-populate from the forward pointer before adding indexes. + op.execute( + sa.text( + "UPDATE agents SET session_id = (" + " SELECT id FROM conversations WHERE conversations.agent_id = agents.id LIMIT 1" + ") WHERE kind = 'session'" + ) + ) + _logger.info("Downgrade: back-populated agents.session_id from conversations.agent_id") + + # Step 3: drop kind, add FK and indexes now that data is correct. + with op.batch_alter_table( + "agents", + recreate="always" if sqlite else "auto", + naming_convention=_AGENTS_NAMING_CONVENTION, + ) as batch_op: + batch_op.drop_index("ix_agents_template_name") + batch_op.drop_column("kind") + batch_op.create_foreign_key( + "fk_agents_session_id", + "conversations", + ["session_id"], + ["id"], + ondelete="CASCADE", + ) + batch_op.create_index("ix_agents_session_id", ["session_id"], unique=True) + batch_op.create_index( + "ix_agents_template_name", + ["name"], + unique=True, + sqlite_where=sa.text("session_id IS NULL"), + postgresql_where=sa.text("session_id IS NULL"), + ) + + if sqlite: + op.execute(sa.text("PRAGMA foreign_keys = ON")) diff --git a/omnigent/entities/agent.py b/omnigent/entities/agent.py index 3e0f0426cfd..b99235e26a3 100644 --- a/omnigent/entities/agent.py +++ b/omnigent/entities/agent.py @@ -26,8 +26,6 @@ class Agent: :param description: Optional free-text description of the agent. :param updated_at: Unix epoch timestamp of the last update, or ``None`` if the agent has never been updated. - :param session_id: Owning conversation/session id for - session-scoped agents. ``None`` for template agents. """ id: str @@ -37,7 +35,7 @@ class Agent: version: int = 1 description: str | None = None updated_at: int | None = None - session_id: str | None = None + session_id: str | None = None # owning conversation id; None for template agents @dataclass diff --git a/omnigent/server/API.md b/omnigent/server/API.md index 0534eeb93c8..ddfb820d41e 100644 --- a/omnigent/server/API.md +++ b/omnigent/server/API.md @@ -570,7 +570,7 @@ Request parts: The server stores the bundle, then creates the `conversations` row and the session-scoped `agents` row in one database transaction. The -new agent row has `agents.session_id` set to the new conversation id, +new agent row has `agents.kind` set to `'session'`, and `conversations.agent_id` points at that agent. If the database agent write fails, the conversation row rolls back. If multipart or bundle parsing fails, no database row is written. diff --git a/omnigent/stores/agent_store/sqlalchemy_store.py b/omnigent/stores/agent_store/sqlalchemy_store.py index 7e611ad4820..8a7eba8c9a8 100644 --- a/omnigent/stores/agent_store/sqlalchemy_store.py +++ b/omnigent/stores/agent_store/sqlalchemy_store.py @@ -5,7 +5,12 @@ from sqlalchemy import and_, asc, desc, or_, select from omnigent.db.converters import sql_agent_to_entity -from omnigent.db.db_models import SqlAgent +from omnigent.db.db_models import ( + AGENT_KIND_SESSION, + AGENT_KIND_TEMPLATE, + SqlAgent, + SqlConversation, +) from omnigent.db.utils import ( get_or_create_engine, make_managed_session_maker, @@ -45,7 +50,7 @@ def create( description: str | None = None, ) -> Agent: """ - Register a new agent in the database. + Register a new template agent in the database. :param agent_id: Pre-generated unique agent identifier, e.g. ``"ag_0f1a2b3c..."``. @@ -62,6 +67,7 @@ def create( name=name, bundle_location=bundle_location, version=1, + kind=AGENT_KIND_TEMPLATE, description=description, ) with self._session() as session: @@ -78,12 +84,24 @@ def get(self, agent_id: str) -> Agent | None: """ with self._session() as session: row = session.get(SqlAgent, agent_id) - return sql_agent_to_entity(row) if row else None + if row is None: + return None + # For session-scoped agents, derive the owning conversation id + # from the forward pointer so callers can use agent.session_id. + session_id: str | None = None + if row.kind == AGENT_KIND_SESSION: + session_id = session.execute( + select(SqlConversation.id).where(SqlConversation.agent_id == agent_id).limit(1) + ).scalar_one_or_none() + return sql_agent_to_entity(row, session_id=session_id) def get_by_name(self, name: str) -> Agent | None: """ Look up a registered template agent by its unique name. + Only agents with ``kind = 'template'`` are returned; session-scoped + copies bound to a specific conversation are excluded. + :param name: The template agent's unique name, e.g. ``"code-assistant"``. :returns: The :class:`Agent` if found, otherwise ``None``. @@ -92,7 +110,7 @@ def get_by_name(self, name: str) -> Agent | None: row = session.execute( select(SqlAgent).where( SqlAgent.name == name, - SqlAgent.session_id.is_(None), + SqlAgent.kind == AGENT_KIND_TEMPLATE, ) ).scalar_one_or_none() return sql_agent_to_entity(row) if row else None @@ -107,6 +125,9 @@ def list( """ List registered template agents with cursor-based pagination. + Only agents with ``kind = 'template'`` are returned; session-scoped + copies are excluded. + :param limit: Maximum number of agents to return. :param after: Cursor agent ID; return agents appearing after this agent in sort order, @@ -119,25 +140,23 @@ def list( with self._session() as session: is_desc = order == "desc" sort_fn = desc if is_desc else asc - template_agent = SqlAgent.session_id.is_(None) - stmt = select(SqlAgent).where(template_agent) + is_template = SqlAgent.kind == AGENT_KIND_TEMPLATE + stmt = select(SqlAgent).where(is_template) if after: sub = ( select(SqlAgent.created_at) - .where(SqlAgent.id == after, template_agent) + .where(SqlAgent.id == after, is_template) .scalar_subquery() ) - # "after" = further in sort direction ts_cmp = SqlAgent.created_at < sub if is_desc else SqlAgent.created_at > sub id_cmp = SqlAgent.id < after if is_desc else SqlAgent.id > after stmt = stmt.where(or_(ts_cmp, and_(SqlAgent.created_at == sub, id_cmp))) if before: sub = ( select(SqlAgent.created_at) - .where(SqlAgent.id == before, template_agent) + .where(SqlAgent.id == before, is_template) .scalar_subquery() ) - # "before" = opposite of sort direction ts_cmp = SqlAgent.created_at > sub if is_desc else SqlAgent.created_at < sub id_cmp = SqlAgent.id > before if is_desc else SqlAgent.id < before stmt = stmt.where(or_(ts_cmp, and_(SqlAgent.created_at == sub, id_cmp))) @@ -199,7 +218,12 @@ def update( row.bundle_location = bundle_location row.version = row.version + 1 row.updated_at = now_epoch() - return sql_agent_to_entity(row) + session_id: str | None = None + if row.kind == AGENT_KIND_SESSION: + session_id = session.execute( + select(SqlConversation.id).where(SqlConversation.agent_id == agent_id).limit(1) + ).scalar_one_or_none() + return sql_agent_to_entity(row, session_id=session_id) def delete(self, agent_id: str) -> bool: """ diff --git a/omnigent/stores/conversation_store/sqlalchemy_store.py b/omnigent/stores/conversation_store/sqlalchemy_store.py index 03e3d26176b..2c0661138be 100644 --- a/omnigent/stores/conversation_store/sqlalchemy_store.py +++ b/omnigent/stores/conversation_store/sqlalchemy_store.py @@ -25,6 +25,7 @@ from omnigent._wrapper_labels import UI_MODE_LABEL_KEY, WRAPPER_LABEL_KEY from omnigent.db.converters import sql_agent_to_entity from omnigent.db.db_models import ( + AGENT_KIND_SESSION, LABEL_VALUE_MAX_LEN, SqlAgent, SqlConversation, @@ -193,7 +194,6 @@ def _new_session_agent_row( agent_name: str, agent_bundle_location: str, agent_description: str | None, - conversation_id: str, now: int, ) -> SqlAgent: """ @@ -203,7 +203,6 @@ def _new_session_agent_row( :param agent_name: Agent name loaded from the uploaded spec. :param agent_bundle_location: Artifact-store key for the bundle. :param agent_description: Optional description from the spec. - :param conversation_id: Owning conversation id. :param now: Unix epoch seconds used for the created field. :returns: Unsaved :class:`SqlAgent` row. """ @@ -213,8 +212,8 @@ def _new_session_agent_row( name=agent_name, bundle_location=agent_bundle_location, version=1, + kind=AGENT_KIND_SESSION, description=agent_description, - session_id=conversation_id, ) @@ -236,7 +235,7 @@ def _created_session_from_rows( conversation_row, labels if labels is not None else {}, ), - agent=sql_agent_to_entity(agent_row), + agent=sql_agent_to_entity(agent_row, session_id=conversation_row.id), ) @@ -2250,7 +2249,6 @@ def create_session_with_agent( agent_name=agent_name, agent_bundle_location=agent_bundle_location, agent_description=agent_description, - conversation_id=conversation_id, now=now, ) session.add(agent_row) @@ -2494,9 +2492,8 @@ def fork_conversation( # Create/bind the fork's session-scoped agent atomically. if creating_clone: - # Mint the clone here so it's born with session_id set (never - # NULL) and rolls back with the fork on failure — never - # leaking as a phantom built-in. + # Mint the clone atomically with the fork so it rolls back + # with the fork on failure — never leaking as a phantom built-in. assert ( agent_id is not None and cloned_agent_name is not None @@ -2508,16 +2505,16 @@ def fork_conversation( agent_name=cloned_agent_name, agent_bundle_location=cloned_agent_bundle_location, agent_description=cloned_agent_description, - conversation_id=new_conv.id, now=now, ) ) session.flush() new_conv.agent_id = agent_id elif agent_id is not None: - agent_row = session.get(SqlAgent, agent_id) - if agent_row is not None: - agent_row.session_id = new_conv.id + # Binding an existing (template) agent to the fork: the forward + # pointer conversations.agent_id is the sole link; no back-pointer + # to update. + new_conv.agent_id = agent_id # Copy labels from the source conversation, minus the # instance-scoped ones (native bridge ids, context metrics) @@ -2617,22 +2614,18 @@ def switch_conversation_agent( if row is None: raise LookupError(f"conversation not found: {conversation_id!r}") - # Replace the session-scoped agent. Ordering matters for two - # constraints: (1) ``conversations.agent_id`` → ``agents.id`` is - # ON DELETE CASCADE, so deleting the old agent while the row still - # references it would cascade-delete the WHOLE conversation; null - # the reference first. (2) ``ix_agents_session_id`` is UNIQUE, so - # the old agent must be gone before the new one claims - # ``session_id``. Hence: null agent_id → delete old → insert new → - # repoint agent_id. The delete is guarded on - # ``session_id == conversation_id`` so a (mistakenly bound) - # built-in agent is never deleted. + # Replace the session-scoped agent. Null the forward pointer first: + # conversations.agent_id is ON DELETE CASCADE, so deleting the old + # agent row while it is still referenced would cascade-delete the + # whole conversation. Only delete the old agent if it is + # session-scoped (kind='session') — template/built-in agents are + # shared and must never be deleted here. old_agent_id = row.agent_id row.agent_id = None session.flush() if old_agent_id is not None: old_agent = session.get(SqlAgent, old_agent_id) - if old_agent is not None and old_agent.session_id == conversation_id: + if old_agent is not None and old_agent.kind == AGENT_KIND_SESSION: session.delete(old_agent) session.flush() @@ -2641,7 +2634,6 @@ def switch_conversation_agent( agent_name=new_agent_name, agent_bundle_location=new_agent_bundle_location, agent_description=new_agent_description, - conversation_id=conversation_id, now=now, ) session.add(new_agent) diff --git a/tests/db/test_converters.py b/tests/db/test_converters.py index 1c977461de8..1c91910cf1e 100644 --- a/tests/db/test_converters.py +++ b/tests/db/test_converters.py @@ -10,7 +10,7 @@ import time from omnigent.db.converters import sql_agent_to_entity -from omnigent.db.db_models import SqlAgent +from omnigent.db.db_models import AGENT_KIND_SESSION, AGENT_KIND_TEMPLATE, SqlAgent from omnigent.db.utils import get_or_create_engine, make_managed_session_maker from omnigent.entities import Agent @@ -30,9 +30,9 @@ def test_basic_conversion(self) -> None: name="research-agent", bundle_location="ag_abc123/sha256hash", version=3, + kind=AGENT_KIND_TEMPLATE, description="Does research", updated_at=1700001000, - session_id="conv_xyz", ) entity = sql_agent_to_entity(row) @@ -44,6 +44,19 @@ def test_basic_conversion(self) -> None: assert entity.version == 3 assert entity.description == "Does research" assert entity.updated_at == 1700001000 + assert entity.session_id is None # template agents always have session_id=None + + def test_session_scoped_agent_passes_session_id(self) -> None: + """session_id is forwarded for session-scoped agents.""" + row = SqlAgent( + id="ag_sess", + created_at=1700000000, + name="session-agent", + bundle_location="ag_sess/hash", + version=1, + kind=AGENT_KIND_SESSION, + ) + entity = sql_agent_to_entity(row, session_id="conv_xyz") assert entity.session_id == "conv_xyz" def test_nullable_fields_as_none(self) -> None: @@ -54,9 +67,9 @@ def test_nullable_fields_as_none(self) -> None: name="minimal-agent", bundle_location="ag_minimal/hash", version=1, + kind=AGENT_KIND_TEMPLATE, description=None, updated_at=None, - session_id=None, ) entity = sql_agent_to_entity(row) @@ -72,6 +85,7 @@ def test_special_characters_in_fields(self) -> None: name="agent-with-emoji-\u2603", bundle_location="ag_unicode/hash", version=1, + kind=AGENT_KIND_TEMPLATE, description="Handles \u00e9\u00e0\u00fc and newlines\nand tabs\t", ) entity = sql_agent_to_entity(row) @@ -91,22 +105,20 @@ def test_round_trip_entity_to_orm_to_entity(self) -> None: version=5, description="A test agent for round-trip verification", updated_at=1700005000, - session_id="conv_rt1", + session_id=None, ) - # Entity -> ORM row (manual construction, mirroring what a store would do) row = SqlAgent( id=original.id, created_at=original.created_at, name=original.name, bundle_location=original.bundle_location, version=original.version, + kind=AGENT_KIND_TEMPLATE, description=original.description, updated_at=original.updated_at, - session_id=original.session_id, ) - # ORM row -> Entity (via the converter) result = sql_agent_to_entity(row) assert result.id == original.id @@ -140,9 +152,9 @@ def test_round_trip_persisted_through_db(self, db_uri: str) -> None: name=original.name, bundle_location=original.bundle_location, version=original.version, + kind=AGENT_KIND_TEMPLATE, description=original.description, updated_at=original.updated_at, - session_id=original.session_id, ) with managed() as session: session.add(row) @@ -171,6 +183,7 @@ def test_version_default_after_persist(self, db_uri: str) -> None: created_at=1700000000, name="default-version", bundle_location="ag_defver/hash", + kind=AGENT_KIND_TEMPLATE, ) with managed() as session: session.add(row) @@ -189,6 +202,7 @@ def test_empty_string_description(self) -> None: name="empty-desc", bundle_location="ag_empty/hash", version=1, + kind=AGENT_KIND_TEMPLATE, description="", ) entity = sql_agent_to_entity(row) diff --git a/tests/db/test_db_models.py b/tests/db/test_db_models.py index f2397318408..70398436c1e 100644 --- a/tests/db/test_db_models.py +++ b/tests/db/test_db_models.py @@ -38,7 +38,7 @@ def _now() -> int: def _make_agent( id: str = "ag_test1", name: str = "test-agent", - session_id: str | None = None, + kind: str = "template", ) -> SqlAgent: return SqlAgent( id=id, @@ -46,7 +46,7 @@ def _make_agent( name=name, bundle_location="ag_test1/abc123", version=1, - session_id=session_id, + kind=kind, ) @@ -107,7 +107,7 @@ def test_persist_and_read(self, db_uri: str) -> None: assert loaded.version == 1 assert loaded.description is None assert loaded.updated_at is None - assert loaded.session_id is None + assert loaded.kind == "template" def test_nullable_columns(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) @@ -125,38 +125,31 @@ def test_nullable_columns(self, db_uri: str) -> None: assert loaded.description == "A test agent" assert loaded.updated_at is not None - def test_session_scoped_agent_fk(self, db_uri: str) -> None: - """session_id FK to conversations must be valid.""" + def test_session_scoped_agent_kind(self, db_uri: str) -> None: + """A session-scoped agent is stored with kind='session'.""" engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) - conv = _make_conversation() - with managed() as session: - session.add(conv) - - agent = _make_agent(session_id="conv_test1") + agent = _make_agent(kind="session") with managed() as session: session.add(agent) with managed() as session: loaded = session.get(SqlAgent, "ag_test1") assert loaded is not None - assert loaded.session_id == "conv_test1" + assert loaded.kind == "session" - def test_unique_session_id_index(self, db_uri: str) -> None: - """ix_agents_session_id is unique -- two agents cannot share the same session_id.""" + def test_multiple_session_agents_allowed(self, db_uri: str) -> None: + """Multiple session-scoped agents are permitted (no unique constraint on kind).""" engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) - conv = _make_conversation() - a1 = _make_agent(id="ag_1", name="agent-1", session_id="conv_test1") - a2 = _make_agent(id="ag_2", name="agent-2", session_id="conv_test1") + a1 = _make_agent(id="ag_1", name="agent-1", kind="session") + a2 = _make_agent(id="ag_2", name="agent-2", kind="session") - with pytest.raises(IntegrityError): - with managed() as session: - session.add(conv) - session.add(a1) - session.add(a2) + with managed() as session: + session.add(a1) + session.add(a2) # ── SqlFile ─────────────────────────────────────────── diff --git a/tests/db/test_migration_agents_session_id.py b/tests/db/test_migration_agents_session_id.py index 989c689ec04..5d42ccd8381 100644 --- a/tests/db/test_migration_agents_session_id.py +++ b/tests/db/test_migration_agents_session_id.py @@ -1,4 +1,4 @@ -"""Tests for the ``agents.session_id`` migration.""" +"""Tests for the agents schema migration that replaces session_id with kind.""" from __future__ import annotations @@ -9,6 +9,7 @@ import sqlalchemy as sa from alembic import command from sqlalchemy.engine import Engine +from sqlalchemy.exc import IntegrityError from omnigent.db.utils import ( _build_alembic_config, @@ -18,13 +19,8 @@ @pytest.fixture -def db_engine(tmp_path: Path) -> Iterator[Engine]: - """ - Create a fresh SQLite database with the full migration chain. - - :param tmp_path: Per-test temporary directory. - :returns: SQLAlchemy engine with migrations applied. - """ +def db_engine(tmp_path) -> Iterator[Engine]: + """Fresh SQLite database with the full migration chain applied.""" db_path = tmp_path / "test.db" uri = f"sqlite:///{db_path}" engine = get_or_create_engine(uri) @@ -34,301 +30,258 @@ def db_engine(tmp_path: Path) -> Iterator[Engine]: clear_engine_cache() -def test_agents_session_id_column_is_nullable_and_indexed(db_engine: Engine) -> None: - """The migration adds nullable, uniquely indexed ``agents.session_id``.""" - columns = sa.inspect(db_engine).get_columns("agents") - session_id_columns = [column for column in columns if column["name"] == "session_id"] - assert len(session_id_columns) == 1, ( - f"Expected one agents.session_id column, got {len(session_id_columns)}. " - f"If 0, the migration did not add the column." - ) - session_id_column = session_id_columns[0] - assert session_id_column["nullable"], "agents.session_id must allow template agents" - - indexes = sa.inspect(db_engine).get_indexes("agents") - session_indexes = [index for index in indexes if index["name"] == "ix_agents_session_id"] - assert len(session_indexes) == 1, ( - f"Expected ix_agents_session_id, got {[index['name'] for index in indexes]}" - ) - # Unique enforces that two agent rows cannot claim the same - # concrete session id while still allowing multiple NULL template - # agents on supported databases. - assert bool(session_indexes[0]["unique"]) is True - - -def test_agents_name_unique_index_is_template_scoped(db_engine: Engine) -> None: - """Registered agent names stay unique while session copies may share them.""" - indexes = sa.inspect(db_engine).get_indexes("agents") - template_indexes = [index for index in indexes if index["name"] == "ix_agents_template_name"] - assert len(template_indexes) == 1, ( - f"Expected ix_agents_template_name, got {[index['name'] for index in indexes]}" - ) - assert bool(template_indexes[0]["unique"]) is True - - with pytest.raises(sa.exc.IntegrityError): - with db_engine.begin() as conn: - conn.execute( - sa.text( - "INSERT INTO agents " - "(id, created_at, name, bundle_location, version) " - "VALUES (:id, :ts, :name, :loc, 1)", - ), - { - "id": "ag_template_one", - "ts": 1700000001, - "name": "template-name", - "loc": "ag_template_one/bundle", - }, - ) - conn.execute( - sa.text( - "INSERT INTO agents " - "(id, created_at, name, bundle_location, version) " - "VALUES (:id, :ts, :name, :loc, 1)", - ), - { - "id": "ag_template_two", - "ts": 1700000002, - "name": "template-name", - "loc": "ag_template_two/bundle", - }, - ) +def test_agents_kind_column_exists_and_is_not_nullable(db_engine: Engine) -> None: + """agents.kind is a NOT NULL column added by the migration.""" + columns = {c["name"]: c for c in sa.inspect(db_engine).get_columns("agents")} + assert "kind" in columns, "agents.kind column must exist after migration" + assert not columns["kind"]["nullable"], "agents.kind must be NOT NULL" -def test_agents_session_id_fk_accepts_existing_session(db_engine: Engine) -> None: - """The FK permits an agent to point at an existing conversation.""" +def test_agents_session_id_column_removed(db_engine: Engine) -> None: + """agents.session_id must no longer exist after the migration.""" + columns = {c["name"] for c in sa.inspect(db_engine).get_columns("agents")} + assert "session_id" not in columns, "agents.session_id must be dropped by migration" + + +def test_agents_session_id_index_removed(db_engine: Engine) -> None: + """ix_agents_session_id must no longer exist after the migration.""" + index_names = {i["name"] for i in sa.inspect(db_engine).get_indexes("agents")} + assert "ix_agents_session_id" not in index_names + + +def test_ix_conversations_agent_id_added(db_engine: Engine) -> None: + """ix_conversations_agent_id must be present after the migration.""" + index_names = {i["name"] for i in sa.inspect(db_engine).get_indexes("conversations")} + assert "ix_conversations_agent_id" in index_names + + +def test_agents_name_unique_index_exists(db_engine: Engine) -> None: + """ix_agents_template_name unique index must still exist.""" + indexes = {i["name"]: i for i in sa.inspect(db_engine).get_indexes("agents")} + assert "ix_agents_template_name" in indexes + assert indexes["ix_agents_template_name"]["unique"] + + +def test_template_agent_kind_stored_and_read(db_engine: Engine) -> None: + """A template agent inserted with kind='template' round-trips correctly.""" with db_engine.begin() as conn: conn.execute( sa.text( - "INSERT INTO conversations " - "(id, created_at, updated_at, root_conversation_id, kind) " - "VALUES (:id, :ts, :ts, :id, 'default')", + "INSERT INTO agents (id, created_at, name, bundle_location, version, kind)" + " VALUES (:id, :ts, :name, :loc, 1, 'template')" ), - {"id": "conv_fk_target", "ts": 1700000000}, + {"id": "ag_tmpl", "ts": 1700000001, "name": "my-template", "loc": "ag_tmpl/bundle"}, ) + kind = conn.execute( + sa.text("SELECT kind FROM agents WHERE id = :id"), {"id": "ag_tmpl"} + ).scalar_one() + assert kind == "template" + + +def test_session_agent_kind_stored_and_read(db_engine: Engine) -> None: + """A session-scoped agent inserted with kind='session' round-trips correctly.""" + with db_engine.begin() as conn: conn.execute( sa.text( - "INSERT INTO agents " - "(id, created_at, name, bundle_location, version, session_id) " - "VALUES (:id, :ts, :name, :loc, 1, :session_id)", + "INSERT INTO agents (id, created_at, name, bundle_location, version, kind)" + " VALUES (:id, :ts, :name, :loc, 1, 'session')" ), - { - "id": "ag_session_bound", - "ts": 1700000001, - "name": "session-bound-agent", - "loc": "ag_session_bound/bundle", - "session_id": "conv_fk_target", - }, + {"id": "ag_sess", "ts": 1700000001, "name": "my-session", "loc": "ag_sess/bundle"}, ) - stored = conn.execute( - sa.text("SELECT session_id FROM agents WHERE id = :id"), - {"id": "ag_session_bound"}, + kind = conn.execute( + sa.text("SELECT kind FROM agents WHERE id = :id"), {"id": "ag_sess"} ).scalar_one() - assert stored == "conv_fk_target" - - -def test_agents_session_id_fk_rejects_missing_session(db_engine: Engine) -> None: - """The FK rejects references to nonexistent conversations.""" - with pytest.raises(sa.exc.IntegrityError): - with db_engine.begin() as conn: - conn.execute( - sa.text( - "INSERT INTO agents " - "(id, created_at, name, bundle_location, version, session_id) " - "VALUES (:id, :ts, :name, :loc, 1, :session_id)", - ), - { - "id": "ag_missing_session", - "ts": 1700000002, - "name": "missing-session-agent", - "loc": "ag_missing_session/bundle", - "session_id": "conv_missing", - }, - ) + assert kind == "session" -def test_agents_session_id_unique_index_rejects_duplicate_session( - db_engine: Engine, -) -> None: - """Only one agent row can claim a concrete session id.""" +def test_agents_session_id_fk_accepts_existing_session(db_engine: Engine) -> None: + """conversations.agent_id (forward pointer) accepts a valid agent id.""" with db_engine.begin() as conn: conn.execute( sa.text( - "INSERT INTO conversations " - "(id, created_at, updated_at, root_conversation_id, kind) " - "VALUES (:id, :ts, :ts, :id, 'default')", + "INSERT INTO agents (id, created_at, name, bundle_location, version, kind)" + " VALUES (:id, :ts, :name, :loc, 1, 'session')" ), - {"id": "conv_unique_target", "ts": 1700000000}, + {"id": "ag_bound", "ts": 1700000001, "name": "bound-agent", "loc": "ag_bound/bundle"}, ) conn.execute( sa.text( - "INSERT INTO agents " - "(id, created_at, name, bundle_location, version, session_id) " - "VALUES (:id, :ts, :name, :loc, 1, :session_id)", + "INSERT INTO conversations" + " (id, created_at, updated_at, root_conversation_id, kind, agent_id)" + " VALUES (:id, :ts, :ts, :id, 'default', :agent_id)" ), - { - "id": "ag_unique_one", - "ts": 1700000001, - "name": "unique-one", - "loc": "ag_unique_one/bundle", - "session_id": "conv_unique_target", - }, + {"id": "conv_bound", "ts": 1700000002, "agent_id": "ag_bound"}, ) + stored = conn.execute( + sa.text("SELECT agent_id FROM conversations WHERE id = :id"), + {"id": "conv_bound"}, + ).scalar_one() + assert stored == "ag_bound" + - with pytest.raises(sa.exc.IntegrityError): +def test_agents_session_id_fk_rejects_missing_session(db_engine: Engine) -> None: + """conversations.agent_id FK rejects a reference to a nonexistent agent.""" + with pytest.raises(IntegrityError): with db_engine.begin() as conn: conn.execute( sa.text( - "INSERT INTO agents " - "(id, created_at, name, bundle_location, version, session_id) " - "VALUES (:id, :ts, :name, :loc, 1, :session_id)", + "INSERT INTO conversations" + " (id, created_at, updated_at, root_conversation_id, kind, agent_id)" + " VALUES (:id, :ts, :ts, :id, 'default', :agent_id)" ), - { - "id": "ag_unique_two", - "ts": 1700000002, - "name": "unique-two", - "loc": "ag_unique_two/bundle", - "session_id": "conv_unique_target", - }, + {"id": "conv_missing", "ts": 1700000002, "agent_id": "ag_nonexistent"}, ) -def test_agents_session_id_allows_duplicate_names_for_distinct_sessions( +def test_agents_template_name_unique_index_rejects_duplicate_template( db_engine: Engine, ) -> None: - """Two session-scoped agent copies can reuse the same spec name.""" - with db_engine.begin() as conn: - for session_id in ["conv_name_one", "conv_name_two"]: - conn.execute( - sa.text( - "INSERT INTO conversations " - "(id, created_at, updated_at, root_conversation_id, kind) " - "VALUES (:id, :ts, :ts, :id, 'default')", - ), - {"id": session_id, "ts": 1700000000}, - ) - for agent_id, session_id in [ - ("ag_name_one", "conv_name_one"), - ("ag_name_two", "conv_name_two"), - ]: + """Two template agents may not share the same name (ix_agents_template_name).""" + with pytest.raises(IntegrityError): + with db_engine.begin() as conn: conn.execute( sa.text( - "INSERT INTO agents " - "(id, created_at, name, bundle_location, version, session_id) " - "VALUES (:id, :ts, :name, :loc, 1, :session_id)", + "INSERT INTO agents (id, created_at, name, bundle_location, version, kind)" + " VALUES (:id1, :ts, 'dup-template', :loc1, 1, 'template')," + " (:id2, :ts, 'dup-template', :loc2, 1, 'template')" ), { - "id": agent_id, + "id1": "ag_dup1", + "id2": "ag_dup2", "ts": 1700000001, - "name": "shared-session-name", - "loc": f"{agent_id}/bundle", - "session_id": session_id, + "loc1": "ag_dup1/bundle", + "loc2": "ag_dup2/bundle", }, ) - session_ids = list( - conn.execute( - sa.text( - "SELECT session_id FROM agents WHERE name = :name ORDER BY session_id", - ), - {"name": "shared-session-name"}, - ).scalars() - ) - assert session_ids == ["conv_name_one", "conv_name_two"] - -def test_agents_session_id_downgrade_round_trip(tmp_path: Path) -> None: - """Downgrade deletes session-scoped rows and restores name uniqueness.""" - db_path = tmp_path / "downgrade.db" - uri = f"sqlite:///{db_path}" - engine = get_or_create_engine(uri) - with engine.begin() as conn: +def test_agents_session_id_allows_duplicate_names_for_distinct_sessions( + db_engine: Engine, +) -> None: + """Two session-scoped agent copies can reuse the same spec name.""" + with db_engine.begin() as conn: conn.execute( sa.text( - "INSERT INTO agents " - "(id, created_at, name, bundle_location, version) " - "VALUES (:id, :ts, :name, :loc, 1)", + "INSERT INTO agents (id, created_at, name, bundle_location, version, kind)" + " VALUES (:id1, :ts, 'shared-name', :loc1, 1, 'session')," + " (:id2, :ts, 'shared-name', :loc2, 1, 'session')" ), { - "id": "ag_downgrade_template", + "id1": "ag_s1", + "id2": "ag_s2", "ts": 1700000001, - "name": "downgrade-shared-name", - "loc": "ag_downgrade_template/bundle", + "loc1": "ag_s1/bundle", + "loc2": "ag_s2/bundle", }, ) + count = conn.execute( + sa.text("SELECT COUNT(*) FROM agents WHERE name = 'shared-name'") + ).scalar_one() + assert count == 2 + + +def test_upgrade_does_not_cascade_delete_conversations(tmp_path: Path) -> None: + """Upgrade must not cascade-delete conversations bound to session-scoped agents. + + On SQLite, batch_alter_table drops and recreates the agents table. If + PRAGMA foreign_keys is ON, the DROP fires the ON DELETE CASCADE on + conversations.agent_id → agents.id and silently wipes every conversation + that owns an agent. This test asserts that upgrade preserves them. + """ + db_path = tmp_path / "upgrade_cascade.db" + uri = f"sqlite:///{db_path}" + + # Build a raw engine (no auto-migration) to set up the pre-our-migration state. + raw_engine = sa.create_engine(uri) + + # Migrate to the revision just before ours. + config = _build_alembic_config(uri) + with raw_engine.begin() as conn: + config.attributes["connection"] = conn + command.upgrade(config, "n1a2b3c4d5e6") + + # Seed one template agent, one session-scoped agent, and the conversation + # bound to it — exactly the data that would be wiped by the cascade bug. + with raw_engine.begin() as conn: conn.execute( sa.text( - "INSERT INTO conversations " - "(id, created_at, updated_at, root_conversation_id, kind) " - "VALUES (:id, :ts, :ts, :id, 'default')", - ), - {"id": "conv_downgrade_session", "ts": 1700000002}, + "INSERT INTO agents (id, created_at, name, bundle_location, version)" + " VALUES ('ag_tmpl', 1, 'my-template', 'ag_tmpl/b', 1)," + " ('ag_sess', 2, 'my-session', 'ag_sess/b', 1)" + ) ) conn.execute( sa.text( - "INSERT INTO agents " - "(id, created_at, name, bundle_location, version, session_id) " - "VALUES (:id, :ts, :name, :loc, 1, :session_id)", - ), - { - "id": "ag_downgrade_session", - "ts": 1700000003, - "name": "downgrade-shared-name", - "loc": "ag_downgrade_session/bundle", - "session_id": "conv_downgrade_session", - }, + "INSERT INTO conversations" + " (id, created_at, updated_at, root_conversation_id, kind, agent_id)" + " VALUES ('conv_1', 3, 3, 'conv_1', 'default', 'ag_sess')" + ) ) + conn.execute(sa.text("UPDATE agents SET session_id = 'conv_1' WHERE id = 'ag_sess'")) + + # Run our migration. + config2 = _build_alembic_config(uri) + with raw_engine.begin() as conn: + config2.attributes["connection"] = conn + command.upgrade(config2, "o1a2b3c4d5e6") + + with raw_engine.begin() as conn: + conv_ids = list(conn.execute(sa.text("SELECT id FROM conversations")).scalars()) + agent_kinds = { + row[0]: row[1] for row in conn.execute(sa.text("SELECT id, kind FROM agents")) + } + + assert "conv_1" in conv_ids, "Upgrade must not cascade-delete bound conversations" + assert agent_kinds.get("ag_sess") == "session" + assert agent_kinds.get("ag_tmpl") == "template" + + raw_engine.dispose() + clear_engine_cache() + + +def test_agents_session_id_downgrade_round_trip(tmp_path: Path) -> None: + """Downgrade restores session_id from conversations.agent_id and drops kind.""" + db_path = tmp_path / "downgrade.db" + uri = f"sqlite:///{db_path}" + engine = get_or_create_engine(uri) + + # Seed data on the upgraded schema: one template, one session-scoped agent. + with engine.begin() as conn: conn.execute( sa.text( - "UPDATE conversations SET agent_id = :agent_id WHERE id = :conversation_id", - ), - { - "agent_id": "ag_downgrade_session", - "conversation_id": "conv_downgrade_session", - }, + "INSERT INTO agents (id, created_at, name, bundle_location, version, kind)" + " VALUES ('ag_tmpl', 1, 'my-template', 'ag_tmpl/b', 1, 'template')," + " ('ag_sess', 2, 'my-session', 'ag_sess/b', 1, 'session')" + ) + ) + conn.execute( + sa.text( + "INSERT INTO conversations" + " (id, created_at, updated_at, root_conversation_id, kind, agent_id)" + " VALUES ('conv_1', 3, 3, 'conv_1', 'default', 'ag_sess')" + ) ) + # Run the downgrade. config = _build_alembic_config(uri) with engine.begin() as conn: config.attributes["connection"] = conn - command.downgrade(config, "b3d5e7f91a23") + command.downgrade(config, "n1a2b3c4d5e6") - inspector = sa.inspect(engine) - columns = {column["name"] for column in inspector.get_columns("agents")} - assert "session_id" not in columns - index_names = {index["name"] for index in inspector.get_indexes("agents")} - assert "ix_agents_session_id" not in index_names - assert "ix_agents_template_name" not in index_names + # kind must be gone, session_id must be back. + columns = {c["name"] for c in sa.inspect(engine).get_columns("agents")} + assert "kind" not in columns + assert "session_id" in columns + # The session-scoped agent should have session_id back-populated from + # conversations.agent_id; the template agent should have NULL. with engine.begin() as conn: - rows = [ - tuple(row) - for row in conn.execute( - sa.text("SELECT id, name FROM agents ORDER BY id"), - ) - ] - session_conversation = conn.execute( - sa.text("SELECT id FROM conversations WHERE id = :id"), - {"id": "conv_downgrade_session"}, - ).first() - assert rows == [("ag_downgrade_template", "downgrade-shared-name")] - assert session_conversation is None - - with pytest.raises(sa.exc.IntegrityError): - with engine.begin() as conn: - conn.execute( - sa.text( - "INSERT INTO agents " - "(id, created_at, name, bundle_location, version) " - "VALUES (:id, :ts, :name, :loc, 1)", - ), - { - "id": "ag_downgrade_duplicate", - "ts": 1700000001, - "name": "downgrade-shared-name", - "loc": "ag_downgrade_duplicate/bundle", - }, - ) + rows = { + row[0]: row[1] + for row in conn.execute(sa.text("SELECT id, session_id FROM agents ORDER BY id")) + } + assert rows["ag_tmpl"] is None + assert rows["ag_sess"] == "conv_1" engine.dispose() clear_engine_cache() diff --git a/tests/stores/test_agent_store.py b/tests/stores/test_agent_store.py index b1da0280fdc..050de6e61f2 100644 --- a/tests/stores/test_agent_store.py +++ b/tests/stores/test_agent_store.py @@ -53,15 +53,14 @@ def test_get_by_name_and_list_hide_session_scoped_agents( conn.execute( sa.text( "INSERT INTO agents " - "(id, created_at, name, bundle_location, version, session_id) " - "VALUES (:id, :ts, :name, :loc, 1, :session_id)", + "(id, created_at, name, bundle_location, version, kind) " + "VALUES (:id, :ts, :name, :loc, 1, 'session')", ), { "id": "ag_agent_store_session", "ts": 1700000001, "name": "session-only-agent", "loc": "ag_agent_store_session/bundle", - "session_id": "conv_agent_store_session", }, ) template_agent = agent_store.create( diff --git a/tests/stores/test_conversation_store.py b/tests/stores/test_conversation_store.py index cd7fb3ec84a..b734b58fa17 100644 --- a/tests/stores/test_conversation_store.py +++ b/tests/stores/test_conversation_store.py @@ -3306,8 +3306,8 @@ def test_fork_clone_agent_is_session_scoped( ) -> None: """A fork that clones an agent creates a session-scoped row, not a built-in. - The clone must be born with ``session_id`` set so it never appears in - the built-in agent list (``session_id IS NULL``) that backs the fork + The clone must be born with ``kind='session'`` so it never appears in + the built-in agent list (``kind='template'``) that backs the fork picker — the regression that surfaced as duplicate "Claude Code" / "Codex" entries in the fork dialog. """ From d7e74a0d64bef328450460d0bc1335f98b1337ea Mon Sep 17 00:00:00 2001 From: Pat Sukprasert Date: Tue, 7 Jul 2026 16:12:00 +0800 Subject: [PATCH 053/546] feat(harness-bench): full-server default + --fast, parallel runs, rich progress, transport labels (#2059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(harness-bench): rich live progress, --jobs parallel, --report file Three CLI/output improvements, built on a structured progress-event seam. - Structured events (events.py): the orchestrator now emits typed BenchEvents (HarnessStarted/Skipped, ProbeStarted/Finished, HarnessFinished) to a ProgressSink, instead of pre-rendered strings. The old per-line output is preserved via LineSink, and a bare-callable `progress=` is auto-adapted to it — back-compat, no caller change required. - Rich live table (richreport.py, --rich/--no-rich): a ProgressSink backed by rich.Live draws one row per harness with per-dimension cells that fill in as probes finish (spinner while running → verdict glyph). Auto-selected on a TTY when rich is available; falls back to LineSink under a pipe/CI or when rich is absent (rich_sink_or_none returns None). Most useful with --jobs. - Bounded parallel (--jobs N / -j, default 1): run up to N harnesses concurrently via an asyncio.Semaphore. Probes WITHIN a harness stay sequential (they share one driver/session with a single in-flight turn); concurrency is only across harnesses, each of which owns its own server/runner. gather preserves input order, so the matrix stays in --harness order regardless of finish order. The cap keeps process/port and gateway load bounded rather than spawning every harness at once. - Report file (--report PATH): write the final matrix to a file; format from --json/--markdown, else inferred from the extension (.json/.md), else a plain (un-colored) grid. Tests: structured-event emission + LineSink adaptation, --jobs order preservation under staggered finishes, and --report file writing (md + json). Offline suite 55 passed / 14 skipped, ruff clean. rich renders live when present; the plain path is unchanged. * feat(harness-bench): share one server+runner across parallel full-server harnesses Folds the shared-server optimization into the parallel path. Previously each full-server harness spawned its own server + runner; under --jobs > 1 that was N server boots + N runners. The Omnigent server is multi-agent/multi-session and a single runner resolves the harness per session from its agent spec, so N SDK harnesses can share ONE server+runner, each registering its own agent + session. - New SharedFullServer (full_server_driver.py): owns the server+runner lifecycle + agent/session registration, extracted from FullServerDriver. - FullServerDriver takes an optional `shared=`: injected → registers on the shared server and spawns nothing; None → owns a private SharedFullServer (back-compat, exactly the old one-server-per-harness behavior for --jobs 1). - run_bench stands up one SharedFullServer for a live, parallel run with >1 full-server harness (via _maybe_shared_full_server), passes it to each, and tears it down after. native-tui harnesses still self-provision (each needs its own host daemon). Cuts the heaviest, slowest part of full-server startup (server boot + health-wait) from N times to once, and roughly halves the process/port count for a parallel SDK run. Gateway load is unchanged (same total turns). Test: a parallel full-server run builds exactly one SharedFullServer and all harnesses register on it. Offline suite 56 passed / 14 skipped, ruff clean; solo full-server path unchanged (back-compat). * refactor(harness-bench): split shared server into its own module; hoist imports Readability/structure cleanup requested in review, no behavior change. - Split full_server.py out of full_server_driver.py: the server+runner lifecycle and agent/session registration (SharedFullServer + spawn/wait/ config helpers + the shared _find_free_port/_mint_bearer/spawn_omnigent_server that native-tui also uses) now live in full_server.py; full_server_driver.py keeps just FullServerDriver and its probe/item-scan helpers. Clear seam: "the server" vs "the driver that runs probes against it". - Hoist function-body imports to module top across the package (Any, shutil, cli_unavailable_reason, omnigent.harness_capabilities/plugins, LineSink, SharedFullServer, socket/io/tarfile/yaml). The only inline imports left are intentional and now commented: the optional `rich` dependency (richreport + its lazy load in __main__) and two documented cycle-avoidance imports (transport→drivers, profile→manifest). - Update consumers (native_tui_driver, bench) to import the shared helpers from full_server; fix the shared-server test to patch bench's namespace (bench now imports SharedFullServer at top). Offline suite 56 passed / 14 skipped, ruff clean, no import cycle. * feat(harness-bench): default SDK harnesses to full-server; add --fast Full-server is a strict coverage superset for SDK harnesses: it observes everything sdk-inproc does (basic / streaming / interrupt / model-override) *plus* the two dimensions sdk-inproc physically cannot reach — Tool calling and Policy DENY, as server-dispatched, policy-gated calls. The only cost is the server boot. So make full-server the default and offer --fast as the opt-out, rather than a per-harness --best selector. Transport is now resolved from the harness *family* + flags (resolve_transport_name): - SDK family (sdk-inproc/full-server) -> full-server by default; --fast picks sdk-inproc (skips the boot; Tool calling + Policy DENY then report SKIPPED, which those probes already emit on the wrap-direct path -- no false DRIFT). - native (native-tui) -> single transport; --fast does not apply. - --transport NAME still overrides the family for any harness, and is mutually exclusive with --fast. The profile's `transport` field stays the family marker (the _is_native applicability gate keys on it), so nothing about probe applicability changes. --list now prints the resolved default transport so it matches what runs. Both driver gates already agree with this: FullServerDriver.unavailable only rejects native profiles (not sdk-inproc-family), and SdkInprocDriver accepts its own family -- so neither default nor --fast self-rejects. Docs (harness-bench-design.md) updated: transport-selection prose, the which-transport-exercises-what table, and the run examples now lead with the full-server default and --fast opt-out. Offline suite 57 passed / 14 skipped, ruff clean. * fix(harness-bench): quiet expected provisioning skips; keep tracebacks for bugs A parallel live run dumped three full tracebacks for the own-auth natives (goose/kimi/hermes) whose forwarder never wires up — an expected, already- handled skip (they show as skipped in the matrix), but the stack dumps break up the --rich table and read like failures. Introduce ProvisioningError (in driver.py) for an *expected* provisioning failure: a known-unrunnable environment through no fault of the bench, e.g. an own-auth native whose vendor CLI is installed but not logged in. native-tui's forwarder-timeout now raises it instead of a bare RuntimeError. run_harness splits on it: an expected ProvisioningError logs one INFO line (reason only, no traceback), while any other exception keeps exc_info=True so a genuine driver bug (e.g. an AssertionError) can't vanish behind a green skip. The matrix output is unchanged either way — the harness is still a capability-neutral skip with the reason shown in its row. Offline suite 58 passed / 14 skipped, ruff clean. * feat(harness-bench): label each matrix row with its resolved transport Show which transport actually produced each row, e.g. `claude-sdk [full-server]`, `kimi-native [native]`. This matters now that transport is resolved from family + flags: an SDK harness's profile.transport is the `sdk-inproc` family marker, but it runs on `full-server` by default -- so the label reflects the *resolved* transport, not the marker, or it would mislabel exactly the rows worth clarifying. - HarnessReport carries the resolved `transport` (the driver class's transport, or the resolve_transport_name result offline). Populated at every report site (success, unavailable-skip, provisioning-skip, offline). - report.py labels the harness column in both the terminal and Markdown renderers (native-tui abbreviated to `native`); render_json adds a distinct `resolved_transport` field alongside the family `transport`. - The rich live table labels its rows too: HarnessSkipped gained a transport field (HarnessStarted already had one), and the sink tracks harness→transport. Offline suite 58 passed / 14 skipped, ruff clean. * docs(harness-bench): refresh README for phase-2 state The README still described the phase-1 MVP (sdk-inproc only, four SDK harnesses, Markdown/JSON output). Bring it current: - Run examples lead with --jobs + --rich; add a Flags section covering --fast, --transport, --jobs, --rich/--no-rich, --report. - New "Transport selection" section: full-server is the SDK default (fullest coverage), --fast opts down to sdk-inproc, natives use native-tui. - Note the per-row transport label and that Tool calling / Policy DENY only get a real verdict on full-server. - Layout table lists the current modules (transport.py, full_server.py split from full_server_driver.py, native_tui_driver.py, events.py, richreport.py). - Scope reflects what is live (3 transports, all natives auto-derived) vs the remaining open items, instead of "phase-1 MVP". * docs(harness-bench): clarify native Tool calling / Policy DENY is a bench gap A reader skimming the matrix could misread the `·` in the native rows' Tool calling / Policy DENY cells as "native harnesses can't do this". They can -- the bench just cannot observe it on native-tui yet. Sharpen both docs to say so unambiguously: - A `·` always means "the bench did not measure this here", never "the harness lacks it". - The native-tui `·` for those two dimensions is a driver/observation gap, not a native-harness limitation: a native tool call is the vendor's own (Bash/Read/...) and a native deny is a vendor permission decision, neither of which is the server-dispatched, policy-gated call the probe watches for. - The which-transport table cells now read "bench can't observe vendor tools/ deny yet" instead of the terse "not yet wired"; the open-items entries lead with "bench observation ... a driver gap, not a native-harness limitation". No behavior change; docs only. * fix(harness-bench): treat any native provisioning failure as a quiet skip The earlier quieting only covered the forwarder-timeout RuntimeError. A native harness can fail provisioning other ways -- goose-native's terminal-ensure returns a 500 (the vendor cannot start a thread), which raised a raw httpx.HTTPStatusError and still dumped a full traceback. Native provisioning drives a live vendor CLI plus a server-native terminal, so any HTTP failure there is an environment/server-state gap, not a bench bug. NativeTuiDriver.__aenter__ now converts httpx.HTTPError into ProvisioningError so the orchestrator skips the harness quietly (reason shown in its row). A programming error (AssertionError, etc.) is not an HTTPError, so it still propagates with its traceback. The deliberate readiness-timeout and agent-not-seeded raises in the provisioning path also became ProvisioningError for consistency. Test: an httpx 500 in provisioning surfaces as ProvisioningError. Offline suite 59 passed / 14 skipped, ruff clean. * test(harness-bench): single import style in test_bench (review) Code-quality review flagged tests.harness_bench.bench being imported both as `from ... import run_bench, run_harness` (top level) and `import ... as bench_mod` (in three test bodies). Drop the in-function module aliases and patch module attributes via monkeypatch's string-target form (`"tests.harness_bench.bench.resolve_driver_class"`), which the file already uses elsewhere -- so there is one import style throughout. No behavior change. Offline suite 59 passed / 14 skipped, ruff clean. * fix(harness-bench): don't reprint the grid under --rich on a terminal Running `--rich` interactively showed the matrix twice: the rich live table (progress, on stderr) and then the plain report grid (deliverable, on stdout), which land on the same terminal and look like a duplicate. The report is not pure duplication -- it carries the legend, per-cell Notes, and any Drift section the rich table omits. So the fix keeps the footer and drops only the grid, and only when it would actually duplicate: - render_table gains grid=True/False; grid=False emits just the footer (legend/drift/notes/skips), no heading or glyph rows. - Sinks expose drew_grid (rich live table True, LineSink False). The CLI prints grid=False only when the sink drew the grid AND stdout is a TTY (same terminal as the stderr progress). Redirect stdout to a file and the report keeps the full grid, so the file stays self-contained. Tests: grid=False drops the grid but keeps the legend; _grid_already_shown is True only for a grid-drawing sink. Offline suite 61 passed / 14 skipped, ruff clean. README output-format note updated. --- docs/harness-bench-design.md | 80 ++-- tests/harness_bench/README.md | 119 ++++-- tests/harness_bench/__main__.py | 147 +++++++- tests/harness_bench/bench.py | 265 +++++++++++--- tests/harness_bench/driver.py | 13 + tests/harness_bench/events.py | 138 +++++++ tests/harness_bench/full_server.py | 311 ++++++++++++++++ tests/harness_bench/full_server_driver.py | 306 ++-------------- tests/harness_bench/native_tui_driver.py | 40 +- tests/harness_bench/report.py | 45 ++- tests/harness_bench/richreport.py | 134 +++++++ tests/harness_bench/test_bench.py | 427 +++++++++++++++++++++- tests/harness_bench/transport.py | 61 +++- 13 files changed, 1666 insertions(+), 420 deletions(-) create mode 100644 tests/harness_bench/events.py create mode 100644 tests/harness_bench/full_server.py create mode 100644 tests/harness_bench/richreport.py diff --git a/docs/harness-bench-design.md b/docs/harness-bench-design.md index ba628207546..6be0d8edb30 100644 --- a/docs/harness-bench-design.md +++ b/docs/harness-bench-design.md @@ -247,8 +247,10 @@ class StreamingProbe(CapabilityProbe): ## Transport drivers: the real ceiling on "all dimensions" -Behavioral probes run through a **transport driver** selected per run -(`--transport`) or from each profile's declared transport. A probe calls +Behavioral probes run through a **transport driver** resolved from the +harness *family* plus flags: SDK harnesses default to `full-server` (`--fast` +picks `sdk-inproc`), natives use `native-tui`, and `--transport NAME` overrides +the family for any harness. A probe calls *semantic* methods on the driver (`run_basic_turn`, `run_streaming_turn`, `run_tool_turn(deny=...)`, `run_interrupt_turn`); the driver owns the *mechanism* and the probe owns the *interpretation*, so one probe runs across @@ -277,7 +279,7 @@ The MVP and most of phase-2 are landed. What exists on `main` today: `misc` pytest group), and the six P0 live probes (basic turn, streaming, tool calling, policy DENY, model override, interrupt) with the `DRIFT` column. -- **Three transport drivers**, selectable via `--transport`: +- **Three transport drivers**, selected by harness *family* with flag overrides: - `sdk-inproc` — drives a harness wrap subprocess directly (the four P0 SDK harnesses: claude-sdk, codex, pi, openai-agents). - `full-server` — a real server + runner; the only transport that exercises @@ -285,6 +287,14 @@ The MVP and most of phase-2 are landed. What exists on `main` today: calls (SDK harnesses only — it registers via an agent bundle). - `native-tui` — a resident vendor CLI in a runner-owned tmux pane, driven over the session HTTP surface via a host daemon. + + SDK harnesses default to **`full-server`** — the fullest coverage, and a + strict superset of what `sdk-inproc` observes (everything sdk-inproc does, + *plus* Tool calling + Policy DENY). `--fast` opts the SDK family down to + `sdk-inproc` when you want to skip the server boot (those two dimensions then + report `·`). Native harnesses have a single transport `--fast` does not touch. + An explicit `--transport NAME` overrides the family default for any harness + and is mutually exclusive with `--fast`. - **Capability-derived matrix** — descriptive columns and declared verdicts come from `harness_capabilities()` (the seam; see `designs/harness-capabilities-bench-seam.md`), so a harness added to the @@ -296,10 +306,15 @@ The MVP and most of phase-2 are landed. What exists on `main` today: ### Not yet wired -- **Tool calling / Policy DENY on `native-tui`** — native tool calls are the - vendor's own and a native deny is a vendor permission decision, not a - server-dispatched `function_call_output`; observing them needs new driver - work. (SDK harnesses have these via `full-server`.) +- **Bench observation of Tool calling / Policy DENY on `native-tui`** — a + *driver gap, not a native-harness limitation*. Native harnesses do call tools + and enforce permissions; the bench cannot yet observe it on this transport. + A native tool call is the vendor's own tool (Bash/Read/...), not a + server-dispatched `function_call_output` the bench can force, and a native + deny is a vendor permission decision, not a server-side policy evaluation the + probe can assert against. So both cells show `·` (not measured), never `✗`. + Wiring the observation needs new driver work. (SDK harnesses get these via + `full-server`.) - **P1 dimensions** — steering, live-queue, resume/fork, elicitation ASK, reasoning, images, cost, compaction. Probes not written yet (report `UNKNOWN`). @@ -372,35 +387,45 @@ stream, the bench flags a real drift on the next run, rather than a false ## Which transport exercises which dimension Not every dimension is observable on every transport, so a `·` (SKIPPED) in a -default run often means "this transport can't exercise it here," not "the -harness lacks it." Two dimensions in particular only get a real verdict on the +run always means "the bench did not measure this here," never "the harness +lacks it." Two dimensions in particular only get a real verdict on the `full-server` transport: -| Dimension | sdk-inproc | full-server | native-tui | +| Dimension | sdk-inproc (`--fast`) | full-server (default) | native-tui | |---|---|---|---| | Basic turn, Streaming, Model override, Interrupt | ✓ | ✓ | ✓ | -| **Tool calling** | · (harness dispatches tools internally) | ✓ (server-dispatched builtin) | · (not yet wired) | -| **Policy DENY** | · (wrap-direct: no tool-call policy hook) | ✓ (spec-baked deny, enforced) | · (not yet wired) | +| **Tool calling** | · (harness dispatches tools internally) | ✓ (server-dispatched builtin) | · (bench can't observe vendor tools yet) | +| **Policy DENY** | · (wrap-direct: no tool-call policy hook) | ✓ (spec-baked deny, enforced) | · (bench can't observe vendor deny yet) | + +The `native-tui` `·` is a *bench observation gap, not a native-harness +limitation*: native harnesses do call tools and enforce permissions, but a +native tool call is the vendor's own (Bash/Read/...) and a native deny is a +vendor permission decision, neither of which is the server-dispatched, +policy-gated call the probe watches for. Giving those cells a real verdict +needs new driver work, not a change to the harnesses. -So to see Tool calling and Policy DENY actually proven, run the SDK harnesses -over `full-server`: +Because `full-server` sees everything `sdk-inproc` does *plus* these two, it is +the **default** for SDK harnesses — a plain live run proves Tool calling and +Policy DENY out of the box: ``` -python -m tests.harness_bench --harness claude-sdk --profile oss --transport full-server +python -m tests.harness_bench --harness claude-sdk --profile oss ``` Live-verified: `claude-sdk` completes the full matrix on `full-server` — Tool calling `✓` and Policy DENY `✓` (the deny is delivered and the blocked -call does not stall the turn). The default `--profile oss` run shows `·` for -those two columns only because it uses `sdk-inproc` (for SDK harnesses) and -`native-tui` (for natives), neither of which routes a tool call through a -server policy evaluation. +call does not stall the turn). Add `--fast` to trade that coverage for a quicker +run on `sdk-inproc`; those two columns then show `·`, since neither `sdk-inproc` +nor `native-tui` (for natives) routes a tool call through a server policy +evaluation. `full-server` covers **SDK harnesses only** — it registers the harness via an agent bundle, which is the SDK-wrap path; native harnesses need the host-daemon -provisioning the `native-tui` driver owns. So Tool calling / Policy DENY for -native harnesses remain genuinely unwired (a follow-up), distinct from the -sdk-inproc `·` which is a transport limitation with `full-server` as the answer. +provisioning the `native-tui` driver owns. So Tool calling / Policy DENY on +native harnesses are not observed by *any* transport yet — a bench follow-up, +not a native-harness gap — distinct from the `--fast` (sdk-inproc) `·`, which +is a transport limitation the default `full-server` run already answers for SDK +harnesses. ## Plugin seamlessness: where it is and isn't @@ -457,10 +482,13 @@ agree with it. hardcoded `_ensure_default_*_agent()` list in `server/app.py` with a loop over `native_agents()`, so any native harness (in-repo or plugin) registers automatically. This is the fix for the plugin-seamlessness seam above. -- **Tool calling / Policy DENY on `native-tui`** — unwired; native tool calls - are the vendor's own and a native deny is a vendor permission decision, not a - server-dispatched `function_call_output`. Needs new driver work. (SDK - harnesses have these via `full-server`.) +- **Bench observation of Tool calling / Policy DENY on `native-tui`** — a + driver gap, not a native-harness limitation: native harnesses call tools and + enforce permissions, but a native tool call is the vendor's own and a native + deny is a vendor permission decision, not the server-dispatched + `function_call_output` the probe watches for. The cells show `·` (not + measured), never `✗`. Needs new driver work. (SDK harnesses get these via + `full-server`.) - **Per-harness native provisioning gaps** the bench has surfaced but not yet resolved: goose-native returns a 500 on the terminal-ensure endpoint; hermes-native's forwarder does not wire up (a lazy-chat / first-turn gate to diff --git a/tests/harness_bench/README.md b/tests/harness_bench/README.md index 9433f7c998d..41fd4248f94 100644 --- a/tests/harness_bench/README.md +++ b/tests/harness_bench/README.md @@ -8,29 +8,71 @@ against a self-declared profile to surface drift. Design and rationale: ## Run it ```bash -# List official harnesses. +# List official harnesses (name, resolved transport, model). python -m tests.harness_bench --list -# Offline (declared) matrix — no turns, no creds. +# Offline (declared) matrix -- no turns, no creds. python -m tests.harness_bench # Live probe one harness against a gateway profile. python -m tests.harness_bench --harness codex --profile my-profile -# Live probe every official harness. -python -m tests.harness_bench --profile my-profile +# Live probe every official harness, several at a time, with a live table. +python -m tests.harness_bench --profile my-profile --jobs 4 --rich ``` -Output formats (mutually exclusive): - -- default: an aligned, ANSI-colored terminal table (color auto-disables - when piped or with `--no-color`), followed by a Notes section explaining - every non-supported cell so a `·` is never opaque. +A non-zero exit means a `DRIFT` cell was found (observed behavior disagrees +with the declared matrix). + +### Flags + +- `--profile NAME` -- Databricks gateway profile. Enables the live layer; + without it the bench renders the declared matrix offline. +- `--harness NAME` -- probe one harness (repeatable). An official name, or a + `module:attr` / `module.ATTR` reference to a community `BenchProfile`. + Defaults to every official harness. +- `--fast` -- run SDK harnesses on `sdk-inproc` instead of the `full-server` + default: skips the server boot for a quicker run, at the cost of the Tool + calling + Policy DENY dimensions (they report `·`). No effect on natives. + Mutually exclusive with `--transport`. +- `--transport NAME` -- force a specific transport driver (`sdk-inproc`, + `full-server`, `native-tui`), overriding the family default. +- `--jobs N` / `-j N` -- run up to N harnesses concurrently (default 1). + Probes within a harness stay sequential; 3-4 is a reasonable ceiling on one + host. Report order always matches input order. +- `--rich` / `--no-rich` -- force / disable the live progress table (auto: rich + on a TTY, plain per-line output otherwise). +- `--report PATH` -- also write the final matrix to PATH; format follows + `--json` / `--markdown`, else inferred from the extension. + +### Output formats (mutually exclusive) + +- default: an aligned, ANSI-colored terminal table (color auto-disables when + piped or with `--no-color`), followed by a Notes section explaining every + non-supported cell so a `·` is never opaque. - `--markdown`: the GitHub-flavored table for docs / PRs. - `--json`: machine-readable, for diffing runs or regenerating docs. -A non-zero exit means a `DRIFT` cell was found (observed behavior -disagrees with the declared matrix). +Each row is labelled with the transport that actually ran it, e.g. +`claude-sdk [full-server]`, `kimi-native [native]`. + +Under `--rich`, the live table (on stderr) already shows the grid, so the +stdout report drops the grid and prints only the legend + notes -- no duplicate +table. When stdout is redirected to a file, the report keeps the full grid so +the file is self-contained. + +## Transport selection + +A profile's `transport` field is the harness *family* marker, not the literal +driver the run uses: + +- **SDK-family** harnesses default to **`full-server`** -- the fullest + coverage, and a strict superset of what `sdk-inproc` observes (everything + sdk-inproc does, plus Tool calling + Policy DENY as server-dispatched, + policy-gated calls). `--fast` opts them down to `sdk-inproc`. +- **native** harnesses use `native-tui` (a resident vendor CLI in a + runner-owned tmux pane); `--fast` does not apply. +- `--transport NAME` overrides the family default for any harness. ## What it reports (P0 dimensions) @@ -38,37 +80,66 @@ disagrees with the declared matrix). `model_override`. Verdicts map to the support-matrix glyphs (`✓ ~ ✗ — ?`), plus `·` skipped and `!! DRIFT`. +A `·` always means "the bench did not measure this here", never "the harness +lacks it". In particular Tool calling and Policy DENY only get a real verdict +on `full-server` (a server-dispatched builtin under a spec-baked deny policy), +so they show `·` on `sdk-inproc` and `native-tui`: + +- **Native harnesses show `·` for Tool calling / Policy DENY, and that is not a + native limitation.** Native harnesses do call tools and enforce permissions; + the bench just cannot observe it on `native-tui` yet. A native tool call is + the vendor's own tool (Bash/Read/...), not a server-dispatched builtin the + bench can force, and a native deny is a vendor permission decision, not a + server-side policy evaluation the probe can assert against. Giving those two + cells a real verdict needs new driver work (an open item), not a change to + the harnesses. +- **SDK harnesses show `·` only under `--fast`** (the wrap-direct `sdk-inproc` + path has no tool-call policy hook); the default `full-server` run proves both + as `✓`. That is why full-server is the SDK default. + ## Layout | File | Role | | --- | --- | | `verdict.py` | `Verdict` / `Priority` / `ProbeResult` and the `reconcile` drift check | | `profile.py` | `BenchProfile` (per-harness self-declaration) + name resolution | -| `manifest.py` | Official profiles, built from `tests/e2e/_harness_probes.py` | -| `driver.py` | `SdkInprocDriver` — spawns a harness wrap, drives turns over SSE | +| `manifest.py` | Official profiles, derived from the capability model + `tests/e2e/_harness_probes.py` | +| `transport.py` | `Driver` protocol, driver registry, family/flag transport resolution | +| `driver.py` | `SdkInprocDriver` (harness wrap over SSE) + `ProvisioningError` | +| `full_server.py` | `SharedFullServer` -- real server+runner lifecycle + agent/session registration | +| `full_server_driver.py` | `FullServerDriver` -- runs probes against a (owned or shared) full server | +| `native_tui_driver.py` | `NativeTuiDriver` -- vendor CLI in tmux via a host daemon; native vendor auto-derivation | | `probes/` | One module per dimension; `ALL_PROBES` is the registry | -| `bench.py` | Orchestrator: probes × harnesses → `BenchMatrix` | -| `report.py` | Markdown / JSON renderers | +| `events.py` | Structured `BenchEvent`s + `ProgressSink` / `LineSink` | +| `richreport.py` | `rich.Live` progress table (optional-dep; falls back to `LineSink`) | +| `bench.py` | Orchestrator: probes x harnesses -> `BenchMatrix`, `--jobs`, shared-server wiring | +| `report.py` | Terminal / Markdown / JSON renderers | | `test_bench.py` | Offline conformance (always) + live layer (gated on `--profile`) | ## Add a harness -- **Official:** add a `BenchProfile` to `manifest.py` (base fields come +- **Official SDK:** add a `BenchProfile` to `manifest.py` (base fields come from `_harness_probes.HARNESS_PROBES`). No probe or driver edits. +- **Native:** nothing to add -- every harness the capability model marks + `NATIVE_TUI` (in-repo or a community plugin) is auto-derived into the matrix + and drivable by name; `native_vendor()` derives what the driver needs. - **Community / out-of-repo:** ship a `BenchProfile` and select it by reference: `--harness mypkg.harness:PROFILE`. No bench edits. ## Add a dimension Add a `CapabilityProbe` subclass under `probes/`, list it in -`probes/__init__.py:ALL_PROBES`, and add its declared verdict to the -profiles. Probes are harness-agnostic — they only call the driver. +`probes/__init__.py:ALL_PROBES`, and add its declared verdict to the profiles +(or derive it from the capability model in `manifest.py`). Probes are +harness-agnostic -- they only call the driver's semantic methods. ## Scope -Phase-1 MVP: the six P0 dimensions above, the `sdk-inproc` transport -driver, and the four official SDK harnesses (claude-sdk, codex, pi, -openai-agents). Phase-2 (per the design doc): native transport drivers -(tmux / app-server / HTTP-SSE), the remaining SDK + native harnesses, and -the P1 dimensions (steering, live-queue, resume/fork, elicitation, -reasoning, images, cost, compaction). +Live today: the six P0 dimensions above; all three transports (`sdk-inproc`, +`full-server`, `native-tui`); the four official SDK harnesses (claude-sdk, +codex, pi, openai-agents) plus every registered native. Not yet wired (see the +design doc's open items): **bench observation** of Tool calling / Policy DENY +on `native-tui` (a driver gap, not a native-harness limitation), +registry-driven server-side native-agent seeding, and the P1 dimensions +(steering, live-queue, resume/fork, elicitation, reasoning, images, cost, +compaction). diff --git a/tests/harness_bench/__main__.py b/tests/harness_bench/__main__.py index 68f913b4314..ea6a598ab77 100644 --- a/tests/harness_bench/__main__.py +++ b/tests/harness_bench/__main__.py @@ -8,9 +8,14 @@ # Dry (offline) render — declared matrix, no turns, no creds. python -m tests.harness_bench - # Live probe one harness against a gateway profile. + # Live probe one harness against a gateway profile (SDK → full-server, + # the default: covers Tool calling + Policy DENY). python -m tests.harness_bench --harness codex --profile my-profile + # Quicker run: SDK harnesses on sdk-inproc (skips the server boot; no + # Tool calling / Policy DENY coverage). + python -m tests.harness_bench --harness codex --profile my-profile --fast + # Live probe all official harnesses, JSON out. python -m tests.harness_bench --profile my-profile --json @@ -25,10 +30,11 @@ import sys from tests.harness_bench.bench import run_bench +from tests.harness_bench.events import LineSink from tests.harness_bench.manifest import OFFICIAL_PROFILES from tests.harness_bench.profile import BenchProfile, resolve_profile from tests.harness_bench.report import render_json, render_markdown, render_table -from tests.harness_bench.transport import driver_registry +from tests.harness_bench.transport import driver_registry, resolve_transport_name def _parse_args(argv: list[str]) -> argparse.Namespace: @@ -64,13 +70,23 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: action="store_false", help="Force the offline (declared-only) render.", ) - parser.add_argument( + transport_grp = parser.add_mutually_exclusive_group() + transport_grp.add_argument( "--transport", metavar="NAME", default=None, help="Transport driver override (e.g. 'sdk-inproc', 'full-server'). " - "Wins over each profile's declared transport. Defaults to the " - "profile's transport.", + "Wins over the family default. By default SDK harnesses run on " + "full-server (fullest coverage: Tool calling + Policy DENY); natives " + "run on native-tui.", + ) + transport_grp.add_argument( + "--fast", + action="store_true", + help="Run SDK harnesses on sdk-inproc instead of the full-server " + "default: skips the server boot for a quicker run, at the cost of the " + "Tool calling + Policy DENY dimensions (reported SKIPPED). No effect on " + "native harnesses. Mutually exclusive with --transport.", ) fmt = parser.add_mutually_exclusive_group() fmt.add_argument( @@ -82,6 +98,37 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument( "--no-color", action="store_true", help="Disable ANSI color in the terminal table." ) + parser.add_argument( + "--jobs", + "-j", + type=int, + default=1, + metavar="N", + help="Run up to N harnesses concurrently (default 1 = sequential). " + "Probes within a harness stay sequential. Higher N cuts wall-clock but " + "raises process / gateway load; 3-4 is a reasonable ceiling on one host.", + ) + rich_grp = parser.add_mutually_exclusive_group() + rich_grp.add_argument( + "--rich", + dest="rich", + action="store_true", + default=None, + help="Force the live rich progress table (needs a TTY + rich).", + ) + rich_grp.add_argument( + "--no-rich", + dest="rich", + action="store_false", + help="Force plain per-line progress (no live table).", + ) + parser.add_argument( + "--report", + metavar="PATH", + default=None, + help="Also write the final matrix to PATH. Format follows --json / " + "--markdown, else inferred from the extension (.json / .md), else plain text.", + ) parser.add_argument("--list", action="store_true", help="List official harnesses and exit.") return parser.parse_args(argv) @@ -96,8 +143,11 @@ def main(argv: list[str] | None = None) -> int: args = _parse_args(argv if argv is not None else sys.argv[1:]) if args.list: + # Show the transport a default run would pick (SDK family → full-server), + # not the raw family marker, so --list matches what actually runs. for name, profile in sorted(OFFICIAL_PROFILES.items()): - print(f"{name}\t{profile.transport}\t{profile.model}") + transport = resolve_transport_name(profile, override=None, fast=False) + print(f"{name}\t{transport}\t{profile.model}") return 0 try: @@ -115,17 +165,22 @@ def main(argv: list[str] | None = None) -> int: ) return 2 + if args.jobs < 1: + print("--jobs must be >= 1", file=sys.stderr) + return 2 + # Live if explicitly forced, or implied by a supplied profile. live = args.live if args.live is not None else bool(args.profile) if live and not args.profile: print("--live requires --profile ", file=sys.stderr) return 2 - # Live runs make network calls that can take tens of seconds per turn. - # Stream progress to stderr (report goes to stdout) so the run is not - # silent; offline is fast enough to stay quiet. - def _progress(line: str) -> None: - print(line, file=sys.stderr, flush=True) + # Progress sink: only for a live run (offline is instant). Prefer the rich + # live table when a TTY + rich are available (or --rich forces it), else + # fall back to plain per-line output on stderr (the report goes to stdout). + sink = None + if live: + sink = _select_progress_sink(args.rich) matrix = asyncio.run( run_bench( @@ -133,9 +188,14 @@ def _progress(line: str) -> None: databricks_profile=args.profile, live=live, transport=args.transport, - progress=_progress if live else None, + fast=args.fast, + progress=sink, + jobs=args.jobs, ) ) + if sink is not None: + sink.close() + # Offline (not live) has nothing observed, so show the declared matrix. declared = not live if args.json: @@ -146,11 +206,72 @@ def _progress(line: str) -> None: # Default: terminal table. Color only when stdout is a real TTY and # not suppressed, so piping to a file / pager stays plain. color = sys.stdout.isatty() and not args.no_color - output = render_table(matrix, color=color, declared=declared) + # If the rich live table already painted the grid to this same terminal, + # drop the grid from the stdout report (keep the legend + notes) so the + # matrix is not printed twice. When stdout is redirected, print it in + # full -- the file needs the grid the on-screen table did not capture. + grid = not (_grid_already_shown(sink) and sys.stdout.isatty()) + output = render_table(matrix, color=color, declared=declared, grid=grid) print(output, end="") + + if args.report: + _write_report(args.report, matrix, json_flag=args.json, markdown_flag=args.markdown) + # A drift is a non-zero exit so CI / scripts notice without parsing output. return 1 if matrix.has_drift else 0 +def _grid_already_shown(sink) -> bool: + """Whether the progress sink already painted the glyph grid to the terminal. + + True only for the rich live table (which sets ``drew_grid = True``); the + plain :class:`LineSink` and a silent run do not, so their report prints the + grid in full. + """ + return bool(getattr(sink, "drew_grid", False)) + + +def _select_progress_sink(rich_flag: bool | None): + """Pick the progress sink for a live run. + + ``rich_flag``: ``True`` forces rich, ``False`` forces plain, ``None`` = + auto (rich on a TTY, plain otherwise). Falls back to the plain + :class:`LineSink` whenever rich is unavailable or not a terminal. + """ + + def _line(msg: str) -> None: + print(msg, file=sys.stderr, flush=True) + + if rich_flag is not False: + # richreport is imported lazily: it is the only place that touches the + # optional `rich` dependency, so a plain/no-rich run never imports it. + from tests.harness_bench.richreport import rich_sink_or_none + + rich_sink = rich_sink_or_none(force=bool(rich_flag)) + if rich_sink is not None: + return rich_sink + if rich_flag is True: + print("--rich requested but rich/TTY unavailable; using plain output", file=sys.stderr) + return LineSink(_line) + + +def _write_report(path: str, matrix, *, json_flag: bool, markdown_flag: bool) -> None: + """Write the matrix to *path*; format from flags, else the extension.""" + if json_flag: + content = render_json(matrix) + elif markdown_flag: + content = render_markdown(matrix, declared=False) + elif path.endswith(".json"): + content = render_json(matrix) + elif path.endswith((".md", ".markdown")): + content = render_markdown(matrix, declared=False) + else: + # Plain, un-colored grid — a file should never carry ANSI codes. + content = render_table(matrix, color=False, declared=False) + with open(path, "w", encoding="utf-8") as fh: + fh.write(content if content.endswith("\n") else content + "\n") + print(f"report written to {path}", file=sys.stderr) + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/tests/harness_bench/bench.py b/tests/harness_bench/bench.py index b812c2b9ba7..ee9f563a1b4 100644 --- a/tests/harness_bench/bench.py +++ b/tests/harness_bench/bench.py @@ -1,29 +1,42 @@ """The bench orchestrator: run probes across harnesses into a matrix. -Sequential by design. Each harness spawns one wrap subprocess with a -single in-flight turn per conversation, so its probes run one after -another over a shared driver; harnesses run one at a time to keep the -subprocess and gateway load bounded. +Probes *within* a harness are sequential — they share one driver/session with +a single in-flight turn per conversation. Harnesses run one at a time by +default (``jobs=1``); ``jobs>1`` runs up to N concurrently, bounded by a +semaphore, to cut wall-clock while keeping process/gateway load capped. Under a +parallel run, full-server harnesses share one server+runner (see +:func:`_maybe_shared_full_server`) rather than each booting their own. """ from __future__ import annotations +import asyncio import contextlib import logging from collections.abc import Callable from dataclasses import dataclass, field +from tests.harness_bench.driver import ProvisioningError +from tests.harness_bench.events import ( + HarnessFinished, + HarnessSkipped, + HarnessStarted, + LineSink, + ProbeFinished, + ProbeStarted, + ProgressSink, +) +from tests.harness_bench.full_server import SharedFullServer from tests.harness_bench.probes import ALL_PROBES, CapabilityProbe from tests.harness_bench.profile import BenchProfile -from tests.harness_bench.transport import resolve_driver_class +from tests.harness_bench.transport import resolve_driver_class, resolve_transport_name from tests.harness_bench.verdict import Applicability, Priority, ProbeResult, Verdict, reconcile _logger = logging.getLogger(__name__) -# A progress sink: the bench calls it with human-readable status lines as it -# spawns harnesses and runs probes. ``None`` (the default) stays silent, which -# is what the pytest layer wants; the CLI passes a stderr writer so a live run -# is not silent for minutes. +# Back-compat: a plain per-line progress callback. The orchestrator now emits +# structured events to a ProgressSink; callers that still pass a line callback +# get it adapted to a LineSink. ``None`` stays silent (the pytest layer). Progress = Callable[[str], None] # The prerequisite probe: if it does not pass, the harness cannot be exercised @@ -58,11 +71,18 @@ def is_drift(self) -> bool: @dataclass(frozen=True) class HarnessReport: - """Every cell for one harness, plus a whole-harness skip reason.""" + """Every cell for one harness, plus a whole-harness skip reason. + + :param transport: The transport that actually ran this harness (the + *resolved* driver, e.g. ``full-server`` for an SDK harness on the + default), which can differ from ``profile.transport`` — that field is + the harness *family* marker, not the effective driver. + """ profile: BenchProfile cells: list[CellResult] skipped_reason: str | None = None + transport: str | None = None @property def has_drift(self) -> bool: @@ -113,6 +133,7 @@ def _uniform_report( observed: ProbeResult, *, skipped_reason: str | None = None, + transport: str | None = None, ) -> HarnessReport: """A report where every applicable probe shares one *observed* result. @@ -128,12 +149,27 @@ def _uniform_report( ) for probe in probes ] - return HarnessReport(profile=profile, cells=cells, skipped_reason=skipped_reason) + return HarnessReport( + profile=profile, cells=cells, skipped_reason=skipped_reason, transport=transport + ) + + +def _as_sink(progress: Progress | ProgressSink | None) -> ProgressSink | None: + """Normalize the ``progress`` argument to a :class:`ProgressSink`. + + Accepts a structured sink (used as-is), a plain line callback (adapted to a + :class:`~tests.harness_bench.events.LineSink`), or ``None`` (silent). + """ + if progress is None: + return None + if isinstance(progress, ProgressSink): + return progress + return LineSink(progress) # a bare callable → line output -def _emit(progress: Progress | None, message: str) -> None: - if progress is not None: - progress(message) +def _emit(sink: ProgressSink | None, event) -> None: + if sink is not None: + sink.emit(event) async def run_harness( @@ -143,7 +179,9 @@ async def run_harness( databricks_profile: str | None = None, live: bool = True, transport: str | None = None, - progress: Progress | None = None, + fast: bool = False, + progress: Progress | ProgressSink | None = None, + shared_full_server=None, ) -> HarnessReport: """Run every applicable probe against one harness. @@ -155,32 +193,53 @@ async def run_harness( cell ``SKIPPED`` with an "offline" note) without spawning anything — used for a fast ``--list``/dry render. :param transport: ``--transport`` override; wins over the profile's - declared transport when set (see :func:`resolve_driver_class`). - :param progress: Optional status sink called with human-readable lines - as the harness spawns and each probe runs. ``None`` stays silent. + family default (see :func:`resolve_driver_class`). + :param fast: ``--fast`` — downgrade the SDK family to sdk-inproc (skip the + server boot, trading Tool calling + Policy DENY coverage). + :param progress: A :class:`ProgressSink` (structured events), a plain + per-line callback (adapted), or ``None`` (silent). + :param shared_full_server: An optional shared + :class:`~tests.harness_bench.full_server_driver.SharedFullServer` to + register this harness on, instead of the driver spawning its own + server+runner. Only used when the resolved driver is the full-server + driver; ignored otherwise. :returns: The :class:`HarnessReport`. """ probes = probes if probes is not None else ALL_PROBES + sink = _as_sink(progress) if not live: - return _uniform_report(profile, probes, ProbeResult.skipped("offline (declared shown)")) + # Offline: still resolve the transport a live run *would* pick, so the + # declared matrix labels each row with its effective transport. + resolved = resolve_transport_name(profile, override=transport, fast=fast) + return _uniform_report( + profile, probes, ProbeResult.skipped("offline (declared shown)"), transport=resolved + ) - driver_cls = resolve_driver_class(profile, override=transport) + driver_cls = resolve_driver_class(profile, override=transport, fast=fast) + resolved_transport = driver_cls.transport unavailable = driver_cls.unavailable(profile, databricks_profile=databricks_profile) if unavailable is not None: - _emit(progress, f"[{profile.harness}] skipped: {unavailable}") + _emit(sink, HarnessSkipped(profile.harness, unavailable, resolved_transport)) return _uniform_report( - profile, probes, ProbeResult.skipped(unavailable), skipped_reason=unavailable + profile, + probes, + ProbeResult.skipped(unavailable), + skipped_reason=unavailable, + transport=resolved_transport, ) assert databricks_profile is not None # guaranteed by the unavailable() check - _emit( - progress, - f"[{profile.harness}] provisioning {driver_cls.transport} transport " - f"(model={profile.model}); first turn may take ~10-30s...", - ) + _emit(sink, HarnessStarted(profile.harness, driver_cls.transport, profile.model)) cells: list[CellResult] = [] - driver_cm = driver_cls(profile, databricks_profile=databricks_profile) + # Only the full-server driver accepts a shared server; pass it through when + # this harness resolved to that transport, else construct plainly. + if shared_full_server is not None and driver_cls.transport == "full-server": + driver_cm = driver_cls( + profile, databricks_profile=databricks_profile, shared=shared_full_server + ) + else: + driver_cm = driver_cls(profile, databricks_profile=databricks_profile) try: entered = await driver_cm.__aenter__() except Exception as exc: @@ -192,15 +251,27 @@ async def run_harness( # __aenter__ may have already spawned the server + daemon and opened a # client before raising, so tear those down here or they leak for the # rest of the run (_teardown null-checks each, so a half-provisioned - # driver is safe to tear down). Log the traceback: this branch also - # catches genuine driver bugs (e.g. an AssertionError), which must not - # vanish silently behind a green-looking skip. - _logger.warning("provisioning failed for %s", profile.harness, exc_info=True) + # driver is safe to tear down). + # + # An expected ProvisioningError (a known-unrunnable environment) logs + # only its reason — the matrix already shows the skip. Any *other* + # exception is a possible driver bug (e.g. an AssertionError), so keep + # its full traceback rather than letting it vanish behind a green skip. + if isinstance(exc, ProvisioningError): + _logger.info("skipping %s: %s", profile.harness, exc) + else: + _logger.warning("provisioning failed for %s", profile.harness, exc_info=True) with contextlib.suppress(Exception): await driver_cm.__aexit__(type(exc), exc, exc.__traceback__) reason = f"provisioning failed: {exc}" - _emit(progress, f"[{profile.harness}] skipped: {reason}") - return _uniform_report(profile, probes, ProbeResult.skipped(reason), skipped_reason=reason) + _emit(sink, HarnessSkipped(profile.harness, reason, resolved_transport)) + return _uniform_report( + profile, + probes, + ProbeResult.skipped(reason), + skipped_reason=reason, + transport=resolved_transport, + ) try: driver = entered prereq_skip: str | None = None @@ -210,18 +281,18 @@ async def run_harness( continue if prereq_skip is not None: observed = ProbeResult.skipped(prereq_skip) - _emit(progress, f"[{profile.harness}] {probe.title}: skipped (prerequisite)") else: - _emit(progress, f"[{profile.harness}] {probe.title}: running...") + _emit(sink, ProbeStarted(profile.harness, probe.name, probe.title)) try: observed = await probe.run(driver, profile) except Exception as exc: observed = ProbeResult(Verdict.UNKNOWN, note=f"probe raised: {exc!r}") - _emit( - progress, - f"[{profile.harness}] {probe.title}: {observed.verdict.name}" - + (f" ({observed.note})" if observed.note else ""), - ) + _emit( + sink, + ProbeFinished( + profile.harness, probe.name, probe.title, observed.verdict, observed.note + ), + ) cell = _cell(probe, profile, observed) cells.append(cell) # If the prerequisite turn did not pass, short-circuit the rest: @@ -230,7 +301,8 @@ async def run_harness( prereq_skip = f"prerequisite '{probe.title}' did not pass ({observed.note})" finally: await driver_cm.__aexit__(None, None, None) - return HarnessReport(profile=profile, cells=cells) + _emit(sink, HarnessFinished(profile.harness)) + return HarnessReport(profile=profile, cells=cells, transport=resolved_transport) async def run_bench( @@ -240,18 +312,99 @@ async def run_bench( databricks_profile: str | None = None, live: bool = True, transport: str | None = None, - progress: Progress | None = None, + fast: bool = False, + progress: Progress | ProgressSink | None = None, + jobs: int = 1, ) -> BenchMatrix: - """Run the bench across *profiles*, sequentially, into a :class:`BenchMatrix`.""" - reports = [ - await run_harness( - p, - probes=probes, - databricks_profile=databricks_profile, - live=live, - transport=transport, - progress=progress, - ) - for p in profiles - ] - return BenchMatrix(reports=reports) + """Run the bench across *profiles* into a :class:`BenchMatrix`. + + :param jobs: Max harnesses to run concurrently. ``1`` (default) is the + original sequential behavior. ``>1`` runs up to *jobs* harnesses at + once, bounded by a semaphore. Probes *within* a harness always run + sequentially — they share one driver/session with a single in-flight + turn — so concurrency is only across harnesses. Report order always + matches *profiles* order regardless of finish order. + + For full-server harnesses under ``jobs`` > 1, one shared server+runner + is spawned and every full-server harness registers its own agent + + session on it (the runner resolves the harness per session), instead of + each harness booting its own server. native-tui harnesses still + self-provision (each needs its own host daemon). + """ + async with _maybe_shared_full_server( + profiles, + databricks_profile=databricks_profile, + live=live, + transport=transport, + fast=fast, + jobs=jobs, + ) as shared: + if jobs <= 1: + reports = [ + await run_harness( + p, + probes=probes, + databricks_profile=databricks_profile, + live=live, + transport=transport, + fast=fast, + progress=progress, + shared_full_server=shared, + ) + for p in profiles + ] + return BenchMatrix(reports=reports) + + semaphore = asyncio.Semaphore(jobs) + + async def _one(p: BenchProfile) -> HarnessReport: + async with semaphore: + return await run_harness( + p, + probes=probes, + databricks_profile=databricks_profile, + live=live, + transport=transport, + fast=fast, + progress=progress, + shared_full_server=shared, + ) + + # gather preserves input order, so the matrix stays in *profiles* order + # even though harnesses finish out of order. + reports = await asyncio.gather(*(_one(p) for p in profiles)) + return BenchMatrix(reports=list(reports)) + + +@contextlib.asynccontextmanager +async def _maybe_shared_full_server( + profiles: list[BenchProfile], + *, + databricks_profile: str | None, + live: bool, + transport: str | None, + fast: bool, + jobs: int, +): + """Yield a shared full-server for parallel full-server runs, else ``None``. + + Only stands one up when it actually helps: a live, parallel run with more + than one harness that resolves to the full-server transport. Otherwise + yields ``None`` and each harness provisions as before (a solo full-server + run still owns its own server, unchanged). + """ + shared = None + if live and jobs > 1 and databricks_profile is not None: + full = [ + p + for p in profiles + if resolve_driver_class(p, override=transport, fast=fast).transport == "full-server" + ] + if len(full) > 1: + shared = SharedFullServer(databricks_profile) + await asyncio.to_thread(shared.__enter__) + try: + yield shared + finally: + if shared is not None: + await asyncio.to_thread(shared.__exit__, None, None, None) diff --git a/tests/harness_bench/driver.py b/tests/harness_bench/driver.py index 5b1bb05c3c0..b572f96072a 100644 --- a/tests/harness_bench/driver.py +++ b/tests/harness_bench/driver.py @@ -29,6 +29,19 @@ from tests.e2e._harness_probes import cli_unavailable_reason from tests.harness_bench.profile import BenchProfile + +class ProvisioningError(RuntimeError): + """An *expected* provisioning failure that should skip the harness quietly. + + Raised by a driver's ``__aenter__`` when the environment cannot bring a + harness up through no fault of the bench — e.g. an own-auth native whose + vendor CLI is installed but not logged in, so its forwarder never wires up. + The orchestrator turns this into a capability-neutral skip and logs only the + reason (no traceback), reserving the full stack for *unexpected* exceptions + that signal a genuine driver bug. + """ + + # Proto-style policy verdict strings the wrap's policy_verdict event accepts. POLICY_ALLOW = "POLICY_ACTION_ALLOW" POLICY_DENY = "POLICY_ACTION_DENY" diff --git a/tests/harness_bench/events.py b/tests/harness_bench/events.py new file mode 100644 index 00000000000..c00e1a64075 --- /dev/null +++ b/tests/harness_bench/events.py @@ -0,0 +1,138 @@ +"""Structured progress events for a bench run. + +The bench emits a small stream of typed events as it works — one harness +starts, a probe starts, a probe finishes with a verdict, a harness finishes. +A *sink* (``ProgressSink``) consumes them. This is the seam that lets the CLI +render progress in more than one way without the orchestrator knowing how: + +- :class:`LineSink` prints the plain ``[harness] Probe: VERDICT`` lines to a + writer (the default; what CI / a piped run wants). +- a rich live-table sink (see :mod:`tests.harness_bench.richreport`) draws one + row per harness with per-dimension cells that fill in as events arrive. + +Events carry structured fields (harness id, probe name/title, verdict, note), +not pre-rendered strings, so a sink can lay them out however it likes and a +parallel run can interleave multiple harnesses' events cleanly. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +from tests.harness_bench.verdict import Verdict + + +@dataclass(frozen=True) +class HarnessStarted: + """A harness began provisioning its transport.""" + + harness: str + transport: str + model: str + + +@dataclass(frozen=True) +class HarnessSkipped: + """A harness was skipped whole (unavailable, or provisioning failed). + + ``transport`` is the resolved transport the skip applies to (``None`` when + it could not be resolved), so a live row can still be labelled with it. + """ + + harness: str + reason: str + transport: str | None = None + + +@dataclass(frozen=True) +class ProbeStarted: + """A probe began running against a harness.""" + + harness: str + probe: str + title: str + + +@dataclass(frozen=True) +class ProbeFinished: + """A probe produced a verdict (or was skipped as a prerequisite casualty).""" + + harness: str + probe: str + title: str + verdict: Verdict + note: str = "" + + +@dataclass(frozen=True) +class HarnessFinished: + """A harness completed all its probes.""" + + harness: str + + +BenchEvent = HarnessStarted | HarnessSkipped | ProbeStarted | ProbeFinished | HarnessFinished + + +@runtime_checkable +class ProgressSink(Protocol): + """Consumes :data:`BenchEvent`\\ s as a bench run emits them. + + ``emit`` is called from the orchestrator (possibly from several concurrent + harness tasks under ``--jobs`` > 1), so a sink that mutates shared state + must tolerate interleaved calls. The built-in sinks are called on one + event loop thread, so no locking is needed there. + """ + + def emit(self, event: BenchEvent) -> None: + """Handle one event.""" + + def close(self) -> None: + """Finalize (flush a live display, etc.). Called once at run end.""" + + +class LineSink: + """A :class:`ProgressSink` that writes the plain per-probe status lines. + + This is the default, TTY-agnostic renderer — the same output the bench + emitted before structured events existed, so a piped or CI run is + unchanged. ``write`` defaults to stderr (the report goes to stdout). + """ + + # Per-line progress does not paint the grid, so the stdout report still + # prints it in full (see the rich sink's ``drew_grid = True``). + drew_grid = False + + def __init__(self, write) -> None: # write: Callable[[str], None] + self._write = write + + def emit(self, event: BenchEvent) -> None: + if isinstance(event, HarnessStarted): + self._write( + f"[{event.harness}] provisioning {event.transport} transport " + f"(model={event.model}); first turn may take ~10-30s..." + ) + elif isinstance(event, HarnessSkipped): + self._write(f"[{event.harness}] skipped: {event.reason}") + elif isinstance(event, ProbeStarted): + self._write(f"[{event.harness}] {event.title}: running...") + elif isinstance(event, ProbeFinished): + suffix = f" ({event.note})" if event.note else "" + self._write(f"[{event.harness}] {event.title}: {event.verdict.name}{suffix}") + # HarnessFinished is silent in line mode (the per-probe lines suffice). + + def close(self) -> None: + pass + + +__all__ = [ + "BenchEvent", + "HarnessFinished", + "HarnessSkipped", + "HarnessStarted", + "LineSink", + "ProbeFinished", + "ProbeStarted", + "ProgressSink", +] diff --git a/tests/harness_bench/full_server.py b/tests/harness_bench/full_server.py new file mode 100644 index 00000000000..8c7347aeba7 --- /dev/null +++ b/tests/harness_bench/full_server.py @@ -0,0 +1,311 @@ +"""Shared full-server infrastructure: spawn a real Omnigent server + runner. + +Split from :mod:`tests.harness_bench.full_server_driver` so the *server +lifecycle* (spawning the server/runner, minting a bearer, registering bench +agents + sessions) lives apart from the *driver* that runs probes against it. +Two consumers use this: + +- :class:`~tests.harness_bench.full_server_driver.FullServerDriver` — one + harness per :class:`SharedFullServer` (solo run), or several harnesses on one + shared server (parallel run; see ``bench.run_bench``). +- :mod:`tests.harness_bench.native_tui_driver` reuses the lower-level spawn + helpers (:func:`spawn_omnigent_server`, :func:`_mint_bearer`, + :func:`_find_free_port`) for its own server + host-daemon topology. +""" + +from __future__ import annotations + +import io +import json +import os +import shutil +import signal +import socket +import subprocess +import tarfile +import time +import uuid +from pathlib import Path +from typing import Any + +import httpx +import yaml + +from omnigent.runner.identity import OMNIGENT_INTERNAL_WS_ORIGIN, token_bound_runner_id +from tests._helpers.compat import ( + apply_runner_env, + apply_server_env, + compat_runner_cwd, + compat_server_cwd, + runner_executable, + server_executable, +) +from tests.e2e.helpers import lookup_databricks_host +from tests.harness_bench.profile import BenchProfile + +_REPO_ROOT = str(Path(__file__).resolve().parents[2]) +_HEALTH_TIMEOUT_S = 90.0 +_POLL_INTERVAL_S = 0.2 + +# The builtin the tool/policy probes drive: read-only, zero setup, server- +# dispatched, and gated at the tool_call phase. Its denial output carries +# _DENY_REASON so a blocked call is unambiguous. +_TOOL_NAME = "list_files" +_DENY_REASON = "bench-policy-deny" + + +def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return int(s.getsockname()[1]) + + +def _mint_bearer(profile: str) -> str: + """Mint a Databricks bearer for *profile* via the CLI (isolated from ambient token env). + + ``env -u DATABRICKS_TOKEN -u DATABRICKS_BEARER`` guards against a stale + ambient credential shadowing profile auth (see omnigent issue #1781). + """ + proc = subprocess.run( + ["databricks", "auth", "token", "--profile", profile, "--output", "json"], + capture_output=True, + text=True, + timeout=30, + check=True, + env={ + k: v + for k, v in os.environ.items() + if k not in ("DATABRICKS_TOKEN", "DATABRICKS_BEARER") + }, + ) + return str(json.loads(proc.stdout)["access_token"]) + + +def spawn_omnigent_server( + tmp: Path, port: int, base_env: dict[str, str], binding_token: str +) -> subprocess.Popen[bytes]: + """Spawn an ``omnigent server`` subprocess writing state under *tmp*. + + Shared by the full-server and native-tui drivers (both need the same + server; only what connects to it differs — a bare runner vs a host + daemon). Writes ``server.log`` / ``bench.db`` / ``artifacts`` under *tmp*. + """ + db_path = tmp / "bench.db" + artifact_dir = tmp / "artifacts" + artifact_dir.mkdir(exist_ok=True) + log = tmp / "server.log" + args = [ + server_executable(), + "-m", + "omnigent.cli", + "server", + "--port", + str(port), + "--database-uri", + f"sqlite:///{db_path}", + "--artifact-location", + str(artifact_dir), + ] + return subprocess.Popen( + args, + env={**base_env, "OMNIGENT_RUNNER_TUNNEL_TOKEN": binding_token}, + cwd=compat_server_cwd(), + stdout=log.open("wb"), + stderr=subprocess.STDOUT, + ) + + +def _spawn_bench_runner( + tmp: Path, base_env: dict[str, str], runner_id: str, binding_token: str, base_url: str +) -> subprocess.Popen[bytes]: + """Spawn a bench runner bound to *base_url* (the full-server execution sandbox).""" + log = tmp / "runner.log" + runner_env = apply_runner_env( + { + **base_env, + "OMNIGENT_RUNNER_ID": runner_id, + "OMNIGENT_RUNNER_TUNNEL_BINDING_TOKEN": binding_token, + "OMNIGENT_RUNNER_PARENT_PID": str(os.getpid()), + "RUNNER_SERVER_URL": base_url, + } + ) + return subprocess.Popen( + [runner_executable(), "-m", "omnigent.runner._entry"], + env=runner_env, + cwd=compat_runner_cwd(), + stdout=log.open("wb"), + stderr=subprocess.STDOUT, + ) + + +def _wait_server_runner_ready(base_url: str, runner_id: str) -> None: + """Poll until the server is healthy and *runner_id* reports online.""" + deadline = time.monotonic() + _HEALTH_TIMEOUT_S + while time.monotonic() < deadline: + try: + health = httpx.get(f"{base_url}/health", timeout=2) + status = httpx.get(f"{base_url}/v1/runners/{runner_id}/status", timeout=2) + if ( + health.status_code == 200 + and status.status_code == 200 + and status.json().get("online") is True + ): + return + except httpx.HTTPError: + # Connection refused / read errors are expected while the server + # and runner are still coming up; keep polling until the timeout. + pass + time.sleep(_POLL_INTERVAL_S) + raise RuntimeError( + f"server+runner not ready within {_HEALTH_TIMEOUT_S}s; logs near {base_url}" + ) + + +def _build_bench_agent_config( + profile: BenchProfile, db_profile: str, *, deny: bool +) -> dict[str, Any]: + """The agent spec for a bench harness: the harness + the read-only builtin, + plus (when *deny*) a baked tool_call-phase deny on that builtin.""" + name = f"bench-{profile.harness}" + ("-deny" if deny else "") + config: dict[str, Any] = { + "spec_version": 1, + "name": name, + "prompt": "You are a helpful assistant used for capability testing.", + "executor": { + "type": "omnigent", + "model": profile.model, + "profile": db_profile, + "config": {"harness": profile.harness}, + }, + # A read-only builtin the server dispatches (and gates at the tool_call + # phase). The tool/policy probes drive a call to it; harmless for basic + # turns (the model just won't call it). + "tools": {"builtins": [_TOOL_NAME]}, + } + if deny: + config["guardrails"] = { + "policies": { + "deny_tool": { + "type": "function", + "function": { + "path": "omnigent.policies.function.make_fixed_action_callable", + "arguments": { + "action": "deny", + "reason": _DENY_REASON, + "on_phases": ["tool_call"], + "on_tools": [_TOOL_NAME], + }, + }, + } + } + } + return config + + +def _bundle_agent_config(config: dict[str, Any]) -> bytes: + """Gzip-tar a spec_version agent config as the ``config.yaml`` bundle member.""" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + payload = yaml.safe_dump(config).encode() + info = tarfile.TarInfo("config.yaml") + info.size = len(payload) + tar.addfile(info, io.BytesIO(payload)) + return buf.getvalue() + + +class SharedFullServer: + """One server + one runner shared by several full-server harnesses. + + The Omnigent server is multi-agent/multi-session, and a single runner + resolves the harness type per session from that session's agent spec (see + ``runner/app.py``). So N SDK harnesses do NOT each need their own + server+runner — they can each register as their own agent + session on one + shared pair, with the runner spawning the right harness subprocess per + session. Under ``--jobs`` > 1 this replaces N server boots + N runners with + one, cutting the heaviest, slowest part of full-server startup. + + Sync context manager (spawn/health-wait are blocking); the orchestrator + bridges it via ``asyncio.to_thread``. ``register_agent`` / ``create_session`` + are the per-harness operations a ``FullServerDriver`` calls against it. + """ + + def __init__(self, db_profile: str) -> None: + self._db_profile = db_profile + self._proc: subprocess.Popen[bytes] | None = None + self._runner: subprocess.Popen[bytes] | None = None + self.client: httpx.Client | None = None + self.runner_id = "" + self.base_url = "" + self._tmp = Path("/tmp") / f"omni-bench-fs-shared-{uuid.uuid4().hex[:8]}" + + def __enter__(self) -> SharedFullServer: + self._tmp.mkdir(mode=0o700, parents=True, exist_ok=True) + host = lookup_databricks_host(self._db_profile) + assert host is not None + bearer = _mint_bearer(self._db_profile) + port = _find_free_port() + self.base_url = f"http://localhost:{port}" + binding_token = uuid.uuid4().hex + self.runner_id = token_bound_runner_id(binding_token) + base_env = { + **os.environ, + "OPENAI_API_KEY": bearer, + "OPENAI_BASE_URL": f"{host}/serving-endpoints", + "DATABRICKS_CONFIG_PROFILE": self._db_profile, + } + apply_server_env(base_env, _REPO_ROOT) + self._proc = spawn_omnigent_server(self._tmp, port, base_env, binding_token) + self._runner = _spawn_bench_runner( + self._tmp, base_env, self.runner_id, binding_token, self.base_url + ) + _wait_server_runner_ready(self.base_url, self.runner_id) + self.client = httpx.Client( + base_url=self.base_url, + timeout=300.0, + headers={"Origin": OMNIGENT_INTERNAL_WS_ORIGIN}, + ) + return self + + def __exit__(self, *exc: object) -> None: + if self.client is not None: + self.client.close() + for proc in (self._runner, self._proc): + if proc is not None and proc.poll() is None: + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=8) + except subprocess.TimeoutExpired: + proc.kill() + shutil.rmtree(self._tmp, ignore_errors=True) + + def register_agent(self, profile: BenchProfile, *, deny: bool) -> str: + """Register a bench agent for *profile*; return its agent name.""" + assert self.client is not None + config = _build_bench_agent_config(profile, self._db_profile, deny=deny) + resp = self.client.post( + "/v1/sessions", + data={"metadata": json.dumps({})}, + files={"bundle": ("agent.tar.gz", _bundle_agent_config(config), "application/gzip")}, + ) + if resp.status_code not in (200, 201, 409): + raise RuntimeError(f"agent register failed: {resp.status_code} {resp.text[:400]}") + return str(config["name"]) + + def create_session(self, agent_name: str) -> str: + """Create a runner-bound session for a registered agent name.""" + assert self.client is not None + listing = self.client.get("/v1/sessions", params={"agent_name": agent_name, "limit": 1}) + listing.raise_for_status() + agent_id = str(listing.json()["data"][0]["agent_id"]) + created = self.client.post("/v1/sessions", json={"agent_id": agent_id}) + created.raise_for_status() + session_id = str(created.json()["id"]) + bound = self.client.patch(f"/v1/sessions/{session_id}", json={"runner_id": self.runner_id}) + bound.raise_for_status() + return session_id + + +__all__ = [ + "SharedFullServer", + "spawn_omnigent_server", +] diff --git a/tests/harness_bench/full_server_driver.py b/tests/harness_bench/full_server_driver.py index 48ddc88809f..1b6dd7b1e56 100644 --- a/tests/harness_bench/full_server_driver.py +++ b/tests/harness_bench/full_server_driver.py @@ -29,40 +29,23 @@ from __future__ import annotations import asyncio -import json -import os -import signal -import subprocess import threading import time -import uuid -from pathlib import Path from typing import Any import httpx -from omnigent.runner.identity import OMNIGENT_INTERNAL_WS_ORIGIN, token_bound_runner_id -from tests._helpers.compat import ( - apply_runner_env, - apply_server_env, - compat_runner_cwd, - compat_server_cwd, - runner_executable, - server_executable, -) +from tests.e2e._harness_probes import cli_unavailable_reason from tests.e2e.helpers import lookup_databricks_host from tests.harness_bench.driver import TurnResult +from tests.harness_bench.full_server import ( + _DENY_REASON, + _POLL_INTERVAL_S, + _TOOL_NAME, + SharedFullServer, +) from tests.harness_bench.profile import BenchProfile -_REPO_ROOT = str(Path(__file__).resolve().parents[2]) -_HEALTH_TIMEOUT_S = 90.0 -_POLL_INTERVAL_S = 0.2 - -# The builtin the tool/policy probes drive: read-only, zero setup, server- -# dispatched, and gated at the tool_call phase. Its denial output carries -# _DENY_REASON so a blocked call is unambiguous. -_TOOL_NAME = "list_files" -_DENY_REASON = "bench-policy-deny" _TOOL_PROMPT = f"List the files using the {_TOOL_NAME} tool, then tell me how many there are." # The server persists an interrupted turn as a synthetic user message whose @@ -79,69 +62,6 @@ _TERMINAL_EVENTS = frozenset({"response.completed", "response.failed", "response.cancelled"}) -def _find_free_port() -> int: - import socket - - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return int(s.getsockname()[1]) - - -def _mint_bearer(profile: str) -> str: - """Mint a Databricks bearer for *profile* via the CLI (isolated from ambient token env). - - ``env -u DATABRICKS_TOKEN -u DATABRICKS_BEARER`` guards against a stale - ambient credential shadowing profile auth (see omnigent issue #1781). - """ - proc = subprocess.run( - ["databricks", "auth", "token", "--profile", profile, "--output", "json"], - capture_output=True, - text=True, - timeout=30, - check=True, - env={ - k: v - for k, v in os.environ.items() - if k not in ("DATABRICKS_TOKEN", "DATABRICKS_BEARER") - }, - ) - return str(json.loads(proc.stdout)["access_token"]) - - -def spawn_omnigent_server( - tmp: Path, port: int, base_env: dict[str, str], binding_token: str -) -> subprocess.Popen[bytes]: - """Spawn an ``omnigent server`` subprocess writing state under *tmp*. - - Shared by the full-server and native-tui drivers (both need the same - server; only what connects to it differs — a bare runner vs a host - daemon). Writes ``server.log`` / ``bench.db`` / ``artifacts`` under *tmp*. - """ - db_path = tmp / "bench.db" - artifact_dir = tmp / "artifacts" - artifact_dir.mkdir(exist_ok=True) - log = tmp / "server.log" - args = [ - server_executable(), - "-m", - "omnigent.cli", - "server", - "--port", - str(port), - "--database-uri", - f"sqlite:///{db_path}", - "--artifact-location", - str(artifact_dir), - ] - return subprocess.Popen( - args, - env={**base_env, "OMNIGENT_RUNNER_TUNNEL_TOKEN": binding_token}, - cwd=compat_server_cwd(), - stdout=log.open("wb"), - stderr=subprocess.STDOUT, - ) - - class FullServerDriver: """Drive turns through a live Omnigent server + runner. @@ -153,22 +73,31 @@ class FullServerDriver: transport = "full-server" - def __init__(self, profile: BenchProfile, *, databricks_profile: str) -> None: + def __init__( + self, + profile: BenchProfile, + *, + databricks_profile: str, + shared: SharedFullServer | None = None, + ) -> None: self._profile = profile self._db_profile = databricks_profile - self._proc: subprocess.Popen[bytes] | None = None - self._runner: subprocess.Popen[bytes] | None = None - self._logs: list[Path] = [] - self._client: httpx.Client | None = None - self._session_id: str | None = None + # When *shared* is given (a parallel run), this driver registers its + # agent + session on that one server+runner and spawns nothing itself. + # When None (a solo / --jobs 1 run), it owns a private SharedFullServer + # for back-compat with the original one-server-per-harness behavior. + self._shared = shared + self._owns_shared = shared is None # A second agent+session whose spec bakes a tool_call deny policy, # created lazily for the policy probe (the REST policy endpoint's # handler allowlist excludes make_fixed_action_callable, so the deny # must ride in the agent spec instead). self._deny_session_id: str | None = None - self._runner_id = "" - self._base_url = "" - self._tmp = Path("/tmp") / f"omni-bench-fs-{uuid.uuid4().hex[:8]}" + self._session_id: str | None = None + + @property + def _client(self) -> httpx.Client | None: + return self._shared.client if self._shared is not None else None @staticmethod def unavailable(profile: BenchProfile, *, databricks_profile: str | None) -> str | None: @@ -191,58 +120,23 @@ def unavailable(profile: BenchProfile, *, databricks_profile: str | None) -> str # Same CLI gate as the wrap driver (same binary requirement), but skip # its transport check — that is sdk-inproc-specific and would misreport # the driver name; the native case is already handled above. - from tests.e2e._harness_probes import cli_unavailable_reason - if profile.cli_binary is not None: return cli_unavailable_reason(profile.cli_binary) return None def __enter__(self) -> FullServerDriver: - self._tmp.mkdir(mode=0o700, parents=True, exist_ok=True) - host = lookup_databricks_host(self._db_profile) - assert host is not None # guaranteed by unavailable() - bearer = _mint_bearer(self._db_profile) - port = _find_free_port() - self._base_url = f"http://localhost:{port}" - - binding_token = uuid.uuid4().hex - runner_id = token_bound_runner_id(binding_token) - - base_env = { - **os.environ, - "OPENAI_API_KEY": bearer, - "OPENAI_BASE_URL": f"{host}/serving-endpoints", - "DATABRICKS_CONFIG_PROFILE": self._db_profile, - } - apply_server_env(base_env, _REPO_ROOT) - - self._proc = self._spawn_server(port, base_env, binding_token) - self._runner = self._spawn_runner(base_env, runner_id, binding_token) - self._wait_ready(runner_id) - - self._client = httpx.Client( - base_url=self._base_url, - timeout=300.0, - headers={"Origin": OMNIGENT_INTERNAL_WS_ORIGIN}, - ) - self._runner_id = runner_id - agent_name = self._register_agent(deny=False) - self._session_id = self._create_session(agent_name, runner_id) + if self._shared is None: + self._shared = SharedFullServer(self._db_profile) + self._shared.__enter__() + agent_name = self._shared.register_agent(self._profile, deny=False) + self._session_id = self._shared.create_session(agent_name) return self def __exit__(self, *exc: object) -> None: - if self._client is not None: - self._client.close() - for proc in (self._runner, self._proc): - if proc is not None and proc.poll() is None: - proc.send_signal(signal.SIGTERM) - try: - proc.wait(timeout=8) - except subprocess.TimeoutExpired: - proc.kill() - import shutil - - shutil.rmtree(self._tmp, ignore_errors=True) + # Only tear down the server we own; an injected shared server is the + # orchestrator's to close after all harnesses finish. + if self._owns_shared and self._shared is not None: + self._shared.__exit__(*exc) # ── async driver protocol ──────────────────────────────── # This driver's provisioning and turns are synchronous (subprocess spawn, @@ -269,137 +163,17 @@ async def run_tool_turn(self, *, deny: bool) -> TurnResult: async def run_interrupt_turn(self) -> TurnResult: return await asyncio.to_thread(self.interrupt_probe_turn) - # ── spawn ──────────────────────────────────────────────── - - def _spawn_server( - self, port: int, base_env: dict[str, str], binding_token: str - ) -> subprocess.Popen[bytes]: - proc = spawn_omnigent_server(self._tmp, port, base_env, binding_token) - self._logs.append(self._tmp / "server.log") - return proc - - def _spawn_runner( - self, base_env: dict[str, str], runner_id: str, binding_token: str - ) -> subprocess.Popen[bytes]: - log = self._tmp / "runner.log" - self._logs.append(log) - runner_env = apply_runner_env( - { - **base_env, - "OMNIGENT_RUNNER_ID": runner_id, - "OMNIGENT_RUNNER_TUNNEL_BINDING_TOKEN": binding_token, - "OMNIGENT_RUNNER_PARENT_PID": str(os.getpid()), - "RUNNER_SERVER_URL": self._base_url, - } - ) - return subprocess.Popen( - [runner_executable(), "-m", "omnigent.runner._entry"], - env=runner_env, - cwd=compat_runner_cwd(), - stdout=log.open("wb"), - stderr=subprocess.STDOUT, - ) - - def _wait_ready(self, runner_id: str) -> None: - deadline = time.monotonic() + _HEALTH_TIMEOUT_S - while time.monotonic() < deadline: - try: - health = httpx.get(f"{self._base_url}/health", timeout=2) - status = httpx.get(f"{self._base_url}/v1/runners/{runner_id}/status", timeout=2) - if ( - health.status_code == 200 - and status.status_code == 200 - and status.json().get("online") is True - ): - return - except httpx.HTTPError: - # Connection refused / read errors are expected while the - # server and runner are still coming up; keep polling until - # they answer or the timeout below fires. - pass - time.sleep(_POLL_INTERVAL_S) - raise RuntimeError( - f"server+runner not ready within {_HEALTH_TIMEOUT_S}s; logs in {self._tmp}" - ) - # ── agent + session ────────────────────────────────────── - - def _register_agent(self, *, deny: bool) -> str: - import io - import tarfile - - import yaml - - assert self._client is not None - name = f"bench-{self._profile.harness}" + ("-deny" if deny else "") - config: dict[str, Any] = { - "spec_version": 1, - "name": name, - "prompt": "You are a helpful assistant used for capability testing.", - "executor": { - "type": "omnigent", - "model": self._profile.model, - "profile": self._db_profile, - "config": {"harness": self._profile.harness}, - }, - # A read-only builtin the server dispatches (and gates at the - # tool_call phase). The tool/policy probes drive a call to it; - # it is harmless for basic turns (the model just won't call it). - "tools": {"builtins": [_TOOL_NAME]}, - } - if deny: - # Bake a tool_call-phase deny on the builtin so the server blocks - # the call the way production policy enforcement does. - config["guardrails"] = { - "policies": { - "deny_tool": { - "type": "function", - "function": { - "path": "omnigent.policies.function.make_fixed_action_callable", - "arguments": { - "action": "deny", - "reason": _DENY_REASON, - "on_phases": ["tool_call"], - "on_tools": [_TOOL_NAME], - }, - }, - } - } - } - # spec_version bundles load via the directory spec loader, which - # recognizes tools.builtins and expects the member named config.yaml. - buf = io.BytesIO() - with tarfile.open(fileobj=buf, mode="w:gz") as tar: - payload = yaml.safe_dump(config).encode() - info = tarfile.TarInfo("config.yaml") - info.size = len(payload) - tar.addfile(info, io.BytesIO(payload)) - resp = self._client.post( - "/v1/sessions", - data={"metadata": json.dumps({})}, - files={"bundle": ("agent.tar.gz", buf.getvalue(), "application/gzip")}, - ) - if resp.status_code not in (200, 201, 409): - raise RuntimeError(f"agent register failed: {resp.status_code} {resp.text[:400]}") - return name - - def _create_session(self, agent_name: str, runner_id: str) -> str: - assert self._client is not None - listing = self._client.get("/v1/sessions", params={"agent_name": agent_name, "limit": 1}) - listing.raise_for_status() - agent_id = str(listing.json()["data"][0]["agent_id"]) - created = self._client.post("/v1/sessions", json={"agent_id": agent_id}) - created.raise_for_status() - session_id = str(created.json()["id"]) - bound = self._client.patch(f"/v1/sessions/{session_id}", json={"runner_id": runner_id}) - bound.raise_for_status() - return session_id + # The server/runner lifecycle and agent/session registration live on + # SharedFullServer now; this driver delegates so a solo run and a parallel + # (shared-server) run go through the same path. def _ensure_deny_session(self) -> str: """Lazily register the deny agent and its session; return the session id.""" + assert self._shared is not None if self._deny_session_id is None: - name = self._register_agent(deny=True) - self._deny_session_id = self._create_session(name, self._runner_id) + name = self._shared.register_agent(self._profile, deny=True) + self._deny_session_id = self._shared.create_session(name) return self._deny_session_id # ── tool / policy probe ────────────────────────────────── diff --git a/tests/harness_bench/native_tui_driver.py b/tests/harness_bench/native_tui_driver.py index 4d072cf817d..35488681373 100644 --- a/tests/harness_bench/native_tui_driver.py +++ b/tests/harness_bench/native_tui_driver.py @@ -50,6 +50,7 @@ import asyncio import os +import shutil import signal import subprocess import threading @@ -61,6 +62,8 @@ import httpx +from omnigent.harness_capabilities import AuthModel, IntegrationMode +from omnigent.harness_plugins import harness_capabilities from omnigent.host.daemon_launch import ( launch_or_reuse_daemon_runner, wait_for_host_online, @@ -69,9 +72,10 @@ from omnigent.native_terminal import bind_session_runner from omnigent.runner.identity import OMNIGENT_INTERNAL_WS_ORIGIN from tests._helpers.compat import apply_runner_env, compat_runner_cwd, runner_executable +from tests.e2e._harness_probes import cli_unavailable_reason from tests.e2e.helpers import lookup_databricks_host -from tests.harness_bench.driver import TurnResult -from tests.harness_bench.full_server_driver import ( +from tests.harness_bench.driver import ProvisioningError, TurnResult +from tests.harness_bench.full_server import ( _find_free_port, _mint_bearer, spawn_omnigent_server, @@ -163,9 +167,6 @@ def native_vendor(harness: str) -> NativeVendor | None: ``native-server`` harnesses (e.g. opencode-native) are a different transport and return ``None``. """ - from omnigent.harness_capabilities import AuthModel, IntegrationMode - from omnigent.harness_plugins import harness_capabilities - caps = harness_capabilities().get(harness) if caps is None or caps.integration_mode is not IntegrationMode.NATIVE_TUI: return None @@ -221,8 +222,6 @@ def unavailable(profile: BenchProfile, *, databricks_profile: str | None) -> str # host; the bench cannot provision a login. Presence on PATH is the # cheapest precondition we can check — a missing login still fails the # live turn, reported as a capability-neutral skip by the probes. - from tests.e2e._harness_probes import cli_unavailable_reason - binary = profile.cli_binary if binary is not None: reason = cli_unavailable_reason(binary) @@ -233,7 +232,18 @@ def unavailable(profile: BenchProfile, *, databricks_profile: str | None) -> str # ── async driver protocol ──────────────────────────────── async def __aenter__(self) -> NativeTuiDriver: - await asyncio.to_thread(self._provision) + try: + await asyncio.to_thread(self._provision) + except httpx.HTTPError as exc: + # Native provisioning drives a live vendor CLI + a server-native + # terminal, so an HTTP failure here (e.g. a 500 from terminal-ensure + # when the vendor cannot start a thread) is an environment/server- + # state gap, not a bench bug. Re-raise as ProvisioningError so the + # orchestrator skips this harness quietly (reason shown in its row) + # instead of dumping a traceback. A programming error (AssertionError + # on a misconfigured profile, etc.) is not an HTTPError, so it still + # propagates loud. + raise ProvisioningError(f"native provisioning HTTP error: {exc}") from exc return self async def __aexit__(self, *exc: object) -> None: @@ -354,7 +364,7 @@ def _wire_native_forwarder(self, host_id: str, workspace: Path) -> None: if snap.status_code == 200 and snap.json().get("external_session_id"): return time.sleep(_POLL_INTERVAL_S) - raise RuntimeError( + raise ProvisioningError( f"native forwarder did not wire up within {_FORWARDER_READY_TIMEOUT_S}s " f"(no external_session_id); logs in {self._tmp}" ) @@ -408,7 +418,9 @@ def _wait_health(self) -> None: # Connection refused while the server boots; keep polling. pass time.sleep(_POLL_INTERVAL_S) - raise RuntimeError(f"server not healthy within {_HEALTH_TIMEOUT_S}s; logs in {self._tmp}") + raise ProvisioningError( + f"server not healthy within {_HEALTH_TIMEOUT_S}s; logs in {self._tmp}" + ) def _wait_host_online(self) -> str: assert self._client is not None @@ -420,7 +432,7 @@ def _wait_host_online(self) -> str: if online: return str(online[0]["host_id"]) time.sleep(_POLL_INTERVAL_S) - raise RuntimeError(f"no host came online within {_HOST_ONLINE_TIMEOUT_S}s") + raise ProvisioningError(f"no host came online within {_HOST_ONLINE_TIMEOUT_S}s") def _agent_id(self, agent_name: str) -> str: assert self._client is not None @@ -429,7 +441,9 @@ def _agent_id(self, agent_name: str) -> str: for agent in resp.json()["data"]: if agent.get("name") == agent_name: return str(agent["id"]) - raise RuntimeError(f"{agent_name!r} not auto-registered on the server") + # A native agent the server did not seed (the hardcoded seeding seam): + # an environment gap, not a bench bug, so skip this harness quietly. + raise ProvisioningError(f"{agent_name!r} not auto-registered on the server") def _teardown(self) -> None: if self._client is not None: @@ -441,8 +455,6 @@ def _teardown(self) -> None: proc.wait(timeout=8) except subprocess.TimeoutExpired: proc.kill() - import shutil - shutil.rmtree(self._tmp, ignore_errors=True) # ── turns ──────────────────────────────────────────────── diff --git a/tests/harness_bench/report.py b/tests/harness_bench/report.py index bbb0d0377ac..0bef11a3d71 100644 --- a/tests/harness_bench/report.py +++ b/tests/harness_bench/report.py @@ -38,6 +38,24 @@ # render is not 24 identical lines. _OFFLINE_NOTE = "offline (declared shown)" +# Short transport labels for the harness column, so each row is self-describing +# about which transport produced it (e.g. `claude-sdk [full-server]`). The +# native-tui driver is abbreviated to `native` to match how it is spoken about. +_TRANSPORT_LABEL = {"native-tui": "native"} + + +def _harness_label(report: HarnessReport) -> str: + """Harness name plus its resolved transport, e.g. ``codex [full-server]``. + + Uses the transport that actually ran (``report.transport``), which for an + SDK harness on the default is ``full-server`` — not ``profile.transport``, + the family marker. Falls back to the bare name when unknown (never resolved). + """ + transport = report.transport + if not transport: + return report.profile.harness + return f"{report.profile.harness} [{_TRANSPORT_LABEL.get(transport, transport)}]" + def _colorize(text: str, verdict: Verdict, color: bool) -> str: if not color: @@ -64,7 +82,9 @@ def _cell_glyph_for_grid(cell: CellResult, declared: bool = False) -> str: return cell.verdict.glyph -def render_table(matrix: BenchMatrix, *, color: bool = False, declared: bool = False) -> str: +def render_table( + matrix: BenchMatrix, *, color: bool = False, declared: bool = False, grid: bool = True +) -> str: """Render *matrix* as an aligned column grid for terminal reading. :param color: When true, colorize each glyph with ANSI (green supported, @@ -73,12 +93,17 @@ def render_table(matrix: BenchMatrix, *, color: bool = False, declared: bool = F :param declared: When true (offline mode), render each cell's *declared* verdict glyph instead of the observed/reconciled one, so the dry matrix shows the capabilities the profile claims rather than ``·``. + :param grid: When false, omit the heading + glyph grid and emit only the + footer (legend, drift, notes, skips). The CLI uses this when the rich + live table already painted the grid to the same terminal, so the report + adds the per-cell explanations without re-printing the grid. """ titles = [p.title for p in ALL_PROBES] names = [p.name for p in ALL_PROBES] # Column widths from the visible (uncolored) content. - harness_w = max(len("Harness"), *(len(r.profile.harness) for r in matrix.reports)) + labels = {id(r): _harness_label(r) for r in matrix.reports} + harness_w = max(len("Harness"), *(len(v) for v in labels.values())) glyphs: dict[tuple[int, str], str] = {} verdicts: dict[tuple[int, str], Verdict] = {} for r in matrix.reports: @@ -107,15 +132,20 @@ def _center(text: str, width: int, verdict: Verdict | None) -> str: rule = " ".join(["-" * harness_w, *["-" * w for w in col_w]]) lines = [header, rule] for r in matrix.reports: - row = [r.profile.harness.ljust(harness_w)] + row = [labels[id(r)].ljust(harness_w)] row += [ _center(glyphs[(id(r), n)], w, verdicts[(id(r), n)]) for n, w in zip(names, col_w, strict=False) ] lines.append(" ".join(row)) - heading = "Harness capability matrix" + (" (declared, not observed)" if declared else "") - out = [heading, "", *lines, "", _legend()] + if grid: + heading = "Harness capability matrix" + (" (declared, not observed)" if declared else "") + out = [heading, "", *lines, "", _legend()] + else: + # The rich live table already showed the grid on this terminal; emit + # only the footer so we add the legend + explanations, not a duplicate. + out = [_legend()] drift = _drift_lines(matrix) if drift: @@ -163,7 +193,7 @@ def render_markdown(matrix: BenchMatrix, *, declared: bool = False) -> str: for report in matrix.reports: by_name = {c.probe_name: c for c in report.cells} cells = [_cell_glyph(by_name[n], declared) if n in by_name else "?" for n in names] - lines.append(f"| `{report.profile.harness}` | " + " | ".join(cells) + " |") + lines.append(f"| `{_harness_label(report)}` | " + " | ".join(cells) + " |") heading = "# Harness capability matrix" + (" (declared, not observed)" if declared else "") out = [heading, "", *lines, "", _legend()] @@ -236,7 +266,10 @@ def render_json(matrix: BenchMatrix) -> str: def _report_json(report: HarnessReport) -> dict[str, Any]: return { "harness": report.profile.harness, + # The family marker the profile declares, plus the transport that + # actually ran (differs for an SDK harness on the full-server default). "transport": report.profile.transport, + "resolved_transport": report.transport, "model": report.profile.model, "owner": report.profile.owner, "auth": report.profile.auth, diff --git a/tests/harness_bench/richreport.py b/tests/harness_bench/richreport.py new file mode 100644 index 00000000000..363d4e5f6d1 --- /dev/null +++ b/tests/harness_bench/richreport.py @@ -0,0 +1,134 @@ +"""A rich live-progress sink for a bench run. + +Draws a table that updates in place as events arrive: one row per harness, +one column per capability dimension, each cell showing the probe's live state +(a spinner while running, then the verdict glyph). Under a parallel run +(``--jobs`` > 1) several rows advance at once, which is exactly what the live +table is for. + +Only usable on a TTY with ``rich`` installed. :func:`rich_sink_or_none` +returns ``None`` when either precondition is missing, so the CLI falls back to +the plain :class:`~tests.harness_bench.events.LineSink`. +""" + +from __future__ import annotations + +from tests.harness_bench.events import ( + BenchEvent, + HarnessSkipped, + HarnessStarted, + ProbeFinished, + ProbeStarted, +) +from tests.harness_bench.probes import ALL_PROBES +from tests.harness_bench.verdict import Verdict + +# Cell state → what the table shows. Verdicts reuse the report glyphs; the two +# transient states (pending/running) are bench-live only. +_VERDICT_GLYPH: dict[Verdict, str] = { + Verdict.SUPPORTED: "[green]✓[/green]", + Verdict.PARTIAL: "[yellow]~[/yellow]", + Verdict.UNSUPPORTED: "[red]✗[/red]", + Verdict.NOT_APPLICABLE: "[dim]—[/dim]", + Verdict.UNKNOWN: "[dim]?[/dim]", + Verdict.SKIPPED: "[dim]·[/dim]", + Verdict.DRIFT: "[bold red]!![/bold red]", +} +_PENDING = "[dim]·[/dim]" +_RUNNING = "[cyan]…[/cyan]" + +# Short transport labels for the harness column (native-tui → native), matching +# the static report renderer. +_TRANSPORT_LABEL = {"native-tui": "native"} + + +def rich_sink_or_none(*, force: bool = False): + """Return a rich live sink, or ``None`` if rich/TTY is unavailable. + + :param force: Build the sink even if stdout is not a TTY (for tests / + explicit ``--rich``). Normally the caller only asks for this when a + terminal is detected. + """ + try: + from rich.console import Console + except ImportError: + return None + console = Console(stderr=True) + if not force and not console.is_terminal: + return None + return _RichLiveSink(console) + + +class _RichLiveSink: + """A :class:`~tests.harness_bench.events.ProgressSink` backed by ``rich.Live``. + + Holds a ``{harness: {dimension: cell-markup}}`` grid and re-renders a table + on every event. Rows appear as harnesses start; a whole-harness skip marks + every cell ``·`` with the reason as a trailing note. + """ + + # This sink paints the full glyph grid to the terminal, so the CLI can skip + # re-printing it in the stdout report (see __main__._grid_already_shown). + drew_grid = True + + def __init__(self, console) -> None: + from rich.live import Live + + self._console = console + self._dimensions = [p.title for p in ALL_PROBES] + self._dim_by_name = {p.name: p.title for p in ALL_PROBES} + # harness → {dim_title: markup}; insertion order = display order. + self._rows: dict[str, dict[str, str]] = {} + self._notes: dict[str, str] = {} + self._transport: dict[str, str] = {} # harness → resolved transport label + self._live = Live(self._render(), console=console, refresh_per_second=8) + self._live.start() + + def _blank_row(self) -> dict[str, str]: + return dict.fromkeys(self._dimensions, _PENDING) + + def _render(self): + from rich.table import Table + + table = Table(title="Harness capability matrix (live)", expand=False) + table.add_column("Harness", no_wrap=True) + for dim in self._dimensions: + table.add_column(dim, justify="center") + for harness, cells in self._rows.items(): + note = self._notes.get(harness) + transport = self._transport.get(harness) + label = harness + if transport: + label += f" [dim]\\[{_TRANSPORT_LABEL.get(transport, transport)}][/dim]" + if note: + label += f" [dim]({note})[/dim]" + table.add_row(label, *(cells[dim] for dim in self._dimensions)) + return table + + def emit(self, event: BenchEvent) -> None: + if isinstance(event, HarnessStarted): + self._rows.setdefault(event.harness, self._blank_row()) + self._transport[event.harness] = event.transport + elif isinstance(event, HarnessSkipped): + self._rows.setdefault(event.harness, self._blank_row()) + # A whole-harness skip: every dimension is ·, reason on the label. + self._notes[event.harness] = event.reason.split(";")[0][:60] + if event.transport: + self._transport[event.harness] = event.transport + elif isinstance(event, ProbeStarted): + row = self._rows.setdefault(event.harness, self._blank_row()) + row[self._dim_by_name.get(event.probe, event.title)] = _RUNNING + elif isinstance(event, ProbeFinished): + row = self._rows.setdefault(event.harness, self._blank_row()) + row[self._dim_by_name.get(event.probe, event.title)] = _VERDICT_GLYPH.get( + event.verdict, _PENDING + ) + # HarnessFinished needs no cell change (all its probes already landed). + self._live.update(self._render()) + + def close(self) -> None: + self._live.update(self._render()) + self._live.stop() + + +__all__ = ["rich_sink_or_none"] diff --git a/tests/harness_bench/test_bench.py b/tests/harness_bench/test_bench.py index 6d255360192..d1ca43f6732 100644 --- a/tests/harness_bench/test_bench.py +++ b/tests/harness_bench/test_bench.py @@ -148,9 +148,305 @@ async def test_offline_render_produces_matrix() -> None: assert "Harness capability matrix" in md for profile in _OFFICIAL: assert profile.harness in md - # JSON is well-formed and carries every harness. + # The harness column is labelled with the *resolved* transport: an SDK + # harness shows its full-server default (not the sdk-inproc family marker), + # a native shows the short `native` label. + assert "`claude-sdk [full-server]`" in md + assert "`claude-native [native]`" in md + # JSON is well-formed and carries every harness, plus the resolved transport. payload = json.loads(render_json(matrix)) assert {h["harness"] for h in payload["harnesses"]} == {p.harness for p in _OFFICIAL} + by_harness = {h["harness"]: h for h in payload["harnesses"]} + assert by_harness["claude-sdk"]["resolved_transport"] == "full-server" + assert by_harness["claude-native"]["resolved_transport"] == "native-tui" + + +def test_grid_already_shown_only_for_grid_drawing_sink() -> None: + """_grid_already_shown is True only for a sink that painted the grid.""" + from tests.harness_bench.__main__ import _grid_already_shown + from tests.harness_bench.events import LineSink + + assert _grid_already_shown(None) is False + assert _grid_already_shown(LineSink(lambda _m: None)) is False + + class _GridSink: + drew_grid = True + + assert _grid_already_shown(_GridSink()) is True + + +async def test_render_table_grid_false_drops_grid_keeps_footer() -> None: + """grid=False omits the heading + glyph rows but keeps the legend/notes. + + This is what the CLI emits when the rich live table already painted the grid + on the same terminal: the report should add the per-cell explanations, not + reprint the grid. + """ + from tests.harness_bench.report import render_table + + matrix = await run_bench(_OFFICIAL, live=False) + full = render_table(matrix, declared=True, grid=True) + footer = render_table(matrix, declared=True, grid=False) + + # The full render has the heading + a harness row; the footer-only render + # has neither, but both carry the legend. + assert "Harness capability matrix" in full + assert "claude-sdk" in full + assert "Harness capability matrix" not in footer + assert "claude-sdk" not in footer + assert "Legend:" in footer + # The offline note is suppressed, so a declared render's footer is just the + # legend -- no dangling "Notes:" header. + assert footer.strip().startswith("Legend:") + + +# ── Progress events / rich / parallel / report (offline) ───────── + + +async def test_run_harness_emits_structured_events_and_linesink_adapts() -> None: + """run_harness emits typed events; a bare-callable progress adapts to LineSink. + + Uses a fake driver so no creds/subprocess are needed: a basic turn passes, + which lets every probe run and produce a ProbeFinished. + """ + from tests.harness_bench.driver import TurnResult + from tests.harness_bench.events import ( + HarnessFinished, + HarnessStarted, + ProbeFinished, + ProbeStarted, + ProgressSink, + ) + + class _CaptureSink: + def __init__(self) -> None: + self.events: list = [] + + def emit(self, event) -> None: + self.events.append(event) + + def close(self) -> None: + pass + + assert isinstance(_CaptureSink(), ProgressSink) # structural conformance + + class _OKDriver: + transport = "sdk-inproc" + + def __init__(self, profile: BenchProfile, *, databricks_profile: str) -> None: + pass + + @staticmethod + def unavailable(profile: BenchProfile, *, databricks_profile: str | None) -> str | None: + return None + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc: object) -> None: + pass + + async def run_basic_turn(self, marker: str) -> TurnResult: + return TurnResult(completed=True, text=marker) + + async def run_streaming_turn(self) -> TurnResult: + return TurnResult(completed=True, text_delta_count=5) + + async def run_tool_turn(self, *, deny: bool) -> TurnResult: + return TurnResult(completed=True) + + async def run_interrupt_turn(self) -> TurnResult: + return TurnResult(cancelled=True) + + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr( + "tests.harness_bench.bench.resolve_driver_class", + lambda p, *, override=None, fast=False: _OKDriver, + ) + try: + profile = BenchProfile( + harness="fake-sdk", model="m", env_prefix="HARNESS_FAKE_SDK_", marker="FAKE_OK" + ) + sink = _CaptureSink() + await run_harness(profile, databricks_profile="oss", live=True, progress=sink) + finally: + monkeypatch.undo() + + kinds = [type(e).__name__ for e in sink.events] + assert kinds[0] == "HarnessStarted" + assert isinstance(sink.events[0], HarnessStarted) + assert kinds[-1] == "HarnessFinished" + assert isinstance(sink.events[-1], HarnessFinished) + # Every probe that ran emits a started+finished pair. + assert any(isinstance(e, ProbeStarted) for e in sink.events) + finished = [e for e in sink.events if isinstance(e, ProbeFinished)] + assert {e.probe for e in finished} >= {"basic_turn", "streaming"} + + # A bare callable is adapted to a LineSink (structured events → lines). + lines: list[str] = [] + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr( + "tests.harness_bench.bench.resolve_driver_class", + lambda p, *, override=None, fast=False: _OKDriver, + ) + try: + await run_harness(profile, databricks_profile="oss", live=True, progress=lines.append) + finally: + monkeypatch.undo() + assert any("Basic turn" in ln for ln in lines) + + +async def test_run_bench_jobs_preserves_order(monkeypatch: pytest.MonkeyPatch) -> None: + """--jobs > 1 runs harnesses concurrently but keeps report order == input order.""" + import asyncio as _asyncio + + from tests.harness_bench.driver import TurnResult + + class _SlowDriver: + transport = "sdk-inproc" + + def __init__(self, profile: BenchProfile, *, databricks_profile: str) -> None: + self._h = profile.harness + + @staticmethod + def unavailable(profile: BenchProfile, *, databricks_profile: str | None) -> str | None: + return None + + async def __aenter__(self): + # Reverse-stagger the delay so, without order preservation, finish + # order would differ from input order. + await _asyncio.sleep(0.02 if self._h.endswith("1") else 0.01) + return self + + async def __aexit__(self, *exc: object) -> None: + pass + + async def run_basic_turn(self, marker: str) -> TurnResult: + return TurnResult(completed=True, text=marker) + + async def run_streaming_turn(self) -> TurnResult: + return TurnResult(completed=True, text_delta_count=3) + + async def run_tool_turn(self, *, deny: bool) -> TurnResult: + return TurnResult(completed=True) + + async def run_interrupt_turn(self) -> TurnResult: + return TurnResult(cancelled=True) + + monkeypatch.setattr( + "tests.harness_bench.bench.resolve_driver_class", + lambda p, *, override=None, fast=False: _SlowDriver, + ) + profiles = [ + BenchProfile(harness=f"fake-{i}", model="m", env_prefix=f"HARNESS_F{i}_", marker="X") + for i in range(3) + ] + matrix = await run_bench(profiles, databricks_profile="oss", live=True, jobs=3) + assert [r.profile.harness for r in matrix.reports] == ["fake-0", "fake-1", "fake-2"] + + +async def test_parallel_full_server_shares_one_server(monkeypatch: pytest.MonkeyPatch) -> None: + """A parallel full-server run builds ONE shared server, reused by every harness. + + Verifies the shared-server optimization: instead of N server+runner boots, + one SharedFullServer is entered once and each harness registers its own + agent+session on it. + """ + from tests.harness_bench.driver import TurnResult + + built: list[object] = [] + + class _FakeShared: + def __init__(self, db_profile: str) -> None: + built.append(self) + self.registered: list[str] = [] + + def __enter__(self): + return self + + def __exit__(self, *exc: object) -> None: + pass + + def register_agent(self, profile, *, deny: bool) -> str: + self.registered.append(profile.harness) + return f"bench-{profile.harness}" + + def create_session(self, agent_name: str) -> str: + return f"sess-{agent_name}" + + # A full-server driver that records which shared server it was handed. + class _FSDriver: + transport = "full-server" + + def __init__(self, profile, *, databricks_profile: str, shared=None) -> None: + self._profile = profile + self._shared = shared + + @staticmethod + def unavailable(profile, *, databricks_profile): + return None + + async def __aenter__(self): + assert self._shared is not None # parallel run injected the shared server + self._shared.register_agent(self._profile, deny=False) + self._shared.create_session(f"bench-{self._profile.harness}") + return self + + async def __aexit__(self, *exc: object) -> None: + pass + + async def run_basic_turn(self, marker: str) -> TurnResult: + return TurnResult(completed=True, text=marker) + + async def run_streaming_turn(self) -> TurnResult: + return TurnResult(completed=True, text_delta_count=3) + + async def run_tool_turn(self, *, deny: bool) -> TurnResult: + return TurnResult(completed=True, tool_call_denied=deny) + + async def run_interrupt_turn(self) -> TurnResult: + return TurnResult(cancelled=True) + + # bench imports SharedFullServer into its own namespace, so patch it there. + monkeypatch.setattr("tests.harness_bench.bench.SharedFullServer", _FakeShared) + monkeypatch.setattr( + "tests.harness_bench.bench.resolve_driver_class", + lambda p, *, override=None, fast=False: _FSDriver, + ) + + profiles = [ + BenchProfile( + harness=f"fs-{i}", + model="m", + env_prefix=f"HARNESS_FS{i}_", + marker="X", + transport="full-server", + ) + for i in range(3) + ] + matrix = await run_bench( + profiles, databricks_profile="oss", live=True, jobs=3, transport="full-server" + ) + # Exactly one shared server, and all three harnesses registered on it. + assert len(built) == 1 + assert sorted(built[0].registered) == ["fs-0", "fs-1", "fs-2"] + assert [r.profile.harness for r in matrix.reports] == ["fs-0", "fs-1", "fs-2"] + + +def test_cli_writes_report_file(tmp_path) -> None: + """`--report PATH` writes the matrix; format follows the extension.""" + from tests.harness_bench.__main__ import main + + md = tmp_path / "matrix.md" + rc = main(["--no-live", "--report", str(md)]) + assert rc == 0 + text = md.read_text() + assert "Harness capability matrix" in text and "| Harness |" in text + + js = tmp_path / "matrix.json" + main(["--no-live", "--report", str(js)]) + payload = json.loads(js.read_text()) + assert payload.get("harnesses") # ── Live layer (gated) ────────────────────────────────────────── @@ -267,7 +563,7 @@ async def __aexit__(self, *exc: object) -> None: ) monkeypatch.setattr( "tests.harness_bench.bench.resolve_driver_class", - lambda p, *, override: _FailingDriver, + lambda p, *, override=None, fast=False: _FailingDriver, ) report = await run_harness(profile, databricks_profile="oss", live=True) @@ -277,6 +573,68 @@ async def __aexit__(self, *exc: object) -> None: assert torn_down == [True], "provisioning-failure path must tear down the driver" +async def test_expected_provisioning_error_logged_quietly( + monkeypatch: pytest.MonkeyPatch, caplog +) -> None: + """A ProvisioningError skips at INFO (no traceback); a generic error warns. + + The branch split keeps the matrix readable: a known-unrunnable environment + (own-auth native not logged in) logs only its reason, while an unexpected + exception keeps its full stack so a genuine driver bug can't hide behind a + green-looking skip. + """ + import logging + + from tests.harness_bench.driver import ProvisioningError + + def _driver_raising(exc: Exception): + class _D: + transport = "stub" + + def __init__(self, profile, *, databricks_profile: str) -> None: + pass + + @staticmethod + def unavailable(profile, *, databricks_profile): + return None + + async def __aenter__(self): + raise exc + + async def __aexit__(self, *e: object) -> None: + pass + + return _D + + profile = BenchProfile(harness="stub", model="m", env_prefix="HARNESS_STUB_", marker="X") + + # Expected failure → a single INFO record, no exception/traceback attached. + monkeypatch.setattr( + "tests.harness_bench.bench.resolve_driver_class", + lambda p, *, override=None, fast=False: _driver_raising( + ProvisioningError("cli not logged in") + ), + ) + with caplog.at_level(logging.INFO, logger="tests.harness_bench.bench"): + await run_harness(profile, databricks_profile="oss", live=True) + provisioning_logs = [r for r in caplog.records if "stub" in r.getMessage()] + assert provisioning_logs, "expected a log line for the skip" + assert all(r.levelno == logging.INFO and r.exc_info is None for r in provisioning_logs) + + # Unexpected failure → WARNING with the traceback attached. + caplog.clear() + monkeypatch.setattr( + "tests.harness_bench.bench.resolve_driver_class", + lambda p, *, override=None, fast=False: _driver_raising(RuntimeError("boom")), + ) + with caplog.at_level(logging.INFO, logger="tests.harness_bench.bench"): + await run_harness(profile, databricks_profile="oss", live=True) + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert warnings and any(r.exc_info is not None for r in warnings), ( + "an unexpected provisioning failure must keep its traceback" + ) + + # ── native-tui transport (offline) ────────────────────────────── @@ -311,6 +669,71 @@ def test_native_tui_registered_and_gates() -> None: assert NativeTuiDriver.unavailable(claude_native, databricks_profile=None) is not None +def test_transport_resolution_family_default_and_fast() -> None: + """SDK family defaults to full-server; --fast downgrades it; natives unaffected. + + This is the core of the "full-server by default, --fast to opt out" model: + the profile's transport is a family marker, and the effective driver comes + from family + flags (see resolve_transport_name). + """ + from tests.harness_bench.transport import resolve_transport_name + + sdk = BenchProfile( + harness="codex", model="m", env_prefix="X_", marker="X", transport="sdk-inproc" + ) + native = BenchProfile( + harness="claude-native", model="m", env_prefix="X_", marker="X", transport="native-tui" + ) + + # SDK family: full-server by default, sdk-inproc under --fast. + assert resolve_transport_name(sdk, override=None, fast=False) == "full-server" + assert resolve_transport_name(sdk, override=None, fast=True) == "sdk-inproc" + + # native: a single transport --fast does not touch. + assert resolve_transport_name(native, override=None, fast=False) == "native-tui" + assert resolve_transport_name(native, override=None, fast=True) == "native-tui" + + # An explicit --transport wins over both the default and --fast, for any + # family (the caller validates the name against the registry separately). + assert resolve_transport_name(sdk, override="sdk-inproc", fast=False) == "sdk-inproc" + assert resolve_transport_name(sdk, override="native-tui", fast=True) == "native-tui" + + +async def test_native_provisioning_http_error_becomes_provisioning_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An HTTP failure in native provisioning surfaces as a ProvisioningError. + + goose-native's terminal-ensure can 500 (the vendor cannot start a thread) — + an environment/server-state gap, not a bench bug. __aenter__ must convert + the raw httpx error into a ProvisioningError so run_harness logs it quietly + (one INFO line) instead of dumping a traceback. + """ + import httpx + + from tests.harness_bench.driver import ProvisioningError + from tests.harness_bench.native_tui_driver import NativeTuiDriver + + profile = BenchProfile( + harness="claude-native", + model="m", + env_prefix="HARNESS_CLAUDE_NATIVE_", + marker="X", + transport="native-tui", + ) + driver = NativeTuiDriver(profile, databricks_profile="oss") + + def _boom() -> None: + request = httpx.Request("POST", "http://localhost/resources/terminals") + response = httpx.Response(500, request=request) + raise httpx.HTTPStatusError("500", request=request, response=response) + + monkeypatch.setattr(driver, "_provision", _boom) + with pytest.raises(ProvisioningError) as exc_info: + await driver.__aenter__() + assert "500" in str(exc_info.value) + + def test_full_server_skips_native_with_accurate_message() -> None: """full-server rejects a native profile by naming the native transport. diff --git a/tests/harness_bench/transport.py b/tests/harness_bench/transport.py index f387064f711..5c72f33552a 100644 --- a/tests/harness_bench/transport.py +++ b/tests/harness_bench/transport.py @@ -20,9 +20,18 @@ observable on full-server via a separate SSE subscription, so "basic turn" and "streaming turn" must be *distinct* calls, not one call with a flag. -Transport selection: each :class:`BenchProfile` declares a default -``transport``; a ``--transport`` CLI override wins over it globally (see -:func:`resolve_driver_class`). +Transport selection (see :func:`resolve_driver_class`). A profile's +``transport`` field is the harness *family* marker, not the literal driver: + +- **SDK-family** harnesses (``sdk-inproc``/``full-server``) default to + ``full-server`` — the fullest coverage, the only transport that exercises + Tool calling + Policy DENY, and a strict superset of what ``sdk-inproc`` + observes. ``--fast`` downgrades them to ``sdk-inproc``, trading that + coverage for skipping the server boot. +- **native** harnesses (``native-tui``) have exactly one transport; ``--fast`` + does not apply to them. + +A ``--transport`` override wins over both, for any family. """ from __future__ import annotations @@ -95,19 +104,45 @@ def driver_registry() -> dict[str, type]: } -def resolve_driver_class(profile: BenchProfile, *, override: str | None) -> type: - """Resolve the driver class for *profile*. +# The SDK harness family: transports that drive an SDK-wrap harness. They +# observe the same core dimensions; full-server additionally reaches Tool +# calling + Policy DENY (server-dispatched) and is a strict coverage superset, +# so it is the default. --fast picks the cheaper sdk-inproc within this family. +_SDK_FAMILY = frozenset({"sdk-inproc", "full-server"}) +_SDK_DEFAULT = "full-server" +_SDK_FAST = "sdk-inproc" - *override* (the ``--transport`` flag) wins over the profile's declared - ``transport`` when set. Raises :class:`KeyError` for an unknown transport - so a typo fails loud rather than silently falling back. + +def resolve_transport_name(profile: BenchProfile, *, override: str | None, fast: bool) -> str: + """Resolve the effective transport *name* for *profile* from family + flags. + + Precedence: an explicit ``--transport`` *override* wins over everything. + Otherwise the profile's ``transport`` names a family: an SDK-family harness + resolves to ``full-server`` (default, fullest coverage) or ``sdk-inproc`` + (under *fast*); a native harness has a single transport that ``--fast`` + does not touch. :param profile: The harness under test. - :param override: A transport name from ``--transport``, or ``None`` to use - the profile's declared transport. - :returns: The driver class to instantiate. + :param override: ``--transport`` value, or ``None``. + :param fast: The ``--fast`` flag — downgrade the SDK family to sdk-inproc. + :returns: The resolved transport name (a key into :func:`driver_registry`). + """ + if override is not None: + return override + if profile.transport in _SDK_FAMILY: + return _SDK_FAST if fast else _SDK_DEFAULT + return profile.transport + + +def resolve_driver_class( + profile: BenchProfile, *, override: str | None = None, fast: bool = False +) -> type: + """Resolve the driver *class* for *profile* (see :func:`resolve_transport_name`). + + Raises :class:`KeyError` for an unknown transport so a typo fails loud + rather than silently falling back. """ - name = override or profile.transport + name = resolve_transport_name(profile, override=override, fast=fast) registry = driver_registry() if name not in registry: raise KeyError( @@ -116,4 +151,4 @@ def resolve_driver_class(profile: BenchProfile, *, override: str | None) -> type return registry[name] -__all__ = ["Driver", "driver_registry", "resolve_driver_class"] +__all__ = ["Driver", "driver_registry", "resolve_driver_class", "resolve_transport_name"] From fc0a4dae424666724c2ff1f1f0080b9cf46d51b6 Mon Sep 17 00:00:00 2001 From: Arthur Liao Date: Tue, 7 Jul 2026 16:26:21 +0800 Subject: [PATCH 054/546] fix(spec): expand env vars in builtin tool config (#2064) Co-authored-by: Arthur Liao <223135116+zycaskevin@users.noreply.github.com> --- omnigent/spec/parser.py | 13 +++++-- tests/spec/test_parser.py | 78 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/omnigent/spec/parser.py b/omnigent/spec/parser.py index 43e22616aec..b314a75050e 100644 --- a/omnigent/spec/parser.py +++ b/omnigent/spec/parser.py @@ -151,7 +151,7 @@ def parse(root: Path, *, expand_env: bool = True) -> AgentSpec: raw_tools = raw.get("tools") llm = _parse_llm(raw_llm, expand_env=expand_env) interaction = _parse_interaction(raw.get("interaction")) - tools_config = _parse_tools_config(raw_tools) + tools_config = _parse_tools_config(raw_tools, expand_env=expand_env) executor = _parse_executor(raw_executor, expand_env=expand_env) # ── Consolidate llm: → executor ──────────────────────────────── # ``executor.model`` and ``executor.connection`` are the primary @@ -346,6 +346,8 @@ def _parse_interaction( def _parse_tools_config( raw: dict[str, Any] | None, + *, + expand_env: bool = True, ) -> ToolsConfig: """ Parse the ``tools:`` block from config.yaml into a @@ -362,7 +364,7 @@ def _parse_tools_config( return ToolsConfig() timeout = int(raw["timeout"]) if "timeout" in raw else 60 retry = _parse_retry(raw.get("retry")) - builtins = _parse_builtin_tools(raw.get("builtins", [])) + builtins = _parse_builtin_tools(raw.get("builtins", []), expand_env=expand_env) sandbox = _parse_sandbox_config(raw.get("sandbox")) return ToolsConfig( agents=raw.get("agents", []), @@ -408,6 +410,8 @@ def _parse_sandbox_config( def _parse_builtin_tools( raw: list[str | dict[str, Any]], + *, + expand_env: bool = True, ) -> list[BuiltinToolConfig]: """ Parse the ``tools.builtins`` list into @@ -423,6 +427,8 @@ def _parse_builtin_tools( engine_id: ${GOOGLE_SEARCH_ENGINE_ID} :param raw: The raw ``builtins`` list from config.yaml. + :param expand_env: Whether to expand ``${VAR}`` references in + tool-specific config fields. ``False`` keeps literals as-is. :returns: A list of :class:`BuiltinToolConfig` instances. :raises OmnigentError: If a dict entry is missing ``name``. """ @@ -438,7 +444,8 @@ def _parse_builtin_tools( code=ErrorCode.INVALID_INPUT, ) # Everything except 'name' is tool-specific config. - config = {str(k): str(v) for k, v in entry.items() if k != "name"} + raw_config = {str(k): str(v) for k, v in entry.items() if k != "name"} + config = expand_env_vars(raw_config) if expand_env else raw_config result.append( BuiltinToolConfig( name=str(name), diff --git a/tests/spec/test_parser.py b/tests/spec/test_parser.py index 9a5b67461d6..f96bd886a0f 100644 --- a/tests/spec/test_parser.py +++ b/tests/spec/test_parser.py @@ -270,6 +270,84 @@ def test_parse_expand_env_false_keeps_var_references( assert spec.llm.connection == {"api_key": "${MY_API_KEY}"} +def test_parse_builtin_tool_config_expands_env_vars( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``${VAR}`` references in builtin tool config values are expanded.""" + monkeypatch.setenv("PERPLEXITY_API_KEY", "pplx-redacted-test-key") + config = { + "spec_version": 1, + "tools": { + "builtins": [ + { + "name": "web_search", + "search_provider": "perplexity", + "api_key": "${PERPLEXITY_API_KEY}", + }, + ], + }, + } + (tmp_path / "config.yaml").write_text(yaml.dump(config)) + + spec = parse(tmp_path) + + builtin = spec.tools.builtins[0] + assert builtin.name == "web_search" + assert builtin.config == { + "search_provider": "perplexity", + "api_key": "pplx-redacted-test-key", + } + + +def test_parse_builtin_tool_config_expand_env_false_keeps_literals( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``expand_env=False`` keeps builtin tool ``${VAR}`` config literal.""" + monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False) + config = { + "spec_version": 1, + "tools": { + "builtins": [ + { + "name": "web_search", + "search_provider": "perplexity", + "api_key": "${PERPLEXITY_API_KEY}", + }, + ], + }, + } + (tmp_path / "config.yaml").write_text(yaml.dump(config)) + + spec = parse(tmp_path, expand_env=False) + + assert spec.tools.builtins[0].config == { + "search_provider": "perplexity", + "api_key": "${PERPLEXITY_API_KEY}", + } + + +def test_parse_builtin_tool_config_unresolved_var_raises( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unresolved ``${VAR}`` in builtin tool config raises clearly.""" + monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False) + config = { + "spec_version": 1, + "tools": { + "builtins": [ + {"name": "web_search", "api_key": "${PERPLEXITY_API_KEY}"}, + ], + }, + } + (tmp_path / "config.yaml").write_text(yaml.dump(config)) + + with pytest.raises(OmnigentError, match=r"Unresolved environment variable"): + parse(tmp_path) + + def test_parse_instructions_multiline_inline(tmp_path: Path) -> None: """Multiline inline instructions are not treated as file paths.""" config = { From 4845b821870b0c0dbde3ee450a6e34fc02938703 Mon Sep 17 00:00:00 2001 From: Tomu Hirata Date: Tue, 7 Jul 2026 18:19:16 +0900 Subject: [PATCH 055/546] refactor(db): remove all FK constraints (Rule R032) (#2081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(db): remove all FK constraints; application owns relationship cleanup Drops all 9 FK constraints (8 CASCADE + 1 SET NULL) from the SQLAlchemy models and adds a new Alembic migration (p1a2b3c4d5e6) to remove them from the live schema, following internal DB standard Rule R032. - db_models.py: remove ForeignKey() from session_permissions.user_id, session_permissions.conversation_id, conversations.parent_conversation_id, conversations.root_conversation_id, conversations.agent_id, conversations.host_id, conversation_items.conversation_id, conversation_labels.conversation_id, and policies.session_id. - migration p1a2b3c4d5e6: upgrade drops all FKs via batch_alter_table (recreate="always" on SQLite); downgrade re-adds them. - delete_conversation: now collects the full conversation subtree via a recursive CTE and explicitly deletes items, labels, comments, policies, and session-permissions for all descendants before deleting conversation rows, replacing the previous reliance on ON DELETE CASCADE. - switch_conversation_agent: removes the defensive null+flush of agent_id before deleting the old session-scoped agent, since there is no longer a CASCADE constraint that would destroy the conversation row. * test(db): update tests for FK removal; fix migration and ORM cascade assertions - Fix migration p1a2b3c4d5e6 to correctly drop all FKs on SQLite by reflecting actual constraint names (including unnamed/None FKs that get convention-derived names during batch rebuild) and drop_constrainting each. Restore host_id FK in downgrade as fk_conversations_host_id_hosts to match the original name so subsequent migrations can find it. - Restore row.agent_id = None + flush before deleting old agent in switch_conversation_agent so SQLAlchemy ORM identity map stays consistent. - Update ORM cascade tests to assert new no-FK behavior (children survive parent deletion; app must clean up explicitly). - Update migration_workspace test to document that host deletion no longer auto-nulls conversations.host_id without a DB FK. - Update permission store cascade test to document that permissions persist after conversation deletion without DB FK cascade. - Update agents migration FK test to document that referential integrity is now the application's responsibility. * fix(db): explicit cleanup in delete_user and delete_host after FK removal delete_user now explicitly deletes session_permissions rows before removing the user row — without the DB CASCADE, orphaned permissions could grant access to a re-created account with the same identifier. delete_host now explicitly nulls conversations.host_id for any sessions still bound to the host before deleting the row — replaces the removed ON DELETE SET NULL FK behavior. Also updates stale FK-reference comments. --- omnigent/db/db_models.py | 17 +- .../versions/p1a2b3c4d5e6_remove_all_fks.py | 191 ++++++++++++++++++ omnigent/server/accounts_store.py | 14 +- .../conversation_store/sqlalchemy_store.py | 76 +++++-- omnigent/stores/host_store.py | 25 ++- tests/db/test_db_models.py | 22 +- tests/db/test_migration_agents_session_id.py | 28 ++- tests/db/test_migration_workspace.py | 21 +- tests/stores/test_permission_store.py | 22 +- 9 files changed, 327 insertions(+), 89 deletions(-) create mode 100644 omnigent/db/migrations/versions/p1a2b3c4d5e6_remove_all_fks.py diff --git a/omnigent/db/db_models.py b/omnigent/db/db_models.py index be1b03d620b..61821415a29 100644 --- a/omnigent/db/db_models.py +++ b/omnigent/db/db_models.py @@ -7,7 +7,6 @@ Boolean, CheckConstraint, Float, - ForeignKey, Index, Integer, String, @@ -218,12 +217,10 @@ class SqlSessionPermission(Base): user_id: Mapped[str] = mapped_column( String(128), - ForeignKey("users.id", ondelete="CASCADE"), primary_key=True, ) conversation_id: Mapped[str] = mapped_column( String(64), - ForeignKey("conversations.id", ondelete="CASCADE"), primary_key=True, ) level: Mapped[int] = mapped_column(Integer, nullable=False) @@ -320,29 +317,23 @@ class SqlConversation(Base): kind: Mapped[str] = mapped_column(String(32), default="default") parent_conversation_id: Mapped[str | None] = mapped_column( String(64), - ForeignKey("conversations.id", ondelete="CASCADE"), nullable=True, ) root_conversation_id: Mapped[str] = mapped_column( String(64), - ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False, ) agent_id: Mapped[str | None] = mapped_column( String(64), - ForeignKey("agents.id", ondelete="CASCADE"), nullable=True, ) runner_id: Mapped[str | None] = mapped_column(String(64), nullable=True) # Host that launched (or should launch) the runner for this # session. Set when a session is created via the Web UI on a - # specific host. FK to hosts.host_id (a unique column); ON DELETE - # SET NULL so removing a host clears the binding rather than - # orphaning it — and host_id -> NULL keeps the - # workspace-required CHECK below satisfied. + # specific host. No FK: host records are managed outside this + # table; deletion is handled explicitly by the application. host_id: Mapped[str | None] = mapped_column( String(64), - ForeignKey("hosts.host_id", ondelete="SET NULL"), nullable=True, ) # Per-session reasoning-effort hint, e.g. "high". Nullable; @@ -485,7 +476,7 @@ class SqlConversationItem(Base): id: Mapped[str] = mapped_column(String(64), primary_key=True) conversation_id: Mapped[str] = mapped_column( - String(64), ForeignKey("conversations.id", ondelete="CASCADE") + String(64), ) response_id: Mapped[str] = mapped_column(String(64)) created_at: Mapped[int] = mapped_column(Integer) @@ -547,7 +538,6 @@ class SqlConversationLabel(Base): conversation_id: Mapped[str] = mapped_column( String(64), - ForeignKey("conversations.id", ondelete="CASCADE"), primary_key=True, ) key: Mapped[str] = mapped_column(String(128), primary_key=True) @@ -654,7 +644,6 @@ class SqlPolicy(Base): # Nullable: NULL for server-wide default policies. session_id: Mapped[str | None] = mapped_column( String(64), - ForeignKey("conversations.id", ondelete="CASCADE"), nullable=True, ) created_at: Mapped[int] = mapped_column(Integer) diff --git a/omnigent/db/migrations/versions/p1a2b3c4d5e6_remove_all_fks.py b/omnigent/db/migrations/versions/p1a2b3c4d5e6_remove_all_fks.py new file mode 100644 index 00000000000..8400b6c3b50 --- /dev/null +++ b/omnigent/db/migrations/versions/p1a2b3c4d5e6_remove_all_fks.py @@ -0,0 +1,191 @@ +"""Remove all FK constraints; application owns relationship cleanup. + +Revision ID: p1a2b3c4d5e6 +Revises: o1a2b3c4d5e6 +Create Date: 2026-07-07 00:00:00.000000 + +Drops all 9 remaining FK constraints (8 CASCADE + 1 SET NULL) from the +schema, following internal DB standard Rule R032 that forbids +database-enforced foreign keys. After this migration the application +is solely responsible for cascading deletes and referential cleanup. + +SQLite note: ``batch_alter_table`` with ``recreate="always"`` rebuilds +the table from scratch without the FK, which is the only reliable way +to remove a FK on SQLite (ALTER TABLE DROP CONSTRAINT is not supported). +Both upgrade and downgrade issue ``PRAGMA foreign_keys = OFF`` (guarded by +dialect) around the batch operations so no accidental cascade fires during +the table rebuilds themselves. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "p1a2b3c4d5e6" +down_revision: str | None = "o1a2b3c4d5e6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_NAMING_CONVENTION = { + "fk": "fk_%(table_name)s_%(column_0_name)s", + "ix": "ix_%(table_name)s_%(column_0_name)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", +} + + +def _is_sqlite() -> bool: + return op.get_bind().dialect.name == "sqlite" + + +def _drop_all_fks_on_table(table_name: str, sqlite: bool) -> None: + """ + Drop all FK constraints on a table. + + SQLite often stores FK constraints without names (name=None) or with + names that differ from the naming convention. When batch_alter_table + runs with recreate="always" and a naming_convention, unnamed FKs are + assigned names by the convention during the rebuild — so we must drop + them by their convention-derived name, not their original None. + + For each FK we compute the name to drop: use the existing name if set, + otherwise derive it from the convention: fk__. + """ + bind = op.get_bind() + fks = sa.inspect(bind).get_foreign_keys(table_name) + with op.batch_alter_table( + table_name, + recreate="always" if sqlite else "auto", + naming_convention=_NAMING_CONVENTION, + ) as batch_op: + for fk in fks: + name = fk["name"] + if name is None: + # Derive the name the convention will assign during rebuild. + col = fk["constrained_columns"][0] + name = f"fk_{table_name}_{col}" + batch_op.drop_constraint(name, type_="foreignkey") + + +def upgrade() -> None: + """Drop all FK constraints from every affected table.""" + sqlite = _is_sqlite() + if sqlite: + op.execute(sa.text("PRAGMA foreign_keys = OFF")) + + for table in ( + "session_permissions", + "conversations", + "conversation_items", + "conversation_labels", + "policies", + ): + _drop_all_fks_on_table(table, sqlite) + + if sqlite: + op.execute(sa.text("PRAGMA foreign_keys = ON")) + + +def downgrade() -> None: + """Re-add all FK constraints.""" + sqlite = _is_sqlite() + if sqlite: + op.execute(sa.text("PRAGMA foreign_keys = OFF")) + + # policies: re-add FK on session_id → conversations.id (CASCADE) + with op.batch_alter_table( + "policies", + recreate="always" if sqlite else "auto", + ) as batch_op: + batch_op.create_foreign_key( + "fk_policies_session_id", + "conversations", + ["session_id"], + ["id"], + ondelete="CASCADE", + ) + + # conversation_labels: re-add FK on conversation_id → conversations.id (CASCADE) + with op.batch_alter_table( + "conversation_labels", + recreate="always" if sqlite else "auto", + ) as batch_op: + batch_op.create_foreign_key( + "fk_conversation_labels_conversation_id", + "conversations", + ["conversation_id"], + ["id"], + ondelete="CASCADE", + ) + + # conversation_items: re-add FK on conversation_id → conversations.id (CASCADE) + with op.batch_alter_table( + "conversation_items", + recreate="always" if sqlite else "auto", + ) as batch_op: + batch_op.create_foreign_key( + "fk_conversation_items_conversation_id", + "conversations", + ["conversation_id"], + ["id"], + ondelete="CASCADE", + ) + + # conversations: re-add all 4 FKs + with op.batch_alter_table( + "conversations", + recreate="always" if sqlite else "auto", + ) as batch_op: + batch_op.create_foreign_key( + "fk_conversations_agent_id", + "agents", + ["agent_id"], + ["id"], + ondelete="CASCADE", + ) + batch_op.create_foreign_key( + "fk_conversations_root_conversation_id", + "conversations", + ["root_conversation_id"], + ["id"], + ondelete="CASCADE", + ) + batch_op.create_foreign_key( + "fk_conversations_parent_conversation_id", + "conversations", + ["parent_conversation_id"], + ["id"], + ondelete="CASCADE", + ) + batch_op.create_foreign_key( + "fk_conversations_host_id_hosts", + "hosts", + ["host_id"], + ["host_id"], + ondelete="SET NULL", + ) + + # session_permissions: re-add both FKs + with op.batch_alter_table( + "session_permissions", + recreate="always" if sqlite else "auto", + ) as batch_op: + batch_op.create_foreign_key( + "fk_session_permissions_conversation_id", + "conversations", + ["conversation_id"], + ["id"], + ondelete="CASCADE", + ) + batch_op.create_foreign_key( + "fk_session_permissions_user_id", + "users", + ["user_id"], + ["id"], + ondelete="CASCADE", + ) + + if sqlite: + op.execute(sa.text("PRAGMA foreign_keys = ON")) diff --git a/omnigent/server/accounts_store.py b/omnigent/server/accounts_store.py index b266504af40..30043e51ba4 100644 --- a/omnigent/server/accounts_store.py +++ b/omnigent/server/accounts_store.py @@ -33,7 +33,7 @@ from sqlalchemy import and_, delete, exists, select, update from sqlalchemy.exc import IntegrityError -from omnigent.db.db_models import SqlAccountToken, SqlUser +from omnigent.db.db_models import SqlAccountToken, SqlSessionPermission, SqlUser from omnigent.db.utils import get_or_create_engine, make_managed_session_maker from omnigent.entities import Account, AccountToken from omnigent.server.auth import RESERVED_USER_LOCAL, RESERVED_USER_PUBLIC @@ -195,15 +195,17 @@ def list_users(self) -> list[Account]: return [_to_account(r) for r in rows if r.id not in _HIDDEN_LIST_USERS] def delete_user(self, user_id: str) -> bool: - """Delete a user row and cascade their permission grants. + """Delete a user row and their permission grants. - Cascade is via the existing ``ON DELETE CASCADE`` foreign - key on ``session_permissions`` (set up by the original - permissions migration). + Explicitly deletes all ``session_permissions`` rows for the user + before removing the user row — the DB no longer cascades this. - :returns: ``True`` if a row was deleted, ``False`` otherwise. + :returns: ``True`` if a user row was deleted, ``False`` otherwise. """ with self._session() as session: + session.execute( + delete(SqlSessionPermission).where(SqlSessionPermission.user_id == user_id) + ) result = session.execute(delete(SqlUser).where(SqlUser.id == user_id)) return result.rowcount > 0 diff --git a/omnigent/stores/conversation_store/sqlalchemy_store.py b/omnigent/stores/conversation_store/sqlalchemy_store.py index 2c0661138be..867d0b638ff 100644 --- a/omnigent/stores/conversation_store/sqlalchemy_store.py +++ b/omnigent/stores/conversation_store/sqlalchemy_store.py @@ -28,9 +28,12 @@ AGENT_KIND_SESSION, LABEL_VALUE_MAX_LEN, SqlAgent, + SqlComment, SqlConversation, SqlConversationItem, SqlConversationLabel, + SqlPolicy, + SqlSessionPermission, SqlUserDailyCost, ) from omnigent.db.utils import ( @@ -1202,7 +1205,6 @@ def get_session_owner(self, conversation_id: str) -> str | None: or ``None`` when the session has no real (non-public) permission grants. """ - from omnigent.db.db_models import SqlSessionPermission from omnigent.server.auth import RESERVED_USER_PUBLIC with self._session() as session: @@ -1549,8 +1551,6 @@ def list_projects( .order_by(SqlConversationLabel.value) ) if accessible_by is not None: - from omnigent.db.db_models import SqlSessionPermission - accessible_ids = select(SqlSessionPermission.conversation_id).where( SqlSessionPermission.user_id == accessible_by ) @@ -1679,8 +1679,6 @@ def list_conversations( # because their agent_id column is NULL. stmt = stmt.where(SqlConversation.agent_id == agent_id) if accessible_by is not None: - from omnigent.db.db_models import SqlSessionPermission - accessible_ids = select(SqlSessionPermission.conversation_id).where( SqlSessionPermission.user_id == accessible_by ) @@ -2614,12 +2612,11 @@ def switch_conversation_agent( if row is None: raise LookupError(f"conversation not found: {conversation_id!r}") - # Replace the session-scoped agent. Null the forward pointer first: - # conversations.agent_id is ON DELETE CASCADE, so deleting the old - # agent row while it is still referenced would cascade-delete the - # whole conversation. Only delete the old agent if it is - # session-scoped (kind='session') — template/built-in agents are - # shared and must never be deleted here. + # Null the forward pointer before deleting the old agent so + # SQLAlchemy's identity map doesn't hold a reference to a deleted + # row when it flushes. The DB no longer enforces any cascade here, + # but the ORM still tracks the relationship. Only delete + # session-scoped agents — template/built-in agents are shared. old_agent_id = row.agent_id row.agent_id = None session.flush() @@ -2694,11 +2691,13 @@ def switch_conversation_agent( async def delete_conversation(self, conversation_id: str) -> bool: """ - Delete a conversation, its items, related tasks, and FTS - records. + Delete a conversation and all of its descendants, cleaning up + every related row explicitly (no DB-level CASCADE). - Deletes in FK-safe order: tasks, FTS records, items, - then the conversation itself. + Collects the full subtree of conversation IDs (the target plus + all direct/indirect children), then deletes their items, labels, + comments, policies, and session-permission rows before deleting + the conversation rows themselves (children before parent). :param conversation_id: Unique conversation identifier, e.g. ``"conv_abc123"``. @@ -2709,12 +2708,51 @@ async def delete_conversation(self, conversation_id: str) -> bool: row = session.get(SqlConversation, conversation_id) if not row: return False - # Delete conversation items and FTS before the conversation row - # (FK constraints: items reference the conversation). - delete_fts_by_conversation(session, conversation_id) + + # Collect all descendant IDs via a recursive CTE so we can + # clean up the full subtree in one pass. + cte = ( + select(SqlConversation.id) + .where(SqlConversation.id == conversation_id) + .cte(name="subtree", recursive=True) + ) + cte = cte.union_all( + select(SqlConversation.id).where( + SqlConversation.parent_conversation_id == cte.c.id + ) + ) + subtree_ids_rows = session.execute(select(cte.c.id)).fetchall() + subtree_ids = [r[0] for r in subtree_ids_rows] + + # Delete per-conversation child rows for every conversation in + # the subtree before touching the conversation rows themselves. + for conv_id in subtree_ids: + delete_fts_by_conversation(session, conv_id) + session.execute( delete(SqlConversationItem).where( - SqlConversationItem.conversation_id == conversation_id + SqlConversationItem.conversation_id.in_(subtree_ids) + ) + ) + session.execute( + delete(SqlConversationLabel).where( + SqlConversationLabel.conversation_id.in_(subtree_ids) + ) + ) + session.execute(delete(SqlComment).where(SqlComment.conversation_id.in_(subtree_ids))) + session.execute(delete(SqlPolicy).where(SqlPolicy.session_id.in_(subtree_ids))) + session.execute( + delete(SqlSessionPermission).where( + SqlSessionPermission.conversation_id.in_(subtree_ids) + ) + ) + + # Delete conversation rows children-first so any residual + # ordering constraints are satisfied. + session.execute( + delete(SqlConversation).where( + SqlConversation.id.in_(subtree_ids), + SqlConversation.id != conversation_id, ) ) session.delete(row) diff --git a/omnigent/stores/host_store.py b/omnigent/stores/host_store.py index 4d486f7efc2..bb92f590158 100644 --- a/omnigent/stores/host_store.py +++ b/omnigent/stores/host_store.py @@ -252,11 +252,12 @@ def upsert_on_connect( # was regenerated after a fresh install or a wiped # ~/.omnigent. host_id is a UNIQUE column that # conversations.host_id references via - # fk_conversations_host_id_hosts (ON DELETE SET NULL, NO - # ON UPDATE CASCADE). Renaming it in place while child - # conversations still point at the old value raises a - # ForeignKeyViolation on Postgres, which crashes the host - # tunnel handler — the host then reconnect-loops forever + # conversations.host_id references it as a plain column + # (no FK). Renaming it in place while child conversations + # still point at the old value is harmless at the DB level, + # but the application nulls host_id on those sessions first. + # On Postgres with an FK this used to raise ForeignKeyViolation + # which crashed the host tunnel handler — no longer applies. # and never registers (no host shows in the UI). SQLite # dev doesn't enforce FKs by default, so this only bites # on the hosted Postgres/Lakebase deploy. @@ -641,15 +642,19 @@ def delete_host(self, host_id: str) -> None: Managed-host teardown: removes the host from the picker AND revokes its launch token in one operation (the row IS the - credential). ``conversations.host_id`` references this row with - ``ON DELETE SET NULL``, so any remaining session bindings are - nulled rather than blocking the delete. No-op when the row does - not exist — deletion is invoked from best-effort cleanup paths - that may race. + credential). Explicitly nulls ``conversations.host_id`` for any + sessions still bound to this host — the DB no longer cascades + this via FK. No-op when the row does not exist — deletion is + invoked from best-effort cleanup paths that may race. :param host_id: Host identifier, e.g. ``"host_a1b2c3d4..."``. """ with self._session() as session: + session.execute( + update(SqlConversation) + .where(SqlConversation.host_id == host_id) + .values(host_id=None) + ) session.execute(sql_delete(SqlHost).where(SqlHost.host_id == host_id)) def revoke_launch_token(self, host_id: str) -> None: diff --git a/tests/db/test_db_models.py b/tests/db/test_db_models.py index 70398436c1e..1d8ef5394ad 100644 --- a/tests/db/test_db_models.py +++ b/tests/db/test_db_models.py @@ -384,8 +384,12 @@ def test_sub_agent_kind(self, db_uri: str) -> None: assert loaded.parent_conversation_id == "conv_parent" assert loaded.root_conversation_id == "conv_parent" - def test_cascade_delete_removes_children(self, db_uri: str) -> None: - """Deleting a parent conversation cascades to child conversations.""" + def test_delete_parent_leaves_children_without_fk(self, db_uri: str) -> None: + """Without DB-level FK cascade, deleting a parent leaves child rows intact. + + The application (delete_conversation) is responsible for cleaning + up the subtree explicitly. + """ engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) @@ -406,8 +410,9 @@ def test_cascade_delete_removes_children(self, db_uri: str) -> None: assert p is not None session.delete(p) + # Without FK cascade the child is NOT automatically deleted. with managed() as session: - assert session.get(SqlConversation, "conv_child2") is None + assert session.get(SqlConversation, "conv_child2") is not None # ── SqlConversationItem ─────────────────────────────── @@ -448,8 +453,12 @@ def test_unique_position_per_conversation(self, db_uri: str) -> None: session.add(item1) session.add(item2) - def test_cascade_delete_with_conversation(self, db_uri: str) -> None: - """Deleting a conversation cascades to its items.""" + def test_delete_conversation_via_orm_leaves_items_without_fk(self, db_uri: str) -> None: + """Without DB-level FK cascade, deleting a conversation leaves its items intact. + + The application (delete_conversation) is responsible for deleting + items explicitly before or after deleting the conversation row. + """ engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) @@ -464,8 +473,9 @@ def test_cascade_delete_with_conversation(self, db_uri: str) -> None: assert c is not None session.delete(c) + # Without FK cascade the item is NOT automatically deleted. with managed() as session: - assert session.get(SqlConversationItem, "msg_del") is None + assert session.get(SqlConversationItem, "msg_del") is not None def test_multiple_items_ordered_by_position(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) diff --git a/tests/db/test_migration_agents_session_id.py b/tests/db/test_migration_agents_session_id.py index 5d42ccd8381..686a299709b 100644 --- a/tests/db/test_migration_agents_session_id.py +++ b/tests/db/test_migration_agents_session_id.py @@ -120,17 +120,23 @@ def test_agents_session_id_fk_accepts_existing_session(db_engine: Engine) -> Non def test_agents_session_id_fk_rejects_missing_session(db_engine: Engine) -> None: - """conversations.agent_id FK rejects a reference to a nonexistent agent.""" - with pytest.raises(IntegrityError): - with db_engine.begin() as conn: - conn.execute( - sa.text( - "INSERT INTO conversations" - " (id, created_at, updated_at, root_conversation_id, kind, agent_id)" - " VALUES (:id, :ts, :ts, :id, 'default', :agent_id)" - ), - {"id": "conv_missing", "ts": 1700000002, "agent_id": "ag_nonexistent"}, - ) + """Without DB FK, conversations.agent_id accepts any value including nonexistent agents. + + Referential integrity is now the application's responsibility. + """ + # No IntegrityError expected — FK has been removed. + with db_engine.begin() as conn: + conn.execute( + sa.text( + "INSERT INTO conversations" + " (id, created_at, updated_at, root_conversation_id, kind, agent_id)" + " VALUES (:id, :ts, :ts, :id, 'default', :agent_id)" + ), + {"id": "conv_missing", "ts": 1700000002, "agent_id": "ag_nonexistent"}, + ) + # Clean up + with db_engine.begin() as conn: + conn.execute(sa.text("DELETE FROM conversations WHERE id = 'conv_missing'")) def test_agents_template_name_unique_index_rejects_duplicate_template( diff --git a/tests/db/test_migration_workspace.py b/tests/db/test_migration_workspace.py index 1ac54486fde..4d3dca57bd8 100644 --- a/tests/db/test_migration_workspace.py +++ b/tests/db/test_migration_workspace.py @@ -168,8 +168,8 @@ def test_check_constraint_allows_host_id_with_workspace( constraint expression is wrong and would block all host launches. """ with db_engine.connect() as conn: - # host_id is an FK to hosts.host_id (enforced), so the host must - # exist before a conversation can reference it. + # Insert a host first (no FK enforced, but needed for the join in + # online_host_ids queries). conn.execute( sa.text( "INSERT INTO hosts " @@ -216,10 +216,10 @@ def test_host_id_is_indexed(db_engine: Engine) -> None: def test_host_id_fk_sets_null_when_host_deleted(db_engine: Engine) -> None: """ - Deleting a host SET-NULLs the bound conversation's ``host_id`` - (FK ``ondelete=SET NULL``) instead of leaving a dangling reference. - ``workspace`` is untouched, and ``host_id -> NULL`` keeps the - workspace-required check satisfied. + After the FK was removed, deleting a host leaves conversations.host_id + as a dangling reference — the application is responsible for nulling it. + This test documents the current (post-FK-removal) DB-level behavior: + host deletion does NOT automatically null conversations.host_id. """ with db_engine.connect() as conn: conn.execute( @@ -247,10 +247,13 @@ def test_host_id_fk_sets_null_when_host_deleted(db_engine: Engine) -> None: sa.text("SELECT host_id, workspace FROM conversations WHERE id = :id"), {"id": "conv_fk"}, ).one() - assert row.host_id is None, ( - f"host deletion should SET NULL conversations.host_id; got {row.host_id!r}." + # No FK cascade: host_id is left dangling after host deletion. + # The application (host store / disconnect handler) is responsible + # for nulling conversations.host_id when a host is removed. + assert row.host_id == "host_del", ( + "Without a DB FK, host deletion must not auto-null conversations.host_id." ) - assert row.workspace == "/ws/foo", "workspace must be untouched by the FK SET NULL." + assert row.workspace == "/ws/foo", "workspace must be untouched." def test_check_constraint_allows_cli_session_workspace_no_host( diff --git a/tests/stores/test_permission_store.py b/tests/stores/test_permission_store.py index 5de0bd18bbe..c053a43f3a0 100644 --- a/tests/stores/test_permission_store.py +++ b/tests/stores/test_permission_store.py @@ -551,14 +551,13 @@ def test_has_any_grants_false_after_revoke(store: SqlAlchemyPermissionStore, db_ # ── cascade delete ─────────────────────────────────────────────────────────── -def test_cascade_delete_removes_permissions_when_conversation_deleted( +def test_permissions_not_auto_deleted_when_conversation_deleted( store: SqlAlchemyPermissionStore, db_uri: str ) -> None: - """When a conversation row is deleted, FK CASCADE removes permission rows. + """Without DB FK cascade, deleting a conversation leaves its permission rows intact. - The session_permissions table has ``ON DELETE CASCADE`` on - ``conversation_id``. Deleting the conversation must clean up all - associated grants without explicit permission-store calls. + The application (delete_conversation) is responsible for explicitly + deleting session_permissions rows when a conversation is removed. """ _ensure_user(store, "alice@test.com") _ensure_user(store, "bob@test.com") @@ -567,10 +566,9 @@ def test_cascade_delete_removes_permissions_when_conversation_deleted( store.grant("alice@test.com", conv_id, level=2) store.grant("bob@test.com", conv_id, level=1) - # Verify grants exist before delete. assert store.has_any_grants(conv_id) is True, "Pre-condition: grants must exist" - # Delete the conversation directly via SQLAlchemy to trigger FK CASCADE. + # Delete the conversation directly — no FK cascade fires. from sqlalchemy import delete as sa_delete from omnigent.db.db_models import SqlConversation @@ -581,13 +579,9 @@ def test_cascade_delete_removes_permissions_when_conversation_deleted( with session_maker() as session: session.execute(sa_delete(SqlConversation).where(SqlConversation.id == conv_id)) - # All grants on the deleted conversation must be gone. - assert store.has_any_grants(conv_id) is False, ( - "Expected no grants after conversation CASCADE delete, but " - "grants still exist. The FK ON DELETE CASCADE is not working." - ) - assert store.list_for_session(conv_id) == [], ( - "Expected [] after CASCADE delete, but grants remain." + # Grants remain — the application must clean them up explicitly. + assert store.has_any_grants(conv_id) is True, ( + "Without FK cascade, permission rows must persist after conversation deletion." ) From 7d9dd710d2012f45b2777ccb3e19807423429d47 Mon Sep 17 00:00:00 2001 From: Serena Ruan <82044803+serena-ruan@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:31:33 +0800 Subject: [PATCH 056/546] fix(claude-native): clear busy state after in-pane /model switch (#2082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native Claude sessions stayed "busy" in the web UI (composer stuck on Stop) after a /model switch, even though the terminal was idle. It self-healed only on the next real message. A surfaced CLI built-in (/model, /effort) becomes a slash_command transcript item that opens its own response id but runs no LLM turn, so no Stop hook ever fires to close it. The forwarder's turn-start edge still published an id-bearing running for it, which opened a streaming activeResponse in the web store; the store suppresses the trailing bare PTY idle while a response is streaming, so nothing cleared it. Gate the turn-start running edge on the turn actually having assistant output (a function_call or assistant message) — the exact turns a later Stop/StopFailure hook will close. Turns that produce no LLM output (slash_command, or terminal_command from !cmd) no longer strand the UI busy. A skill that does trigger an LLM turn shares its id with the assistant text it produces, so running still fires one poll later when that output appears. Co-authored-by: Isaac --- omnigent/claude_native_forwarder.py | 35 ++++++ tests/test_claude_native_forwarder.py | 158 +++++++++++++++++++++----- 2 files changed, 163 insertions(+), 30 deletions(-) diff --git a/omnigent/claude_native_forwarder.py b/omnigent/claude_native_forwarder.py index cffa1373747..28abbab5c4e 100644 --- a/omnigent/claude_native_forwarder.py +++ b/omnigent/claude_native_forwarder.py @@ -2839,6 +2839,31 @@ async def _ensure_state_for_transcript( return state +def _turn_has_assistant_output(items: list[ClaudeTranscriptItem], response_id: str) -> bool: + """ + Whether ``response_id`` has assistant-generated output among ``items``. + + The turn-start ``running`` edge should open a streaming turn only for an id + that a later ``Stop``/``StopFailure`` hook will close — i.e. one produced by + an actual LLM turn. Assistant text (``message`` with ``role=assistant``) and + tool calls (``function_call``) qualify; a ``slash_command`` (``/model``, + ``/effort``) or ``terminal_command`` (``!cmd``) item opens an id with no LLM + turn behind it, so it must not. + + :param items: Transcript items read this poll. + :param response_id: The current turn's response id. + :returns: ``True`` when an assistant-output item carries ``response_id``. + """ + for item in items: + if item.response_id != response_id: + continue + if item.item_type == "function_call": + return True + if item.item_type == "message" and item.data.get("role") == "assistant": + return True + return False + + async def _forward_available_items( *, client: httpx.AsyncClient, @@ -2902,9 +2927,19 @@ async def _forward_available_items( # status post must not abort item forwarding (the items below are the # primary payload); the turn-end idle/failed edge still carries the id to # close the lifecycle, and the badge is unaffected either way. + # + # Only open the streaming turn for an id that has ASSISTANT output in this + # poll's items. A surfaced CLI built-in (``/model``, ``/effort``) or a + # ``!cmd`` becomes a slash_command / terminal_command item that opens its + # own response id but runs no LLM turn, so no ``Stop`` hook ever fires to + # close it — a ``running`` opened for it would strand the web composer in + # its "Stop"/busy state until the next real message. A skill that DOES + # trigger an LLM turn shares its id with the assistant text it produces, so + # ``running`` still fires — one poll later, when that output appears. if ( current_response_id is not None and dedupe.posted_running_response_id != current_response_id + and _turn_has_assistant_output(items, current_response_id) ): try: await post_external_session_status( diff --git a/tests/test_claude_native_forwarder.py b/tests/test_claude_native_forwarder.py index d04eed05722..b08b00dfc17 100644 --- a/tests/test_claude_native_forwarder.py +++ b/tests/test_claude_native_forwarder.py @@ -1166,11 +1166,14 @@ async def test_forwarder_posts_visible_transcript_items(tmp_path: Path) -> None: ) ) try: - # The turn-start ``running`` status (carrying the turn's response id) - # posts first, then the seven transcript items, then the ``Stop`` → - # idle status. Collect the running edge + 7 items; the trailing idle is - # not asserted here. - requests = [await _get_recorded_request(server) for _index in range(8)] + # Collect the seven transcript items. This transcript's final turn is a + # ``!bash`` command (a ``terminal_command``, no assistant output), so + # ``current_response_id`` lands on a turn that runs no LLM turn and thus + # gets no id-bearing ``running`` edge (that would strand the web UI busy + # with no ``Stop`` hook to close it). The turn-start ``running`` edge is + # asserted for a real assistant turn in + # ``test_forwarder_emits_turn_start_running_with_response_id``. + requests = [await _get_recorded_item_request(server) for _index in range(7)] finally: task.cancel() with pytest.raises(asyncio.CancelledError): @@ -1179,26 +1182,9 @@ async def test_forwarder_posts_visible_transcript_items(tmp_path: Path) -> None: server.server_close() thread.join(timeout=5.0) - assert [request["path"] for request in requests] == ["/v1/sessions/conv_abc/events"] * 8 - assert [request["body"]["type"] for request in requests] == [ - "external_session_status", - "external_conversation_item", - "external_conversation_item", - "external_conversation_item", - "external_conversation_item", - "external_conversation_item", - "external_conversation_item", - "external_conversation_item", - ] - # The leading status is the turn-start ``running`` edge carrying the turn's - # response id (what drives the live tool-card spinner on the client). - assert requests[0]["body"]["data"]["status"] == "running" - assert isinstance(requests[0]["body"]["data"].get("response_id"), str) - posted = [ - request["body"]["data"] - for request in requests - if request["body"]["type"] == "external_conversation_item" - ] + assert [request["path"] for request in requests] == ["/v1/sessions/conv_abc/events"] * 7 + assert [request["body"]["type"] for request in requests] == ["external_conversation_item"] * 7 + posted = [request["body"]["data"] for request in requests] assert [item["item_type"] for item in posted] == [ "message", "function_call", @@ -1236,11 +1222,6 @@ async def test_forwarder_posts_visible_transcript_items(tmp_path: Path) -> None: assert posted[5]["response_id"] == posted[6]["response_id"] assert posted[5]["response_id"] != posted[4]["response_id"] assert posted[1]["response_id"].startswith("resp_claude_") - # The turn-start running edge carries an assistant turn's response id (here - # the whole multi-turn transcript flushes in one poll, so it's the last - # turn's id). The single-turn id↔function_call match is asserted directly in - # test_forwarder_emits_turn_start_running_with_response_id. - assert requests[0]["body"]["data"]["response_id"].startswith("resp_claude_") @pytest.mark.asyncio @@ -6922,3 +6903,120 @@ async def test_forwarder_emits_turn_start_running_with_response_id(tmp_path: Pat and body["body"]["data"]["item_type"] == "function_call" ) assert function_call["body"]["data"]["response_id"] == running_rid + + +@pytest.mark.asyncio +async def test_forwarder_does_not_leave_running_open_for_slash_command_only_turn( + tmp_path: Path, +) -> None: + """ + A ``/model``-only turn must not leave an id-bearing ``running`` dangling. + + Surfaced CLI built-ins (``/model``, ``/effort``, ...) become a + ``slash_command`` item that opens its OWN response id but produce no LLM + turn — so no ``Stop`` hook ever fires to close it. The forwarder's + turn-start edge still publishes ``running`` + that id, which opens a + streaming ``activeResponse`` in the web UI. Because the web store + suppresses the trailing bare (id-less) PTY ``idle`` while a response is + streaming, nothing clears it: the composer's Stop button stays lit and + the session looks busy even though the terminal is free. + + The invariant: a poll that forwards only a slash-command item (no + assistant output) must either skip the id-bearing ``running`` edge or + emit a matching ``idle``/``failed`` carrying the same id, so the turn's + lifecycle closes. + """ + bridge_dir = tmp_path / "bridge" + transcript_path = tmp_path / "session.jsonl" + transcript_path.write_text( + "\n".join( + [ + json.dumps( + { + "type": "assistant", + "uuid": "prior-assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "Earlier reply."}], + }, + } + ), + json.dumps( + { + "type": "user", + "uuid": "slash-model", + "message": { + "role": "user", + "content": ( + "/model\n" + " model\n" + " opus" + ), + }, + } + ), + ] + ) + + "\n", + encoding="utf-8", + ) + state = forwarder.TranscriptForwardState( + transcript_path=transcript_path, + line_cursor=0, + byte_offset=0, + cursor_fingerprint=forwarder._jsonl_cursor_fingerprint(transcript_path, 0), + ) + retry_tracker = forwarder._PostRetryTracker( + max_permanent_attempts=2, + base_delay_s=0.0, + max_delay_s=0.0, + ) + requests: list[dict[str, Any]] = [] + + def _handle_request(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content.decode("utf-8")) + assert isinstance(payload, dict) + requests.append(payload) + return httpx.Response(202, json={}) + + transport = httpx.MockTransport(_handle_request) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + dedupe = forwarder._ForwardDedupeState() + await forwarder._forward_available_items( + client=client, + session_id="conv_abc", + bridge_dir=bridge_dir, + agent_name="claude-native-ui", + state=state, + retry_tracker=retry_tracker, + dedupe=dedupe, + ) + + statuses = [ + request["data"] for request in requests if request["type"] == "external_session_status" + ] + running_ids = { + status.get("response_id") + for status in statuses + if status["status"] == "running" and status.get("response_id") is not None + } + closed_ids = { + status.get("response_id") for status in statuses if status["status"] in ("idle", "failed") + } + # Any id-bearing ``running`` opened for the slash-command-only turn must + # be closed within the same poll — otherwise the web UI is stuck busy + # until the next real message. (No LLM turn means no later Stop hook.) + dangling = running_ids - closed_ids + assert not dangling, ( + "slash-command-only turn left an id-bearing running status open with " + f"no matching idle/failed: {dangling}" + ) + # Stronger: the forwarder opens NO id-bearing running for this turn at all + # (there is no assistant output to render live, so nothing to stream). + assert running_ids == set() + # The slash_command item itself still forwards — the switch stays visible + # in the web transcript; only the phantom ``running`` edge is suppressed. + forwarded = [ + request["data"] for request in requests if request["type"] == "external_conversation_item" + ] + assert any(item["item_type"] == "slash_command" for item in forwarded) From 90b0cbe72edbd4ab73106f0a07d50a55bb5ec864 Mon Sep 17 00:00:00 2001 From: Serena Ruan <82044803+serena-ruan@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:32:48 +0800 Subject: [PATCH 057/546] feat(web): select an existing git worktree when starting a session (#2088) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): select an existing git worktree when starting a session The new-session worktree field previously only created a new worktree off a branch name, and picking a directory that was already an existing worktree errored ("branch already exists"). This adds first-class support for starting a session directly in an existing worktree. The branch input is now a combobox: focusing it lists the repo's existing worktrees, typing filters them, picking one starts the session in that worktree (no git opts sent — so no branch-already-exists guard), and a name matching none creates a new worktree as before. A concise warning flags that the session starts in an existing worktree. Backend adds a read-only list_worktrees host git op, the matching list_worktrees tunnel frame pair, a server proxy, and GET /hosts/{id}/worktrees (owner-scoped; non-git path → 400 → empty list in the picker), mirroring the existing create/remove worktree plumbing. Co-authored-by: Isaac * fix: prettier-format worktree UI + regenerate openapi.json CI caught two gaps: the new worktree combobox files weren't prettier-formatted, and the new GET /hosts/{id}/worktrees route made the checked-in openapi.json stale. Regenerated via scripts/dump_openapi.py. Co-authored-by: Isaac * test(e2e-ui): cover selecting an existing worktree in start-session Drives the branch combobox end-to-end: focusing it lists the repo's existing worktrees (stubbed GET /hosts/{id}/worktrees), selecting one points the workspace at that dir and sends no git spec on create. Mirrors the existing test_start_session_add_worktree harness. Co-authored-by: Isaac --- omnigent/host/connect.py | 46 ++++ omnigent/host/frames.py | 99 ++++++++ omnigent/host/git_worktree.py | 74 ++++++ omnigent/server/host_registry.py | 3 + omnigent/server/routes/_host_worktree.py | 47 ++++ omnigent/server/routes/host_tunnel.py | 13 + omnigent/server/routes/hosts.py | 65 +++++ openapi.json | 56 ++++ .../start_session/test_start_session.py | 98 +++++++ tests/host/test_frames.py | 66 +++++ tests/host/test_git_worktree.py | 55 ++++ .../integration/test_hosts_worktrees.py | 239 ++++++++++++++++++ web/src/hooks/useHostWorktrees.ts | 84 ++++++ web/src/shell/NewChatDialog.flow.test.tsx | 3 + web/src/shell/NewChatDialog.test.tsx | 173 +++++++++++++ web/src/shell/NewChatDialog.tsx | 239 ++++++++++++++++-- 16 files changed, 1337 insertions(+), 23 deletions(-) create mode 100644 tests/server/integration/test_hosts_worktrees.py create mode 100644 web/src/hooks/useHostWorktrees.ts diff --git a/omnigent/host/connect.py b/omnigent/host/connect.py index d69eca7a03a..7f50ecc0d48 100644 --- a/omnigent/host/connect.py +++ b/omnigent/host/connect.py @@ -36,6 +36,8 @@ HostListDirEntry, HostListDirFrame, HostListDirResultFrame, + HostListWorktreesFrame, + HostListWorktreesResultFrame, HostRemoveWorktreeFrame, HostRemoveWorktreeResultFrame, HostRunnerExitedFrame, @@ -49,6 +51,7 @@ from omnigent.host.git_worktree import ( WorktreeError, create_worktree, + list_worktrees, remove_worktree, ) from omnigent.host.identity import HostIdentity, load_or_create_host_identity @@ -1522,6 +1525,47 @@ async def _handle_remove_worktree( status="ok", ) + async def _handle_list_worktrees( + self, + frame: HostListWorktreesFrame, + ) -> HostListWorktreesResultFrame: + """Handle a ``host.list_worktrees`` request from the server. + + Runs the blocking git work in a worker thread so the tunnel + loop keeps servicing pings. + + :param frame: The list-worktrees request frame. + :returns: Result frame with the worktrees on success, or + ``status: "failed"`` with an error message. + """ + try: + # Pause the orphan reaper while git runs — see + # _handle_create_worktree above and _reap_orphans_once. + with self._host_subprocess_op(): + worktrees = await asyncio.to_thread( + list_worktrees, + repo_path=frame.repo_path, + ) + except WorktreeError as exc: + return HostListWorktreesResultFrame( + request_id=frame.request_id, + status="failed", + error=exc.message, + ) + return HostListWorktreesResultFrame( + request_id=frame.request_id, + status="ok", + worktrees=[ + { + "path": wt.path, + "branch": wt.branch, + "is_main": wt.is_main, + "detached": wt.detached, + } + for wt in worktrees + ], + ) + async def run(self) -> None: """Run the host process with reconnection. @@ -1858,6 +1902,8 @@ async def _dispatch_host_frame( await ws.send(encode_host_frame(await self._handle_create_worktree(frame))) elif isinstance(frame, HostRemoveWorktreeFrame): await ws.send(encode_host_frame(await self._handle_remove_worktree(frame))) + elif isinstance(frame, HostListWorktreesFrame): + await ws.send(encode_host_frame(await self._handle_list_worktrees(frame))) def run_host_process( diff --git a/omnigent/host/frames.py b/omnigent/host/frames.py index 16e384c1eb9..16dfe3491b6 100644 --- a/omnigent/host/frames.py +++ b/omnigent/host/frames.py @@ -51,6 +51,8 @@ class HostFrameKind(str, Enum): CREATE_WORKTREE_RESULT = "host.create_worktree_result" REMOVE_WORKTREE = "host.remove_worktree" REMOVE_WORKTREE_RESULT = "host.remove_worktree_result" + LIST_WORKTREES = "host.list_worktrees" + LIST_WORKTREES_RESULT = "host.list_worktrees_result" CREATE_DIR = "host.create_dir" CREATE_DIR_RESULT = "host.create_dir_result" @@ -430,6 +432,44 @@ class HostRemoveWorktreeResultFrame: error: str | None = None +@dataclass +class HostListWorktreesFrame: + """Server → host: list the git worktrees of a repository. + + Backs ``GET /v1/hosts/{id}/worktrees``, used by the Web UI's + new-session worktree picker to show worktrees a session can start + in directly. Read-only; the host derives the main work tree from + ``repo_path`` (so a linked worktree resolves the same list). + + :param request_id: Correlates the result, e.g. ``"req_wt_ls_1"``. + :param repo_path: Absolute path inside the repo (the picked dir or + a subdir), e.g. ``"/Users/alice/myrepo"``. + """ + + request_id: str + repo_path: str + + +@dataclass +class HostListWorktreesResultFrame: + """Host → server: outcome of a list-worktrees request. + + :param request_id: Correlates to the + :class:`HostListWorktreesFrame`, e.g. ``"req_wt_ls_1"``. + :param status: ``"ok"`` or ``"failed"``. + :param worktrees: One dict per worktree with keys ``path`` (str), + ``branch`` (str | None), ``is_main`` (bool), ``detached`` + (bool), main first. ``None`` on failure. + :param error: Error message when ``status`` is ``"failed"``, e.g. + ``"not a git repository"``. ``None`` on success. + """ + + request_id: str + status: str + worktrees: list[dict[str, Any]] | None = None + error: str | None = None + + @dataclass class HostCreateDirFrame: """Server → host: create a new directory on the host. @@ -489,6 +529,8 @@ class HostCreateDirResultFrame: | HostCreateWorktreeResultFrame | HostRemoveWorktreeFrame | HostRemoveWorktreeResultFrame + | HostListWorktreesFrame + | HostListWorktreesResultFrame | HostCreateDirFrame | HostCreateDirResultFrame ) @@ -676,6 +718,24 @@ def encode_host_frame(frame: HostFrame) -> str: "error": frame.error, } ) + if isinstance(frame, HostListWorktreesFrame): + return _encode_payload( + { + "kind": HostFrameKind.LIST_WORKTREES.value, + "request_id": frame.request_id, + "repo_path": frame.repo_path, + } + ) + if isinstance(frame, HostListWorktreesResultFrame): + return _encode_payload( + { + "kind": HostFrameKind.LIST_WORKTREES_RESULT.value, + "request_id": frame.request_id, + "status": frame.status, + "worktrees": frame.worktrees, + "error": frame.error, + } + ) if isinstance(frame, HostCreateDirFrame): return _encode_payload( { @@ -782,6 +842,10 @@ def _decode_known_host_frame( return _decode_remove_worktree(msg) case HostFrameKind.REMOVE_WORKTREE_RESULT: return _decode_remove_worktree_result(msg) + case HostFrameKind.LIST_WORKTREES: + return _decode_list_worktrees(msg) + case HostFrameKind.LIST_WORKTREES_RESULT: + return _decode_list_worktrees_result(msg) case HostFrameKind.CREATE_DIR: return _decode_create_dir(msg) case HostFrameKind.CREATE_DIR_RESULT: @@ -1032,6 +1096,41 @@ def _decode_remove_worktree_result( ) +def _decode_list_worktrees(msg: dict[str, Any]) -> HostListWorktreesFrame: + """Decode a host.list_worktrees request frame. + + :param msg: Decoded frame object. + :returns: Typed host.list_worktrees frame. + """ + return HostListWorktreesFrame( + request_id=_required_str(msg, "request_id"), + repo_path=_required_str(msg, "repo_path"), + ) + + +def _decode_list_worktrees_result( + msg: dict[str, Any], +) -> HostListWorktreesResultFrame: + """Decode a host.list_worktrees_result frame. + + :param msg: Decoded frame object. + :returns: Typed host.list_worktrees_result frame. + """ + raw = msg.get("worktrees") + if raw is not None: + if not isinstance(raw, list): + raise ValueError("frame field must be a list or null: 'worktrees'") + for entry in raw: + if not isinstance(entry, dict): + raise ValueError("each entry in 'worktrees' must be a JSON object") + return HostListWorktreesResultFrame( + request_id=_required_str(msg, "request_id"), + status=_required_str(msg, "status"), + worktrees=raw, + error=_optional_nullable_str(msg, "error"), + ) + + def _decode_create_dir(msg: dict[str, Any]) -> HostCreateDirFrame: """Decode a host.create_dir request frame. diff --git a/omnigent/host/git_worktree.py b/omnigent/host/git_worktree.py index 6839f633c2c..da592540829 100644 --- a/omnigent/host/git_worktree.py +++ b/omnigent/host/git_worktree.py @@ -171,6 +171,80 @@ def _main_work_tree(repo_path: str) -> str: raise WorktreeError(f"could not resolve main work tree for {repo_path}") +@dataclass +class WorktreeInfo: + """One entry from ``git worktree list``. + + :param path: Absolute worktree directory, e.g. + ``"/Users/alice/myrepo-worktrees/feature-login"``. + :param branch: Checked-out branch without the ``refs/heads/`` + prefix, e.g. ``"feature/login"``. ``None`` when the worktree + is in detached-HEAD state. + :param is_main: ``True`` for the repository's main work tree (the + first ``git worktree list`` record), ``False`` for linked + worktrees. + :param detached: ``True`` when the worktree has a detached HEAD + (no branch checked out). + """ + + path: str + branch: str | None + is_main: bool + detached: bool + + +def list_worktrees(*, repo_path: str) -> list[WorktreeInfo]: + """List the git worktrees of the repository containing ``repo_path``. + + Resolves the main work tree first (so a linked worktree resolves the + same list as the main checkout), then parses + ``git worktree list --porcelain``. The first record is always the + main work tree; the rest are linked worktrees. + + :param repo_path: Absolute path inside a git repository — the + directory the user picked, e.g. ``"/Users/alice/myrepo"``. + :returns: One :class:`WorktreeInfo` per worktree, main first. + :raises WorktreeError: If ``repo_path`` is not a directory or not + inside a git work tree, or if ``git worktree list`` fails. + """ + repo_root = _main_work_tree(repo_path) + result = _run_git(["worktree", "list", "--porcelain"], cwd=repo_root) + if result.returncode != 0: + raise _git_error("git worktree list failed", result) + + worktrees: list[WorktreeInfo] = [] + path: str | None = None + branch: str | None = None + detached = False + for line in result.stdout.splitlines(): + if line.startswith("worktree "): + path = line[len("worktree ") :].strip() + branch = None + detached = False + elif line.startswith("branch "): + ref = line[len("branch ") :].strip() + branch = ref[len("refs/heads/") :] if ref.startswith("refs/heads/") else ref + elif line == "detached": + detached = True + elif line == "" and path is not None: + # Blank line terminates a record. + worktrees.append( + WorktreeInfo( + path=path, + branch=branch, + is_main=not worktrees, + detached=detached, + ) + ) + path = None + # The porcelain output may omit a trailing blank line for the last record. + if path is not None: + worktrees.append( + WorktreeInfo(path=path, branch=branch, is_main=not worktrees, detached=detached) + ) + return worktrees + + def _local_branch_exists(repo_root: str, branch_name: str) -> bool: """Return whether a local branch already exists in the repo. diff --git a/omnigent/server/host_registry.py b/omnigent/server/host_registry.py index e13783b34bc..56f5a3f632a 100644 --- a/omnigent/server/host_registry.py +++ b/omnigent/server/host_registry.py @@ -212,6 +212,9 @@ class HostConnection: pending_remove_worktrees: dict[str, asyncio.Future[dict[str, Any]]] = field( default_factory=dict, ) + pending_list_worktrees: dict[str, asyncio.Future[dict[str, Any]]] = field( + default_factory=dict, + ) pending_create_dirs: dict[str, asyncio.Future[dict[str, Any]]] = field( default_factory=dict, ) diff --git a/omnigent/server/routes/_host_worktree.py b/omnigent/server/routes/_host_worktree.py index 2f06f7b788c..970deafc9d4 100644 --- a/omnigent/server/routes/_host_worktree.py +++ b/omnigent/server/routes/_host_worktree.py @@ -16,6 +16,7 @@ from omnigent.host.frames import ( HostCreateWorktreeFrame, + HostListWorktreesFrame, HostRemoveWorktreeFrame, encode_host_frame, ) @@ -228,3 +229,49 @@ async def remove_worktree_on_host( raise WorktreeProxyError( f"worktree removal failed: {result.get('error') or 'host reported no detail'}" ) + + +async def list_worktrees_on_host( + *, + host_registry: HostRegistry, + host_conn: HostConnection, + repo_path: str, +) -> list[dict[str, object]]: + """ + Send a ``host.list_worktrees`` frame and await the result. + + :param host_registry: Server-side registry; used to enqueue the + outbound frame on the host's send queue. + :param host_conn: Live host connection to list worktrees on. + :param repo_path: Absolute path inside the source repo on the + host — the canonical picked directory, e.g. + ``"/Users/alice/myrepo"``. + :returns: One dict per worktree with keys ``path``, ``branch``, + ``is_main``, ``detached`` (main first). + :raises WorktreeHostUnavailableError: If the host connection drops + or doesn't respond within :data:`_WORKTREE_TIMEOUT_S`. + :raises WorktreeProxyError: If the host reports a listing failure. + """ + request_id = secrets.token_hex(8) + frame = encode_host_frame( + HostListWorktreesFrame( + request_id=request_id, + repo_path=repo_path, + ) + ) + result = await _await_host_worktree_result( + host_registry=host_registry, + host_conn=host_conn, + pending=host_conn.pending_list_worktrees, + request_id=request_id, + frame=frame, + op="worktree listing", + ) + if result.get("status") != "ok": + raise WorktreeProxyError( + f"worktree listing failed: {result.get('error') or 'host reported no detail'}" + ) + worktrees = result.get("worktrees") + if not isinstance(worktrees, list): + raise WorktreeProxyError("host returned an incomplete worktree list") + return worktrees diff --git a/omnigent/server/routes/host_tunnel.py b/omnigent/server/routes/host_tunnel.py index 7f469f3478b..06f9674edaa 100644 --- a/omnigent/server/routes/host_tunnel.py +++ b/omnigent/server/routes/host_tunnel.py @@ -31,6 +31,7 @@ HostHelloFrame, HostLaunchRunnerResultFrame, HostListDirResultFrame, + HostListWorktreesResultFrame, HostRemoveWorktreeResultFrame, HostRunnerExitedFrame, HostStatResultFrame, @@ -530,6 +531,18 @@ async def _receive_loop( ) continue + if isinstance(frame, HostListWorktreesResultFrame): + list_wt_future = conn.pending_list_worktrees.pop(frame.request_id, None) + if list_wt_future is not None and not list_wt_future.done(): + list_wt_future.set_result( + { + "status": frame.status, + "worktrees": frame.worktrees, + "error": frame.error, + } + ) + continue + if isinstance(frame, HostCreateDirResultFrame): create_dir_future = conn.pending_create_dirs.pop(frame.request_id, None) if create_dir_future is not None and not create_dir_future.done(): diff --git a/omnigent/server/routes/hosts.py b/omnigent/server/routes/hosts.py index c7561d910a5..136c316aeea 100644 --- a/omnigent/server/routes/hosts.py +++ b/omnigent/server/routes/hosts.py @@ -917,4 +917,69 @@ async def create_host_directory( "path": result.get("path"), } + @router.get("/hosts/{host_id}/worktrees") + async def list_host_worktrees( + request: Request, + host_id: str, + path: str = Query(...), + ) -> dict[str, Any]: + """ + List the git worktrees of a repository on a host. + + Used by the Web UI's new-session worktree picker to show the + worktrees a session can start in directly. Owner-scoped exactly + like the filesystem browse endpoints; NOT scoped to a session. + A path that is not a git repository is reported as 400 so the + picker can quietly fall back to "no worktrees". + + :param request: FastAPI request (for auth). + :param host_id: Host identifier, e.g. ``"host_a1b2c3d4..."``. + :param path: Absolute path inside the repo on the host to list + worktrees for, e.g. ``"/Users/alice/myrepo"``. + :returns: ``{"object": "list", "data": [{path, branch, + is_main, detached}, ...]}`` (main first). + :raises HTTPException: 404 if host not found, 403 if not owned + by caller, 409 if host is offline/unresponsive, 400 on path + validation or a non-git path. + """ + from omnigent.server.routes._host_worktree import ( + WorktreeHostUnavailableError, + WorktreeProxyError, + list_worktrees_on_host, + ) + + # require_user: unauthenticated callers 401 instead of slipping + # past the owner check below as None. + user_id = require_user(request, auth_provider) + + host = await asyncio.to_thread(host_store.get_host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="host not found") + if user_id is not None and host.owner != user_id: + raise HTTPException(status_code=403, detail="not your host") + + if not path.strip(): + raise HTTPException(status_code=400, detail="path must not be empty") + if "\x00" in path: + raise HTTPException(status_code=400, detail="path must not contain NUL bytes") + + conn = host_registry.get(host.host_id) + if conn is None: + raise HTTPException(status_code=409, detail="host is offline") + + try: + worktrees = await list_worktrees_on_host( + host_registry=host_registry, + host_conn=conn, + repo_path=path, + ) + except WorktreeHostUnavailableError as exc: + raise HTTPException(status_code=409, detail=exc.message) from exc + except WorktreeProxyError as exc: + # Not a git repo / git failure — user-correctable; the picker + # treats this as "no worktrees here". + raise HTTPException(status_code=400, detail=exc.message) from exc + + return {"object": "list", "data": worktrees} + return router diff --git a/openapi.json b/openapi.json index c959721cba3..a8d783f7f3e 100644 --- a/openapi.json +++ b/openapi.json @@ -6544,6 +6544,62 @@ ] } }, + "/v1/hosts/{host_id}/worktrees": { + "get": { + "description": "List the git worktrees of a repository on a host.\n\nUsed by the Web UI's new-session worktree picker to show the\nworktrees a session can start in directly. Owner-scoped exactly\nlike the filesystem browse endpoints; NOT scoped to a session.\nA path that is not a git repository is reported as 400 so the\npicker can quietly fall back to \"no worktrees\".\n\n**Returns:** `{\"object\": \"list\", \"data\": [{path, branch, is_main, detached}, ...]}` (main first).\n\n**Raises**\n\n- `HTTPException` \u2014 404 if host not found, 403 if not owned by caller, 409 if host is offline/unresponsive, 400 on path validation or a non-git path.", + "operationId": "list_host_worktrees_v1_hosts__host_id__worktrees_get", + "parameters": [ + { + "description": "Host identifier, e.g. `\"host_a1b2c3d4...\"`.", + "in": "path", + "name": "host_id", + "required": true, + "schema": { + "title": "Host Id", + "type": "string" + } + }, + { + "description": "Absolute path inside the repo on the host to list worktrees for, e.g. `\"/Users/alice/myrepo\"`.", + "in": "query", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response List Host Worktrees V1 Hosts Host Id Worktrees Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Host Worktrees", + "tags": [ + "hosts" + ] + } + }, "/v1/info": { "get": { "description": "Runtime capabilities probe for the SPA + CLI.\n\nReturned at app boot by the frontend (and by `omnigent login` when it needs to choose between flows). Drives\nconditional route registration and chrome on the SPA side\n\u2014 when `accounts_enabled` is false, the SPA never\nregisters `/login`, `/register`, `/members` and\nnever renders the AccountMenu, so the bundle behaves\nidentically to a pre-PR-2008 build for header / OIDC\ndeploys (in particular, the internal hosted product that\nsyncs from this repo).\n\nAuthentication: this endpoint is intentionally UNAUTHED\nso the SPA can probe it before holding a session cookie.\nIt exposes no sensitive state \u2014 only the active auth\nsource, the login URL, whether first-run admin setup is\nstill pending (`needs_setup`), coarse capability\nbooleans (`databricks_features`,\n`managed_sandboxes_enabled`), the short sandbox\nprovider name (`sandbox_provider`) the web UI labels the\nnew-session sandbox option with, and the installed\n`server_version` (already public via `/api/version`).", diff --git a/tests/e2e_ui/start_session/test_start_session.py b/tests/e2e_ui/start_session/test_start_session.py index 6bb04dd3eee..6e8f1a12395 100644 --- a/tests/e2e_ui/start_session/test_start_session.py +++ b/tests/e2e_ui/start_session/test_start_session.py @@ -66,6 +66,9 @@ # ``…/filesystem/home/e2e/projects``; it never matches the bare # ``/v1/hosts`` list (no ``/filesystem`` segment). _FILESYSTEM_RE = re.compile(r"/v1/hosts/[^/]+/filesystem") +# The worktree-list endpoint the branch combobox queries for the picked repo. +# Distinct ``/worktrees`` segment, so it never collides with ``/filesystem``. +_WORKTREES_RE = re.compile(r"/v1/hosts/[^/]+/worktrees") def _run_in_fresh_loop(coro: Coroutine[Any, Any, None]) -> None: @@ -1731,6 +1734,101 @@ async def _drive_add_worktree(base_url: str, session_id: str) -> None: await browser.close() +def test_start_session_select_existing_worktree(seeded_session: tuple[str, str]) -> None: + """Picking an existing worktree starts in its directory with no git opts. + + The branch chip's input doubles as a combobox: focusing it lists the + repo's existing worktrees (``GET /v1/hosts/{id}/worktrees``). Selecting + one must (a) point the workspace at that worktree's directory and + (b) send NO ``git`` spec on ``POST /v1/sessions`` — the session starts + directly in the existing worktree rather than creating a new one. + """ + base_url, session_id = seeded_session + _run_in_fresh_loop(_drive_select_existing_worktree(base_url, session_id)) + + +async def _drive_select_existing_worktree(base_url: str, session_id: str) -> None: + async with async_playwright() as pw: + browser = await pw.chromium.launch() + page = await browser.new_page() + try: + create_bodies: list[dict[str, Any]] = [] + await _register_common_routes( + page, created_session_id=session_id, create_bodies=create_bodies + ) + + async def handle_worktrees(route: Route) -> None: + # The main tree (is_main) plus one linked worktree. The picker + # hides the main tree, so only "feature/x" is offered. + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps( + { + "object": "list", + "data": [ + { + "path": "/work/repo", + "branch": "main", + "is_main": True, + "detached": False, + }, + { + "path": "/work/repo-worktrees/feature-x", + "branch": "feature/x", + "is_main": False, + "detached": False, + }, + ], + } + ), + ) + + # Registered after the common routes so it wins for its URL. + await page.route(_WORKTREES_RE, handle_worktrees) + + await page.add_init_script( + f"""window.localStorage.setItem( + "omnigent:recent-workspaces", + JSON.stringify({{ {_HOST_ID}: ["/work/repo"] }}) + );""" + ) + + await page.goto(f"{base_url}/") + await page.get_by_test_id("new-chat-landing-input").wait_for( + state="visible", timeout=30_000 + ) + + # Open the worktree chip; focusing the branch combobox reveals the + # repo's existing (linked) worktrees. The main tree is filtered out, + # so only the one linked worktree is offered. + await page.get_by_test_id("new-chat-landing-branch-chip").click() + await page.get_by_test_id("new-chat-landing-branch-input").focus() + option = page.get_by_test_id("new-chat-landing-worktree-option") + await expect(option).to_have_count(1) + await expect(option).to_contain_text("feature/x") + await option.click() + + # The warning confirms the session will start in the existing + # worktree (rather than creating a new one). + await expect( + page.get_by_test_id("new-chat-landing-existing-worktree-warning") + ).to_be_visible() + + await page.get_by_test_id("new-chat-landing-input").fill("work in the worktree") + await page.get_by_test_id("new-chat-landing-submit").click() + + await _wait_until(lambda: len(create_bodies) == 1) + body = create_bodies[0] + assert body["host_id"] == _HOST_ID, body + # Workspace is the worktree dir; NO git spec is sent (starting in an + # existing worktree creates nothing). + assert body["workspace"] == "/work/repo-worktrees/feature-x", body + assert body.get("git") is None, body + finally: + await browser.close() + + # Session-bound agents the discovery scan returns. Both clone names below root # to the built-in "claude-native-ui", so the picker must drop both; the fork of # a fork (two nested suffixes) is the case a single-layer strip missed. diff --git a/tests/host/test_frames.py b/tests/host/test_frames.py index 2d7a9c21424..5280a2b58cb 100644 --- a/tests/host/test_frames.py +++ b/tests/host/test_frames.py @@ -18,6 +18,8 @@ HostListDirEntry, HostListDirFrame, HostListDirResultFrame, + HostListWorktreesFrame, + HostListWorktreesResultFrame, HostRemoveWorktreeFrame, HostRemoveWorktreeResultFrame, HostRunnerExitedFrame, @@ -837,6 +839,70 @@ def test_remove_worktree_result_frame_round_trip() -> None: assert decoded == original +# ── host.list_worktrees frames ────────────────────────── + + +def test_list_worktrees_frame_round_trip() -> None: + """Verify HostListWorktreesFrame survives encode → decode. + + A garbled repo_path would list the wrong repository's worktrees. + """ + original = HostListWorktreesFrame( + request_id="req_wt_ls_1", + repo_path="/Users/alice/myrepo", + ) + decoded = decode_host_frame(encode_host_frame(original)) + assert isinstance(decoded, HostListWorktreesFrame) + assert decoded == original + + +def test_list_worktrees_result_frame_round_trip() -> None: + """Verify HostListWorktreesResultFrame survives encode → decode. + + The worktree dicts feed the picker; a dropped or reshaped field + would break branch prefill / start-in-worktree selection. + """ + original = HostListWorktreesResultFrame( + request_id="req_wt_ls_1", + status="ok", + worktrees=[ + {"path": "/Users/alice/myrepo", "branch": "main", "is_main": True, "detached": False}, + { + "path": "/Users/alice/myrepo-worktrees/feature-login", + "branch": "feature/login", + "is_main": False, + "detached": False, + }, + ], + ) + decoded = decode_host_frame(encode_host_frame(original)) + assert isinstance(decoded, HostListWorktreesResultFrame) + assert decoded == original + + +def test_list_worktrees_result_frame_failure_round_trip() -> None: + """Verify a failed list-worktrees result carries its error and null list.""" + original = HostListWorktreesResultFrame( + request_id="req_wt_ls_1", + status="failed", + error="not a git repository", + ) + decoded = decode_host_frame(encode_host_frame(original)) + assert isinstance(decoded, HostListWorktreesResultFrame) + assert decoded.worktrees is None + assert decoded.error == "not a git repository" + + +def test_list_worktrees_result_frame_rejects_non_list() -> None: + """A non-list ``worktrees`` field is rejected, not coerced.""" + bad = ( + '{"kind": "host.list_worktrees_result", "request_id": "r", ' + '"status": "ok", "worktrees": "nope"}' + ) + with pytest.raises(ValueError, match="worktrees"): + decode_host_frame(bad) + + # ── host.create_dir frames ────────────────────────────── diff --git a/tests/host/test_git_worktree.py b/tests/host/test_git_worktree.py index 6cdc873b457..87606e56b0a 100644 --- a/tests/host/test_git_worktree.py +++ b/tests/host/test_git_worktree.py @@ -18,6 +18,7 @@ CreatedWorktree, WorktreeError, create_worktree, + list_worktrees, remove_worktree, validate_branch_name, ) @@ -315,6 +316,60 @@ def test_remove_worktree_missing_path_fails(git_repo: Path) -> None: assert "does not exist" in exc.value.message +def test_list_worktrees_returns_main_first(git_repo: Path) -> None: + """With no linked worktrees, only the main tree is listed.""" + result = list_worktrees(repo_path=str(git_repo)) + assert len(result) == 1 + main = result[0] + assert main.path == str(git_repo) + assert main.branch == "main" + assert main.is_main is True + assert main.detached is False + + +def test_list_worktrees_includes_linked(git_repo: Path) -> None: + """A created worktree shows up with its branch and is not flagged main.""" + created = create_worktree(repo_path=str(git_repo), branch_name="feature/login") + result = list_worktrees(repo_path=str(git_repo)) + # Main first, then the linked worktree. + assert result[0].is_main is True + linked = next(w for w in result if not w.is_main) + assert linked.path == created.worktree_path + assert linked.branch == "feature/login" + assert linked.detached is False + + +def test_list_worktrees_from_linked_resolves_same_list(git_repo: Path) -> None: + """Listing from inside a linked worktree resolves the main repo's full list.""" + created = create_worktree(repo_path=str(git_repo), branch_name="feature/a") + # Query from the linked worktree — should still see BOTH worktrees. + result = list_worktrees(repo_path=created.worktree_path) + paths = {w.path for w in result} + assert str(git_repo) in paths + assert created.worktree_path in paths + + +def test_list_worktrees_reports_detached_head(git_repo: Path) -> None: + """A detached-HEAD worktree lists with ``branch=None`` and ``detached=True``.""" + head = _rev_parse(git_repo) + wt = git_repo.parent / "myrepo-worktrees" / "detached" + wt.parent.mkdir(parents=True, exist_ok=True) + # Add a worktree checked out at a bare commit → detached HEAD. + _git(git_repo, "worktree", "add", "--detach", str(wt), head) + result = list_worktrees(repo_path=str(git_repo)) + detached = next(w for w in result if w.path == str(wt)) + assert detached.branch is None + assert detached.detached is True + + +def test_list_worktrees_non_git_path_fails(tmp_path: Path) -> None: + """A non-git directory fails loud (the route maps this to 'no worktrees').""" + plain = (tmp_path / "plain").resolve() + plain.mkdir() + with pytest.raises(WorktreeError): + list_worktrees(repo_path=str(plain)) + + @pytest.mark.parametrize( "bad", [ diff --git a/tests/server/integration/test_hosts_worktrees.py b/tests/server/integration/test_hosts_worktrees.py new file mode 100644 index 00000000000..42ea7db8e3a --- /dev/null +++ b/tests/server/integration/test_hosts_worktrees.py @@ -0,0 +1,239 @@ +""" +Integration tests for ``GET /v1/hosts/{id}/worktrees``. + +Wires up a real host tunnel + REST router pair, drives a fake host +that auto-replies to ``host.list_worktrees`` frames, and exercises +the endpoint's contract end-to-end. Backs the Web UI's new-session +worktree picker (branch prefill / start-in-existing-worktree). +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from typing import Any + +import pytest +from asgiref.testing import ApplicationCommunicator +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from omnigent.host.frames import ( + HostHelloFrame, + HostListWorktreesFrame, + HostListWorktreesResultFrame, + decode_host_frame, + encode_host_frame, +) +from omnigent.server.host_registry import HostRegistry +from omnigent.server.routes.host_tunnel import create_host_tunnel_router +from omnigent.server.routes.hosts import create_hosts_router +from omnigent.stores.conversation_store.sqlalchemy_store import ( + SqlAlchemyConversationStore, +) +from omnigent.stores.host_store import HostStore + +# Same liveness-race flake mitigation as test_hosts_filesystem: the +# mock-WS host can be deregistered under parallel CI load, yielding a +# spurious 409. Tests are sub-second; retry masks the race. +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.flaky(reruns=2, reruns_delay=1), +] + +_HOST_ID = "host_wt_test" +_HOST_NAME = "wt-test-laptop" + + +def _websocket_scope(path: str) -> dict[str, object]: + """Build a minimal ASGI WebSocket scope. + + :param path: WebSocket path, e.g. ``"/v1/hosts/X/tunnel"``. + :returns: ASGI scope dict. + """ + return { + "type": "websocket", + "asgi": {"version": "3.0"}, + "scheme": "ws", + "path": path, + "raw_path": path.encode("ascii"), + "query_string": b"", + "headers": [], + "client": ("127.0.0.1", 50000), + "server": ("testserver", 80), + "subprotocols": [], + } + + +def _hello_text(name: str = _HOST_NAME) -> str: + """Encode a hello frame for tests. + + :param name: Host name reported in the hello frame. + :returns: JSON-encoded hello frame. + """ + return encode_host_frame( + HostHelloFrame(version="0.1.0-test", frame_protocol_version=1, name=name) + ) + + +@pytest.fixture() +def wt_app( + db_uri: str, +) -> tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore]: + """ + App with host tunnel + REST routes for worktree-list tests. + + :param db_uri: SQLite URI fixture. + :returns: (app, registry, host_store, conv_store). + """ + registry = HostRegistry() + host_store = HostStore(db_uri) + conv_store = SqlAlchemyConversationStore(db_uri) + app = FastAPI() + app.include_router(create_host_tunnel_router(registry, host_store), prefix="/v1") + app.include_router( + create_hosts_router(registry, host_store, conv_store), + prefix="/v1", + ) + return app, registry, host_store, conv_store + + +@pytest.fixture() +async def wt_setup( + wt_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore], +) -> AsyncIterator[ + tuple[FastAPI, HostRegistry, ApplicationCommunicator, dict[str, dict[str, Any]]] +]: + """ + Connect a mock host and auto-reply to list_worktrees frames. + + Tests register fake replies in ``replies`` (repo_path → reply + dict) before calling the REST endpoint. The auto-replier decodes + outbound frames and resolves the matching pending future — the + same wiring host_tunnel.py does in production. + + :param wt_app: The fixture above. + :returns: Async iterator yielding the wired-up state. + """ + app, registry, _hs, _cs = wt_app + path = f"/v1/hosts/{_HOST_ID}/tunnel" + comm = ApplicationCommunicator(app, _websocket_scope(path)) + await comm.send_input({"type": "websocket.connect"}) + accepted = await comm.receive_output(timeout=1.0) + assert accepted["type"] == "websocket.accept" + await comm.send_input({"type": "websocket.receive", "text": _hello_text()}) + while registry.get(_HOST_ID) is None: + await asyncio.sleep(0.01) + + replies: dict[str, dict[str, Any]] = {} + stop_drain = asyncio.Event() + + async def _drain() -> None: + """Drain outbound WS frames and feed back the configured reply.""" + while not stop_drain.is_set(): + try: + output = await comm.receive_output(timeout=0.5) + except asyncio.TimeoutError: + continue + if output.get("type") != "websocket.send": + continue + text = output.get("text") + if not isinstance(text, str): + continue + frame = decode_host_frame(text) + if not isinstance(frame, HostListWorktreesFrame): + continue + reply = replies.get(frame.repo_path) + if reply is None: + reply_frame = HostListWorktreesResultFrame( + request_id=frame.request_id, + status="failed", + error="not a git repository", + ) + else: + reply_frame = HostListWorktreesResultFrame( + request_id=frame.request_id, + status=reply.get("status", "ok"), + worktrees=reply.get("worktrees"), + error=reply.get("error"), + ) + await comm.send_input( + {"type": "websocket.receive", "text": encode_host_frame(reply_frame)} + ) + + drain_task = asyncio.create_task(_drain()) + try: + yield app, registry, comm, replies + finally: + stop_drain.set() + try: + await asyncio.wait_for(drain_task, timeout=1.0) + except asyncio.TimeoutError: + drain_task.cancel() + + +async def test_list_worktrees_returns_data( + wt_setup: tuple[FastAPI, HostRegistry, ApplicationCommunicator, dict[str, dict[str, Any]]], +) -> None: + """The endpoint returns ``{"object": "list", "data": [...]}`` from the host.""" + app, _reg, _comm, replies = wt_setup + replies["/Users/corey/repo"] = { + "worktrees": [ + {"path": "/Users/corey/repo", "branch": "main", "is_main": True, "detached": False}, + { + "path": "/Users/corey/repo-worktrees/feature-x", + "branch": "feature/x", + "is_main": False, + "detached": False, + }, + ], + } + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get( + f"/v1/hosts/{_HOST_ID}/worktrees", + params={"path": "/Users/corey/repo"}, + ) + assert resp.status_code == 200, resp.text + payload = resp.json() + assert payload["object"] == "list" + branches = [w["branch"] for w in payload["data"]] + assert branches == ["main", "feature/x"] + assert payload["data"][1]["is_main"] is False + + +async def test_list_worktrees_non_git_path_400( + wt_setup: tuple[FastAPI, HostRegistry, ApplicationCommunicator, dict[str, dict[str, Any]]], +) -> None: + """A non-git path (host reports failed) maps to 400 so the picker shows nothing.""" + app, _reg, _comm, _replies = wt_setup + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + # No reply registered → the drain replies "failed: not a git repository". + resp = await client.get( + f"/v1/hosts/{_HOST_ID}/worktrees", + params={"path": "/tmp/not-a-repo"}, + ) + assert resp.status_code == 400, resp.text + + +async def test_list_worktrees_missing_path_param_422( + wt_setup: tuple[FastAPI, HostRegistry, ApplicationCommunicator, dict[str, dict[str, Any]]], +) -> None: + """The ``path`` query param is required.""" + app, _reg, _comm, _replies = wt_setup + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get(f"/v1/hosts/{_HOST_ID}/worktrees") + assert resp.status_code == 422, resp.text + + +async def test_list_worktrees_unknown_host_404( + wt_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore], +) -> None: + """An unknown host id yields 404 (existence is gated before the offline check).""" + app, _reg, _hs, _cs = wt_app + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get( + f"/v1/hosts/{_HOST_ID}/worktrees", + params={"path": "/Users/corey/repo"}, + ) + # No host record was created (no tunnel connected) → 404, not 409. + assert resp.status_code == 404, resp.text diff --git a/web/src/hooks/useHostWorktrees.ts b/web/src/hooks/useHostWorktrees.ts new file mode 100644 index 00000000000..073dee7e003 --- /dev/null +++ b/web/src/hooks/useHostWorktrees.ts @@ -0,0 +1,84 @@ +import { useQuery } from "@tanstack/react-query"; + +import { authenticatedFetch } from "@/lib/identity"; + +/** + * One worktree of a repository, as returned by + * ``GET /v1/hosts/{id}/worktrees``. Mirrors the host's + * ``git worktree list`` output. + */ +export interface HostWorktree { + /** + * Absolute worktree directory on the host, e.g. + * ``"/Users/alice/myrepo-worktrees/feature-login"``. + */ + path: string; + /** + * Checked-out branch without the ``refs/heads/`` prefix, e.g. + * ``"feature/login"``. ``null`` when the worktree is in + * detached-HEAD state. + */ + branch: string | null; + /** + * ``true`` for the repository's main work tree. The picker hides + * it — starting "in the main repo" is just picking the directory. + */ + is_main: boolean; + /** ``true`` when the worktree has a detached HEAD (no branch). */ + detached: boolean; +} + +interface HostWorktreesResponse { + object: string; + data: HostWorktree[]; +} + +/** + * Fetch the git worktrees of a repository on a host. + * + * A 400 response means the path is not a git repository (or git + * failed) — the picker treats that as "no worktrees here", so we + * resolve to an empty list rather than throwing. Other non-OK + * responses throw so React Query surfaces the error. + * + * @param hostId Host identifier, e.g. ``"host_a1b2..."``. + * @param repoPath Absolute path inside the repo to list worktrees for. + * @returns The repository's worktrees (main first), or ``[]`` when the + * path is not a git repository. + */ +async function fetchHostWorktrees(hostId: string, repoPath: string): Promise { + const params = new URLSearchParams({ path: repoPath }); + const res = await authenticatedFetch( + `/v1/hosts/${encodeURIComponent(hostId)}/worktrees?${params.toString()}`, + ); + if (res.status === 400) { + // Not a git repository — no worktrees to offer. + return []; + } + if (!res.ok) { + throw new Error(`host worktrees fetch failed: HTTP ${res.status}`); + } + const body = (await res.json()) as HostWorktreesResponse; + return body.data; +} + +/** + * React Query hook: list the git worktrees of a repository on a host. + * + * Lazy — only fires when both ``hostId`` and ``repoPath`` are set. + * Cached per (host, repoPath). A non-git path resolves to an empty + * list (see {@link fetchHostWorktrees}). + * + * @param hostId Host id, e.g. ``"host_a1b2..."``. ``null`` disables. + * @param repoPath Absolute repo path. ``null`` disables. + * @returns React Query result with ``data: HostWorktree[]``. + */ +export function useHostWorktrees(hostId: string | null, repoPath: string | null) { + return useQuery({ + queryKey: ["host-worktrees", hostId, repoPath], + queryFn: () => fetchHostWorktrees(hostId as string, repoPath as string), + enabled: hostId !== null && repoPath !== null && repoPath !== "", + staleTime: 5_000, + placeholderData: (prev) => prev, + }); +} diff --git a/web/src/shell/NewChatDialog.flow.test.tsx b/web/src/shell/NewChatDialog.flow.test.tsx index c188ba47da0..8e040b4e91f 100644 --- a/web/src/shell/NewChatDialog.flow.test.tsx +++ b/web/src/shell/NewChatDialog.flow.test.tsx @@ -58,6 +58,9 @@ vi.mock("@/hooks/useHostFilesystem", () => ({ // an idle mutation keeps it inert for these tests. useCreateHostDirectory: () => ({ mutateAsync: vi.fn(), isPending: false }), })); +vi.mock("@/hooks/useHostWorktrees", () => ({ + useHostWorktrees: () => ({ data: undefined }), +})); // No other sessions in scope — keep the conflict hooks inert so they don't // issue their own /health fetch or surface a warning. The warning is covered // in NewChatDialog.test.tsx. diff --git a/web/src/shell/NewChatDialog.test.tsx b/web/src/shell/NewChatDialog.test.tsx index 13833bc04d6..6201d1a709d 100644 --- a/web/src/shell/NewChatDialog.test.tsx +++ b/web/src/shell/NewChatDialog.test.tsx @@ -17,6 +17,7 @@ import { matchSkillInvocation, normalizeWorkspacePath, sessionsSharingDirectory, + worktreePathTail, NewChatLandingScreen, resetLandingDraft, } from "./NewChatDialog"; @@ -26,6 +27,7 @@ import { authenticatedFetch } from "@/lib/identity"; import { useHosts, type Host } from "@/hooks/useHosts"; import { useAvailableAgents, type AvailableAgent } from "@/hooks/useAvailableAgents"; import { useHostFilesystem, type HostFilesystemEntry } from "@/hooks/useHostFilesystem"; +import { useHostWorktrees } from "@/hooks/useHostWorktrees"; import { useDirectorySessions } from "@/hooks/useDirectorySessions"; import { useRunnerHealthRegistration } from "@/hooks/RunnerHealthProvider"; import type { Conversation } from "@/hooks/useConversations"; @@ -47,6 +49,9 @@ vi.mock("@/hooks/useHostFilesystem", () => ({ // an idle mutation keeps it inert for these tests. useCreateHostDirectory: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })), })); +// Mocked so it doesn't hit authenticatedFetch (which would pollute the +// call list the create-flow assertions index into positionally). +vi.mock("@/hooks/useHostWorktrees", () => ({ useHostWorktrees: vi.fn() })); vi.mock("@/hooks/useDirectorySessions", () => ({ useDirectorySessions: vi.fn(), })); @@ -86,6 +91,7 @@ const authenticatedFetchMock = vi.mocked(authenticatedFetch); const useHostsMock = vi.mocked(useHosts); const useAvailableAgentsMock = vi.mocked(useAvailableAgents); const useHostFilesystemMock = vi.mocked(useHostFilesystem); +const useHostWorktreesMock = vi.mocked(useHostWorktrees); const useDirectorySessionsMock = vi.mocked(useDirectorySessions); const useRunnerHealthMock = vi.mocked(useRunnerHealthRegistration); const setPendingInitialPromptMock = vi.mocked(setPendingInitialPrompt); @@ -349,6 +355,18 @@ describe("sandbox repository helpers", () => { ])("deriveRepoName(%j) === %j", (url, expected) => { expect(deriveRepoName(url)).toBe(expected); }); + + it.each<[string, string]>([ + // Deep path → leading ellipsis + last two segments (the disambiguating tail). + ["/Users/me/myrepo-worktrees/feature-x", "…/myrepo-worktrees/feature-x"], + // Two-or-fewer segments → returned unchanged (nothing useful to trim). + ["/Users", "/Users"], + ["/a/b", "/a/b"], + // Trailing slash doesn't create an empty tail segment. + ["/Users/me/myrepo-worktrees/feature-x/", "…/myrepo-worktrees/feature-x"], + ])("worktreePathTail(%j) === %j", (path, expected) => { + expect(worktreePathTail(path)).toBe(expected); + }); }); // deriveHomeDir resolves the working-directory default for a first-ever @@ -560,6 +578,7 @@ function setupLandingMocks() { useHostsMock.mockReset(); useAvailableAgentsMock.mockReset(); useHostFilesystemMock.mockReset(); + useHostWorktreesMock.mockReset(); useDirectorySessionsMock.mockReset(); useRunnerHealthMock.mockReset(); setOmnigentHostConfig({}); @@ -578,6 +597,9 @@ function setupLandingMocks() { error: null, isPlaceholderData: false, } as unknown as ReturnType); + useHostWorktreesMock.mockReturnValue({ + data: undefined, + } as unknown as ReturnType); mockHosts([host("online")]); mockAgents([ { @@ -1032,6 +1054,157 @@ describe("NewChatLandingScreen", () => { expect(screen.queryByTestId("workspace-picker-conflict")).toBeNull(); }); + it("lists existing worktrees and starts directly in a selected one (no git opts)", async () => { + // The seeded repo has one linked worktree; the main tree is filtered out. + useHostWorktreesMock.mockReturnValue({ + data: [ + { path: "/Users/corey/repo", branch: "main", is_main: true, detached: false }, + { + path: "/Users/corey/repo-worktrees/feature-x", + branch: "feature/x", + is_main: false, + detached: false, + }, + ], + } as unknown as ReturnType); + authenticatedFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ id: "conv_new" }), + } as unknown as Response); + renderLanding(); + await waitFor(() => + expect(screen.getByTestId("new-chat-landing-workspace-chip").textContent).toContain("repo"), + ); + + // Open the worktree popover, focus the branch combobox to reveal the + // existing-worktree dropdown, and select the one linked worktree. + fireEvent.click(screen.getByTestId("new-chat-landing-branch-chip")); + fireEvent.focus(screen.getByTestId("new-chat-landing-branch-input")); + const options = screen.getAllByTestId("new-chat-landing-worktree-option"); + expect(options).toHaveLength(1); // main tree excluded + expect(options[0].textContent).toContain("feature/x"); + // onMouseDown (fires before the input's blur) drives selection. + fireEvent.mouseDown(options[0]); + + // Selecting a worktree auto-closes the popover. + await waitFor(() => expect(screen.queryByTestId("new-chat-landing-branch-input")).toBeNull()); + + // Reopen the chip: the warning shows and the branch field is prefilled with + // the selected worktree's branch. + fireEvent.click(screen.getByTestId("new-chat-landing-branch-chip")); + await screen.findByTestId("new-chat-landing-existing-worktree-warning"); + expect((screen.getByTestId("new-chat-landing-branch-input") as HTMLInputElement).value).toBe( + "feature/x", + ); + + fireEvent.change(screen.getByTestId("new-chat-landing-input"), { + target: { value: "work in the worktree" }, + }); + fireEvent.submit(screen.getByTestId("new-chat-landing-composer")); + + await waitFor(() => expect(authenticatedFetchMock).toHaveBeenCalledTimes(1)); + const [, init] = authenticatedFetchMock.mock.calls[0]; + const body = JSON.parse((init as RequestInit).body as string) as Record; + // Workspace is bound straight to the worktree dir; NO git opts are sent + // (starting in an existing worktree creates nothing). + expect(body.workspace).toBe("/Users/corey/repo-worktrees/feature-x"); + expect(body.git).toBeUndefined(); + }); + + it("creates a new worktree when the prefilled branch name is edited", async () => { + useHostWorktreesMock.mockReturnValue({ + data: [ + { + path: "/Users/corey/repo-worktrees/feature-x", + branch: "feature/x", + is_main: false, + detached: false, + }, + ], + } as unknown as ReturnType); + authenticatedFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ id: "conv_new" }), + } as unknown as Response); + renderLanding(); + await waitFor(() => + expect(screen.getByTestId("new-chat-landing-workspace-chip").textContent).toContain("repo"), + ); + + fireEvent.click(screen.getByTestId("new-chat-landing-branch-chip")); + fireEvent.focus(screen.getByTestId("new-chat-landing-branch-input")); + fireEvent.mouseDown(screen.getByTestId("new-chat-landing-worktree-option")); + // Selection auto-closes the popover — reopen to edit the prefilled branch. + await waitFor(() => expect(screen.queryByTestId("new-chat-landing-branch-input")).toBeNull()); + fireEvent.click(screen.getByTestId("new-chat-landing-branch-chip")); + await screen.findByTestId("new-chat-landing-existing-worktree-warning"); + + // Edit the branch away from the prefill: now it's a NEW worktree request. + fireEvent.change(screen.getByTestId("new-chat-landing-branch-input"), { + target: { value: "feature/y" }, + }); + // Warning gone once the name diverges from the existing worktree's branch. + expect(screen.queryByTestId("new-chat-landing-existing-worktree-warning")).toBeNull(); + + fireEvent.change(screen.getByTestId("new-chat-landing-input"), { + target: { value: "branch off" }, + }); + fireEvent.submit(screen.getByTestId("new-chat-landing-composer")); + + await waitFor(() => expect(authenticatedFetchMock).toHaveBeenCalledTimes(1)); + const [, init] = authenticatedFetchMock.mock.calls[0]; + const body = JSON.parse((init as RequestInit).body as string) as { + git?: { branch_name: string }; + }; + // A new worktree for the edited branch name is requested. + expect(body.git?.branch_name).toBe("feature/y"); + }); + + it("filters the worktree dropdown as you type in the branch combobox", async () => { + useHostWorktreesMock.mockReturnValue({ + data: [ + { + path: "/Users/corey/repo-worktrees/feature-x", + branch: "feature/x", + is_main: false, + detached: false, + }, + { + path: "/Users/corey/repo-worktrees/bugfix-login", + branch: "bugfix/login", + is_main: false, + detached: false, + }, + ], + } as unknown as ReturnType); + renderLanding(); + await waitFor(() => + expect(screen.getByTestId("new-chat-landing-workspace-chip").textContent).toContain("repo"), + ); + + fireEvent.click(screen.getByTestId("new-chat-landing-branch-chip")); + // Radix autofocuses the branch combobox on open, so the dropdown of both + // worktrees shows immediately (a focus event keeps it open in jsdom too). + fireEvent.focus(screen.getByTestId("new-chat-landing-branch-input")); + expect(screen.getAllByTestId("new-chat-landing-worktree-option")).toHaveLength(2); + + // Typing in the branch field narrows to matching branch/path substrings. + fireEvent.change(screen.getByTestId("new-chat-landing-branch-input"), { + target: { value: "bugfix" }, + }); + const options = screen.getAllByTestId("new-chat-landing-worktree-option"); + expect(options).toHaveLength(1); + expect(options[0].textContent).toContain("bugfix/login"); + + // A name matching nothing hides the dropdown entirely — that name becomes + // a NEW worktree on submit rather than selecting an existing one. + fireEvent.change(screen.getByTestId("new-chat-landing-branch-input"), { + target: { value: "brand-new-branch" }, + }); + expect(screen.queryByTestId("new-chat-landing-worktree-dropdown")).toBeNull(); + expect(screen.queryByTestId("new-chat-landing-worktree-option")).toBeNull(); + }); + it("shows no conflict banner when no live session shares the directory", async () => { // Default setup: no other directory sessions → nothing to warn about. renderLanding(); diff --git a/web/src/shell/NewChatDialog.tsx b/web/src/shell/NewChatDialog.tsx index f0a529da24d..b82e17dc843 100644 --- a/web/src/shell/NewChatDialog.tsx +++ b/web/src/shell/NewChatDialog.tsx @@ -87,6 +87,7 @@ import { useRecentWorkspaces } from "@/hooks/useRecentWorkspaces"; import { useDirectorySessions } from "@/hooks/useDirectorySessions"; import { useRunnerHealthRegistration } from "@/hooks/RunnerHealthProvider"; import { useHostFilesystem, type HostFilesystemEntry } from "@/hooks/useHostFilesystem"; +import { useHostWorktrees } from "@/hooks/useHostWorktrees"; import { useNativeServerSwitcherForMainSurface } from "@/hooks/useNativeServerSwitcher"; import type { WorkspaceFile } from "@/hooks/useWorkspaceChangedFiles"; import type { Conversation } from "@/hooks/useConversations"; @@ -387,6 +388,22 @@ export function normalizeWorkspacePath(path: string): string | null { return stripped === "" ? "/" : stripped; } +/** + * Shorten an absolute path to its last two segments with a leading + * ellipsis, so worktree rows show the disambiguating tail (e.g. + * ``"…/myrepo-worktrees/feature-x"``) instead of a shared prefix that + * truncates to the same string for every entry. + * + * @param path Absolute path, e.g. ``"/Users/me/myrepo-worktrees/feature-x"``. + * @returns The tail, prefixed with ``"…/"`` when segments were dropped; + * the original path when it already has two or fewer segments. + */ +export function worktreePathTail(path: string): string { + const segments = path.replace(/\/+$/, "").split("/").filter(Boolean); + if (segments.length <= 2) return path; + return `…/${segments.slice(-2).join("/")}`; +} + /** * Existing sessions that would share an on-disk working directory with a new * session created in ``workspace`` on ``hostId``. @@ -1669,6 +1686,7 @@ type LandingDraft = { workspace: string; branchName: string; baseBranch: string; + prefilledBranch: string; permissionMode: string; approvalMode: string; bypassSandbox: boolean; @@ -1836,6 +1854,13 @@ export function NewChatLandingScreen() { const [workspace, setWorkspace] = useState(() => landingDraft?.workspace ?? ""); const [branchName, setBranchName] = useState(() => landingDraft?.branchName ?? ""); const [baseBranch, setBaseBranch] = useState(() => landingDraft?.baseBranch ?? ""); + // Branch prefilled from the existing worktree the current workspace points + // at. When `branchName` still equals this, the session starts directly in + // that worktree (no git opts). Editing the field away from it means the user + // wants a *new* worktree off that name. + const [prefilledBranch, setPrefilledBranch] = useState( + () => landingDraft?.prefilledBranch ?? "", + ); // Project to file the new session under (an implicit collection stored as a // conversation_labels row). Empty = unfiled. Applied right after create. // Pre-filled from a `?project=` query param so the sidebar's per-project @@ -1907,6 +1932,8 @@ export function NewChatLandingScreen() { }, []); // Controls the working-directory popover so picking a directory closes it. const [workspacePopoverOpen, setWorkspacePopoverOpen] = useState(false); + // Controlled so selecting an existing worktree can close the popover. + const [worktreePopoverOpen, setWorktreePopoverOpen] = useState(false); const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); // "Connect a host" instructions modal, opened from the host dropdown. @@ -1929,6 +1956,7 @@ export function NewChatLandingScreen() { workspace, branchName, baseBranch, + prefilledBranch, permissionMode, approvalMode, bypassSandbox, @@ -2153,6 +2181,67 @@ export function NewChatLandingScreen() { return counts; }, [conflictCandidates, runnerHealth]); + // Existing git worktrees of the picked directory's repo, for the + // worktree picker. Skipped for sandbox sessions (server-managed) and + // when no directory is picked. A non-git path resolves to []. + const worktreesEnabled = !sandboxSelected && selectedHostId !== null && workspaceTrimmed !== ""; + const { data: hostWorktrees } = useHostWorktrees( + worktreesEnabled ? selectedHostId : null, + worktreesEnabled ? workspaceTrimmed : null, + ); + // Linked worktrees (exclude the main work tree — "starting in the main + // repo" is just picking that directory, not selecting a worktree). + const linkedWorktrees = useMemo( + () => (hostWorktrees ?? []).filter((w) => !w.is_main), + [hostWorktrees], + ); + // The worktree the picked directory currently points at, if any. Set when + // the user navigated the picker straight into a worktree folder, or clicked + // one in the list below. + const activeWorktree = useMemo(() => { + const target = normalizeWorkspacePath(workspaceTrimmed); + if (target === null) return null; + return linkedWorktrees.find((w) => normalizeWorkspacePath(w.path) === target) ?? null; + }, [linkedWorktrees, workspaceTrimmed]); + // When the workspace lands on an existing worktree, prefill the branch + // field with its branch and remember it as the prefill. Leaving the + // worktree clears the prefill (but not a name the user typed themselves). + useEffect(() => { + const branch = activeWorktree?.branch ?? ""; + if (branch !== "") { + setPrefilledBranch(branch); + setBranchName(branch); + } else { + setPrefilledBranch((prev) => { + // Only clear the field if it still holds the previous prefill — + // don't wipe a branch name the user typed for a new worktree. + setBranchName((cur) => (cur === prev ? "" : cur)); + return ""; + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeWorktree?.path]); + // True when the session should start directly in the existing worktree: + // the workspace is a worktree and the branch field still holds its + // prefilled branch (the user hasn't edited it to request a new worktree). + const startInExistingWorktree = + activeWorktree !== null && prefilledBranch !== "" && branchName.trim() === prefilledBranch; + // A new, isolated worktree is created only when a branch is named and the + // workspace isn't already sitting on that existing worktree. + const shouldCreateWorktree = branchName.trim() !== "" && !startInExistingWorktree; + // The branch input doubles as a combobox: focusing it reveals existing + // worktrees, and what the user types filters them (match on branch or path + // substring, case-insensitive). Typing a name that matches none = a new + // worktree; picking a match = start in that existing worktree. + const [branchInputFocused, setBranchInputFocused] = useState(false); + const filteredWorktrees = useMemo(() => { + const q = branchName.trim().toLowerCase(); + if (q === "") return linkedWorktrees; + return linkedWorktrees.filter( + (w) => (w.branch ?? "").toLowerCase().includes(q) || w.path.toLowerCase().includes(q), + ); + }, [linkedWorktrees, branchName]); + // Sandbox repo inputs are valid when blank (empty workspace), or when // the URL passes the shape check; a branch without a URL is dangling. const sandboxRepoValid = @@ -2333,6 +2422,8 @@ export function NewChatLandingScreen() { : sandboxSelected ? sandboxLabel : (selectedHost?.name ?? (onlineHosts.length === 0 ? "No hosts" : "Select host")); + // The chip shows just the branch (the "(existing)" distinction lives in the + // popover's warning; appending it here only gets clipped by the chip's cap). const worktreeLabel = branchName.trim() || "No worktree"; // Sandbox repository chip label: repo name (server's clone-dir rule) // plus the pinned branch, e.g. "repo#main"; placeholder when unset. @@ -2428,6 +2519,11 @@ export function NewChatLandingScreen() { setCreateError(null); try { const trimmedBranch = branchName.trim(); + // `shouldCreateWorktree` (component scope): true only when a branch is + // named and the workspace isn't already an existing worktree. Starting + // in an existing worktree sends no git opts — the workspace is bound + // straight to that dir, which also sidesteps the "branch already + // exists" guard. const agent = agentList.find((a) => a.id === effectiveAgentId); const nativeLabels = nativeWrapperLabelsForAgent(agent); const agentSupportsPermissionMode = nativeAgentHasCapability(agent, "permissionMode"); @@ -2452,7 +2548,7 @@ export function NewChatLandingScreen() { // Launch the runner on the selected host. The multipart create // only stores DB rows — launchRunner binds + starts the runner. if (!sandboxSelected && selectedHostId && workspaceTrimmed) { - const gitOpts = trimmedBranch + const gitOpts = shouldCreateWorktree ? { branchName: trimmedBranch, baseBranch: baseBranch.trim() || undefined } : undefined; await launchRunner(selectedHostId, data.id, workspaceTrimmed, gitOpts); @@ -2474,7 +2570,7 @@ export function NewChatLandingScreen() { : { host_id: selectedHostId, workspace: workspaceTrimmed, - git: trimmedBranch + git: shouldCreateWorktree ? { branch_name: trimmedBranch, base_branch: baseBranch.trim() || undefined } : undefined, }), @@ -3229,11 +3325,12 @@ export function NewChatLandingScreen() { } onNavigate={setWorkspace} // Warn when browsing into a directory other live agents - // occupy. Suppressed once a git branch is named — that - // starts an isolated worktree, so there's no shared-dir - // conflict regardless of the picked directory. + // occupy. Suppressed only when a NEW isolated worktree + // will be created (no shared-dir conflict then). When + // starting directly in an existing worktree the branch + // is prefilled but the dir IS shared, so keep warning. occupancyForPath={ - branchName.trim() === "" + !shouldCreateWorktree ? (abs) => occupancyByDir.get(normalizeWorkspacePath(abs) ?? "") ?? 0 : undefined } @@ -3248,7 +3345,7 @@ export function NewChatLandingScreen() { {/* Git worktree chip — hidden for sandbox sessions (worktree creation requires a caller-supplied host_id). */} {!sandboxSelected && ( - + - +
- setBranchName(e.target.value)} - placeholder="feature/my-branch" - className="rounded-md border border-input bg-background px-3 py-2 text-xs outline-none transition-colors focus-visible:border-ring" - data-testid="new-chat-landing-branch-input" - /> - {branchName.trim() !== "" && ( + {/* Help text sits above the field. The warning for a picked + existing worktree stays below the input (contextual to the + selection). */} +

+ New branch name, or pick an existing worktree. Leave blank to start directly + in the working directory. +

+ {/* The branch field is a combobox: focusing it reveals the + repo's existing worktrees, and typing filters them. + Picking one starts in that worktree; a name matching none + creates a new worktree. */} +
+ setBranchName(e.target.value)} + onFocus={() => setBranchInputFocused(true)} + // Delay so a click on a dropdown option registers + // before the list unmounts on blur. + onBlur={() => setTimeout(() => setBranchInputFocused(false), 120)} + placeholder="feature/my-branch" + role="combobox" + aria-expanded={branchInputFocused && filteredWorktrees.length > 0} + aria-autocomplete="list" + // Suppress the browser's native autofill dropdown so it + // doesn't overlay our worktree combobox. `off` alone is + // ignored by some browsers, so also disable spellcheck / + // autocorrect and give it an unrecognized name. + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck={false} + name="omnigent-worktree-branch" + className="rounded-md border border-input bg-background px-3 py-2 text-xs outline-none transition-colors focus-visible:border-ring" + data-testid="new-chat-landing-branch-input" + /> + {branchInputFocused && filteredWorktrees.length > 0 && ( +
+ + Existing worktrees + +
    + {filteredWorktrees.map((w) => { + const selected = + normalizeWorkspacePath(w.path) === + normalizeWorkspacePath(workspaceTrimmed); + return ( +
  • + +
  • + ); + })} +
+
+ )} +
+ {/* Base branch only matters when creating a NEW worktree + — hidden once the workspace points at an existing one + (no worktree is created, so there's nothing to base). */} + {branchName.trim() !== "" && !startInExistingWorktree && ( setBaseBranch(e.target.value)} - placeholder="Base branch (defaults to current branch)" + placeholder="Base branch (defaults to current)" aria-label="Base branch" className="rounded-md border border-input bg-background px-3 py-2 text-xs outline-none transition-colors focus-visible:border-ring" data-testid="new-chat-landing-base-branch-input" /> )} -

- Creates an isolated git worktree for a new branch. Leave blank to start - directly in the working directory. -

+ {startInExistingWorktree && ( +

+ Starts in existing worktree, edit the name to create a new one. +

+ )}
From c641d0deff579a27b249e9b95399544c1c0fe8ca Mon Sep 17 00:00:00 2001 From: Serena Ruan <82044803+serena-ruan@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:57:40 +0800 Subject: [PATCH 058/546] feat(web): make sidebar Search open the command palette (#2086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): make sidebar Search open the command palette The sidebar's "Search sessions" box was an inline filter that only narrowed the visible list. Session search (title + chat content) already lives in the ⌘K command palette, so point the box at it instead of duplicating a weaker filter. - Sidebar: replace the search input with a "Search" button that opens the palette, showing a ⌘K badge on hover/focus. Drop the inline searchQuery/debounce state; the list is now unfiltered. - CommandPalette: list Sessions above Actions (the palette doubles as the session-search entry point). Cap the session list to 5 while the query is empty so Actions stays visible without scrolling; typing lifts the cap. Indent session rows to align with the icon-prefixed actions. Placeholder → "Search sessions or run a command". - AppShell: wire the button to the palette; mount the palette in embedded mode too (the ⌘K hotkey stays disabled there). Co-authored-by: Isaac * test(e2e-ui): regenerate visual baselines * test(e2e-ui): retarget sidebar search tests to the command palette The sidebar's "Search sessions" input became a "Search" button that opens the command palette, so the two E2E tests that located the old searchbox were failing. - test_sidebar_hotkeys: probe sidebar collapse/expand width via the "Search" button (data-testid=sidebar-search-button) instead of the removed search input. - test_sidebar_search: drive the server-side search round-trip through the palette (opened from the Search button) — matching query lists the session, non-matching empties it — the same chain the old inline filter exercised. Co-authored-by: Isaac * test(e2e-ui): fix sidebar search tests for the palette (verified locally) The first retarget pass had two real bugs, both now reproduced and fixed against a local live server + Chromium: - test_bracket_chord: the collapse probe measured the search control's width, but the new Search button (a flex item, min-width:auto) floors at its content width and stays 260px on collapse — the old input shrank to 0. Probe the sidebar
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

x<|e z`?SwdBf87{DQ*$7#eqHmsraMIQ+_2HD5nUD7UpWHJBVi2wV%SY@pc!umv`RmBQws* zCfD|8ADS@%oT;h%nF=ZV8uO$0%}5pu==MtC-e{je@2E`GwQG&-w83%@_KE89{;k4jHZXvc1!|fm%z#7Uy=DZ zNjngpV!A}X2q^cyOYO%QsTMy?dHpW5Q^l$#&UZd(CHu(RQU36ao(dz)TJVna#Yu0X=?9Z z1A>`3ld`of${hN`=RRox?g&5HP7X}v7hrxY(#fTNT8bz;i`!8wrINHr0PHhex}n5) zIlH~ylr?d}YqD+gRC9fk94J%Pw8=hvWLVXX^_WtbFRS1z|KORe+R9(=I~y$wxUuO>iIJ01GF*QW ztm1G2sL$W}X;&wI`v@v(^Pzi35nq;|3n7l;Blw=HGX57;8NW0^-Wjp*h&bRSzE8;k zwR~__Jj7+V`SBDnmECHox=`u(Nz;`!j65$_+njorgcYQdm_*jHIZnZl-Ta?wbhbug9 zoc(qBt^LU86ys1Tf%b{-NS{xDilSsQ_UdP<<~P`zvH*04q`y9uD~*4vx+uh#8}g8R z)@_^4rrR_lUuzb-fl_?wkNv(!2-Iyqo)mPYB#Qo@#?CS-u3%ZzcyP}Q65QS02`+)) z?hu>=cXyZI?(Xh`ySo#DySv=UdFQV8e!gEbv-hms-PP6I)nC=_8Vg0)H!E>o%`-d+ z(m7W9uz~ayWS!w0vqmk^E(1u6J`~!vu3h~efXr{MjfBLXm-$XaI1{5+YHSu)T(DcZ z=a$XCq_KnPYoGDS>*8TL_vp}$1nh`kdypQB|M9}Qv9Wl>IRwUBnMB8U!K0$SC$t?h zlcmi4di z-cOAOM2xZ)wtO}IT>{b75pQkfW^bgEj}n4a(>{_9t=X;ow!Y%ZPe$gDPKloS%NY=T$jv*E;>>N zxW%#3mg!mWri#viEcqc#B?2T1Z^*)Lo4Ubkr@o6|UN` zO6=P6?Oz>B-;v0YSa)G4z%-a_E8#;d=*3|*;Z;3Mu2N|Xa*=^#B95|a%+Ei|08TDw4Qg3zD3NZR%_4Uy-I zqXK;8Y*AH&p87z181bwhvI|m8-UM7EII=H@;%|G{tEsCU-|%(inBZ#j#T{WS)b}3CK9cTh<=bCLDhzWahHQ?0ma)Z}G!RsDw~tXfZF zcu8Tw-9lBD)c$RrqNL)hR%hM4m&SWo)C|6!8=s2_KHwMJ;^G#$i|o8M9nLu>$3v3( zv)aYNLA4$+wgtAxaeMft{XqVG)XwilN$IQ4zr|KnbPWYcWZiVX9UsSkV|sWzWvtfQ z1z@a~+LEXra~#~t-uT0l?-LT&(Fpvs3_xrkbe^B6YZ-3(X%|sKDcTqWjR5~S!N6C% zLUue{i&0JMlP$7}&lntTlDpVv?^c%#%LrZ%*1$k@_k**RGFv~bdCHl^4#uN4+Ih{N zlIxw7qIG=P(3Rxpp^vdTTpAO!M;(YVihBd(UI?YjBp9FMgN5Ga zj;0-NFF=9^0%*WiV`Wtvp{m`k*-UU##i(5Tt%!UfJ_7_qT{%ZR@lXFHBU68dW!!7V`*t$pDGODB5LlRglWn zHv=?U|5xHPQU3)}r3h(`lJwH3^Ki?*DwzF_`YuVl?!%Kyd>PX)f6g`=1K}_jVK-3Bny_@Hkd; zyvqYqH9>szrk~fJsk4);rYz&y%Tpvsz=D**so4z466*ZE+iXOMR*IA}zm^hx-tU5* zEugCXpIM;&`ci|}69v#{y-`5W^liEPjrr!x?&&}ar4d<4R+_T&upDVtOF{afas5a4 zlfcBZkn7zA*HaU{*#>JSaPc<~rFb?U??+ygX;gu2bRausng!OCBICHdd^d)gMfpR7 zKVDE{hI{L}HhX|*BeNFG1z^qAKL|g3pU`|7L(om__6D-ogXFt&Ymjqy z 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 Date: Fri, 17 Jul 2026 19:47:13 -0700 Subject: [PATCH 455/546] Slack integration to support approval / elicitation flow (#2820) Slack integration to support approval / elicitation flow --- integrations/slack/.gitignore | 10 - integrations/slack/.python-version | 1 - integrations/slack/README.md | 194 ++- integrations/slack/pyproject.toml | 47 +- integrations/slack/src/omnigent_slack/app.py | 34 + .../slack/src/omnigent_slack/approvals.py | 405 +++++ .../slack/src/omnigent_slack/auth_manager.py | 4 +- .../slack/src/omnigent_slack/dispatcher.py | 70 - .../slack/src/omnigent_slack/events.py | 448 ++++++ .../slack/src/omnigent_slack/notifications.py | 47 + .../slack/src/omnigent_slack/oauth.py | 5 +- .../slack/src/omnigent_slack/omnigent.py | 635 +++++--- .../slack/src/omnigent_slack/service.py | 1111 ++++++++++--- .../slack/src/omnigent_slack/setup.py | 4 +- .../slack/src/omnigent_slack/tokens.py | 4 +- integrations/slack/tests/test_approvals.py | 316 ++++ integrations/slack/tests/test_auth_manager.py | 9 +- integrations/slack/tests/test_client_auth.py | 1 - integrations/slack/tests/test_config.py | 3 +- integrations/slack/tests/test_dispatcher.py | 99 -- .../slack/tests/test_notifications.py | 40 + integrations/slack/tests/test_oauth.py | 1 - integrations/slack/tests/test_omnigent.py | 582 ++++++- integrations/slack/tests/test_service.py | 1137 +++++++++++++- integrations/slack/tests/test_setup.py | 11 +- integrations/slack/tests/test_tokens.py | 1 - integrations/slack/uv.lock | 1370 ----------------- pyproject.toml | 17 + uv.lock | 9 - 29 files changed, 4501 insertions(+), 2114 deletions(-) delete mode 100644 integrations/slack/.gitignore delete mode 100644 integrations/slack/.python-version create mode 100644 integrations/slack/src/omnigent_slack/approvals.py delete mode 100644 integrations/slack/src/omnigent_slack/dispatcher.py create mode 100644 integrations/slack/src/omnigent_slack/events.py create mode 100644 integrations/slack/src/omnigent_slack/notifications.py create mode 100644 integrations/slack/tests/test_approvals.py delete mode 100644 integrations/slack/tests/test_dispatcher.py create mode 100644 integrations/slack/tests/test_notifications.py delete mode 100644 integrations/slack/uv.lock diff --git a/integrations/slack/.gitignore b/integrations/slack/.gitignore deleted file mode 100644 index 26219620316..00000000000 --- a/integrations/slack/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -.env -.venv/ -.uv-cache/ -__pycache__/ -*.py[cod] -.pytest_cache/ -.ruff_cache/ -.mypy_cache/ -data/*.sqlite3 -data/*.sqlite3-* diff --git a/integrations/slack/.python-version b/integrations/slack/.python-version deleted file mode 100644 index e4fba218358..00000000000 --- a/integrations/slack/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 diff --git a/integrations/slack/README.md b/integrations/slack/README.md index 4fb4c7362c0..1adc5003626 100644 --- a/integrations/slack/README.md +++ b/integrations/slack/README.md @@ -10,10 +10,8 @@ Omnigent identity against it. 1. Create a Slack app with Socket Mode **and** Interactivity enabled (Socket Mode delivers the interactive button/modal payloads — no request URL needed). -2. Add bot scopes for `app_mentions:read`, `chat:write`, `im:write` (to DM users - the setup button), `commands` (for the `/omnigent` slash command), - `team:read` (to label the login request with the workspace name), and the - history scopes for the channel types where the bot will run. +2. Add the OAuth scopes and event subscriptions listed under **Required scopes** + below. 3. Add a slash command `/omnigent` (Features → Slash Commands). In Socket Mode the request URL is ignored, so any placeholder works. 4. Install the app into the workspace. @@ -24,6 +22,51 @@ Omnigent identity against it. accepted as an authorized device-grant client. 6. Run the bot — see **Running the bot** below. +## Required scopes + +The bot uses two tokens, each carrying different scopes. + +### Bot token scopes (`OMNIGENT_SLACK_BOT_TOKEN`, `xoxb-…`) + +Add these under **OAuth & Permissions → Scopes → Bot Token Scopes**. All are +required for the bot's core behaviour: + +| Scope | Why it's needed | +| --- | --- | +| `app_mentions:read` | Receive `app_mention` events — the only way the bot joins a channel thread. | +| `chat:write` | Post, delete, and stream replies (`chat.postMessage`, `chat.delete`, `chat.startStream`), including ephemeral setup nudges (`chat.postEphemeral`). | +| `im:write` | Open a DM with the user (`conversations.open`) to send the setup button and logout confirmation. | +| `im:history` | Read direct messages. DMs are a first-class entry point and do **not** fire `app_mention`, so without this the bot can't respond in DMs. | +| `commands` | Register and receive the `/omnigent` slash command. | +| `team:read` | Read the workspace name (`team.info`) to label the delegated-login request. | + +**Channel history — add per channel type where the bot will run.** These back +the plain-`message` event; add only the ones matching where you'll use the bot: + +| Scope | Channel type | +| --- | --- | +| `channels:history` | Public channels | +| `groups:history` | Private channels | +| `mpim:history` | Group DMs | + +If you only use the bot via DMs and channel `@mention`s, `im:history` alone is +enough and the three channel-history scopes can be omitted. + +### App-level token scope (`OMNIGENT_SLACK_APP_TOKEN`, `xapp-…`) + +| Scope | Why it's needed | +| --- | --- | +| `connections:write` | Open the Socket Mode connection. Socket Mode fails to connect without it. | + +### Event subscriptions + +Under **Event Subscriptions → Subscribe to bot events**, add: + +- `app_mention` +- `message.im` (DMs) +- `message.channels` / `message.groups` / `message.mpim` — only for the channel + types whose history scope you added above. + ## Running the bot @@ -52,14 +95,7 @@ The bot lives in the separate `omnigent-slack` package, which must be installed to find it. Install it as the `slack` extra of omnigent: ```bash -uv pip install "omnigent[slack]" # or, from a source checkout: uv sync --extra slack -``` - -If it isn't installed, the command prints this hint. From a source checkout you -can also run the entry point directly, without the `omni` CLI: - -```bash -uv run omnigent-slack +uv tool install "omnigent[slack]" # or, from a source checkout: uv sync --extra slack ``` Set `LOG_LEVEL=DEBUG` in `.env` when diagnosing why Slack events are not producing replies. @@ -175,38 +211,116 @@ message, the bot opens a fresh streaming reply in the same thread and keeps going, so a long answer arrives live across as many messages as it needs. Replies in that Slack thread continue the same Omnigent session. A channel thread belongs to whoever started it; a follow-up `@mention` from a different -user is not added to that session. +user is not added to that session — that user instead gets a private +("Only visible to you") note explaining why, and pointing them to start their +own thread. + +### Multi-agent turns + +`session.status: idle` is an ambiguous turn boundary, so at each idle the bot +waits before deciding the turn is over — on two timescales: + +- **Settle (short, ~2s).** A single agent oscillates `running`/`idle` *while + still streaming* its answer, with sub-second gaps between bursts. Every idle + first waits a brief settle window for the next burst, so a reply is never + truncated mid-answer. A genuinely final idle adds only this small tail. +- **Snapshot + poll (coarse).** If still quiet after the settle, the bot checks + the session's rolled-up status, which reads `running` while any sub-agent + child is still working (a fan-out orchestrator like `debby` parked between + wake cycles). While running it polls (every 5s, up to a 10-minute cap) so a + slow sub-agent keeps the turn alive; otherwise the turn ends. + +While the agent works before the first tokens arrive, the thread shows a +"Working on it…" placeholder. It's removed only once the reply is actually on +screen — on the first streamed chunk, or after the finalizing flush for a +buffered answer — so there's never an empty gap between the placeholder +disappearing and the reply appearing. + +### Approvals & questions + +When the agent needs the user — a tool-call approval, or a multiple-choice +question — the turn pauses and the bot surfaces it in the thread. It renders +the server's `response.elicitation_request` in one of three ways: + +- **Approval** (a gated tool call): an **Approve / Deny** card with a preview of + the pending action. Click to resume; deny (or let it sit past the wait + window) to refuse. +- **Question** (Claude's `AskUserQuestion` and equivalents): the choices render + as radio buttons (or checkboxes for multi-select) with a **Submit** — the + selected labels are sent back to the agent as its answer, exactly like the + web UI. +- **Free-form input** (a request for typed values the bot can't collect with + buttons): the bot posts a link to resolve the request in the Omnigent web UI, + rather than mishandling it. The turn stays alive (via the idle grace window) + so it resumes once you answer there. + +The classification is by the *decision shape*, not the server's delivery mode. +The server defaults to `url`-mode elicitations (carrying a suggested standalone +approve page), but the bot still renders a `url`-mode approval or question +natively and posts the verdict to the resolve endpoint — only genuinely +uncollectable typed input falls back to the link. + +The card is updated in place with the outcome once answered, and the "Working +on it…" placeholder is cleared while parked so it doesn't sit stale. Multiple +requests in one turn are handled in order. + +This mirrors the web UI and CLI — the bot consumes `response.elicitation_request` +and posts the verdict (with any selections as `content`) back to the session's +resolve endpoint. + +While a request is outstanding the turn stays open, so a message sent to that +thread meanwhile is deflected like any other mid-turn message (see below). If the +user answers the request in the web UI instead of clicking the Slack card, the +bot notices (it polls for external resolution) and continues without waiting. An +unanswered card gives up after a few minutes so it can't hold the thread open +indefinitely — the user can just re-send. + +**One turn at a time per thread.** Each turn opens its own event stream, so the +bot runs one turn per thread at a time — a second concurrent stream would render +into Slack twice. There is no queue. A message that arrives *while a thread is +still streaming* is not run: the bot privately ("Only visible to you") tells the +user it's still working and to re-send once it has replied, or to continue right +now in the web UI (which accepts concurrent input and shows any pending actions). +A message to a thread that is idle again runs normally — Slack stays a full +conversational surface, not just a way to kick a session off. Messages that race +the check are safe regardless: the server buffers a message that lands mid-turn +and runs it as a continuation. + +**Ordering.** A streamed reply is a single Slack message anchored to the moment +it opened, so text kept flowing into it would sort *before* any card or notice +posted mid-turn — inverting cause and effect. The bot avoids this by *sealing* +the current reply at each interruption (approval card, policy/file notice): the +answer so far ends there, the out-of-band message sorts after it, and anything +the agent says next opens a fresh reply below. So the thread reads in true +order — reply, card, continued reply — even across several approvals in one turn. + +### Turn progress + +Beyond the streamed answer, the bot surfaces a few other signals when the +harness emits them: + +- **Thinking** — while the agent reasons before producing output, the + placeholder switches to a "Thinking…" indicator so a long think isn't silent. +- **Plan / todos** — a task list (from harnesses that report one, e.g. Claude + Code's `TodoWrite`) is posted once and edited in place as items progress. +- **Blocked by policy** — when a tool call is hard-blocked by policy (a DENY, + with no approval offered), the bot posts why, so an absent action is + explained rather than silent. +- **Produced files** — a note naming any file artifact the agent generated. + +All are best-effort and never interrupt the answer stream. ## Development This integration is a **separate package** (`omnigent-slack`) with heavy deps -(slack_bolt, aiohttp) kept out of the core `omnigent` install. Working on the -integration in isolation uses its own env: +(slack_bolt, aiohttp) kept out of the core `omnigent` install. It resolves as an +editable path dep of the root `omnigent` package via the `slack` extra (see +`[tool.uv.sources]` in the root `pyproject.toml`), and shares the root's dev +tooling (ruff, mypy, pytest) and config rather than carrying its own. Work on it +from the repo-root env: ```bash -# From integrations/slack/ — the integration's own env (slack_bolt, etc.): -uv run pytest -uv run ruff check -uv run mypy src -uv run omnigent-slack # run the bot directly +# From the repo root — add the slack extra to your existing extras: +uv sync --extra slack # e.g. --extra all --extra dev --extra slack +uv run omni integration slack ``` - -To drive the bot through the `omni integration slack` CLI, install it **into the -same environment as** `omni` via the `slack` extra — the CLI shells out to -`python -m omnigent_slack` and only finds it on the `omni` interpreter's path. -In a source checkout the extra resolves `omnigent-slack` from -`integrations/slack` as an editable path dep (see `[tool.uv.sources]` in the -root `pyproject.toml`): - -```bash -# From the repo root (the omnigent core env): -uv sync --extra slack # add to your existing extras, e.g. --extra all --extra dev --extra slack - -# Then, from anywhere: -omni integration slack status -omni integration slack start -``` - -Without the extra, `omni integration slack …` prints an install hint rather -than launching. The editable path dep means source edits are picked up on the -next daemon (re)start — no reinstall needed. diff --git a/integrations/slack/pyproject.toml b/integrations/slack/pyproject.toml index 73d48d6abe7..d153ae43bce 100644 --- a/integrations/slack/pyproject.toml +++ b/integrations/slack/pyproject.toml @@ -1,9 +1,13 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + [project] name = "omnigent-slack" version = "0.1.0" description = "Slack Socket Mode bot that drives Omnigent sessions." readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.12" dependencies = [ "aiosqlite>=0.21.0", "aiohttp>=3.12.0", @@ -15,42 +19,5 @@ dependencies = [ "slack-sdk>=3.43.0", ] -[project.scripts] -omnigent-slack = "omnigent_slack.__main__:main" - -[dependency-groups] -dev = [ - "mypy>=1.16.0", - "pytest>=8.4.0", - "pytest-asyncio>=1.0.0", - "respx>=0.22.0", - "ruff>=0.12.0", -] - -[build-system] -requires = ["uv_build>=0.8.0,<0.9.0"] -build-backend = "uv_build" - -[tool.ruff] -line-length = 100 -target-version = "py311" -exclude = [".uv-cache", ".venv", "docs"] - -[tool.ruff.lint] -select = ["E", "F", "I", "UP", "B", "ASYNC"] - -[tool.mypy] -python_version = "3.11" -strict = true -warn_unreachable = true - -[[tool.mypy.overrides]] -module = [ - "slack_bolt.*", - "slack_sdk.*", -] -ignore_missing_imports = true - -[tool.pytest.ini_options] -asyncio_mode = "auto" -testpaths = ["tests"] +[tool.hatch.build.targets.wheel] +packages = ["src/omnigent_slack"] diff --git a/integrations/slack/src/omnigent_slack/app.py b/integrations/slack/src/omnigent_slack/app.py index 2f55f49f0d7..2ffd135eeae 100644 --- a/integrations/slack/src/omnigent_slack/app.py +++ b/integrations/slack/src/omnigent_slack/app.py @@ -7,6 +7,14 @@ from slack_bolt.adapter.socket_mode.aiohttp import AsyncSocketModeHandler from slack_bolt.async_app import AsyncApp +from omnigent_slack.approvals import ( + ACTION_APPROVE, + ACTION_DENY, + ACTION_FORM_ANSWER, + ACTION_FORM_CANCEL, + ACTION_FORM_SUBMIT, + route_elicitation_click, +) from omnigent_slack.auth_manager import AuthManager, pack_user_key from omnigent_slack.config import load_settings from omnigent_slack.omnigent import OmnigentClientPool @@ -110,3 +118,29 @@ async def handle_message( if not body.get("team_id") and not event.get("team"): return await service.handle_message(body=body, event=event, client=client, context=context) + + @app.action(ACTION_APPROVE) + async def handle_approve(ack: Any, body: dict[str, Any], client: Any) -> None: + await ack() + await route_elicitation_click(service, client, body, accepted=True) + + @app.action(ACTION_DENY) + async def handle_deny(ack: Any, body: dict[str, Any], client: Any) -> None: + await ack() + await route_elicitation_click(service, client, body, accepted=False) + + @app.action(ACTION_FORM_SUBMIT) + async def handle_form_submit(ack: Any, body: dict[str, Any], client: Any) -> None: + await ack() + await route_elicitation_click(service, client, body, accepted=True, is_form_submit=True) + + @app.action(ACTION_FORM_CANCEL) + async def handle_form_cancel(ack: Any, body: dict[str, Any], client: Any) -> None: + await ack() + await route_elicitation_click(service, client, body, accepted=False, is_form_submit=True) + + @app.action(ACTION_FORM_ANSWER) + async def handle_form_answer(ack: Any) -> None: + # Radio/checkbox selection changes are read from state.values at submit + # time; ack each change so Slack doesn't flag an unhandled interaction. + await ack() diff --git a/integrations/slack/src/omnigent_slack/approvals.py b/integrations/slack/src/omnigent_slack/approvals.py new file mode 100644 index 00000000000..019edd0b3fd --- /dev/null +++ b/integrations/slack/src/omnigent_slack/approvals.py @@ -0,0 +1,405 @@ +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from typing import Any, Protocol + +from omnigent_slack.omnigent import ElicitationRequest +from omnigent_slack.text import truncate_for_slack + +_logger = logging.getLogger(__name__) + +# Block Kit action ids. Binary approve/deny each carry the resolve target in +# their ``value``; the form Submit does too, while the per-question radio/ +# checkbox inputs are read from the submit payload's ``state.values``. +ACTION_APPROVE = "omnigent_approve_tool" +ACTION_DENY = "omnigent_deny_tool" +ACTION_FORM_SUBMIT = "omnigent_form_submit" +ACTION_FORM_CANCEL = "omnigent_form_cancel" +# The radio/checkbox inputs share this action id; they need a (no-op) handler +# registered so Slack doesn't flag an unhandled interaction, but their values +# are read from ``state.values`` at submit time, not on each change. +ACTION_FORM_ANSWER = "omnigent_form_answer" + +# Per-question input blocks are keyed ``omnigent_q::`` so the +# submit handler can map each answer back to its question without extra state. +_QUESTION_BLOCK_PREFIX = "omnigent_q::" + +# How long the turn worker waits for a click before giving up (and declining, so +# the server-side park releases). Bounded so an unanswered request can't hold the +# thread's turn open indefinitely — while a turn streams, follow-up messages to +# that thread are deflected, so a parked card would block them until it clears. +# Kept short: a user who's engaging answers within a couple of minutes; if they've +# walked away, failing fast frees the thread (they can re-send). Note this is only +# the cap — an answer via the web UI unblocks immediately (external-resolution poll). +DEFAULT_ELICITATION_TIMEOUT_SECONDS = 3 * 60 + + +@dataclass(frozen=True, slots=True) +class Verdict: + """A user's answer to an elicitation. + + ``accepted`` picks the MCP action; ``content`` carries form answers for a + form elicitation, else ``None``. As delivered from the click handler the + answers are option indices (``{question_key: index|indices}``); the service + maps them to full labels via :func:`resolve_form_answers` before forwarding. + """ + + accepted: bool + content: dict[str, Any] | None = None + + +class ElicitationCoordinator: + """Bridges the turn worker (which blocks awaiting a verdict) and the Slack + button handler (which delivers it). + + The worker registers a future keyed by ``elicitation_id`` and awaits it; + the block-action handler resolves that future when the user answers. Both + run on the same asyncio loop (slack_bolt's), so setting the future's result + from the handler is safe. + """ + + def __init__(self, timeout_seconds: float = DEFAULT_ELICITATION_TIMEOUT_SECONDS) -> None: + # All access is on the single slack_bolt event loop (register/await from + # the turn worker, resolve from the block-action handler), so plain dict + # ops are safe without a lock. + self._pending: dict[str, asyncio.Future[Verdict]] = {} + self._timeout = timeout_seconds + + def register(self, elicitation_id: str) -> None: + """Register a waiter for ``elicitation_id`` synchronously. + + Must be called BEFORE the approval card is posted, so a fast click can't + arrive at :meth:`resolve` before the future exists (a lost wakeup that + would silently drop the verdict). :meth:`await_verdict` then awaits it. + """ + self._pending[elicitation_id] = asyncio.get_running_loop().create_future() + + async def await_verdict(self, elicitation_id: str) -> Verdict | None: + """Block on the pre-:meth:`register`ed future until answered or timeout. + + Returns the :class:`Verdict`, or ``None`` when no one answered within + the timeout (the caller then declines so the server doesn't hang). + Registers on demand if the caller skipped :meth:`register` (keeps the + method usable standalone, e.g. in tests). + """ + future = self._pending.get(elicitation_id) + if future is None: + self.register(elicitation_id) + future = self._pending[elicitation_id] + try: + return await asyncio.wait_for(future, timeout=self._timeout) + except TimeoutError: + return None + finally: + self._pending.pop(elicitation_id, None) + + def resolve(self, elicitation_id: str, verdict: Verdict) -> bool: + """Deliver a verdict for a waiting elicitation. + + Returns whether a live waiter was found — ``False`` means the answer + arrived after the worker gave up (timeout) or a duplicate click, so the + caller can note the request already closed. + """ + future = self._pending.get(elicitation_id) + if future is None or future.done(): + return False + future.set_result(verdict) + return True + + +def _resolve_value(request: ElicitationRequest, owner_user_id: str) -> str: + # " " — carried on every control so the + # handler can (a) route the verdict to the right session and (b) verify the + # clicking user is the thread owner before resolving (authorization gate). + return f"{owner_user_id} {request.session_id} {request.elicitation_id}" + + +def elicitation_card_blocks( + request: ElicitationRequest, owner_user_id: str +) -> list[dict[str, Any]]: + """Block Kit blocks for a pending elicitation. + + A form elicitation (``AskUserQuestion``) renders each question as a + radio/checkbox input plus a Submit; a binary elicitation renders Approve / + Deny. Both controls carry the resolve target AND the owner id, so a + non-owner's click can be rejected even though the card is visible to the + whole channel. + """ + if request.is_form: + return _form_card_blocks(request, owner_user_id) + return _binary_card_blocks(request, owner_user_id) + + +def _binary_card_blocks(request: ElicitationRequest, owner_user_id: str) -> list[dict[str, Any]]: + value = _resolve_value(request, owner_user_id) + prompt = truncate_for_slack(request.message, limit=2000) + blocks: list[dict[str, Any]] = [ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f":lock: *Approval needed*\n{prompt}"}, + } + ] + if request.content_preview: + preview = truncate_for_slack(request.content_preview, limit=2500) + blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": f"```{preview}```"}}) + blocks.append( + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Approve"}, + "style": "primary", + "action_id": ACTION_APPROVE, + "value": value, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Deny"}, + "style": "danger", + "action_id": ACTION_DENY, + "value": value, + }, + ], + } + ) + return blocks + + +def _form_card_blocks(request: ElicitationRequest, owner_user_id: str) -> list[dict[str, Any]]: + value = _resolve_value(request, owner_user_id) + prompt = truncate_for_slack(request.message, limit=2000) + blocks: list[dict[str, Any]] = [ + {"type": "section", "text": {"type": "mrkdwn", "text": f":speech_balloon: {prompt}"}} + ] + for question in request.questions: + # Slack caps the option value at 75 chars, but the agent needs the FULL + # label — so carry the option INDEX as the value (short, unique) and + # display the (possibly truncated) label as text. The index is mapped + # back to the untruncated label at resolve time (`resolve_form_answers`). + options = [ + { + "text": {"type": "plain_text", "text": _plain(opt.label)}, + "value": str(index), + } + for index, opt in enumerate(question.options) + ] + element = { + "type": "checkboxes" if question.multi_select else "radio_buttons", + "action_id": ACTION_FORM_ANSWER, + "options": options, + } + blocks.append( + { + "type": "section", + "block_id": f"{_QUESTION_BLOCK_PREFIX}{_plain(question.key, limit=200)}", + "text": {"type": "mrkdwn", "text": f"*{_plain(question.question, limit=140)}*"}, + "accessory": element, + } + ) + blocks.append( + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Submit"}, + "style": "primary", + "action_id": ACTION_FORM_SUBMIT, + "value": value, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Cancel"}, + "action_id": ACTION_FORM_CANCEL, + "value": value, + }, + ], + } + ) + return blocks + + +def resolved_card_blocks(request: ElicitationRequest, *, outcome: str) -> list[dict[str, Any]]: + """Blocks that replace the card once answered (no controls). + + ``outcome`` is a short past-tense label (``"Approved"``, ``"Denied"``, + ``"Answered"``, ``"Timed out"``, ``"Cancelled"``). + """ + icon = { + "Approved": ":white_check_mark:", + "Answered": ":white_check_mark:", + # "Answered elsewhere" covers accept OR reject in the web UI — neutral + # icon since we don't know which way it went. + "Answered elsewhere": ":information_source:", + "Denied": ":no_entry:", + "Cancelled": ":no_entry:", + }.get(outcome, ":hourglass:") + text = f"{icon} *{outcome}*\n{truncate_for_slack(request.message, limit=2000)}" + if outcome == "Timed out": + # A timeout declines server-side so the thread's queue is freed; tell the + # user the request was dropped and that re-sending starts a fresh attempt. + text += "\n_No response in time — I declined it. Send your message again to retry._" + return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}] + + +def _plain(text: str, limit: int = 75) -> str: + # Slack option text/value are capped (75 chars for option value/text). + return text if len(text) <= limit else text[: limit - 1] + "…" + + +@dataclass(frozen=True, slots=True) +class ClickTarget: + """The routing/authorization data carried on an elicitation control.""" + + owner_user_id: str + session_id: str + elicitation_id: str + + +def parse_action_value(value: str) -> ClickTarget | None: + """Parse a control ``value`` into its owner / session / elicitation ids.""" + parts = value.split(" ", 2) + if len(parts) != 3 or not all(parts): + return None + return ClickTarget(owner_user_id=parts[0], session_id=parts[1], elicitation_id=parts[2]) + + +def parse_form_answers(state_values: dict[str, Any]) -> dict[str, Any]: + """Build the ``{question_key: option_index}`` map from a submit's ``state.values``. + + Reads each ``omnigent_q::`` input block: a radio yields the single + selected option's value; checkboxes yield the list of selected values. + Option values are the option INDEX (as a string), not the label — the label + can exceed Slack's 75-char value cap, so it's carried by index and mapped + back to the full label in :func:`resolve_form_answers`. Unanswered questions + are omitted. + """ + answers: dict[str, Any] = {} + for block_id, actions in state_values.items(): + if not isinstance(block_id, str) or not block_id.startswith(_QUESTION_BLOCK_PREFIX): + continue + if not isinstance(actions, dict): + continue + state = actions.get(ACTION_FORM_ANSWER) + if not isinstance(state, dict): + continue + key = block_id[len(_QUESTION_BLOCK_PREFIX) :] + selected = state.get("selected_option") + if isinstance(selected, dict) and isinstance(selected.get("value"), str): + answers[key] = selected["value"] + continue + multi = state.get("selected_options") + if isinstance(multi, list): + indices = [ + o["value"] + for o in multi + if isinstance(o, dict) and isinstance(o.get("value"), str) + ] + if indices: + answers[key] = indices + return answers + + +def resolve_form_answers( + request: ElicitationRequest, raw: dict[str, Any] | None +) -> dict[str, Any]: + """Map the index-based ``parse_form_answers`` map to full option labels. + + The card carries each option by index (labels can exceed Slack's 75-char + value cap), so this resolves indices back to the untruncated labels the + server forwards to the agent — keyed by each question's full ``key``. An + index that doesn't resolve to an option is dropped; a question with no + resolvable answer is omitted. + """ + if not raw: + return {} + # Match each answer's (possibly truncated) block key back to its question. + by_block_key = {_plain(q.key, limit=200): q for q in request.questions} + answers: dict[str, Any] = {} + for block_key, value in raw.items(): + question = by_block_key.get(block_key) + if question is None: + continue + labels = question.options + if isinstance(value, list): + resolved = [ + labels[i].label + for s in value + if (i := _as_index(s)) is not None and i < len(labels) + ] + if resolved: + answers[question.key] = resolved + else: + i = _as_index(value) + if i is not None and i < len(labels): + answers[question.key] = labels[i].label + return answers + + +def _as_index(value: Any) -> int | None: + if not isinstance(value, str) or not value.isdigit(): + return None + return int(value) + + +class _ElicitationSink(Protocol): + async def handle_elicitation_action( + self, *, elicitation_id: str, verdict: Verdict + ) -> bool: ... + + async def reject_non_owner_click( + self, client: Any, body: dict[str, Any], target: ClickTarget + ) -> None: ... + + +def _clicking_user_id(body: dict[str, Any]) -> str | None: + user = body.get("user") + uid = user.get("id") if isinstance(user, dict) else None + return uid if isinstance(uid, str) else None + + +async def route_elicitation_click( + sink: _ElicitationSink, + client: Any, + body: dict[str, Any], + *, + accepted: bool, + is_form_submit: bool = False, +) -> None: + """Route a Block Kit interaction to the waiting turn worker. + + Enforces the per-thread owner boundary: the control carries the owner id, so + a click from anyone else (the card is visible channel-wide) is rejected + before any verdict is delivered — fail-safe, matching the message-routing + owner check. Otherwise hands a :class:`Verdict` to ``sink``; a click that + arrives after the worker gave up finds no waiter and is dropped. + """ + actions = body.get("actions") or [] + value = actions[0].get("value") if actions and isinstance(actions[0], dict) else None + target = parse_action_value(value) if isinstance(value, str) else None + if target is None: + return + + clicker = _clicking_user_id(body) + if clicker != target.owner_user_id: + _logger.info( + "Rejecting non-owner elicitation click elicitation_id=%s owner=%s clicker=%s", + target.elicitation_id, + target.owner_user_id, + clicker, + ) + await sink.reject_non_owner_click(client, body, target) + return + + content: dict[str, Any] | None = None + if is_form_submit and accepted: + state_values = (body.get("state") or {}).get("values") or {} + content = parse_form_answers(state_values) if isinstance(state_values, dict) else None + delivered = await sink.handle_elicitation_action( + elicitation_id=target.elicitation_id, verdict=Verdict(accepted=accepted, content=content) + ) + if not delivered: + _logger.info("Approval click had no waiter elicitation_id=%s", target.elicitation_id) diff --git a/integrations/slack/src/omnigent_slack/auth_manager.py b/integrations/slack/src/omnigent_slack/auth_manager.py index 9f95cd7110e..32ed632856c 100644 --- a/integrations/slack/src/omnigent_slack/auth_manager.py +++ b/integrations/slack/src/omnigent_slack/auth_manager.py @@ -159,7 +159,9 @@ async def authorize(self, *, server_url: str, client_id: str) -> PendingLogin: OIDC mode, which has no client identifier. """ assert self._tokens is not None, "delegated auth not enabled" - return await start_login(server_url, client_id=client_id, client_secret=self._client_secret) + return await start_login( + server_url, client_id=client_id, client_secret=self._client_secret + ) def await_authorization_in_background( self, diff --git a/integrations/slack/src/omnigent_slack/dispatcher.py b/integrations/slack/src/omnigent_slack/dispatcher.py deleted file mode 100644 index e658dc6d67b..00000000000 --- a/integrations/slack/src/omnigent_slack/dispatcher.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from collections.abc import Awaitable, Callable - -from omnigent_slack.models import SlackTurn, ThreadKey - -TurnWorker = Callable[[SlackTurn], Awaitable[None]] - - -class ThreadTurnDispatcher: - def __init__(self, worker: TurnWorker, idle_timeout_seconds: float = 60.0) -> None: - self._worker = worker - self._idle_timeout_seconds = idle_timeout_seconds - self._queues: dict[ThreadKey, asyncio.Queue[SlackTurn]] = {} - self._tasks: dict[ThreadKey, asyncio.Task[None]] = {} - self._lock = asyncio.Lock() - self._logger = logging.getLogger(__name__) - - async def enqueue(self, turn: SlackTurn) -> None: - async with self._lock: - queue = self._queues.get(turn.key) - if queue is None: - queue = asyncio.Queue() - self._queues[turn.key] = queue - self._tasks[turn.key] = asyncio.create_task(self._run_queue(turn.key, queue)) - self._logger.debug("Created turn queue for %s", turn.key.display()) - await queue.put(turn) - self._logger.info( - "Queued Slack turn thread=%s queue_size=%s create_if_missing=%s", - turn.key.display(), - queue.qsize(), - turn.create_if_missing, - ) - - async def shutdown(self) -> None: - async with self._lock: - tasks = list(self._tasks.values()) - for task in tasks: - task.cancel() - await asyncio.gather(*tasks, return_exceptions=True) - - async def _run_queue(self, key: ThreadKey, queue: asyncio.Queue[SlackTurn]) -> None: - try: - while True: - try: - turn = await asyncio.wait_for(queue.get(), timeout=self._idle_timeout_seconds) - except TimeoutError: - self._logger.debug("Closing idle turn queue for %s", key.display()) - return - try: - self._logger.info("Running queued Slack turn thread=%s", key.display()) - await self._worker(turn) - except Exception: - self._logger.exception("Slack turn failed for %s", key.display()) - finally: - queue.task_done() - finally: - async with self._lock: - if self._queues.get(key) is queue: - if queue.empty(): - self._queues.pop(key, None) - self._tasks.pop(key, None) - else: - # A turn slipped in after the idle timeout fired but - # before this teardown reacquired the lock. The queue - # stays registered, so no future enqueue would spawn a - # worker — re-arm one here to keep draining it. - self._tasks[key] = asyncio.create_task(self._run_queue(key, queue)) diff --git a/integrations/slack/src/omnigent_slack/events.py b/integrations/slack/src/omnigent_slack/events.py new file mode 100644 index 00000000000..ff57b4d803b --- /dev/null +++ b/integrations/slack/src/omnigent_slack/events.py @@ -0,0 +1,448 @@ +from __future__ import annotations + +import json +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from typing import Any + + +class OmnigentError(RuntimeError): + """Base error for the Omnigent client and its event parsing.""" + + +@dataclass(frozen=True, slots=True) +class ElicitationOption: + """One selectable choice in an ``AskUserQuestion`` form question.""" + + label: str + description: str | None = None + + +@dataclass(frozen=True, slots=True) +class ElicitationQuestion: + """One question in an ``AskUserQuestion`` form elicitation. + + ``key`` is what the answer map is keyed by when resolving — the server's + question ``id`` if present, else the question text (matches the web form). + """ + + key: str + question: str + options: list[ElicitationOption] + multi_select: bool = False + + +@dataclass(frozen=True, slots=True) +class ElicitationRequest: + """A server-initiated request parsed off the event stream. + + The Omnigent server parks a running turn when a tool call trips an approval + policy OR the agent asks the user to choose (``AskUserQuestion``), emitting + ``response.elicitation_request``. Two shapes the bot renders differently: + + - **binary** (``questions`` empty): a yes/no approval → Approve / Deny card. + - **form** (``questions`` non-empty): a multiple-choice ask → one option + button per choice; the click resolves with the chosen label as ``content``. + """ + + elicitation_id: str + message: str + # Session that owns the resolve endpoint. Usually the streaming session, + # but a mirrored sub-agent prompt carries its own ``target_session_id``. + session_id: str + policy_name: str | None = None + content_preview: str | None = None + # MCP elicitation mode: "form" (inline) or "url" (out-of-band page). + mode: str = "form" + # Non-empty for a form-mode ``AskUserQuestion`` elicitation. + questions: list[ElicitationQuestion] = field(default_factory=list) + # True when the elicitation asks for typed/structured input we can't collect + # with Slack buttons (a non-empty requestedSchema that isn't AskUserQuestion). + needs_typed_input: bool = False + + @property + def is_form(self) -> bool: + return bool(self.questions) + + @property + def is_supported(self) -> bool: + """Whether the bot can render this elicitation natively in Slack. + + Classified by the *decision shape*, NOT the delivery ``mode``. A + ``url``-mode elicitation just carries a suggested out-of-band approve + page; the verdict can still be posted to the resolve endpoint, so a + ``url``-mode binary approval or ``AskUserQuestion`` renders natively + (Approve/Deny card, or option buttons) exactly like a ``form``-mode one. + Only a request for free-form typed input we can't collect with buttons + (a non-empty ``requestedSchema`` that isn't an ``AskUserQuestion``) is + unsupported — that's surfaced with a link to resolve in the web UI. + """ + if self.is_form: + return True + return not self.needs_typed_input + + +async def iter_sse_events(lines: AsyncIterator[str]) -> AsyncIterator[dict[str, Any]]: + event_name: str | None = None + data_lines: list[str] = [] + + async for raw_line in lines: + line = raw_line.rstrip("\r") + if line == "": + event = _decode_sse_event(event_name, data_lines) + event_name = None + data_lines = [] + if event is None: + continue + if event == "[DONE]": + break + if isinstance(event, str): + continue + yield event + continue + + if line.startswith(":"): + continue + + field, separator, value = line.partition(":") + if separator and value.startswith(" "): + value = value[1:] + if field == "event": + event_name = value + elif field == "data": + data_lines.append(value) + + event = _decode_sse_event(event_name, data_lines) + if isinstance(event, dict): + yield event + + +def is_terminal_event(event: dict[str, Any]) -> bool: + # A turn ends at the SESSION level, not the response level. Orchestrator + # agents emit a `response.completed`/`turn.completed` every time they end a + # turn to wait on a background sub-agent, then resume with more responses in + # the same turn — so treating those as terminal cuts the stream off at the + # first sub-agent dispatch. `session.status` is the authoritative signal: + # `running` -> `waiting` (parked on async work) -> `running` -> `idle`, and + # only `idle`/`failed` mean the turn is truly over. + event_type = str(event.get("type")) + if event_type == "session.status": + return str(event.get("status")) in {"idle", "failed"} + # Explicit turn/response failure and cancellation still end the turn; keep + # them as a fallback in case the session settles without an `idle` edge. + return event_type in { + "response.failed", + "response.cancelled", + "turn.failed", + "turn.cancelled", + } + + +def is_elicitation_request(event: dict[str, Any]) -> bool: + """True for a ``response.elicitation_request`` event. + + Like a soft idle, this is an AMBIGUOUS boundary for the read loop: the + consumer parks (posts a card, blocks for the verdict) while it's handled, so + the SSE connection goes unread for as long as the user takes to answer. When + the loop resumes it must NOT go straight into an unbounded read — the stale + connection may never deliver the post-resolve events. The caller routes the + next read through the grace disambiguation (poll status) instead. + """ + return event.get("type") == "response.elicitation_request" + + +def is_soft_idle_event(event: dict[str, Any]) -> bool: + """True for a ``session.status: idle`` edge — an AMBIGUOUS turn boundary. + + A fan-out orchestrator (e.g. ``debby``) ends its turn to wait on sub-agents + and settles to ``idle`` between wake cycles, then a sub-agent completion + re-injects a message that wakes it and it resumes. So ``idle`` means either + "parked, will be re-woken" or "genuinely done" — the caller disambiguates + with a grace window (does anything else arrive shortly?). ``failed`` and the + explicit cancel/fail events are NOT soft: they end the turn immediately. + """ + return event.get("type") == "session.status" and event.get("status") == "idle" + + +def extract_delta(event: dict[str, Any]) -> str | None: + if event.get("type") != "response.output_text.delta": + return None + delta = event.get("delta") + return delta if isinstance(delta, str) else None + + +def extract_elicitation_request( + event: dict[str, Any], stream_session_id: str +) -> ElicitationRequest | None: + """Parse a ``response.elicitation_request`` event into an approval request. + + ``stream_session_id`` is the session whose stream this event arrived on; it + is the resolve target unless the event names a ``target_session_id`` (a + sub-agent prompt mirrored into an ancestor stream). + """ + if event.get("type") != "response.elicitation_request": + return None + elicitation_id = event.get("elicitation_id") + if not isinstance(elicitation_id, str) or not elicitation_id: + return None + params = event.get("params") + params = params if isinstance(params, dict) else {} + target = params.get("target_session_id") + message = params.get("message") + policy_name = params.get("policy_name") + content_preview = params.get("content_preview") + mode = params.get("mode") + questions = _parse_ask_user_question(params.get("ask_user_question")) + # A non-empty requestedSchema means the server wants typed/structured input. + # AskUserQuestion (parsed into `questions`) is the one such shape we render; + # anything else with a schema we can't collect via buttons. + schema = params.get("requestedSchema") + needs_typed_input = bool(isinstance(schema, dict) and schema) and not questions + return ElicitationRequest( + elicitation_id=elicitation_id, + message=message if isinstance(message, str) and message else "Approve this action?", + session_id=target if isinstance(target, str) and target else stream_session_id, + policy_name=policy_name if isinstance(policy_name, str) else None, + content_preview=content_preview if isinstance(content_preview, str) else None, + mode=mode if isinstance(mode, str) and mode else "form", + questions=questions, + needs_typed_input=needs_typed_input, + ) + + +def _parse_ask_user_question(raw: Any) -> list[ElicitationQuestion]: + """Parse the ``ask_user_question`` params extra into typed questions. + + The server stamps this on a form-mode elicitation (Claude Code's built-in + ``AskUserQuestion`` tool, and the agy/codex equivalents). Each answer is + keyed by the question ``id`` when present, else its text — matching the web + form so selections round-trip to the agent identically. Malformed or empty + payloads yield an empty list (the elicitation renders as binary approve/deny). + """ + if not isinstance(raw, dict): + return [] + questions_raw = raw.get("questions") + if not isinstance(questions_raw, list): + return [] + questions: list[ElicitationQuestion] = [] + for entry in questions_raw: + if not isinstance(entry, dict): + continue + text = entry.get("question") + if not isinstance(text, str) or not text: + continue + options: list[ElicitationOption] = [] + for opt in entry.get("options") or []: + if not isinstance(opt, dict): + continue + label = opt.get("label") + if not isinstance(label, str) or not label: + continue + description = opt.get("description") + desc = description if isinstance(description, str) and description else None + options.append(ElicitationOption(label=label, description=desc)) + if not options: + continue + qid = entry.get("id") + key = qid if isinstance(qid, str) and qid else text + questions.append( + ElicitationQuestion( + key=key, + question=text, + options=options, + multi_select=entry.get("multiSelect") is True, + ) + ) + return questions + + +def extract_policy_denied(event: dict[str, Any]) -> str | None: + """Return the deny reason for a ``response.policy_denied`` event. + + The DENY counterpart to an elicitation ASK: a native harness tool call was + hard-blocked by policy with no approval offered. Observational — there's + nothing to respond to; the bot just surfaces why the action didn't happen. + """ + if event.get("type") != "response.policy_denied": + return None + reason = event.get("reason") + return reason if isinstance(reason, str) and reason else "Blocked by policy." + + +@dataclass(frozen=True, slots=True) +class OutputFile: + """A file artifact the agent produced during the turn.""" + + file_id: str + filename: str | None = None + + +@dataclass(frozen=True, slots=True) +class SessionActivity: + """The server's view of whether a session is busy right now. + + ``status`` is the rolled-up session status (``running``/``waiting`` = busy, + ``idle``/``failed`` = free, ``None`` = snapshot unreadable). ``pending_elicitation`` + is ``True`` when the session is parked awaiting a decision. Mirrors the web + UI's send-gating: these are the two states where a new prompt should wait. + """ + + status: str | None + pending_elicitation: bool + + @property + def is_busy(self) -> bool: + # Matches the web UI's computeIsWorking: the server is actively working. + return self.status in ("running", "waiting", "launching") + + @property + def needs_user_action(self) -> bool: + return self.pending_elicitation + + +def extract_output_file(event: dict[str, Any]) -> OutputFile | None: + """Parse a ``response.output_file.done`` event into a file artifact.""" + if event.get("type") != "response.output_file.done": + return None + file_id = event.get("file_id") + if not isinstance(file_id, str) or not file_id: + return None + filename = event.get("filename") + return OutputFile( + file_id=file_id, + filename=filename if isinstance(filename, str) and filename else None, + ) + + +def extract_todos(event: dict[str, Any]) -> list[dict[str, Any]] | None: + """Return the current todo list for a ``session.todos`` event. + + Each entry carries ``content`` (str), ``status`` (``pending`` / + ``in_progress`` / ``completed``) and ``activeForm`` (str) keys. Returns + ``None`` for non-todo events; an empty list is a real "no todos" update. + """ + if event.get("type") != "session.todos": + return None + todos = event.get("todos") + if not isinstance(todos, list): + return None + return [item for item in todos if isinstance(item, dict)] + + +def extract_error_text(event: dict[str, Any]) -> str | None: + event_type = str(event.get("type")) + if event_type == "response.error": + error = event.get("error") + if isinstance(error, dict): + message = error.get("message") + if isinstance(message, str): + return message + message = event.get("message") + if isinstance(message, str): + return message + if event_type in {"response.failed", "turn.failed"}: + response = event.get("response") + if isinstance(response, dict): + last_error = response.get("error") or response.get("last_error") + if isinstance(last_error, dict): + message = last_error.get("message") + if isinstance(message, str): + return message + error = event.get("error") + if isinstance(error, dict): + message = error.get("message") + if isinstance(message, str): + return message + if isinstance(error, str): + return error + return None + + +def extract_assistant_text(event_or_item: dict[str, Any]) -> str | None: + if event_or_item.get("type") == "response.output_item.done": + item = event_or_item.get("item") + return extract_assistant_text(item) if isinstance(item, dict) else None + + item_type = event_or_item.get("type") + if item_type != "message": + return None + + data = event_or_item.get("data") + message = data if isinstance(data, dict) else event_or_item + if message.get("role") != "assistant": + return None + + content = message.get("content") + if not isinstance(content, list): + return None + + parts: list[str] = [] + for block in content: + if not isinstance(block, dict): + continue + text = block.get("text") + if isinstance(text, str): + parts.append(text) + return "".join(parts).strip() or None + + +def _decode_sse_event( + event_name: str | None, data_lines: list[str] +) -> dict[str, Any] | str | None: + if not data_lines: + return None + data = "\n".join(data_lines) + if data == "[DONE]": + return data + try: + payload = json.loads(data) + except json.JSONDecodeError as exc: + raise OmnigentError(f"Invalid SSE JSON payload: {data}") from exc + if not isinstance(payload, dict): + return None + if event_name and "type" not in payload: + payload["type"] = event_name + return payload + + +def _first_str(payload: Any, keys: tuple[str, ...], *, nested: tuple[str, ...] = ()) -> str | None: + """First string value found at ``keys`` on ``payload``, recursing into + ``nested`` keys. Used to pull ids out of variously-nested API responses. + """ + if not isinstance(payload, dict): + return None + for key in keys: + value = payload.get(key) + if isinstance(value, str): + return value + for key in nested: + value = _first_str(payload.get(key), keys, nested=nested) + if value: + return value + return None + + +def _extract_session_id(payload: Any) -> str | None: + return _first_str(payload, ("id", "session_id", "conversation_id"), nested=("session", "data")) + + +def _extract_runner_id(payload: Any) -> str | None: + return _first_str(payload, ("id", "runner_id"), nested=("runner", "data")) + + +def _host_id(host: dict[str, Any]) -> str | None: + return _first_str(host, ("id", "host_id")) + + +def _extract_list(payload: Any, key: str) -> list[Any] | None: + if not isinstance(payload, dict): + return None + value = payload.get(key) + return value if isinstance(value, list) else None + + +def _is_host_online(host: dict[str, Any]) -> bool: + if host.get("online") is True or host.get("host_online") is True: + return True + status = host.get("status") + return isinstance(status, str) and status.lower() == "online" diff --git a/integrations/slack/src/omnigent_slack/notifications.py b/integrations/slack/src/omnigent_slack/notifications.py new file mode 100644 index 00000000000..690975688ee --- /dev/null +++ b/integrations/slack/src/omnigent_slack/notifications.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from typing import Any + +from omnigent_slack.omnigent import OutputFile +from omnigent_slack.text import truncate_for_slack + +# Status → checkbox glyph for the rendered todo list. +_TODO_MARK = { + "completed": ":white_check_mark:", + "in_progress": ":hourglass_flowing_sand:", + "pending": ":white_large_square:", +} + + +def format_todos(todos: list[dict[str, Any]]) -> str | None: + """Render a todo-list update as a Slack message, or ``None`` if empty. + + Uses ``activeForm`` (the gerund) for the in-progress item and ``content`` + otherwise, mirroring how Claude Code presents its own list. + """ + lines: list[str] = [] + for todo in todos: + status = str(todo.get("status") or "pending") + mark = _TODO_MARK.get(status, ":white_large_square:") + if status == "in_progress": + label = todo.get("activeForm") or todo.get("content") or "" + else: + label = todo.get("content") or todo.get("activeForm") or "" + label = str(label).strip() + if not label: + continue + lines.append(f"{mark} {label}") + if not lines: + return None + return truncate_for_slack("*Plan*\n" + "\n".join(lines)) + + +def format_output_file(file: OutputFile) -> str: + """Render a produced-file notice.""" + name = file.filename or file.file_id + return f":page_facing_up: Produced a file: *{name}*" + + +def format_policy_denied(reason: str) -> str: + """Render a policy-DENY notice (the block-without-asking counterpart).""" + return f":no_entry: Blocked by policy: {truncate_for_slack(reason, limit=2000)}" diff --git a/integrations/slack/src/omnigent_slack/oauth.py b/integrations/slack/src/omnigent_slack/oauth.py index 4c1424e0208..277394d6904 100644 --- a/integrations/slack/src/omnigent_slack/oauth.py +++ b/integrations/slack/src/omnigent_slack/oauth.py @@ -28,6 +28,7 @@ from __future__ import annotations import asyncio +import contextlib import enum from collections.abc import Awaitable, Callable from dataclasses import dataclass @@ -371,10 +372,8 @@ async def refresh(self, refresh_token: str) -> TokenResult: async def revoke(self, refresh_token: str) -> None: """Revoke the grant behind a refresh token. Best-effort.""" - try: + with contextlib.suppress(httpx.HTTPError): await self._client.post("/oauth/revoke", data={"refresh_token": refresh_token}) - except httpx.HTTPError: - pass def _error_code(response: httpx.Response) -> str | None: diff --git a/integrations/slack/src/omnigent_slack/omnigent.py b/integrations/slack/src/omnigent_slack/omnigent.py index 66f4ed6ca53..77274b29fc4 100644 --- a/integrations/slack/src/omnigent_slack/omnigent.py +++ b/integrations/slack/src/omnigent_slack/omnigent.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import contextlib import json import logging import random @@ -11,9 +12,70 @@ import httpx - -class OmnigentError(RuntimeError): - pass +# Pure event parsing, DTOs, and the base error live in ``events``; the client +# and pool here build on them. Re-exported below so existing +# ``from omnigent_slack.omnigent import extract_delta`` sites keep working. +from omnigent_slack.events import ( + ElicitationOption, + ElicitationQuestion, + ElicitationRequest, + OmnigentError, + OutputFile, + SessionActivity, + _extract_list, + _extract_runner_id, + _extract_session_id, + _host_id, + _is_host_online, + extract_assistant_text, + extract_delta, + extract_elicitation_request, + extract_error_text, + extract_output_file, + extract_policy_denied, + extract_todos, + is_elicitation_request, + is_soft_idle_event, + is_terminal_event, + iter_sse_events, +) + +__all__ = [ + "AuthRequiredError", + "AuthResolver", + "ClientAuth", + "ElicitationOption", + "ElicitationQuestion", + "ElicitationRequest", + "HostUnavailableError", + "OmnigentClient", + "OmnigentClientPool", + "OmnigentError", + "OutputFile", + "RunnerUnavailableError", + "ServerUnreachableError", + "SessionActivity", + "ValidatedServer", + "extract_assistant_text", + "extract_delta", + "extract_elicitation_request", + "extract_error_text", + "extract_output_file", + "extract_policy_denied", + "extract_todos", + "is_elicitation_request", + "is_soft_idle_event", + "is_terminal_event", + "iter_sse_events", +] + +_logger = logging.getLogger(__name__) + +# Sentinels for the idle-grace disambiguation. ``_NO_RESUMPTION``: the grace +# window elapsed and the snapshot confirms the turn is over. ``_RESUMED``: the +# stream produced another event (or ended), so the turn continues. +_NO_RESUMPTION = object() +_RESUMED = object() class RunnerUnavailableError(OmnigentError): @@ -95,9 +157,15 @@ def __init__( runner_launch_timeout_seconds: float = 60.0, auth: ClientAuth | None = None, ) -> None: + # Bounded read timeout for ordinary requests so a stalled server can't + # hang a call indefinitely and wedge the per-thread turn queue. The + # long-lived SSE stream overrides this with ``read=None`` at its call + # site (see ``stream_session_events``), since a live tail legitimately + # blocks between events. + self._timeout = timeout self._client = httpx.AsyncClient( base_url=base_url.rstrip("/"), - timeout=httpx.Timeout(timeout, read=None), + timeout=httpx.Timeout(timeout), ) self._runner_launch_timeout_seconds = runner_launch_timeout_seconds self._auth = auth @@ -116,7 +184,10 @@ async def _request(self, method: str, url: str, **kwargs: Any) -> httpx.Response # server itself is unreachable — distinct from an HTTP error response, # which ``_raise_for_status`` classifies. used_token = self._auth.access_token if self._auth is not None else None - headers = {**self._auth_headers(), **(kwargs.pop("headers", None) or {})} + # Pop caller headers once — a second pop would return None and silently + # drop them on the 401 retry below. + custom_headers = kwargs.pop("headers", None) or {} + headers = {**self._auth_headers(), **custom_headers} try: response = await self._client.request(method, url, headers=headers, **kwargs) except httpx.HTTPError as exc: @@ -128,7 +199,7 @@ async def _request(self, method: str, url: str, **kwargs: Any) -> httpx.Response if response.status_code == 401 and self._auth is not None: new_token = await self._auth.refresh(used_token) if new_token: - retry_headers = {**self._auth_headers(), **(kwargs.pop("headers", None) or {})} + retry_headers = {**self._auth_headers(), **custom_headers} try: response = await self._client.request( method, url, headers=retry_headers, **kwargs @@ -159,7 +230,9 @@ async def validate(self) -> ValidatedServer: return ValidatedServer(agents=agents, online_hosts=online_hosts) async def create_session(self, agent_id: str, title: str) -> str: - self._logger.info("Creating Omnigent session agent_id=%s title=%r", agent_id, title) + # Don't log the title — it embeds the user's message text; log only the + # agent id (everywhere else we log lengths, not content). + self._logger.info("Creating Omnigent session agent_id=%s", agent_id) response = await self._request( "POST", "/v1/sessions", @@ -190,6 +263,46 @@ async def submit_message(self, session_id: str, text: str) -> None: await _raise_for_status(response) self._logger.debug("Submitted Omnigent message session_id=%s", session_id) + async def resolve_elicitation( + self, + session_id: str, + elicitation_id: str, + *, + accepted: bool, + content: dict[str, Any] | None = None, + ) -> None: + """Deliver a verdict for a parked elicitation. + + ``accepted`` picks the MCP action (``accept``/``decline``). ``content`` + carries form answers for a form-mode elicitation (e.g. AskUserQuestion's + ``{question: selected_label}`` map, which the server forwards to the + agent as the tool result) — omitted for a binary approve/deny. + + Posts to the dedicated resolve endpoint (the id rides in the URL). The + server returns 202 on delivery and 404/409 when the elicitation is + already gone (cancel race / already resolved) — all benign, so only an + unexpected status is surfaced. + """ + self._logger.info( + "Resolving Omnigent elicitation session_id=%s elicitation_id=%s accepted=%s " + "has_content=%s", + session_id, + elicitation_id, + accepted, + content is not None, + ) + body: dict[str, Any] = {"action": "accept" if accepted else "decline"} + if content: + body["content"] = content + response = await self._request( + "POST", + f"/v1/sessions/{session_id}/elicitations/{elicitation_id}/resolve", + json=body, + ) + if response.status_code in (200, 202, 404, 409): + return + await _raise_for_status(response) + async def launch_runner( self, session_id: str, @@ -222,9 +335,13 @@ async def launch_runner( # the chosen host can't serve the session — surface it as host-unavailable # so the caller can tell the user to start a host. if response.status_code in (404, 409): - raise HostUnavailableError( - f"Omnigent host {target_host} is not available: {response.text}" + self._logger.warning( + "Omnigent host unavailable host=%s status=%s body=%r", + target_host, + response.status_code, + response.text, ) + raise HostUnavailableError(f"Omnigent host {target_host} is not available.") await _raise_for_status(response) payload = response.json() runner_id = _extract_runner_id(payload) @@ -286,7 +403,9 @@ async def _select_random_online_host(self) -> str: if _is_host_online(host) and (host_id := _host_id(host)) is not None ] if not host_ids: - raise HostUnavailableError("No online Omnigent hosts are available to launch a runner.") + raise HostUnavailableError( + "No online Omnigent hosts are available to launch a runner." + ) host_id = random.choice(host_ids) self._logger.info( "Selected random Omnigent host host_id=%s candidates=%s", @@ -333,6 +452,9 @@ async def stream_session_events( f"/v1/sessions/{session_id}/stream", params={"idle": "false"}, headers=self._auth_headers(), + # A live tail blocks between events — disable the read timeout + # for the stream only (ordinary requests keep the bounded one). + timeout=httpx.Timeout(self._timeout, read=None), ) as response: await _raise_for_status(response) self._logger.debug("Connected to Omnigent SSE stream session_id=%s", session_id) @@ -349,9 +471,14 @@ async def run_turn( *, workspace: str | None = None, host_id: str | None = None, + idle_grace_seconds: float = 600.0, + idle_poll_seconds: float = 5.0, + idle_settle_seconds: float = 2.0, ) -> AsyncIterator[dict[str, Any]]: try: - async for event in self._run_turn_once(session_id, text): + async for event in self._run_turn_once( + session_id, text, idle_grace_seconds, idle_poll_seconds, idle_settle_seconds + ): yield event return except RunnerUnavailableError: @@ -364,44 +491,273 @@ async def run_turn( ) await self.launch_runner(session_id, workspace=workspace, host_id=host_id) - async for event in self._run_turn_once(session_id, text): + async for event in self._run_turn_once( + session_id, text, idle_grace_seconds, idle_poll_seconds, idle_settle_seconds + ): yield event - async def _run_turn_once(self, session_id: str, text: str) -> AsyncIterator[dict[str, Any]]: + async def _run_turn_once( + self, + session_id: str, + text: str, + idle_grace_seconds: float, + idle_poll_seconds: float, + idle_settle_seconds: float, + ) -> AsyncIterator[dict[str, Any]]: async with self.stream_session_events(session_id) as events: await self.submit_message(session_id, text) - async for event in events: - self._logger.debug( - "Received Omnigent event session_id=%s type=%s", - session_id, - event.get("type"), - ) - yield event - if is_terminal_event(event): - self._logger.info( - "Omnigent turn reached terminal event session_id=%s type=%s", + iterator = events.__aiter__() + # A single in-flight "next event" task, reused across idle grace + # windows. Timing it out must NOT cancel the underlying __anext__ + # (that would terminate the async generator), so we keep the task + # alive with asyncio.wait and only await it again next window. + pending: asyncio.Task[dict[str, Any]] | None = None + # The FIRST read is unbounded — the turn hasn't started producing yet, + # so a bare wait is correct (the server may be `idle` for a beat right + # after submit before it goes `running`). Every read AFTER the first + # event is disambiguated via the grace window: this makes the turn + # ALWAYS bounded — it can only stay alive while the server reports + # `running`. A stream that goes silent without a terminal/idle event + # (half-open connection, or an `idle` edge missed because the consumer + # was parked on an elicitation) would otherwise block this read + # forever and wedge the thread. Active streaming pays NO latency: the + # settle-wait returns immediately when events are flowing, so the + # status poll only fires after a genuine quiet gap. + disambiguate = False + try: + while True: + if pending is None: + pending = asyncio.ensure_future(iterator.__anext__()) + + if disambiguate: + # Time out the wait WITHOUT cancelling the in-flight read + # (cancelling __anext__ would kill the generator); on a + # quiet window consult the rolled-up snapshot — while a + # sub-agent child is still running the parent reads + # `running`, so keep waiting (a slow child can outlast the + # grace window). Ends only when the snapshot is not running. + resumed = await self._await_within_grace( + pending, + session_id, + idle_grace_seconds, + idle_poll_seconds, + idle_settle_seconds, + ) + if resumed is _NO_RESUMPTION: + pending.cancel() + self._logger.info( + "Omnigent turn settled idle with no resumption session_id=%s", + session_id, + ) + break + + try: + event = await pending + except StopAsyncIteration: + break + pending = None + + self._logger.debug( + "Received Omnigent event session_id=%s type=%s", session_id, event.get("type"), ) - break + yield event + + # Every subsequent read is bounded by the grace disambiguation. + disambiguate = True + + # A HARD terminal (failed/cancelled) ends the turn now. A soft + # `idle` is NOT terminal here: a fan-out orchestrator settles + # idle between wake cycles, so we DON'T break — the next + # (bounded) read either resumes when more arrives or ends via + # the status poll when the server is genuinely done. + if is_terminal_event(event) and not is_soft_idle_event(event): + self._logger.info( + "Omnigent turn reached terminal event session_id=%s type=%s", + session_id, + event.get("type"), + ) + break + finally: + # Cancel and AWAIT the in-flight read so the underlying httpx + # stream isn't still running when the context manager closes it + # (aclose on a mid-flight async generator raises "already + # running"). Swallow the cancellation/stop that surfaces here. + if pending is not None: + pending.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await pending + + async def _await_within_grace( + self, + pending: asyncio.Task[dict[str, Any]], + session_id: str, + grace_seconds: float, + poll_seconds: float, + settle_seconds: float, + ) -> object: + """After a soft ``idle``, wait for the stream to resume or confirm it ended. + + Waits on the in-flight read WITHOUT cancelling it (``asyncio.wait`` + leaves the task pending, so the async generator survives to be awaited + again). Returns ``_RESUMED`` when the stream produced another event (or + ended — the caller's ``await`` then surfaces ``StopAsyncIteration``), or + ``_NO_RESUMPTION`` once the turn is genuinely over. + + Two timescales, because ``idle`` is doubly ambiguous: + + 1. **Settle wait** (``settle_seconds``, short): a claude-native turn + oscillates ``running``/``idle`` *while still streaming* its answer, + with sub-second gaps between bursts. So EVERY idle first waits a short + settle window for the next burst — ending here would truncate the + reply mid-answer. Bounded and small so a genuinely-final idle adds + only a brief tail. + 2. **Snapshot + poll** (``poll_seconds``, coarse): if still quiet after + the settle, consult the rolled-up status. A fan-out orchestrator + parked between wake cycles reads ``running`` (a sub-agent is working), + so keep polling — a slow child can take many seconds. Only when the + snapshot is no longer ``running`` is the turn over. + + ``grace_seconds`` caps the total wait so a stuck session can't park the + turn forever. + """ + deadline = asyncio.get_running_loop().time() + grace_seconds + while True: + # Settle: wait briefly for the next streaming burst. Handles the + # mid-answer running/idle oscillation without truncating. + done, _ = await asyncio.wait({pending}, timeout=settle_seconds) + if done: + return _RESUMED + # Still quiet — is the session genuinely done, or a fan-out parent + # waiting on a sub-agent (rolled-up status still `running`)? + status = await self.get_session_status(session_id) + # The status fetch is a network round-trip; the next event may have + # arrived during it. Re-check before ending, or we'd cancel a + # completed read and truncate the reply. + if pending.done(): + return _RESUMED + # Only a DEFINITIVE not-running status ends the turn. A ``None`` here + # is a best-effort snapshot failure (transient network/server blip) — + # treating it as "done" would truncate a still-live fan-out on a + # momentary hiccup, so keep waiting until the grace cap instead. + if status is not None and status != "running": + return _NO_RESUMPTION + if asyncio.get_running_loop().time() >= deadline: + self._logger.info( + "Idle grace cap (%ss) elapsed while still running session_id=%s; " + "ending turn to avoid parking forever", + grace_seconds, + session_id, + ) + return _NO_RESUMPTION + # Fan-out parent still working — wait a coarser poll for resumption. + done, _ = await asyncio.wait({pending}, timeout=poll_seconds) + if done: + return _RESUMED + self._logger.debug( + "Idle poll quiet but session still running (sub-agent " + "outstanding) session_id=%s; continuing to wait", + session_id, + ) + + async def _get_json(self, url: str, **kwargs: Any) -> dict[str, Any] | None: + """Best-effort GET returning the JSON body as a dict, else ``None``. + + Shared by the read-only status/elicitation/items probes, all of which + must degrade gracefully (a transient failure must never abort or wedge a + turn). Swallows transport/HTTP errors AND a non-JSON body — callers get + ``None`` and apply their own conservative default. + """ + try: + response = await self._request("GET", url, **kwargs) + await _raise_for_status(response) + payload = response.json() + except (OmnigentError, ValueError): + # ValueError covers json.JSONDecodeError (non-JSON 200 body). + return None + return payload if isinstance(payload, dict) else None + + async def get_session_status(self, session_id: str) -> str | None: + """Fetch the session's rolled-up status from the snapshot. + + The snapshot's ``status`` rolls direct sub-agent child activity into the + parent: a fan-out orchestrator parked between wake cycles reads + ``running`` here (a child is still working) even though its own runner + emitted ``idle`` on the stream. That makes this the authoritative "is the + turn really over?" check when a stream ``idle`` is ambiguous. Best-effort + — returns ``None`` on any failure so the caller falls back to the timer. + """ + snapshot = await self._get_json(f"/v1/sessions/{session_id}") + status = snapshot.get("status") if snapshot else None + return status if isinstance(status, str) else None + + async def get_session_activity(self, session_id: str) -> SessionActivity: + """Snapshot of whether the SERVER considers this session busy. + + Mirrors the web UI's send-gating (``computeIsWorking`` + + pending-elicitation): a session is busy when its rolled-up ``status`` is + ``running``/``waiting``, and needs user action when it has a pending + elicitation. Both are SERVER-derived — the authoritative "can I submit a + new prompt now?" signal — unlike any local connection bookkeeping. One + GET. Best-effort: an unreadable snapshot returns ``unknown`` so the caller + can decide conservatively (we treat unknown as "go ahead", since the + server itself safely buffers a message that races a turn). + """ + snapshot = await self._get_json(f"/v1/sessions/{session_id}") + if snapshot is None: + return SessionActivity(status=None, pending_elicitation=False) + status = snapshot.get("status") + return SessionActivity( + status=status if isinstance(status, str) else None, + pending_elicitation=bool(self._parse_pending(snapshot)), + ) + + @staticmethod + def _parse_pending(snapshot: dict[str, Any] | None) -> list[dict[str, Any]]: + pending = snapshot.get("pending_elicitations") if snapshot else None + return [e for e in pending if isinstance(e, dict)] if isinstance(pending, list) else [] + + async def is_elicitation_pending(self, session_id: str, elicitation_id: str) -> bool: + """Whether ``elicitation_id`` is still outstanding on the server. + + Lets a Slack-side waiter detect that the elicitation was resolved + *elsewhere* (the web UI, another client) and stop waiting. Best-effort: + on a read failure returns ``True`` (assume still pending) so a transient + hiccup doesn't spuriously abandon the wait. + """ + snapshot = await self._get_json(f"/v1/sessions/{session_id}") + if snapshot is None: + return True # read failed — assume still pending, don't abandon. + return any( + e.get("elicitation_id") == elicitation_id for e in self._parse_pending(snapshot) + ) + + async def latest_assistant_message(self, session_id: str) -> tuple[str | None, str] | None: + """Return ``(item_id, text)`` of the newest assistant message, or None. - async def latest_assistant_text(self, session_id: str) -> str | None: + The id lets a caller tell *this* turn's message from a prior turn's — a + blind "latest text" fetch would otherwise resurrect the previous answer + when the current turn produced none (e.g. a denied approval). ``item_id`` + is ``None`` when the message carries no id, so a caller can't mistake two + id-less messages for the same one. Best-effort: the outer ``None`` on any + read failure (the caller must not be left mid-turn if the snapshot fetch + fails). + """ self._logger.debug("Fetching latest Omnigent assistant item session_id=%s", session_id) - response = await self._request( - "GET", - f"/v1/sessions/{session_id}/items", - params={"limit": 100, "order": "desc"}, + payload = await self._get_json( + f"/v1/sessions/{session_id}/items", params={"limit": 100, "order": "desc"} ) - await _raise_for_status(response) - payload = response.json() - items = payload.get("data", []) + items = payload.get("data") if payload else None if not isinstance(items, list): return None for item in items: - if isinstance(item, dict): - text = extract_assistant_text(item) - if text: - return text + if not isinstance(item, dict): + continue + text = extract_assistant_text(item) + if text: + item_id = item.get("id") + return (item_id if isinstance(item_id, str) and item_id else None, text) return None @@ -486,213 +842,28 @@ async def aclose_all(self) -> None: await client.aclose() -async def iter_sse_events(lines: AsyncIterator[str]) -> AsyncIterator[dict[str, Any]]: - event_name: str | None = None - data_lines: list[str] = [] - - async for raw_line in lines: - line = raw_line.rstrip("\r") - if line == "": - event = _decode_sse_event(event_name, data_lines) - event_name = None - data_lines = [] - if event is None: - continue - if event == "[DONE]": - break - if isinstance(event, str): - continue - yield event - continue - - if line.startswith(":"): - continue - - field, separator, value = line.partition(":") - if separator and value.startswith(" "): - value = value[1:] - if field == "event": - event_name = value - elif field == "data": - data_lines.append(value) - - event = _decode_sse_event(event_name, data_lines) - if isinstance(event, dict): - yield event - - -def is_terminal_event(event: dict[str, Any]) -> bool: - # A turn ends at the SESSION level, not the response level. Orchestrator - # agents emit a `response.completed`/`turn.completed` every time they end a - # turn to wait on a background sub-agent, then resume with more responses in - # the same turn — so treating those as terminal cuts the stream off at the - # first sub-agent dispatch. `session.status` is the authoritative signal: - # `running` -> `waiting` (parked on async work) -> `running` -> `idle`, and - # only `idle`/`failed` mean the turn is truly over. - event_type = str(event.get("type")) - if event_type == "session.status": - return str(event.get("status")) in {"idle", "failed"} - # Explicit turn/response failure and cancellation still end the turn; keep - # them as a fallback in case the session settles without an `idle` edge. - return event_type in { - "response.failed", - "response.cancelled", - "turn.failed", - "turn.cancelled", - } - - -def extract_delta(event: dict[str, Any]) -> str | None: - if event.get("type") != "response.output_text.delta": - return None - delta = event.get("delta") - return delta if isinstance(delta, str) else None - - -def extract_error_text(event: dict[str, Any]) -> str | None: - event_type = str(event.get("type")) - if event_type == "response.error": - error = event.get("error") - if isinstance(error, dict): - message = error.get("message") - if isinstance(message, str): - return message - message = event.get("message") - if isinstance(message, str): - return message - if event_type in {"response.failed", "turn.failed"}: - response = event.get("response") - if isinstance(response, dict): - last_error = response.get("error") or response.get("last_error") - if isinstance(last_error, dict): - message = last_error.get("message") - if isinstance(message, str): - return message - error = event.get("error") - if isinstance(error, dict): - message = error.get("message") - if isinstance(message, str): - return message - if isinstance(error, str): - return error - return None - - -def extract_assistant_text(event_or_item: dict[str, Any]) -> str | None: - if event_or_item.get("type") == "response.output_item.done": - item = event_or_item.get("item") - return extract_assistant_text(item) if isinstance(item, dict) else None - - item_type = event_or_item.get("type") - if item_type != "message": - return None - - data = event_or_item.get("data") - message = data if isinstance(data, dict) else event_or_item - if message.get("role") != "assistant": - return None - - content = message.get("content") - if not isinstance(content, list): - return None - - parts: list[str] = [] - for block in content: - if not isinstance(block, dict): - continue - text = block.get("text") - if isinstance(text, str): - parts.append(text) - return "".join(parts).strip() or None - - -def _decode_sse_event(event_name: str | None, data_lines: list[str]) -> dict[str, Any] | str | None: - if not data_lines: - return None - data = "\n".join(data_lines) - if data == "[DONE]": - return data - try: - payload = json.loads(data) - except json.JSONDecodeError as exc: - raise OmnigentError(f"Invalid SSE JSON payload: {data}") from exc - if not isinstance(payload, dict): - return None - if event_name and "type" not in payload: - payload["type"] = event_name - return payload - - -def _extract_session_id(payload: Any) -> str | None: - if isinstance(payload, dict): - for key in ("id", "session_id", "conversation_id"): - value = payload.get(key) - if isinstance(value, str): - return value - for key in ("session", "data"): - value = _extract_session_id(payload.get(key)) - if value: - return value - return None - - -def _extract_list(payload: Any, key: str) -> list[Any] | None: - if not isinstance(payload, dict): - return None - value = payload.get(key) - return value if isinstance(value, list) else None - - -def _runner_id(runner: dict[str, Any]) -> str | None: - for key in ("id", "runner_id"): - value = runner.get(key) - if isinstance(value, str): - return value - return None - - -def _extract_runner_id(payload: Any) -> str | None: - if isinstance(payload, dict): - value = _runner_id(payload) - if value: - return value - for key in ("runner", "data"): - value = _extract_runner_id(payload.get(key)) - if value: - return value - return None - - -def _host_id(host: dict[str, Any]) -> str | None: - for key in ("id", "host_id"): - value = host.get(key) - if isinstance(value, str): - return value - return None - - -def _is_host_online(host: dict[str, Any]) -> bool: - if host.get("online") is True or host.get("host_online") is True: - return True - status = host.get("status") - return isinstance(status, str) and status.lower() == "online" - - async def _raise_for_status(response: httpx.Response) -> None: try: response.raise_for_status() except httpx.HTTPStatusError as exc: error_code = _extract_error_code(response) + # The raw server body can carry internal paths/stack traces; log it for + # operators but keep it out of the exception message, which surfaces to + # the Slack channel (visible to everyone in the thread). + _logger.warning( + "Omnigent request failed status=%s url=%s body=%r", + response.status_code, + response.request.url, + response.text, + ) if response.status_code == 503 and error_code == "runner_unavailable": - raise RunnerUnavailableError( - f"Omnigent runner unavailable for {response.request.url}: {response.text}" - ) from exc + raise RunnerUnavailableError("Omnigent runner is unavailable.") from exc if response.status_code == 401: raise AuthRequiredError( f"Omnigent server requires authentication for {response.request.url}" ) from exc raise OmnigentError( - f"Omnigent request failed with {response.status_code}: {response.text}" + f"Omnigent request failed with status {response.status_code}." ) from exc diff --git a/integrations/slack/src/omnigent_slack/service.py b/integrations/slack/src/omnigent_slack/service.py index 072b009af92..c124eead4f7 100644 --- a/integrations/slack/src/omnigent_slack/service.py +++ b/integrations/slack/src/omnigent_slack/service.py @@ -1,21 +1,42 @@ from __future__ import annotations +import asyncio +import contextlib import logging +from dataclasses import dataclass from typing import Any, Protocol from slack_sdk.errors import SlackApiError +from omnigent_slack.approvals import ( + ClickTarget, + ElicitationCoordinator, + Verdict, + elicitation_card_blocks, + resolve_form_answers, + resolved_card_blocks, +) from omnigent_slack.auth_manager import pack_user_key -from omnigent_slack.dispatcher import ThreadTurnDispatcher from omnigent_slack.models import SlackTurn, ThreadKey +from omnigent_slack.notifications import ( + format_output_file, + format_policy_denied, + format_todos, +) from omnigent_slack.omnigent import ( AuthRequiredError, + ElicitationRequest, HostUnavailableError, + OmnigentClient, OmnigentClientPool, ServerUnreachableError, extract_assistant_text, extract_delta, + extract_elicitation_request, extract_error_text, + extract_output_file, + extract_policy_denied, + extract_todos, ) from omnigent_slack.setup import SetupFlow, host_unavailable_text from omnigent_slack.store import SQLiteStore @@ -23,7 +44,7 @@ class SlackStreamProtocol(Protocol): - async def append(self, *, markdown_text: str) -> Any: ... + async def append(self, *, markdown_text: str | None = ..., chunks: Any = ...) -> Any: ... async def stop(self, *, markdown_text: str | None = ...) -> Any: ... @@ -31,13 +52,20 @@ async def stop(self, *, markdown_text: str | None = ...) -> Any: ... class SlackClientProtocol(Protocol): async def chat_postMessage(self, **kwargs: Any) -> dict[str, Any]: ... + async def chat_postEphemeral(self, **kwargs: Any) -> dict[str, Any]: ... + async def chat_delete(self, **kwargs: Any) -> dict[str, Any]: ... + async def chat_update(self, **kwargs: Any) -> dict[str, Any]: ... + async def chat_stream(self, **kwargs: Any) -> SlackStreamProtocol: ... -# Immediate acknowledgement shown while the session spins up and before the -# first streamed tokens arrive; deleted once real content starts streaming. +# Immediate acknowledgement shown while the session spins up and while the agent +# works before the first streamed tokens arrive. Deleted only once real content +# is actually on screen — on the first flushed delta, or after the finalizing +# stop() for a buffered answer — so the thread never shows an empty gap between +# the placeholder vanishing and the reply appearing. _ACK_TEXT = "_Working on it…_" _SERVER_UNREACHABLE_TEXT = ( @@ -59,6 +87,50 @@ async def chat_stream(self, **kwargs: Any) -> SlackStreamProtocol: ... # continues into it rather than treating this as a turn failure. _STREAM_CLOSED_ERROR = "message_not_in_streaming_state" +# How often, while awaiting a Slack Approve/Deny click, to check whether the +# elicitation was resolved elsewhere (web UI, another client) so the turn can +# stop waiting and continue instead of blocking to the coordinator timeout. +_EXTERNAL_RESOLVE_POLL_SECONDS = 3.0 + +# Sentinel: the elicitation was resolved outside Slack, so the bot must NOT post +# its own verdict — just continue the turn. +_RESOLVED_EXTERNALLY = object() + + +class _TurnAborted(Exception): + """A turn can't proceed; ``text`` is the user-facing reason to deliver.""" + + def __init__(self, text: str) -> None: + super().__init__(text) + self.text = text + + +@dataclass +class _StreamState: + """Mutable per-turn state threaded through the stream event dispatch.""" + + # Timestamp of the live plan/todo message, edited in place across updates. + todos_ts: str | None = None + # In-band ``response.error`` text captured for finalization. + error_text: str | None = None + # Set when a known error was delivered mid-stream and the turn should stop. + aborted: bool = False + + +def _turn_error_text(exc: BaseException, server_url: str) -> str | None: + """User-facing message for a known startup/turn error, else ``None``. + + Single source of truth shared by the session-creation and mid-turn error + paths so the two stay in sync. + """ + if isinstance(exc, AuthRequiredError): + return _AUTH_REQUIRED_TEXT + if isinstance(exc, ServerUnreachableError): + return _SERVER_UNREACHABLE_TEXT + if isinstance(exc, HostUnavailableError): + return host_unavailable_text(server_url) + return None + def _is_stream_closed_error(exc: BaseException) -> bool: return ( @@ -92,6 +164,9 @@ def __init__( # Number of streaming messages opened; >1 means the reply was split # because Slack closed an earlier segment mid-turn. self.segments = 0 + # Whether text has been appended but not yet flushed to Slack (the SDK + # buffers until buffer_size). Lets ``flush`` skip an empty API call. + self._pending_unflushed = False async def _open(self) -> SlackStreamProtocol: self._stream = await self._client.chat_stream( @@ -117,8 +192,28 @@ async def append(self, markdown_text: str) -> bool: # Slack finalized the message out from under us; continue the answer # in a fresh streaming reply so nothing stalls or is lost. flushed = await (await self._open()).append(markdown_text=markdown_text) + # Track buffered-but-unflushed text so ``flush`` can force it visible. + self._pending_unflushed = flushed is None return flushed is not None + async def flush(self) -> None: + # Force any buffered-but-unflushed text onto the screen NOW, without + # finalizing the segment. The SDK flushes its buffer when ``append`` is + # called with ``chunks`` set (even an empty list), so a short answer + # doesn't stay invisible until the segment is stopped. Used before an + # out-of-band post so streamed text appears BEFORE the card/notice, not + # coincident with it (matches the web UI's live reveal). No-op when + # nothing is buffered or no stream is open. + if self._stream is None or not self._pending_unflushed: + return + try: + await self._stream.append(chunks=[]) + except SlackApiError as exc: + if not _is_stream_closed_error(exc): + raise + # Segment was finalized under us; the buffered text already landed. + self._pending_unflushed = False + async def stop(self, markdown_text: str | None = None) -> None: # chat.stopStream rejects empty text, so only pass markdown_text when # there is some. Nothing ever streamed and no tail to deliver → no-op. @@ -135,6 +230,30 @@ async def stop(self, markdown_text: str | None = None) -> None: await self._open() await self._stop_current(markdown_text) + async def seal(self) -> None: + """Finalize the current streaming segment so a later message sorts after it. + + Slack orders messages by the timestamp fixed when a streaming message + opens, so text appended to a long-lived stream stays anchored there. + Before posting any out-of-band message mid-turn (an approval card, a + policy/file notice), seal the current answer segment: it ends here, the + out-of-band message sorts after it, and the next append opens a fresh + segment that sorts after *that* — keeping chronological order across an + interruption. No-op when nothing is streaming. + """ + if self._stream is None: + return + stream = self._stream + # Drop the reference first so the next append opens a fresh segment even + # if the stop below races a Slack-side finalize. + self._stream = None + self._pending_unflushed = False + try: + await stream.stop() + except SlackApiError as exc: + if not _is_stream_closed_error(exc): + raise + async def _stop_current(self, markdown_text: str | None) -> None: assert self._stream is not None if markdown_text: @@ -143,6 +262,158 @@ async def _stop_current(self, markdown_text: str | None) -> None: await self._stream.stop() +class _AnswerReply: + """Owns one turn's streamed answer: the live reply, the accumulated text, + the "Working on it…" placeholder, and the interruption/finalization rules. + + Centralizes three invariants that were previously enforced by convention + inside the turn loop: + + - **Placeholder visibility.** The ``ack`` is removed only once real content + is on screen — the first append that actually flushes to Slack, or the + finalizing ``stop()`` for a buffered answer — so the thread never shows a + gap between the placeholder vanishing and the reply appearing. + - **Seal ⇒ forget.** Sealing a segment before an out-of-band message + (approval card, notice) also resets the accumulated text, so the tail + reconciliation only ever considers the current segment. + - **Tail reconciliation.** The final answer is whatever streamed; if the + model reported a final item beyond the deltas, only the remainder is + appended, and a no-delta answer falls back to the committed item. + """ + + def __init__( + self, + client: SlackClientProtocol, + key: ThreadKey, + *, + recipient_user_id: str, + ack_ts: str | None, + logger: logging.Logger, + ) -> None: + self._reply = _LiveReply(client, key, recipient_user_id=recipient_user_id) + self._client = client + self._key = key + self._ack_ts = ack_ts + self._logger = logger + self._streamed = "" + self._final: str | None = None + # Text put on screen in each sealed segment this turn. Unlike + # ``_streamed``/``_final`` (which reset at each seal), this survives + # interruptions, so the no-delta fallback can tell whether the server's + # newest assistant message is one we ALREADY showed (a trailing notice + # sealed off an answer we streamed → don't re-post) from a genuinely new + # message that never streamed (e.g. the post-elicitation answer arrived + # only committed → DO recover it). + self._delivered_texts: list[str] = [] + + @property + def segments(self) -> int: + return self._reply.segments + + @property + def streamed_len(self) -> int: + return len(self._streamed) + + async def add_delta(self, delta: str) -> None: + # Append the delta; the SDK buffers and only flushes to Slack once the + # buffer fills. Clear the placeholder only on the flush that actually + # puts content on screen — never while still buffering — so there's no + # empty gap. + self._streamed += delta + if await self._reply.append(delta): + await self._clear_ack() + + def set_final(self, text: str) -> None: + self._final = text + + async def seal_for_interruption(self) -> None: + # Before an out-of-band message: reveal any buffered streamed text FIRST + # (so it appears above the interruption as it did on screen in the web UI, + # not coincident with the card), drop the placeholder (it would sit stale + # above the interruption for the whole wait), finalize the current segment + # so the interruption sorts after it, and forget the accumulated text so + # the next segment reconciles independently. Record what this segment + # delivered BEFORE resetting, so the fallback can recognize an + # already-shown message and not re-post it. + await self._reply.flush() + shown = self._streamed + self._tail() + if shown: + self._delivered_texts.append(shown) + await self._clear_ack() + await self._reply.seal() + self._streamed, self._final = "", None + + async def finalize(self, *, error_text: str | None) -> bool: + # Deliver the answer tail, then clear the placeholder only after that + # final flush (a short buffered answer becomes visible only at stop()). + # Returns whether a real answer was delivered — when an error also + # occurred, the caller posts the failure as a separate reply so the + # answer stays intact; when nothing was produced, the error IS the reply. + tail = self._tail() + delivered_answer = bool(self._streamed or tail) + if delivered_answer: + await self._reply.stop(tail or None) + else: + await self._reply.stop( + f"Omnigent request failed: {error_text}" + if error_text + else "Omnigent completed without returning response text." + ) + await self._clear_ack() + return delivered_answer + + def _tail(self) -> str: + if self._final and self._final.startswith(self._streamed): + return self._final[len(self._streamed) :] + if self._final and not self._streamed: + return self._final + return "" + + def needs_fallback_text(self) -> bool: + # True when the current (final) segment has no answer to deliver — the + # caller may then recover the server's newest committed message. This is + # a per-segment check; ``already_delivered`` guards against re-posting a + # message an earlier sealed segment already showed. + return not self._streamed and not self._tail() + + def already_delivered(self, text: str) -> bool: + # Whether ``text`` matches something already put on screen this turn (a + # sealed segment, or the current one). Lets the fallback distinguish a + # message that already streamed but was sealed off by a trailing notice + # (don't re-post) from one that never streamed (recover it). + candidate = text.strip() + if not candidate: + return True + shown = [*self._delivered_texts, self._streamed + self._tail()] + return any(candidate == s.strip() for s in shown if s) + + def set_fallback_text(self, text: str) -> None: + self._final = text + + async def stop_with(self, text: str) -> None: + # Terminal notice (auth/unreachable/host errors, or a no-op abort): clear + # the placeholder, then deliver ``text`` as a plain thread reply. Empty + # text is a silent stop (nothing to say). A notice is not a streamed + # answer, so it goes via a normal message, not the streaming reply. + await self._clear_ack() + if text: + await self._client.chat_postMessage( + channel=self._key.channel_id, + thread_ts=self._key.thread_ts, + text=truncate_for_slack(text), + ) + + async def _clear_ack(self) -> None: + # Best-effort, idempotent: a failed delete must not abort the turn. + if not self._ack_ts: + return + ack_ts, self._ack_ts = self._ack_ts, None + try: + await self._client.chat_delete(channel=self._key.channel_id, ts=ack_ts) + except Exception: + self._logger.warning("Ack delete failed thread=%s; continuing", self._key.display()) + + class SlackOmnigentService: def __init__( self, @@ -152,6 +423,7 @@ def __init__( setup: SetupFlow, server_url: str, bot_user_id: str | None = None, + elicitations: ElicitationCoordinator | None = None, ) -> None: self._store = store self._pool = pool @@ -161,11 +433,36 @@ def __init__( # ignored, so a config change points every thread at the new server. self._server_url = server_url self._bot_user_id = bot_user_id - self._dispatcher = ThreadTurnDispatcher(self._run_turn) + # Bridges a parked turn (blocked awaiting the user) to the button/form + # interaction that answers it. Shared with the block-action handler. + self._elicitations = elicitations or ElicitationCoordinator() + # How often, while awaiting a Slack click, to poll for external + # resolution (overridable in tests to avoid real-time waits). + self._external_resolve_poll_seconds = _EXTERNAL_RESOLVE_POLL_SECONDS + # Threads with a turn actively streaming IN THIS PROCESS. Each turn opens + # its own SSE stream; two at once would render the same events into Slack + # twice. This is a LOCAL concurrency guard (reserved synchronously, before + # any await, so two racing messages can't both pass) — necessary because + # the server-activity check alone races: claude-native flips to `idle` + # between streaming bursts, so a snapshot mid-turn can read "not busy" + # while a local stream is still live. The guard is safe from stale-wedge + # because every turn is bounded (the elicitation grace fix guarantees it + # ends and releases). The server-activity check (see _route_turn) is the + # SEPARATE cross-surface signal (web-UI busy / pending action). + self._active_threads: set[ThreadKey] = set() + # In-flight turn tasks, tracked so shutdown can cancel them. + self._turn_tasks: set[asyncio.Task[None]] = set() self._logger = logging.getLogger(__name__) + @property + def elicitations(self) -> ElicitationCoordinator: + return self._elicitations + async def shutdown(self) -> None: - await self._dispatcher.shutdown() + tasks = list(self._turn_tasks) + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) async def handle_app_mention( self, @@ -202,7 +499,9 @@ async def handle_app_mention( ) return - self._logger.info("Accepted Slack app_mention thread=%s chars=%s", key.display(), len(text)) + self._logger.info( + "Accepted Slack app_mention thread=%s chars=%s", key.display(), len(text) + ) await self._route_turn( key=key, event=event, @@ -281,66 +580,144 @@ async def _route_turn( in_channel: bool, ) -> None: requester = str(event.get("user") or "") - record = await self._store.get_session(key) + if not requester: + # No authenticated Slack user on the event — we can't attribute the + # message to an owner, so we refuse to route it. Never fall through to + # an owner-less turn (that would be an unguarded, adoptable session). + self._logger.warning("Dropping Slack event with no user thread=%s", key.display()) + return - if record is not None: - # An existing thread belongs to whoever started it. A follow-up from - # a different user (only possible in a channel) is not added to the - # session for now — silently ignore it. - if record.owner_user_id and record.owner_user_id != requester: + # LOCAL concurrency guard: reserve the thread SYNCHRONOUSLY here (no await + # before this add) so two near-simultaneous messages can't both open a + # stream and double-render. If already reserved, a turn is streaming in + # this process → deflect. This is distinct from the server-activity check + # below: claude-native reads `idle` between bursts, so the server snapshot + # alone would let a 2nd turn slip in mid-stream. The reservation is held + # until either a spawned turn's finally releases it, or we release it + # below on any path that does NOT spawn. + if key in self._active_threads: + self._logger.info( + "Thread already streaming in-process thread=%s; deflecting", key.display() + ) + record = await self._store.get_session(key) + if record is not None and record.owner_user_id != requester: + await self._notify_non_owner(client, key, requester) + else: + await self._notify_thread_busy(client, key, requester, needs_action=False) + return + self._active_threads.add(key) + spawned = False + try: + record = await self._store.get_session(key) + + if record is not None: + # An existing thread belongs to whoever started it. A follow-up + # from a different user (only possible in a channel) is not added + # to the session. Tell that user — privately — why nothing + # happened. A record with no stored owner is treated as locked + # (fail closed): only match when owner is known AND == requester. + if record.owner_user_id != requester: + self._logger.info( + "Ignoring follow-up from non-owner thread=%s owner=%s requester=%s", + key.display(), + record.owner_user_id, + requester, + ) + await self._notify_non_owner(client, key, requester) + return + # Cross-surface check: the SERVER decides busy/awaiting-action + # (web UI or another client may be driving the session), mirroring + # the web UI's send gate. The local guard above already prevents a + # concurrent Slack stream; this catches activity elsewhere. + omnigent = await self._pool.get( + self._server_url, pack_user_key(key.team_id, requester) + ) + activity = await omnigent.get_session_activity(record.session_id) + if activity.needs_user_action or activity.is_busy: + self._logger.info( + "Server busy thread=%s status=%s pending=%s; deflecting", + key.display(), + activity.status, + activity.pending_elicitation, + ) + await self._notify_thread_busy( + client, key, requester, needs_action=activity.needs_user_action + ) + return + self._spawn_turn( + SlackTurn( + key=key, + text=text, + user_id=requester, + create_if_missing=False, + title=_session_title(event, text), + slack_client=client, + agent_id="", + owner_user_id=record.owner_user_id or requester, + workspace=record.workspace, + host_id=record.host_id, + ) + ) + spawned = True + return + + config = await self._store.get_user_config(key.team_id, requester) + if config is None: self._logger.info( - "Ignoring follow-up from non-owner thread=%s owner=%s requester=%s", + "Unconfigured user thread=%s user=%s; prompting setup", key.display(), - record.owner_user_id, requester, ) + await self._setup.prompt_unconfigured( + client, + requester, + channel=key.channel_id, + thread_ts=key.thread_ts, + in_channel=in_channel, + ) return - await self._dispatcher.enqueue( + + self._spawn_turn( SlackTurn( key=key, text=text, user_id=requester, - create_if_missing=False, + create_if_missing=True, title=_session_title(event, text), slack_client=client, - agent_id="", - owner_user_id=record.owner_user_id or requester, - workspace=record.workspace, - host_id=record.host_id, + agent_id=config.agent_id, + owner_user_id=requester, + workspace=config.workspace, + host_id=config.host_id, ) ) - return - - config = await self._store.get_user_config(key.team_id, requester) - if config is None: - self._logger.info( - "Unconfigured user thread=%s user=%s; prompting setup", - key.display(), - requester, - ) - await self._setup.prompt_unconfigured( - client, - requester, - channel=key.channel_id, - thread_ts=key.thread_ts, - in_channel=in_channel, - ) - return - - await self._dispatcher.enqueue( - SlackTurn( - key=key, - text=text, - user_id=requester, - create_if_missing=True, - title=_session_title(event, text), - slack_client=client, - agent_id=config.agent_id, - owner_user_id=requester, - workspace=config.workspace, - host_id=config.host_id, - ) - ) + spawned = True + finally: + # Release the reservation unless a turn was spawned — the spawned + # turn's ``_run_turn_tracked`` finally owns the release from here on. + if not spawned: + self._active_threads.discard(key) + + def _spawn_turn(self, turn: SlackTurn) -> None: + """Run a reserved turn as a background task, tracked for shutdown. + + The thread is already reserved in ``_active_threads`` by ``_route_turn`` + (synchronously, before any await); ``_run_turn_tracked`` releases it when + the turn ends. + """ + task = asyncio.create_task(self._run_turn_tracked(turn)) + self._turn_tasks.add(task) + task.add_done_callback(self._turn_tasks.discard) + + async def _run_turn_tracked(self, turn: SlackTurn) -> None: + try: + await self._run_turn(turn) + except asyncio.CancelledError: + raise + except Exception: + self._logger.exception("Slack turn failed for %s", turn.key.display()) + finally: + self._active_threads.discard(turn.key) async def _run_turn(self, turn: SlackTurn) -> None: self._logger.info("Starting turn thread=%s chars=%s", turn.key.display(), len(turn.text)) @@ -350,169 +727,222 @@ async def _run_turn(self, turn: SlackTurn) -> None: # Acknowledge immediately: a new session's create + runner launch can take # several seconds, and the streamed reply message only appears once the - # first tokens flush, so post a lightweight placeholder now and delete it - # once real content starts streaming (or when the turn ends). + # first tokens flush, so post a lightweight placeholder now. ack_ts = await self._post_ack(turn.slack_client, turn.key) + reply = _AnswerReply( + turn.slack_client, + turn.key, + recipient_user_id=turn.owner_user_id, + ack_ts=ack_ts, + logger=self._logger, + ) - record = await self._store.get_session(turn.key) - session_id = record.session_id if record is not None else None + try: + session_id = await self._ensure_session(turn, omnigent, reply) + except _TurnAborted as aborted: + await reply.stop_with(aborted.text) + return if session_id is None: - if not turn.create_if_missing: - self._logger.info( - "No session found and creation disabled thread=%s", - turn.key.display(), - ) - return - try: - session_id = await omnigent.create_session(turn.agent_id, turn.title) - runner_id = await omnigent.launch_runner( - session_id, - workspace=turn.workspace or "", - host_id=turn.host_id, - ) - except AuthRequiredError: - self._logger.info("Auth required thread=%s; prompting re-login", turn.key.display()) - await self._clear_ack(turn.slack_client, turn.key, ack_ts) - await self._post_reply(turn.slack_client, turn.key, _AUTH_REQUIRED_TEXT) - return - except ServerUnreachableError: - self._logger.info("Server unreachable thread=%s", turn.key.display()) - await self._clear_ack(turn.slack_client, turn.key, ack_ts) - await self._post_reply(turn.slack_client, turn.key, _SERVER_UNREACHABLE_TEXT) - return - except HostUnavailableError: - self._logger.info("Host unavailable thread=%s", turn.key.display()) - await self._clear_ack(turn.slack_client, turn.key, ack_ts) - await self._post_reply( - turn.slack_client, turn.key, host_unavailable_text(self._server_url) - ) - return - except Exception as exc: - # Any other failure spinning up the session (e.g. a 500 from - # create_session/launch_runner surfaced as OmnigentError) must - # still clear the placeholder and report — otherwise the thread - # is stranded showing "Working on it…". - self._logger.exception( - "Failed to start Omnigent session thread=%s", turn.key.display() - ) - await self._clear_ack(turn.slack_client, turn.key, ack_ts) - await self._post_failure_reply(turn.slack_client, turn.key, str(exc)) - return - await self._store.upsert_session( - turn.key, - session_id, - turn.title, - owner_user_id=turn.owner_user_id, - host_id=turn.host_id, - workspace=turn.workspace, - ) - self._logger.info( - "Mapped Slack thread to new Omnigent session thread=%s session_id=%s runner_id=%s", - turn.key.display(), - session_id, - runner_id, - ) - else: + # No session and creation disabled (a follow-up on a dead thread): + # nothing to run. Drop the placeholder so it doesn't linger. + await reply.stop_with("") + return + + # Baseline the newest assistant message BEFORE the turn runs, so the + # no-delta fallback below can tell this turn's answer from a prior one. + baseline = await omnigent.latest_assistant_message(session_id) + + try: + error_text = await self._stream_turn(turn, omnigent, session_id, reply) + except _TurnAborted: + # A known mid-stream error already delivered its message and stopped + # the reply; nothing left to finalize. + return + + if reply.needs_fallback_text(): + # The current segment delivered nothing (e.g. a post-elicitation + # answer that arrived only as a committed item, never streamed). + # Recover the server's newest assistant message, but only when it's + # genuinely new: it must differ from the pre-turn baseline (else a + # no-answer turn like a denied approval would resurrect the PREVIOUS + # turn's message) AND not be something an earlier sealed segment this + # turn already showed (else a trailing notice would re-post the answer + # we just streamed). Compare the whole (id, text) tuple so an id-less + # message is judged by its text, not a blank id. + latest = await omnigent.latest_assistant_message(session_id) + if ( + latest is not None + and latest != baseline + and not reply.already_delivered(latest[1]) + ): + reply.set_fallback_text(latest[1]) + delivered_answer = await reply.finalize(error_text=error_text) + if error_text and delivered_answer: + await self._post_failure_reply(turn.slack_client, turn.key, error_text) + + self._logger.info( + "Completed Slack turn thread=%s session=%s streamed_chars=%s segments=%s errored=%s", + turn.key.display(), + session_id, + reply.streamed_len, + reply.segments, + bool(error_text), + ) + + async def _ensure_session( + self, turn: SlackTurn, omnigent: OmnigentClient, reply: _AnswerReply + ) -> str | None: + """Return the session id for this turn, creating one if needed. + + Returns ``None`` when there's no session and creation is disabled (a + follow-up on a thread whose session is gone). Raises :class:`_TurnAborted` + with a user-facing message when session startup fails. + """ + record = await self._store.get_session(turn.key) + if record is not None: self._logger.info( "Using existing Omnigent session thread=%s session_id=%s", turn.key.display(), - session_id, + record.session_id, ) + return record.session_id - slack_client = turn.slack_client + if not turn.create_if_missing: + self._logger.info( + "No session found and creation disabled thread=%s", turn.key.display() + ) + return None - # Stream the reply live: append each delta and finalize with a stop. - # Slack renders markdown_text server-side and owns chunking, so there's - # no mrkdwn conversion, no progress-edit throttle, and no msg_too_long - # handling on our side. _LiveReply transparently opens a fresh streaming - # message if Slack finalizes one mid-turn, so a long turn keeps streaming - # across as many messages as it needs. - reply = _LiveReply(slack_client, turn.key, recipient_user_id=turn.owner_user_id) + try: + session_id = await omnigent.create_session(turn.agent_id, turn.title) + runner_id = await omnigent.launch_runner( + session_id, workspace=turn.workspace or "", host_id=turn.host_id + ) + except (AuthRequiredError, ServerUnreachableError, HostUnavailableError) as exc: + self._logger.info("Session startup failed thread=%s: %s", turn.key.display(), exc) + raise _TurnAborted(_turn_error_text(exc, self._server_url) or str(exc)) from exc + except Exception as exc: + # Any other startup failure (e.g. a 500 surfaced as OmnigentError) + # must still report rather than strand the thread on "Working on it…". + self._logger.exception( + "Failed to start Omnigent session thread=%s", turn.key.display() + ) + raise _TurnAborted(f":warning: Omnigent request failed: {exc}") from exc - streamed_text = "" - final_text: str | None = None - error_text: str | None = None + await self._store.upsert_session( + turn.key, + session_id, + turn.title, + owner_user_id=turn.owner_user_id, + host_id=turn.host_id, + workspace=turn.workspace, + ) + self._logger.info( + "Mapped Slack thread to new Omnigent session thread=%s session_id=%s runner_id=%s", + turn.key.display(), + session_id, + runner_id, + ) + return session_id + async def _stream_turn( + self, + turn: SlackTurn, + omnigent: OmnigentClient, + session_id: str, + reply: _AnswerReply, + ) -> str | None: + """Stream the turn's events into ``reply``. Returns any error text. + + Slack renders markdown server-side and owns chunking, so there's no + mrkdwn conversion or msg_too_long handling here — just event routing. + A known auth/reachability error aborts the turn with a user-facing + message (delivered here); any other exception, or an in-band + ``response.error`` event, becomes error text used at finalization. + """ + # Timestamp of the live plan/todo message, edited in place across updates. + state = _StreamState() try: - async for omnigent_event in omnigent.run_turn( + async for event in omnigent.run_turn( session_id, turn.text, workspace=turn.workspace, host_id=turn.host_id ): - delta = extract_delta(omnigent_event) - if delta: - # Drop the placeholder only once an append actually flushes to - # Slack (the SDK buffers deltas in memory first). Deleting it - # any earlier would leave the thread empty for the seconds - # until the streamed message is really on screen. - streamed_text += delta - if await reply.append(delta): - await self._clear_ack(slack_client, turn.key, ack_ts) - ack_ts = None - - item_text = extract_assistant_text(omnigent_event) - if item_text: - final_text = item_text - - event_error = extract_error_text(omnigent_event) - if event_error: - error_text = event_error - except AuthRequiredError: - self._logger.info("Auth required mid-turn thread=%s", turn.key.display()) - await self._clear_ack(slack_client, turn.key, ack_ts) - await reply.stop(_AUTH_REQUIRED_TEXT) + await self._dispatch_stream_event(event, turn, omnigent, session_id, reply, state) + except (AuthRequiredError, ServerUnreachableError, HostUnavailableError) as exc: + self._logger.info("Turn error mid-stream thread=%s: %s", turn.key.display(), exc) + await reply.stop_with(_turn_error_text(exc, self._server_url) or str(exc)) + state.aborted = True + except Exception as exc: + self._logger.exception("Omnigent turn failed for %s", turn.key.display()) + state.error_text = str(exc) + if state.aborted: + raise _TurnAborted("") # already delivered; signal the caller to stop + return state.error_text + + async def _dispatch_stream_event( + self, + event: dict[str, Any], + turn: SlackTurn, + omnigent: OmnigentClient, + session_id: str, + reply: _AnswerReply, + state: _StreamState, + ) -> None: + """Route one stream event to the reply or an out-of-band message. + + Out-of-band messages (elicitation card, policy/file notice, first todo + post) seal the current answer segment first so they sort in + chronological order. Mutates ``state`` for the todo-message timestamp + and any in-band error text. + """ + client = turn.slack_client + + delta = extract_delta(event) + if delta: + await reply.add_delta(delta) return - except ServerUnreachableError: - self._logger.info("Server unreachable mid-turn thread=%s", turn.key.display()) - await self._clear_ack(slack_client, turn.key, ack_ts) - await reply.stop(_SERVER_UNREACHABLE_TEXT) + + elicitation = extract_elicitation_request(event, session_id) + if elicitation is not None: + # Parked awaiting the user: seal the answer so far (it sorts before + # the card), then post the card and block for the verdict. The stream + # stays open (session sits in `waiting`), and resumed text opens a + # fresh segment after the card. + await reply.seal_for_interruption() + await self._handle_elicitation( + omnigent, client, turn.key, turn.owner_user_id, elicitation + ) return - except HostUnavailableError: - self._logger.info("Host unavailable mid-turn thread=%s", turn.key.display()) - await self._clear_ack(slack_client, turn.key, ack_ts) - await reply.stop(host_unavailable_text(self._server_url)) + + denied_reason = extract_policy_denied(event) + if denied_reason is not None: + await reply.seal_for_interruption() + await self._post_reply(client, turn.key, format_policy_denied(denied_reason)) return - except Exception as exc: - self._logger.exception("Omnigent turn failed for %s", turn.key.display()) - error_text = str(exc) - - # The full answer is whatever streamed; if the model reported a final - # item that adds text beyond the deltas, append only the remainder so we - # don't duplicate what already streamed. When nothing streamed, fall back - # to the latest assistant item. - tail = "" - if final_text and final_text.startswith(streamed_text): - tail = final_text[len(streamed_text) :] - elif final_text and not streamed_text: - tail = final_text - if not streamed_text and not tail: - tail = (await omnigent.latest_assistant_text(session_id)) or "" - - if streamed_text or tail: - await reply.stop(tail or None) - if error_text: - await self._post_failure_reply(slack_client, turn.key, error_text) - else: - fallback = ( - f"Omnigent request failed: {error_text}" - if error_text - else "Omnigent completed without returning response text." + + output_file = extract_output_file(event) + if output_file is not None: + await reply.seal_for_interruption() + await self._post_reply(client, turn.key, format_output_file(output_file)) + return + + todos = extract_todos(event) + if todos is not None: + # The first plan post is a new out-of-band message → seal before it; + # later updates edit it in place (no boundary, no fragmentation). + if state.todos_ts is None: + await reply.seal_for_interruption() + state.todos_ts = await self._post_or_update_todos( + client, turn.key, todos, state.todos_ts ) - await reply.stop(fallback) + return - # Clear the placeholder only after final delivery. A short answer buffers - # entirely in the SDK and doesn't reach Slack until stop() flushes it, so - # deleting the placeholder any earlier would leave a gap where the thread - # shows nothing. - await self._clear_ack(slack_client, turn.key, ack_ts) - ack_ts = None + item_text = extract_assistant_text(event) + if item_text: + reply.set_final(item_text) - self._logger.info( - "Completed Slack turn thread=%s session_id=%s streamed_chars=%s segments=%s errored=%s", - turn.key.display(), - session_id, - len(streamed_text), - reply.segments, - bool(error_text), - ) + event_error = extract_error_text(event) + if event_error: + state.error_text = event_error async def _post_ack(self, client: SlackClientProtocol, key: ThreadKey) -> str | None: # Best-effort: a failed ack must not abort the turn. @@ -528,20 +958,31 @@ async def _post_ack(self, client: SlackClientProtocol, key: ThreadKey) -> str | ts = response.get("ts") return str(ts) if ts else None - async def _clear_ack( + async def _post_or_update_todos( self, client: SlackClientProtocol, key: ThreadKey, - ack_ts: str | None, - ) -> None: - # Best-effort: a failed delete must not abort the turn or clobber the - # streamed answer. - if not ack_ts: - return + todos: list[dict[str, Any]], + todos_ts: str | None, + ) -> str | None: + # Render the plan once and edit it in place on later updates so the + # thread carries a single, current plan message rather than a pile of + # snapshots. Best-effort throughout. + text = format_todos(todos) + if text is None: + return todos_ts try: - await client.chat_delete(channel=key.channel_id, ts=ack_ts) + if todos_ts is None: + response = await client.chat_postMessage( + channel=key.channel_id, thread_ts=key.thread_ts, text=text + ) + ts = response.get("ts") + return str(ts) if ts else None + await client.chat_update(channel=key.channel_id, ts=todos_ts, text=text) + return todos_ts except Exception: - self._logger.warning("Ack delete failed thread=%s; continuing", key.display()) + self._logger.warning("Todo update failed thread=%s; continuing", key.display()) + return todos_ts async def _post_reply( self, @@ -569,6 +1010,266 @@ async def _post_failure_reply( text=f":warning: Omnigent request failed: {error_text}", ) + async def _post_ephemeral( + self, + client: SlackClientProtocol, + key: ThreadKey, + user_id: str, + text: str, + ) -> None: + # Best-effort "Only visible to you" note, anchored in-thread. Used to + # explain privately why a message wasn't acted on, without cluttering the + # thread. A failed post must never abort handling. + try: + await client.chat_postEphemeral( + channel=key.channel_id, + user=user_id, + thread_ts=key.thread_ts, + text=text, + ) + except Exception: + self._logger.warning("Ephemeral notice failed thread=%s; continuing", key.display()) + + async def _notify_non_owner( + self, client: SlackClientProtocol, key: ThreadKey, user_id: str + ) -> None: + await self._post_ephemeral( + client, + key, + user_id, + "This Omnigent thread belongs to whoever started it, so I can't " + "add your message to it. Start a new thread by mentioning me " + "(or DM me) to get your own session.", + ) + + async def _notify_thread_busy( + self, + client: SlackClientProtocol, + key: ThreadKey, + user_id: str, + *, + needs_action: bool, + ) -> None: + """Tell the owner their message can't run because the server is busy. + + Mirrors the web UI's two "can't send now" states: (a) ``needs_action`` — + the session is parked awaiting a decision, so the user must answer the + pending request (in Slack above, or the web UI); (b) otherwise the server + is running/waiting, so wait for the reply or interrupt in the web UI. The + message was NOT run and is NOT queued — a message to an idle thread runs + normally, so re-sending once the session frees works. + """ + record = await self._store.get_session(key) + link = self._session_web_link(record.session_id) if record is not None else None + if needs_action: + text = ( + ":hourglass: I'm waiting on your response to the request above before I can " + "continue. Answer it here" + ) + text += f", or in the <{link}|web UI>." if link else "." + else: + text = ( + ":hourglass: I'm still working on your previous message in this thread — " + "I handle one at a time here, so send this again once I've replied" + ) + text += f", or wait / interrupt in the <{link}|web UI>." if link else "." + await self._post_ephemeral(client, key, user_id, text) + + async def handle_elicitation_action(self, *, elicitation_id: str, verdict: Verdict) -> bool: + """Deliver a button/form verdict to the waiting turn worker. + + Returns whether a live waiter received it — ``False`` means the request + already expired or was answered, so the caller can tell the user. + """ + return self._elicitations.resolve(elicitation_id, verdict) + + async def reject_non_owner_click( + self, client: SlackClientProtocol, body: dict[str, Any], target: ClickTarget + ) -> None: + """Privately tell a non-owner their click on someone else's card was ignored. + + The verdict is NOT delivered (the owner check already blocked it); this + is just feedback so the clicker isn't left wondering. Channel/thread come + from the interaction body (a Block Kit action payload). + """ + channel = (body.get("channel") or {}).get("id") + clicker = (body.get("user") or {}).get("id") + message = body.get("message") or {} + thread_ts = message.get("thread_ts") or message.get("ts") + if not isinstance(channel, str) or not isinstance(clicker, str): + return + try: + await client.chat_postEphemeral( + channel=channel, + user=clicker, + thread_ts=thread_ts if isinstance(thread_ts, str) else None, + text=( + "This request belongs to whoever started the thread — only they " + "can answer it. Start your own thread by mentioning me (or DM me)." + ), + ) + except Exception: + self._logger.warning("Non-owner click ephemeral failed; continuing") + + def _session_link(self, session_id: str, elicitation_id: str) -> str: + # Deep link to the elicitation's approve page in the Omnigent web UI, so + # a user can resolve a request the bot can't render in Slack. + base = self._server_url.rstrip("/") + return f"{base}/approve/{session_id}/{elicitation_id}" + + def _session_web_link(self, session_id: str) -> str: + # Link to the session's conversation page in the Omnigent web UI, where a + # user can continue a thread that's mid-turn in Slack (the web UI accepts + # concurrent input and shows any pending actions). + base = self._server_url.rstrip("/") + return f"{base}/c/{session_id}" + + async def _handle_elicitation( + self, + omnigent: OmnigentClient, + client: SlackClientProtocol, + key: ThreadKey, + owner_user_id: str, + request: ElicitationRequest, + ) -> None: + """Post the elicitation card, wait for the answer, and resolve it. + + Renders a multiple-choice form (``AskUserQuestion``) or a binary + Approve/Deny, blocks the turn worker until the user answers or the wait + times out (a timeout declines so the server-side park doesn't hang + either), then updates the card in place with the outcome and forwards + the verdict — including any form selections as ``content``. + + For an elicitation the bot can't render (a ``url``-mode page or a + request for typed input), it posts a link to resolve in the Omnigent web + UI and returns without blocking — the user completes it there and the + stream resumes (the turn stays alive via the idle grace window). + """ + if not request.is_supported: + await self._post_reply( + client, + key, + ( + ":link: Omnigent needs input I can't collect here " + f"({request.message}). Open the session to respond:\n" + f"{self._session_link(request.session_id, request.elicitation_id)}" + ), + ) + self._logger.info( + "Unsupported elicitation surfaced as web link thread=%s elicitation_id=%s mode=%s", + key.display(), + request.elicitation_id, + request.mode, + ) + return + + self._logger.info( + "Elicitation requested thread=%s elicitation_id=%s policy=%s form=%s", + key.display(), + request.elicitation_id, + request.policy_name, + request.is_form, + ) + # Register the waiter BEFORE posting the card: a fast click could + # otherwise reach the action handler before the awaiter exists and be + # dropped (silent timeout-deny). Registering first closes that window. + self._elicitations.register(request.elicitation_id) + posted = await client.chat_postMessage( + channel=key.channel_id, + thread_ts=key.thread_ts, + text="Omnigent needs your input to continue.", + blocks=elicitation_card_blocks(request, owner_user_id), + ) + card_ts = posted.get("ts") + + verdict = await self._await_verdict_or_external(omnigent, request) + if verdict is _RESOLVED_EXTERNALLY: + # The user answered elsewhere (web UI, another client). The server + # already has the verdict; don't post our own. Just clear the card + # and let the turn continue. + outcome = "Answered elsewhere" + else: + assert verdict is None or isinstance(verdict, Verdict) + content: dict[str, Any] | None = None + if verdict is None: + # Nobody answered in time — decline so the server park releases. + verdict = Verdict(accepted=False) + outcome = "Timed out" + elif request.is_form: + # A form Submit is an accept with selections; Cancel is a decline. + # Selections arrive as option indices — map them back to the full + # labels the agent expects (labels can exceed Slack's value cap). + content = resolve_form_answers(request, verdict.content) + outcome = "Answered" if verdict.accepted else "Cancelled" + else: + outcome = "Approved" if verdict.accepted else "Denied" + await omnigent.resolve_elicitation( + request.session_id, + request.elicitation_id, + accepted=verdict.accepted, + content=content, + ) + self._logger.info( + "Elicitation resolved thread=%s elicitation_id=%s outcome=%s", + key.display(), + request.elicitation_id, + outcome, + ) + + if isinstance(card_ts, str): + # Best-effort: replace the card with its outcome (no controls). A + # failed update must not abort the turn. + try: + await client.chat_update( + channel=key.channel_id, + ts=card_ts, + text=f"Request {outcome.lower()}.", + blocks=resolved_card_blocks(request, outcome=outcome), + ) + except Exception: + self._logger.warning( + "Elicitation card update failed thread=%s; continuing", key.display() + ) + + async def _await_verdict_or_external( + self, omnigent: OmnigentClient, request: ElicitationRequest + ) -> Verdict | None | object: + """Wait for a Slack button verdict OR external resolution. + + The turn worker blocks here on the Slack card, but the user may instead + answer in the web UI (or another client). Since this worker isn't + reading the stream while blocked, it can't see ``elicitation_resolved`` — + so it also polls the server, and if the elicitation is no longer pending + it returns ``_RESOLVED_EXTERNALLY`` to stop waiting (the verdict is + already recorded server-side; posting our own would be wrong). Otherwise + returns the :class:`Verdict` from the click, or ``None`` on timeout. + + Without this, a web-UI answer would leave the worker blocked until the + coordinator timeout — holding the thread's turn open (and deflecting its + follow-ups) the whole time. + """ + verdict_task = asyncio.ensure_future( + self._elicitations.await_verdict(request.elicitation_id) + ) + try: + while True: + done, _ = await asyncio.wait( + {verdict_task}, timeout=self._external_resolve_poll_seconds + ) + if verdict_task in done: + return verdict_task.result() + if not await omnigent.is_elicitation_pending( + request.session_id, request.elicitation_id + ): + return _RESOLVED_EXTERNALLY + finally: + # Stop the coordinator waiter if we returned on the external path, so + # a later stray click doesn't resolve a dead future. + if not verdict_task.done(): + verdict_task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await verdict_task + async def _accept_event( self, body: dict[str, Any], diff --git a/integrations/slack/src/omnigent_slack/setup.py b/integrations/slack/src/omnigent_slack/setup.py index 111052f825b..b59db19d781 100644 --- a/integrations/slack/src/omnigent_slack/setup.py +++ b/integrations/slack/src/omnigent_slack/setup.py @@ -208,7 +208,7 @@ async def _open_connecting_modal(self, client: Any, trigger_id: str) -> str | No """ try: resp = await client.views_open(trigger_id=trigger_id, view=connecting_modal()) - except Exception as exc: # noqa: BLE001 — surface as a no-op; nothing opened + except Exception as exc: self._logger.warning("Could not open setup modal: %s", exc) return None view = resp.get("view") if hasattr(resp, "get") else None @@ -373,7 +373,7 @@ async def _team_name(self, client: Any, team_id: str) -> str: """ try: resp = await client.team_info(team=team_id) - except Exception as exc: # noqa: BLE001 — label lookup must never block login + except Exception as exc: self._logger.info("team.info lookup failed team=%s error=%s", team_id, exc) return "" team = resp.get("team") if hasattr(resp, "get") else None diff --git a/integrations/slack/src/omnigent_slack/tokens.py b/integrations/slack/src/omnigent_slack/tokens.py index 824b6e88c6e..e1d87b1ff80 100644 --- a/integrations/slack/src/omnigent_slack/tokens.py +++ b/integrations/slack/src/omnigent_slack/tokens.py @@ -148,7 +148,9 @@ async def list_for_user(self, team_id: str, user_id: str) -> list[tuple[str, Tok out.append( ( str(row[0]), - TokenRecord(access_token=access, refresh_token=refresh, updated_at=int(row[3])), + TokenRecord( + access_token=access, refresh_token=refresh, updated_at=int(row[3]) + ), ) ) return out diff --git a/integrations/slack/tests/test_approvals.py b/integrations/slack/tests/test_approvals.py new file mode 100644 index 00000000000..03176ebb38e --- /dev/null +++ b/integrations/slack/tests/test_approvals.py @@ -0,0 +1,316 @@ +import asyncio +from typing import Any + +from omnigent_slack.approvals import ( + ACTION_APPROVE, + ACTION_DENY, + ACTION_FORM_ANSWER, + ACTION_FORM_SUBMIT, + ClickTarget, + ElicitationCoordinator, + Verdict, + elicitation_card_blocks, + parse_action_value, + parse_form_answers, + resolve_form_answers, + resolved_card_blocks, + route_elicitation_click, +) +from omnigent_slack.omnigent import ElicitationOption, ElicitationQuestion, ElicitationRequest + +# Thread owner used across click tests; the value carried on every control is +# " " so a non-owner click can be rejected. +_OWNER = "U_owner" + + +class _RecordingSink: + def __init__(self, delivered: bool = True) -> None: + self.calls: list[tuple[str, Verdict]] = [] + self.rejections: list[ClickTarget] = [] + self._delivered = delivered + + async def handle_elicitation_action(self, *, elicitation_id: str, verdict: Verdict) -> bool: + self.calls.append((elicitation_id, verdict)) + return self._delivered + + async def reject_non_owner_click( + self, client: Any, body: dict[str, Any], target: ClickTarget + ) -> None: + self.rejections.append(target) + + +def _click_body(value: Any, *, user_id: str = _OWNER) -> dict[str, Any]: + return {"actions": [{"value": value}], "user": {"id": user_id}} + + +def _binary() -> ElicitationRequest: + return ElicitationRequest( + elicitation_id="elicit_1", + message="Approve Edit()?", + session_id="conv_1", + policy_name="approve_edits", + content_preview='{"name": "Edit"}', + ) + + +def _form() -> ElicitationRequest: + return ElicitationRequest( + elicitation_id="elicit_form", + message="A couple of questions", + session_id="conv_1", + questions=[ + ElicitationQuestion( + key="store", + question="Where should it store data?", + options=[ElicitationOption("Redis"), ElicitationOption("Memory")], + ), + ElicitationQuestion( + key="langs", + question="Which languages?", + options=[ElicitationOption("Python"), ElicitationOption("Go")], + multi_select=True, + ), + ], + ) + + +async def test_coordinator_delivers_verdict_to_waiter() -> None: + coord = ElicitationCoordinator() + approved = Verdict(accepted=True) + + async def click() -> None: + for _ in range(50): + if coord.resolve("elicit_1", approved): + return + await asyncio.sleep(0.01) + + task = asyncio.create_task(click()) + verdict = await coord.await_verdict("elicit_1") + await task + assert verdict is approved + + +async def test_coordinator_times_out_to_none() -> None: + coord = ElicitationCoordinator(timeout_seconds=0.05) + assert await coord.await_verdict("elicit_1") is None + + +async def test_resolve_without_waiter_returns_false() -> None: + coord = ElicitationCoordinator() + assert coord.resolve("nope", Verdict(accepted=True)) is False + + +async def test_register_then_resolve_before_await_is_not_lost() -> None: + # A click can arrive between posting the card and the worker awaiting. As + # long as the future was registered first, the verdict is captured and the + # subsequent await returns it (no lost wakeup). + coord = ElicitationCoordinator() + coord.register("elicit_1") + approved = Verdict(accepted=True) + assert coord.resolve("elicit_1", approved) is True # click before await + assert await coord.await_verdict("elicit_1") is approved + + +async def test_resolve_is_single_shot() -> None: + coord = ElicitationCoordinator() + waiter = asyncio.create_task(coord.await_verdict("elicit_1")) + await asyncio.sleep(0.02) + assert coord.resolve("elicit_1", Verdict(accepted=False)) is True + # Second click finds the future already done → not delivered. + assert coord.resolve("elicit_1", Verdict(accepted=True)) is False + assert (await waiter).accepted is False + + +def test_binary_card_has_buttons_carrying_ids() -> None: + blocks = elicitation_card_blocks(_binary(), _OWNER) + actions = next(b for b in blocks if b["type"] == "actions") + ids = {e["action_id"] for e in actions["elements"]} + assert ids == {ACTION_APPROVE, ACTION_DENY} + for element in actions["elements"]: + # " " — owner carried for the auth gate. + assert element["value"] == f"{_OWNER} conv_1 elicit_1" + assert any('{"name": "Edit"}' in str(b) for b in blocks) + + +def test_form_card_renders_inputs_per_question() -> None: + blocks = elicitation_card_blocks(_form(), _OWNER) + # One input block per question, keyed so the submit handler can map answers. + inputs = { + b["block_id"]: b["accessory"]["type"] + for b in blocks + if isinstance(b.get("block_id"), str) and b["block_id"].startswith("omnigent_q::") + } + assert inputs == {"omnigent_q::store": "radio_buttons", "omnigent_q::langs": "checkboxes"} + # A Submit carrying the resolve target. + actions = next(b for b in blocks if b["type"] == "actions") + submit = next(e for e in actions["elements"] if e["action_id"] == ACTION_FORM_SUBMIT) + assert submit["value"] == f"{_OWNER} conv_1 elicit_form" + + +def test_parse_form_answers_single_and_multi() -> None: + # Option values are indices (the label can exceed Slack's 75-char cap); they + # are mapped back to labels later by resolve_form_answers. + state_values = { + "omnigent_q::store": {ACTION_FORM_ANSWER: {"selected_option": {"value": "0"}}}, + "omnigent_q::langs": { + ACTION_FORM_ANSWER: {"selected_options": [{"value": "0"}, {"value": "1"}]} + }, + # An unrelated block is ignored. + "other": {"x": {}}, + } + assert parse_form_answers(state_values) == {"store": "0", "langs": ["0", "1"]} + + +def test_parse_form_answers_omits_unanswered() -> None: + state_values = { + "omnigent_q::store": {ACTION_FORM_ANSWER: {"selected_option": None}}, + "omnigent_q::langs": {ACTION_FORM_ANSWER: {"selected_options": []}}, + } + assert parse_form_answers(state_values) == {} + + +def test_resolve_form_answers_maps_indices_to_full_labels() -> None: + # A label longer than Slack's 75-char option-value cap must round-trip to the + # agent intact — carried by index, resolved back to the untruncated label. + long_label = "A very long option label " * 5 # > 75 chars + request = ElicitationRequest( + elicitation_id="e", + message="pick", + session_id="c", + questions=[ + ElicitationQuestion( + key="store", + question="where", + options=[ElicitationOption(long_label), ElicitationOption("Memory")], + ), + ElicitationQuestion( + key="langs", + question="which", + options=[ElicitationOption("Python"), ElicitationOption("Go")], + multi_select=True, + ), + ], + ) + raw = {"store": "0", "langs": ["0", "1"]} + assert resolve_form_answers(request, raw) == { + "store": long_label, + "langs": ["Python", "Go"], + } + + +def test_resolve_form_answers_drops_unknown_indices() -> None: + request = ElicitationRequest( + elicitation_id="e", + message="pick", + session_id="c", + questions=[ + ElicitationQuestion( + key="store", + question="where", + options=[ElicitationOption("Redis")], + ) + ], + ) + # Out-of-range / non-numeric indices are dropped; empty answer omits the key. + assert resolve_form_answers(request, {"store": "9"}) == {} + assert resolve_form_answers(request, {"store": ["9", "x"]}) == {} + assert resolve_form_answers(request, None) == {} + + +def test_resolved_card_drops_controls() -> None: + blocks = resolved_card_blocks(_binary(), outcome="Approved") + assert not any(b.get("type") == "actions" for b in blocks) + assert "Approved" in blocks[0]["text"]["text"] + + +def test_parse_action_value_roundtrip() -> None: + assert parse_action_value(f"{_OWNER} conv_1 elicit_1") == ClickTarget( + owner_user_id=_OWNER, session_id="conv_1", elicitation_id="elicit_1" + ) + # An elicitation id may itself contain spaces — only the first two splits are + # the owner and session; the remainder is the elicitation id. + assert parse_action_value("U1 conv_1 elicit with spaces") == ClickTarget( + owner_user_id="U1", session_id="conv_1", elicitation_id="elicit with spaces" + ) + assert parse_action_value("conv_1 elicit_1") is None # legacy 2-part value + assert parse_action_value("malformed") is None + assert parse_action_value("") is None + + +async def test_route_binary_click_forwards_verdict() -> None: + sink = _RecordingSink() + await route_elicitation_click( + sink, None, _click_body(f"{_OWNER} conv_1 elicit_1"), accepted=True + ) + assert len(sink.calls) == 1 + eid, verdict = sink.calls[0] + assert eid == "elicit_1" + assert verdict.accepted is True and verdict.content is None + + +async def test_route_form_submit_carries_answers() -> None: + sink = _RecordingSink() + body = { + "actions": [{"value": f"{_OWNER} conv_1 elicit_form"}], + "user": {"id": _OWNER}, + "state": { + "values": { + "omnigent_q::store": {ACTION_FORM_ANSWER: {"selected_option": {"value": "0"}}}, + } + }, + } + await route_elicitation_click(sink, None, body, accepted=True, is_form_submit=True) + eid, verdict = sink.calls[0] + assert eid == "elicit_form" + assert verdict.accepted is True + # Carried as an option index; resolved to the label later in the service. + assert verdict.content == {"store": "0"} + + +async def test_route_form_cancel_is_decline_without_content() -> None: + sink = _RecordingSink() + body = { + "actions": [{"value": f"{_OWNER} conv_1 elicit_form"}], + "user": {"id": _OWNER}, + "state": {"values": {}}, + } + await route_elicitation_click(sink, None, body, accepted=False, is_form_submit=True) + _eid, verdict = sink.calls[0] + assert verdict.accepted is False and verdict.content is None + + +async def test_route_click_ignores_malformed_body() -> None: + sink = _RecordingSink() + await route_elicitation_click(sink, None, {"actions": []}, accepted=True) + await route_elicitation_click(sink, None, _click_body("no-space-value"), accepted=False) + await route_elicitation_click(sink, None, _click_body(None), accepted=False) + assert sink.calls == [] + assert sink.rejections == [] + + +async def test_route_click_tolerates_stale_click() -> None: + sink = _RecordingSink(delivered=False) + await route_elicitation_click( + sink, None, _click_body(f"{_OWNER} conv_1 elicit_1"), accepted=True + ) + assert len(sink.calls) == 1 # attempted; sink reported no waiter + + +async def test_route_rejects_non_owner_click() -> None: + # A click from anyone but the thread owner is rejected before any verdict is + # delivered — the card is visible channel-wide but only the owner can act. + sink = _RecordingSink() + body = _click_body(f"{_OWNER} conv_1 elicit_1", user_id="U_intruder") + await route_elicitation_click(sink, None, body, accepted=True) + assert sink.calls == [] + assert sink.rejections == [ + ClickTarget(owner_user_id=_OWNER, session_id="conv_1", elicitation_id="elicit_1") + ] + + +async def test_route_owner_click_is_accepted() -> None: + sink = _RecordingSink() + body = _click_body(f"{_OWNER} conv_1 elicit_1", user_id=_OWNER) + await route_elicitation_click(sink, None, body, accepted=True) + assert len(sink.calls) == 1 + assert sink.rejections == [] diff --git a/integrations/slack/tests/test_auth_manager.py b/integrations/slack/tests/test_auth_manager.py index d3a533173dc..12c47cc849f 100644 --- a/integrations/slack/tests/test_auth_manager.py +++ b/integrations/slack/tests/test_auth_manager.py @@ -6,7 +6,6 @@ import httpx import respx from cryptography.fernet import Fernet - from omnigent_slack.auth_manager import AuthManager, slack_client_id from omnigent_slack.tokens import EncryptedTokenStore, TokenStore @@ -34,7 +33,9 @@ async def test_disabled_without_key() -> None: def _mock_authorize() -> None: # Device-grant path: /v1/me → accounts mode, then the device authorize. - respx.get(_BASE + "/v1/me").mock(return_value=httpx.Response(401, json={"login_url": "/login"})) + respx.get(_BASE + "/v1/me").mock( + return_value=httpx.Response(401, json={"login_url": "/login"}) + ) respx.post(_BASE + "/oauth/device/authorize").mock( return_value=httpx.Response( 200, @@ -237,7 +238,9 @@ async def test_oidc_login_stores_session_jwt_no_refresh(tmp_path: Path) -> None: ) ) respx.get(_BASE + "/auth/cli-poll").mock( - return_value=httpx.Response(200, json={"token": "sess", "user_id": "a@x", "expires_in": 60}) + return_value=httpx.Response( + 200, json={"token": "sess", "user_id": "a@x", "expires_in": 60} + ) ) mgr, store = await _manager(tmp_path) diff --git a/integrations/slack/tests/test_client_auth.py b/integrations/slack/tests/test_client_auth.py index 86bb1207a58..820eee9770e 100644 --- a/integrations/slack/tests/test_client_auth.py +++ b/integrations/slack/tests/test_client_auth.py @@ -4,7 +4,6 @@ import httpx import respx - from omnigent_slack.omnigent import ClientAuth, OmnigentClient, OmnigentClientPool _BASE = "http://omnigent.test" diff --git a/integrations/slack/tests/test_config.py b/integrations/slack/tests/test_config.py index b24dd1ceaf2..7ecd3264846 100644 --- a/integrations/slack/tests/test_config.py +++ b/integrations/slack/tests/test_config.py @@ -3,9 +3,8 @@ from pathlib import Path import pytest -from pydantic import ValidationError - from omnigent_slack.config import Settings +from pydantic import ValidationError def _load() -> Settings: diff --git a/integrations/slack/tests/test_dispatcher.py b/integrations/slack/tests/test_dispatcher.py deleted file mode 100644 index 2a16cd43640..00000000000 --- a/integrations/slack/tests/test_dispatcher.py +++ /dev/null @@ -1,99 +0,0 @@ -import asyncio - -from omnigent_slack.dispatcher import ThreadTurnDispatcher -from omnigent_slack.models import SlackTurn, ThreadKey - - -def _turn(key: ThreadKey, text: str) -> SlackTurn: - return SlackTurn( - key=key, - text=text, - user_id="U", - create_if_missing=False, - title="title", - slack_client=object(), - agent_id="ag_1", - owner_user_id="U", - ) - - -async def test_dispatcher_runs_turns_in_thread_order() -> None: - seen: list[str] = [] - done = asyncio.Event() - - async def worker(turn: SlackTurn) -> None: - await asyncio.sleep(0) - seen.append(turn.text) - if len(seen) == 3: - done.set() - - dispatcher = ThreadTurnDispatcher(worker, idle_timeout_seconds=0.1) - key = ThreadKey(team_id="T", channel_id="C", thread_ts="1") - - for text in ["one", "two", "three"]: - await dispatcher.enqueue(_turn(key, text)) - - await asyncio.wait_for(done.wait(), timeout=1) - await dispatcher.shutdown() - - assert seen == ["one", "two", "three"] - - -async def test_enqueue_during_idle_teardown_is_not_wedged() -> None: - """A turn that arrives while an idle worker is tearing down must still run. - - Reproduces the race where ``_run_queue`` times out on an empty queue and - decides to exit, then ``enqueue`` slips a turn in before the teardown - ``finally`` reacquires the lock. The queue stays registered, so no new - worker is ever spawned and the turn is stranded. - """ - seen: list[str] = [] - processed = asyncio.Event() - - async def worker(turn: SlackTurn) -> None: - seen.append(turn.text) - processed.set() - - dispatcher = ThreadTurnDispatcher(worker, idle_timeout_seconds=0.05) - key = ThreadKey(team_id="T", channel_id="C", thread_ts="1") - - # Gate the teardown's lock acquisition so a concurrent enqueue wins the - # race: the worker has decided to exit but has not yet run its finally. - original_lock = dispatcher._lock - teardown_reached = asyncio.Event() - release_teardown = asyncio.Event() - - class _GatedLock: - def __init__(self) -> None: - self._enter_count = 0 - - async def __aenter__(self) -> None: - self._enter_count += 1 - # The first acquisition after startup is enqueue's; the teardown - # acquisition is the one we stall so enqueue can slip ahead. - if self._enter_count == 2: - teardown_reached.set() - await release_teardown.wait() - await original_lock.acquire() - - async def __aexit__(self, *exc: object) -> None: - original_lock.release() - - dispatcher._lock = _GatedLock() # type: ignore[assignment] - - # Let the worker spawn and hit its idle timeout → enter teardown. - await dispatcher.enqueue(_turn(key, "first")) - await asyncio.wait_for(processed.wait(), timeout=1) - processed.clear() - await asyncio.wait_for(teardown_reached.wait(), timeout=1) - - # Enqueue arrives before teardown finishes. Restore the real lock so the - # new enqueue path (and any re-armed worker) runs unhindered. - dispatcher._lock = original_lock # type: ignore[assignment] - await dispatcher.enqueue(_turn(key, "second")) - release_teardown.set() - - await asyncio.wait_for(processed.wait(), timeout=1) - await dispatcher.shutdown() - - assert seen == ["first", "second"] diff --git a/integrations/slack/tests/test_notifications.py b/integrations/slack/tests/test_notifications.py new file mode 100644 index 00000000000..be5940c9ce2 --- /dev/null +++ b/integrations/slack/tests/test_notifications.py @@ -0,0 +1,40 @@ +from omnigent_slack.notifications import ( + format_output_file, + format_policy_denied, + format_todos, +) +from omnigent_slack.omnigent import OutputFile + + +def test_format_todos_renders_marks_and_active_form() -> None: + text = format_todos( + [ + {"content": "Write tests", "status": "completed", "activeForm": "Writing tests"}, + {"content": "Ship it", "status": "in_progress", "activeForm": "Shipping it"}, + {"content": "Celebrate", "status": "pending", "activeForm": "Celebrating"}, + ] + ) + assert text is not None + assert ":white_check_mark: Write tests" in text + # In-progress uses the gerund (activeForm). + assert ":hourglass_flowing_sand: Shipping it" in text + assert ":white_large_square: Celebrate" in text + assert text.startswith("*Plan*") + + +def test_format_todos_empty_is_none() -> None: + assert format_todos([]) is None + # Entries with no usable label are skipped, leaving nothing to show. + assert format_todos([{"status": "pending"}]) is None + + +def test_format_output_file_prefers_filename() -> None: + assert "report.pdf" in format_output_file(OutputFile(file_id="f1", filename="report.pdf")) + # Falls back to the id when unnamed. + assert "f1" in format_output_file(OutputFile(file_id="f1")) + + +def test_format_policy_denied() -> None: + text = format_policy_denied("No shell commands allowed.") + assert "Blocked by policy" in text + assert "No shell commands allowed." in text diff --git a/integrations/slack/tests/test_oauth.py b/integrations/slack/tests/test_oauth.py index a820dc31870..8ed8bcbde27 100644 --- a/integrations/slack/tests/test_oauth.py +++ b/integrations/slack/tests/test_oauth.py @@ -3,7 +3,6 @@ import httpx import pytest import respx - from omnigent_slack.oauth import ( AuthMode, AuthorizationExpiredError, diff --git a/integrations/slack/tests/test_omnigent.py b/integrations/slack/tests/test_omnigent.py index a21e9265c33..37626b7e14d 100644 --- a/integrations/slack/tests/test_omnigent.py +++ b/integrations/slack/tests/test_omnigent.py @@ -1,8 +1,8 @@ +import asyncio from collections.abc import AsyncIterator import httpx import respx - from omnigent_slack.omnigent import ( AuthRequiredError, HostUnavailableError, @@ -12,6 +12,10 @@ RunnerUnavailableError, ServerUnreachableError, extract_assistant_text, + extract_elicitation_request, + extract_output_file, + extract_policy_denied, + extract_todos, is_terminal_event, iter_sse_events, ) @@ -402,6 +406,347 @@ async def test_run_turn_streams_across_multiple_responses_until_session_idle() - assert deltas == ["Explorer dispatched.", "Here is the report."] +@respx.mock +async def test_run_turn_resumes_after_idle_when_stream_continues() -> None: + # A fan-out orchestrator ends its turn to wait on sub-agents, settling to + # `idle` between wake cycles, then resumes with more output when a sub-agent + # completes. The bot must NOT stop at the first idle: within the grace + # window the stream delivers more, so the turn keeps going to the real end. + sse_body = ( + 'data: {"type":"response.output_text.delta","delta":"Fanning out."}\n\n' + 'data: {"type":"session.status","conversation_id":"conv_1","status":"idle"}\n\n' + 'data: {"type":"response.output_text.delta","delta":"Collecting results."}\n\n' + 'data: {"type":"session.status","conversation_id":"conv_1","status":"idle"}\n\n' + 'data: {"type":"response.output_text.delta","delta":"All done."}\n\n' + 'data: {"type":"session.status","conversation_id":"conv_1","status":"idle"}\n\n' + "data: [DONE]\n\n" + ) + respx.get("http://omnigent.test/v1/sessions/conv_1/stream").mock( + return_value=httpx.Response(200, text=sse_body) + ) + respx.post("http://omnigent.test/v1/sessions/conv_1/events").mock( + return_value=httpx.Response(200, json={}) + ) + client = OmnigentClient("http://omnigent.test") + + try: + deltas = [ + event.get("delta") + async for event in client.run_turn("conv_1", "go", idle_grace_seconds=5.0) + if event.get("type") == "response.output_text.delta" + ] + finally: + await client.aclose() + + # All three segments streamed across the intermediate idle edges — the turn + # only ends at the final idle when the stream itself closes. + assert deltas == ["Fanning out.", "Collecting results.", "All done."] + + +@respx.mock +async def test_run_turn_transient_idle_midstream_does_not_truncate() -> None: + # claude-native oscillates running/idle WHILE still streaming its answer, + # with a sub-second gap before the next burst — and the snapshot reads `idle` + # during that gap. The settle wait must catch the resumption rather than + # ending the turn on the transient idle (which truncated the reply). + async def _bursty_stream() -> AsyncIterator[bytes]: + yield b'data: {"type":"response.output_text.delta","delta":"Part one. "}\n\n' + yield b'data: {"type":"session.status","status":"idle"}\n\n' + # Real gap before the next burst — shorter than the settle window. + await asyncio.sleep(0.2) + yield b'data: {"type":"response.output_text.delta","delta":"Part two. "}\n\n' + yield b'data: {"type":"session.status","status":"idle"}\n\n' + await asyncio.sleep(0.2) + yield b'data: {"type":"response.output_text.delta","delta":"Part three."}\n\n' + yield b'data: {"type":"session.status","status":"idle"}\n\n' + yield b"data: [DONE]\n\n" + + respx.get("http://omnigent.test/v1/sessions/conv_1/stream").mock( + return_value=httpx.Response(200, stream=_bursty_stream()) + ) + respx.post("http://omnigent.test/v1/sessions/conv_1/events").mock( + return_value=httpx.Response(200, json={}) + ) + # Snapshot reads `idle` during the gaps — the WRONG signal to end on. The + # settle wait must win over it while text is still coming. + respx.get("http://omnigent.test/v1/sessions/conv_1").mock( + return_value=httpx.Response(200, json={"status": "idle"}) + ) + client = OmnigentClient("http://omnigent.test") + + try: + deltas = [ + event.get("delta") + async for event in client.run_turn( + "conv_1", + "go", + idle_grace_seconds=5.0, + idle_poll_seconds=5.0, + idle_settle_seconds=1.0, + ) + if event.get("type") == "response.output_text.delta" + ] + finally: + await client.aclose() + + # All three bursts delivered — no mid-answer truncation despite the idles. + assert deltas == ["Part one. ", "Part two. ", "Part three."] + + +@respx.mock +async def test_run_turn_ends_when_stream_goes_silent_without_idle_event() -> None: + # Incident 3cca0d8d: the stream produces output then goes SILENT with NO + # terminal/idle event ever arriving (half-open connection, or the `idle` edge + # was missed while the consumer was parked). A bare read would block forever, + # holding the thread's reservation and deflecting every follow-up. Every read + # after the first event is now grace-bounded, so the turn ends when the + # snapshot shows the server is idle. + async def _silent_after_output() -> AsyncIterator[bytes]: + yield b'data: {"type":"response.output_text.delta","delta":"Some answer."}\n\n' + await asyncio.sleep(30) # then nothing: no idle, no [DONE] — a bare read hangs + + respx.get("http://omnigent.test/v1/sessions/conv_1/stream").mock( + return_value=httpx.Response(200, stream=_silent_after_output()) + ) + respx.post("http://omnigent.test/v1/sessions/conv_1/events").mock( + return_value=httpx.Response(200, json={}) + ) + # The server is actually done (idle) — the stream just never told us. + respx.get("http://omnigent.test/v1/sessions/conv_1").mock( + return_value=httpx.Response(200, json={"status": "idle"}) + ) + client = OmnigentClient("http://omnigent.test") + + async def _drain() -> list[str | None]: + return [ + event.get("delta") + async for event in client.run_turn( + "conv_1", + "go", + idle_grace_seconds=5.0, + idle_poll_seconds=0.05, + idle_settle_seconds=0.05, + ) + if event.get("type") == "response.output_text.delta" + ] + + try: + # Must finish well within the 30s silent stall — bounded by the poll. + deltas = await asyncio.wait_for(_drain(), timeout=5.0) + finally: + await client.aclose() + + assert deltas == ["Some answer."] # delivered, then the turn ended cleanly + + +@respx.mock +async def test_run_turn_ends_when_idle_grace_elapses_and_snapshot_idle() -> None: + # A truly-final idle: the stream stays open briefly (no `[DONE]`) but nothing + # more arrives within the grace window, and the snapshot confirms the session + # is idle — so the turn ends rather than hanging on the late delta. + async def _slow_stream() -> AsyncIterator[bytes]: + yield b'data: {"type":"response.output_text.delta","delta":"Answer."}\n\n' + yield b'data: {"type":"session.status","status":"idle"}\n\n' + await asyncio.sleep(0.4) + yield b'data: {"type":"response.output_text.delta","delta":"too late"}\n\n' + + respx.get("http://omnigent.test/v1/sessions/conv_1/stream").mock( + return_value=httpx.Response(200, stream=_slow_stream()) + ) + respx.post("http://omnigent.test/v1/sessions/conv_1/events").mock( + return_value=httpx.Response(200, json={}) + ) + # Snapshot says idle → nothing outstanding → end the turn. + respx.get("http://omnigent.test/v1/sessions/conv_1").mock( + return_value=httpx.Response(200, json={"status": "idle"}) + ) + client = OmnigentClient("http://omnigent.test") + + try: + deltas = [ + event.get("delta") + async for event in client.run_turn( + "conv_1", + "go", + idle_grace_seconds=5.0, + idle_poll_seconds=0.05, + idle_settle_seconds=0.05, + ) + if event.get("type") == "response.output_text.delta" + ] + finally: + await client.aclose() + + # The settle window (0.05s) was quiet and the snapshot is idle, so the turn + # ended at the idle before the late delta (0.4s); it was never delivered. + assert deltas == ["Answer."] + + +@respx.mock +async def test_run_turn_does_not_hang_after_elicitation_when_stream_silent() -> None: + # Incident 10f1d893: after an elicitation, the consumer parks to handle it, + # leaving the SSE connection unread. When it resumes, the (now stale) stream + # delivers nothing more and never closes — a bare read would hang forever, + # wedging the thread. The loop must treat the elicitation like a soft idle: + # settle-wait, then poll the snapshot, and END when the session is idle. + async def _stalls_after_elicitation() -> AsyncIterator[bytes]: + yield b'data: {"type":"response.output_text.delta","delta":"Before deleting."}\n\n' + yield ( + b'data: {"type":"response.elicitation_request",' + b'"elicitation_id":"e1","params":{"message":"Approve?"}}\n\n' + ) + # Then nothing: no more events, no [DONE]. A bare read here hangs. + await asyncio.sleep(30) + + respx.get("http://omnigent.test/v1/sessions/conv_1/stream").mock( + return_value=httpx.Response(200, stream=_stalls_after_elicitation()) + ) + respx.post("http://omnigent.test/v1/sessions/conv_1/events").mock( + return_value=httpx.Response(200, json={}) + ) + # The server has gone idle (the turn actually finished server-side). + respx.get("http://omnigent.test/v1/sessions/conv_1").mock( + return_value=httpx.Response(200, json={"status": "idle"}) + ) + client = OmnigentClient("http://omnigent.test") + + async def _drain() -> list[str]: + return [ + event.get("type") + async for event in client.run_turn( + "conv_1", + "go", + idle_grace_seconds=5.0, + idle_poll_seconds=0.05, + idle_settle_seconds=0.05, + ) + ] + + try: + # Must complete well within the stream's 30s stall — bounded by the poll, + # not hanging on the read. + types = await asyncio.wait_for(_drain(), timeout=5.0) + finally: + await client.aclose() + + # The elicitation event was surfaced, then the turn ended cleanly (no hang). + assert "response.elicitation_request" in types + + +async def test_await_within_grace_waits_while_snapshot_running() -> None: + # The idle-disambiguation helper: each quiet poll consults the snapshot. + # While the rolled-up status is `running` (a sub-agent child is still + # working), it keeps waiting past the poll interval rather than ending; + # once the in-flight read completes it reports resumption. + from omnigent_slack.omnigent import _NO_RESUMPTION + + async def _slow_read() -> dict[str, object]: + # Longer than the poll interval, so several polls fire first. + await asyncio.sleep(0.15) + return {"type": "response.output_text.delta", "delta": "Collected."} + + client = OmnigentClient("http://omnigent.test") + + async def _running_status(session_id: str) -> str | None: + return "running" # child still working across every quiet poll + + client.get_session_status = _running_status # type: ignore[method-assign] + pending = asyncio.ensure_future(_slow_read()) + try: + # Poll every 0.05s, generous 5s cap — resumes well before the cap. + result = await client._await_within_grace(pending, "conv_1", 5.0, 0.05, 0.05) + finally: + await client.aclose() + + # Snapshot said running across the quiet polls, so it waited for the read + # to complete (resumption) instead of returning the end sentinel. + assert result is not _NO_RESUMPTION + assert pending.done() and pending.result()["delta"] == "Collected." + + +async def test_await_within_grace_ends_when_snapshot_not_running() -> None: + from omnigent_slack.omnigent import _NO_RESUMPTION + + async def _silent_read() -> dict[str, object]: + await asyncio.sleep(5.0) # never completes within the poll interval + return {"type": "response.output_text.delta", "delta": "too late"} + + client = OmnigentClient("http://omnigent.test") + + async def _idle_status(session_id: str) -> str | None: + return "idle" + + client.get_session_status = _idle_status # type: ignore[method-assign] + pending = asyncio.ensure_future(_silent_read()) + try: + result = await client._await_within_grace(pending, "conv_1", 5.0, 0.02, 0.02) + finally: + pending.cancel() + await client.aclose() + + # First quiet poll + snapshot idle → the turn is genuinely over. + assert result is _NO_RESUMPTION + + +async def test_await_within_grace_keeps_waiting_on_transient_status_none() -> None: + # A None status is a best-effort snapshot failure (transient blip), NOT a + # confirmed end. Treating it as "done" would truncate a still-live fan-out on + # a momentary hiccup. The loop must keep waiting until the grace cap instead. + from omnigent_slack.omnigent import _NO_RESUMPTION + + async def _never_read() -> dict[str, object]: + await asyncio.sleep(60.0) + return {"type": "response.output_text.delta", "delta": "never"} + + client = OmnigentClient("http://omnigent.test") + calls = 0 + + async def _flaky_status(session_id: str) -> str | None: + nonlocal calls + calls += 1 + return None # snapshot fetch keeps failing + + client.get_session_status = _flaky_status # type: ignore[method-assign] + pending = asyncio.ensure_future(_never_read()) + try: + # Cap 0.08s, poll 0.02s → several polls; each returns None but must not + # end early — only the cap ends it. + result = await client._await_within_grace(pending, "conv_1", 0.08, 0.02, 0.02) + finally: + pending.cancel() + await client.aclose() + + assert result is _NO_RESUMPTION # ended by the cap, not the first None + assert calls >= 2 # kept polling through the transient failures + + +async def test_await_within_grace_cap_ends_even_while_running() -> None: + # The cap is a backstop: if the snapshot stays `running` forever (stuck + # session), the turn still ends once the total grace cap elapses rather than + # parking indefinitely. + from omnigent_slack.omnigent import _NO_RESUMPTION + + async def _never_read() -> dict[str, object]: + await asyncio.sleep(60.0) + return {"type": "response.output_text.delta", "delta": "never"} + + client = OmnigentClient("http://omnigent.test") + + async def _stuck_running(session_id: str) -> str | None: + return "running" # never settles + + client.get_session_status = _stuck_running # type: ignore[method-assign] + pending = asyncio.ensure_future(_never_read()) + try: + # Poll 0.02s, cap 0.05s → a couple of polls then the cap ends it. + result = await client._await_within_grace(pending, "conv_1", 0.05, 0.02, 0.02) + finally: + pending.cancel() + await client.aclose() + + assert result is _NO_RESUMPTION + + @respx.mock async def test_client_raises_runner_unavailable() -> None: respx.post("http://omnigent.test/v1/sessions/conv_1/events").mock( @@ -423,3 +768,238 @@ async def test_client_raises_runner_unavailable() -> None: await client.aclose() assert raised is True + + +def test_extract_elicitation_request_parses_fields() -> None: + req = extract_elicitation_request( + { + "type": "response.elicitation_request", + "elicitation_id": "elicit_abc", + "params": { + "message": "Approve running rm?", + "policy_name": "approve_shell", + "content_preview": '{"command": "rm -rf x"}', + }, + }, + "conv_stream", + ) + assert req is not None + assert req.elicitation_id == "elicit_abc" + assert req.message == "Approve running rm?" + assert req.policy_name == "approve_shell" + assert req.content_preview == '{"command": "rm -rf x"}' + # No target_session_id → resolve against the streaming session. + assert req.session_id == "conv_stream" + + +def test_extract_elicitation_request_uses_target_session_when_mirrored() -> None: + req = extract_elicitation_request( + { + "type": "response.elicitation_request", + "elicitation_id": "elicit_child", + "params": {"message": "child asks", "target_session_id": "conv_child"}, + }, + "conv_parent", + ) + assert req is not None + # A mirrored sub-agent prompt resolves against the child, not the parent. + assert req.session_id == "conv_child" + + +def test_extract_elicitation_request_ignores_other_events() -> None: + assert extract_elicitation_request({"type": "response.output_text.delta"}, "s") is None + # Missing/blank id is not a usable request. + assert ( + extract_elicitation_request({"type": "response.elicitation_request", "params": {}}, "s") + is None + ) + + +@respx.mock +async def test_resolve_elicitation_posts_accept() -> None: + route = respx.post( + "http://omnigent.test/v1/sessions/conv_1/elicitations/elicit_1/resolve" + ).mock(return_value=httpx.Response(202, json={"queued": False})) + client = OmnigentClient("http://omnigent.test") + try: + await client.resolve_elicitation("conv_1", "elicit_1", accepted=True) + finally: + await client.aclose() + assert route.calls.last.request.read() == b'{"action":"accept"}' + + +@respx.mock +async def test_resolve_elicitation_decline_and_benign_statuses() -> None: + # 404/409 are benign (already resolved / cancel race) — no raise. + respx.post("http://omnigent.test/v1/sessions/conv_1/elicitations/gone/resolve").mock( + return_value=httpx.Response(404, json={}) + ) + client = OmnigentClient("http://omnigent.test") + try: + await client.resolve_elicitation("conv_1", "gone", accepted=False) + finally: + await client.aclose() + + +@respx.mock +async def test_get_session_activity_maps_server_state() -> None: + # The server snapshot is the authoritative "is this session busy?" signal. + def snap(status: str, pending: list[dict[str, object]]) -> httpx.Response: + return httpx.Response(200, json={"status": status, "pending_elicitations": pending}) + + client = OmnigentClient("http://omnigent.test") + try: + route = respx.get("http://omnigent.test/v1/sessions/conv_1") + + route.mock(return_value=snap("running", [])) + a = await client.get_session_activity("conv_1") + assert a.is_busy and not a.needs_user_action + + route.mock(return_value=snap("waiting", [{"elicitation_id": "e1"}])) + a = await client.get_session_activity("conv_1") + assert a.is_busy and a.needs_user_action + + route.mock(return_value=snap("idle", [])) + a = await client.get_session_activity("conv_1") + assert not a.is_busy and not a.needs_user_action + + # An idle session that still has a pending elicitation needs action. + route.mock(return_value=snap("idle", [{"elicitation_id": "e2"}])) + a = await client.get_session_activity("conv_1") + assert not a.is_busy and a.needs_user_action + finally: + await client.aclose() + + +@respx.mock +async def test_get_session_activity_unreadable_snapshot_is_not_busy() -> None: + # A best-effort read failure must not report busy — the server safely buffers + # a message that races a turn, so "go ahead" is the safe conservative default. + respx.get("http://omnigent.test/v1/sessions/conv_1").mock(return_value=httpx.Response(500)) + client = OmnigentClient("http://omnigent.test") + try: + a = await client.get_session_activity("conv_1") + finally: + await client.aclose() + assert a.status is None + assert not a.is_busy and not a.needs_user_action + + +def test_extract_policy_denied() -> None: + assert ( + extract_policy_denied( + {"type": "response.policy_denied", "conversation_id": "c1", "reason": "No shell."} + ) + == "No shell." + ) + # Missing reason falls back to a generic message. + assert extract_policy_denied({"type": "response.policy_denied"}) == "Blocked by policy." + # Non-matching events return None. + assert extract_policy_denied({"type": "response.output_text.delta"}) is None + + +def test_extract_output_file() -> None: + f = extract_output_file( + {"type": "response.output_file.done", "file_id": "file_1", "filename": "report.pdf"} + ) + assert f is not None and f.file_id == "file_1" and f.filename == "report.pdf" + # No filename → None filename, still a valid artifact. + f2 = extract_output_file({"type": "response.output_file.done", "file_id": "file_2"}) + assert f2 is not None and f2.filename is None + # Missing id / wrong type → None. + assert extract_output_file({"type": "response.output_file.done"}) is None + assert extract_output_file({"type": "session.status"}) is None + + +def test_extract_todos() -> None: + todos = extract_todos( + { + "type": "session.todos", + "conversation_id": "c1", + "todos": [ + {"content": "A", "status": "completed", "activeForm": "Doing A"}, + {"content": "B", "status": "in_progress", "activeForm": "Doing B"}, + ], + } + ) + assert todos is not None and len(todos) == 2 + # An empty list is a real "no todos" update, distinct from a non-todo event. + assert extract_todos({"type": "session.todos", "todos": []}) == [] + assert extract_todos({"type": "session.status"}) is None + + +def test_elicitation_url_mode_binary_is_supported() -> None: + # `url` mode only carries a suggested approve page; a binary approval (empty + # requestedSchema) is still rendered natively as Approve/Deny, not fobbed + # off to the web link. This is the default server mode. + req = extract_elicitation_request( + { + "type": "response.elicitation_request", + "elicitation_id": "e1", + "params": { + "mode": "url", + "message": "Agent wants to run a shell command. Approve?", + "phase": "tool_call", + "requestedSchema": {}, + "url": "/approve/conv_1/e1", + }, + }, + "conv_1", + ) + assert req is not None + assert req.mode == "url" + assert not req.is_form + assert req.is_supported is True + + +def test_elicitation_typed_schema_is_unsupported() -> None: + # A requestedSchema with fields (and no AskUserQuestion) needs typed input we + # can't collect with buttons — unsupported regardless of mode. + for mode in ("form", "url"): + req = extract_elicitation_request( + { + "type": "response.elicitation_request", + "elicitation_id": "e1", + "params": { + "mode": mode, + "message": "Enter a value", + "requestedSchema": { + "type": "object", + "properties": {"name": {"type": "string"}}, + }, + }, + }, + "conv_1", + ) + assert req is not None + assert req.needs_typed_input is True + assert req.is_supported is False + + +def test_elicitation_binary_and_form_are_supported() -> None: + binary = extract_elicitation_request( + { + "type": "response.elicitation_request", + "elicitation_id": "e1", + "params": {"message": "Approve?"}, + }, + "conv_1", + ) + assert binary is not None and binary.is_supported is True and not binary.is_form + + form = extract_elicitation_request( + { + "type": "response.elicitation_request", + "elicitation_id": "e2", + "params": { + "message": "Pick", + "requestedSchema": {"type": "object"}, + "ask_user_question": { + "questions": [{"question": "Q?", "options": [{"label": "A"}]}] + }, + }, + }, + "conv_1", + ) + # Even with a schema present, an AskUserQuestion is a supported form. + assert form is not None and form.is_form and form.is_supported is True diff --git a/integrations/slack/tests/test_service.py b/integrations/slack/tests/test_service.py index 9f6551ff61e..e678c51ce65 100644 --- a/integrations/slack/tests/test_service.py +++ b/integrations/slack/tests/test_service.py @@ -3,9 +3,7 @@ from pathlib import Path from typing import Any -from slack_sdk.errors import SlackApiError -from slack_sdk.web.async_slack_response import AsyncSlackResponse - +from omnigent_slack.approvals import Verdict, parse_action_value from omnigent_slack.models import ThreadKey, UserConfig from omnigent_slack.omnigent import ( AuthRequiredError, @@ -13,8 +11,10 @@ OmnigentError, ServerUnreachableError, ) -from omnigent_slack.service import SlackOmnigentService +from omnigent_slack.service import _ACK_TEXT, SlackOmnigentService from omnigent_slack.store import SQLiteStore +from slack_sdk.errors import SlackApiError +from slack_sdk.web.async_slack_response import AsyncSlackResponse class FakeStream: @@ -42,16 +42,29 @@ def __init__( self.appended: list[str] = [] self.stopped = False self.stop_text: str | None = None + # Monotonic rank of when this stream's message opened, relative to other + # posts/streams on the same client. Slack orders by the timestamp fixed + # at open time, so this models a segment's position in the thread. + self.open_order = client._tick() self._close_after = close_after self.closed = False # Whether the placeholder ack was still live the moment this stream first # put content on screen (a mid-stream flush, or the finalizing stop for a # short answer that never filled the buffer). self.ack_live_when_visible: bool | None = None + # Monotonic rank of when this stream's text first became visible (first + # flush/stop). Lets a test assert content was revealed before a later + # out-of-band post (e.g. an approval card), not coincident with it. + self.first_visible_order: int | None = None + # Rank of a FORCED flush (append with chunks — our _LiveReply.flush), + # None if the buffer was only ever revealed by the finalizing stop. + self.forced_flush_order: int | None = None self._buffer_size = buffer_size self._pending = 0 def _record_ack_state(self) -> None: + if self.first_visible_order is None: + self.first_visible_order = self._client._tick() if self.ack_live_when_visible is None: self.ack_live_when_visible = any( ack["ts"] not in self._client.deleted_ts for ack in self._client.acks @@ -71,16 +84,28 @@ def _raise_closed(self) -> None: ), ) - async def append(self, *, markdown_text: str) -> dict[str, Any] | None: + async def append( + self, *, markdown_text: str | None = None, chunks: Any = None + ) -> dict[str, Any] | None: if self.closed: self._raise_closed() - self.appended.append(markdown_text) + if markdown_text is not None: + self.appended.append(markdown_text) + self._pending += len(markdown_text) if self._close_after is not None and len(self.appended) >= self._close_after: self.closed = True - # Buffer until the SDK's threshold, then "flush" to Slack. - self._pending += len(markdown_text) - if self._pending < self._buffer_size: + # The SDK flushes when the buffer crosses the threshold OR when called + # with ``chunks`` set (a forced flush, even chunks=[]). Otherwise buffer. + if chunks is None and self._pending < self._buffer_size: + return None + if chunks is not None and self._pending == 0: + # Forced flush with nothing buffered → no-op (matches an empty flush). return None + if chunks is not None: + # A forced flush (our _LiveReply.flush) — record its position so a + # test can assert buffered text was revealed via flush, before a + # later out-of-band post, rather than only at the finalizing stop. + self.forced_flush_order = self._client._tick() self._pending = 0 self._record_ack_state() return {"ok": True} @@ -109,27 +134,49 @@ def __init__(self) -> None: self.posts: list[dict[str, Any]] = [] self.acks: list[dict[str, Any]] = [] self.deleted_ts: list[str] = [] + self.updates: list[dict[str, Any]] = [] + # Ephemeral ("Only visible to you") notices — private, not durable posts. + self.ephemerals: list[dict[str, Any]] = [] self.streams: list[FakeStream] = [] self._next_ts = 0 + self._order = 0 # When set, every stream this client opens auto-closes after this many # appended deltas — simulating Slack finalizing the message mid-turn. self.stream_close_after: int | None = None + def _tick(self) -> int: + # Monotonic rank stamped on each post/stream-open so tests can assert + # the thread's chronological order (Slack sorts by creation timestamp). + self._order += 1 + return self._order + async def chat_postMessage(self, **kwargs: Any) -> dict[str, Any]: self._next_ts += 1 ts = f"bot-{self._next_ts}" - entry = {**kwargs, "ts": ts} + entry = {**kwargs, "ts": ts, "order": self._tick()} self.posts.append(entry) - if kwargs.get("text") == "_Working on it…_": + if kwargs.get("text") == _ACK_TEXT: self.acks.append(entry) return {"ok": True, "ts": ts} + async def chat_postEphemeral(self, **kwargs: Any) -> dict[str, Any]: + self.ephemerals.append({**kwargs}) + return {"ok": True, "message_ts": "ephemeral"} + async def chat_delete(self, **kwargs: Any) -> dict[str, Any]: ts = kwargs.get("ts") self.deleted_ts.append(str(ts)) self.posts = [p for p in self.posts if p.get("ts") != ts] return {"ok": True} + async def chat_update(self, **kwargs: Any) -> dict[str, Any]: + ts = kwargs.get("ts") + self.updates.append({**kwargs}) + for post in self.posts: + if post.get("ts") == ts: + post.update(kwargs) + return {"ok": True, "ts": ts} + async def chat_stream(self, **kwargs: Any) -> FakeStream: # Only the first stream auto-closes (Slack finalizes the idle message); # the continuation the bot opens streams fresh, mirroring reality. @@ -155,8 +202,43 @@ def __init__(self, final_text: str = "hello final") -> None: self.bound: list[str] = [] self.launched: list[tuple[str, str, str | None]] = [] self.turns: list[tuple[str, str]] = [] + self.resolved: list[tuple[str, str, bool]] = [] + self.resolved_content: list[dict[str, Any] | None] = [] self.next_session_id = "conv_1" self.final_text = final_text + # Rolled-up status the grace window polls at a soft idle; default idle so + # a turn ends promptly unless a test sets it to "running". + self.status = "idle" + # Newest assistant message the server would return, for the no-delta + # fallback. ``latest_message_id`` pins the id (else each call gets a + # fresh id, so the fallback treats it as new relative to the baseline). + self.latest_message: str | None = None + self.latest_message_id: str | None = None + self._latest_calls = 0 + # Whether an outstanding elicitation is still pending server-side. Default + # True so the Slack-click path is exercised; a test sets it False to + # simulate the user answering elsewhere (web UI). + self.elicitation_pending = True + # Server activity reported at ROUTE time (before a turn) — the gate that + # decides whether a new message runs or is deflected. Defaults to free + # (idle, no pending) so a follow-up runs; a test sets these to simulate a + # busy or awaiting-input session. Kept separate from ``status`` (which the + # in-turn grace window polls) so the two don't collide. + self.route_status: str | None = "idle" + self.route_pending_elicitation = False + + async def get_session_status(self, session_id: str) -> str | None: + return self.status + + async def get_session_activity(self, session_id: str) -> Any: + from omnigent_slack.omnigent import SessionActivity + + return SessionActivity( + status=self.route_status, pending_elicitation=self.route_pending_elicitation + ) + + async def is_elicitation_pending(self, session_id: str, elicitation_id: str) -> bool: + return self.elicitation_pending async def create_session(self, agent_id: str, title: str) -> str: self.created.append((agent_id, title)) @@ -190,8 +272,27 @@ async def run_turn( } yield {"type": "response.completed", "response": {"status": "completed"}} - async def latest_assistant_text(self, session_id: str) -> str | None: - return None + async def latest_assistant_message(self, session_id: str) -> tuple[str, str] | None: + # (item_id, text) of the newest assistant message, or None. Tests that + # exercise the no-delta fallback set ``latest_message``; the id must + # differ from the pre-turn baseline for the fallback to fire, so a + # counter makes each call's id unique unless a test pins it. + if self.latest_message is None: + return None + self._latest_calls += 1 + item_id = self.latest_message_id or f"msg-{self._latest_calls}" + return (item_id, self.latest_message) + + async def resolve_elicitation( + self, + session_id: str, + elicitation_id: str, + *, + accepted: bool, + content: dict[str, Any] | None = None, + ) -> None: + self.resolved.append((session_id, elicitation_id, accepted)) + self.resolved_content.append(content) class FakePool: @@ -403,6 +504,64 @@ async def run_turn( yield {"type": "response.completed", "response": {"status": "completed"}} +class NoDeltaIdleClient(FakeOmnigentClient): + """Mirrors a real claude-native short answer: NO text deltas — the answer + arrives only as a committed ``output_item.done`` — and the turn ends on + ``session.status: idle`` (not ``response.completed``), exercising the grace + window. The ack must stay live until the buffered answer is on screen. + """ + + async def run_turn( + self, + session_id: str, + text: str, + *, + workspace: str | None = None, + host_id: str | None = None, + ) -> AsyncIterator[dict[str, Any]]: + self.turns.append((session_id, text)) + yield {"type": "session.status", "status": "running"} + yield { + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": self.final_text}], + }, + } + yield {"type": "session.status", "status": "idle"} + + +async def test_no_delta_idle_answer_keeps_ack_until_visible(tmp_path: Path) -> None: + # Regression guard for the real claude-native shape: no deltas, answer only + # in output_item.done, turn ends on session.status idle. The "Working on it…" + # placeholder must remain live until the buffered answer is delivered at + # stop() — never deleted early leaving the thread momentarily empty. + store = await _store(tmp_path) + slack = FakeSlackClient() + omnigent = NoDeltaIdleClient(final_text="Here is the answer.") + service, _pool, _setup = _service(store, omnigent) + # Snapshot idle so the grace window ends promptly. + omnigent.status = "idle" # type: ignore[attr-defined] + await _configure_user(store, "T1", "U1") + + await service.handle_app_mention( + body={"team_id": "T1", "event_id": "Ev1"}, + event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> hi"}, + client=slack, + context={"bot_user_id": "B1"}, + ) + stream = await _wait_for_stream_stop(slack) + await service.shutdown() + + assert stream.text == "Here is the answer." + # The ack was live when the answer became visible, and cleared afterward — + # so the thread never showed an empty gap. + assert stream.ack_live_when_visible is True + assert len(slack.acks) == 1 + assert slack.acks[0]["ts"] in slack.deleted_ts + + async def test_long_answer_streams_in_full(tmp_path: Path) -> None: # A long answer is streamed and finalized without any splitting/msg_too_long # handling — Slack owns chunking for streams. @@ -506,9 +665,6 @@ async def run_turn( "response": {"error": {"message": "boom"}}, } - async def latest_assistant_text(self, session_id: str) -> str | None: - return None - omnigent = ErroringNoAnswerClient() service, _pool, _setup = _service(store, omnigent) await _configure_user(store, "T1", "U1") @@ -714,6 +870,170 @@ async def test_direct_message_reply_reuses_existing_session(tmp_path: Path) -> N assert omnigent.turns == [("conv_existing", "follow up")] +async def test_message_while_server_busy_is_deflected(tmp_path: Path) -> None: + # The decision to accept is the SERVER's: if the snapshot reports the session + # running/waiting, a new message is NOT run and NOT queued — the user is + # privately told to wait or interrupt in the web UI. (Local connection state + # is not consulted, so a stale reservation can't wrongly report busy.) + store = await _store(tmp_path) + key = ThreadKey(team_id="T1", channel_id="D1", thread_ts="100.1") + await store.upsert_session(key, "conv_existing", "title", owner_user_id="U1") + slack = FakeSlackClient() + omnigent = FakeOmnigentClient() + omnigent.route_status = "running" # server is busy at route time + service, _pool, _setup = _service(store, omnigent) + + await service.handle_message( + body={"team_id": "T1", "event_id": "Ev2"}, + event={ + "channel": "D1", + "channel_type": "im", + "thread_ts": "100.1", + "ts": "101.1", + "user": "U1", + "text": "second while busy", + }, + client=slack, + context={"bot_user_id": "B1"}, + ) + await service.shutdown() + + # Deflected (not run) with a busy notice pointing at the web UI. + assert omnigent.turns == [] + busy = [e for e in slack.ephemerals if "still working on your previous" in e["text"].lower()] + assert len(busy) == 1 + assert busy[0]["user"] == "U1" + # The web UI is a Slack mrkdwn hyperlink (), not a bare URL. + assert "/c/conv_existing|web UI>" in busy[0]["text"] + + +async def test_second_message_while_local_stream_active_is_deflected(tmp_path: Path) -> None: + # Even when the SERVER snapshot momentarily reads idle (claude-native flips to + # idle between streaming bursts), a turn already streaming IN THIS PROCESS + # must block a second turn — a 2nd stream would render every event twice + # (the duplicate-responses bug). The local reservation catches this before + # the server-activity check. + store = await _store(tmp_path) + key = ThreadKey(team_id="T1", channel_id="D1", thread_ts="100.1") + await store.upsert_session(key, "conv_existing", "title", owner_user_id="U1") + slack = FakeSlackClient() + + release = asyncio.Event() + + class BlockingClient(FakeOmnigentClient): + async def run_turn( + self, + session_id: str, + text: str, + *, + workspace: str | None = None, + host_id: str | None = None, + ) -> AsyncIterator[dict[str, Any]]: + self.turns.append((session_id, text)) + await release.wait() # hold the first turn streaming locally + yield {"type": "session.status", "status": "idle"} + + omnigent = BlockingClient() + omnigent.route_status = "idle" # server LOOKS idle (the race window) + service, _pool, _setup = _service(store, omnigent) + + async def _send(text: str, ts: str, event_id: str) -> None: + await service.handle_message( + body={"team_id": "T1", "event_id": event_id}, + event={ + "channel": "D1", + "channel_type": "im", + "thread_ts": "100.1", + "ts": ts, + "user": "U1", + "text": text, + }, + client=slack, + context={"bot_user_id": "B1"}, + ) + + await _send("first", "101.1", "Ev1") + for _ in range(100): # wait until the first turn is actually streaming + if omnigent.turns: + break + await asyncio.sleep(0.02) + await _send("second", "102.1", "Ev2") + + # Only the first turn ran; the second was deflected despite the idle snapshot. + assert omnigent.turns == [("conv_existing", "first")] + busy = [e for e in slack.ephemerals if "still working on your previous" in e["text"].lower()] + assert len(busy) == 1 + release.set() + await service.shutdown() + + +async def test_message_while_awaiting_action_points_to_pending_request(tmp_path: Path) -> None: + # A session parked on a pending elicitation: a new message can't proceed. The + # user is told to answer the pending request (here or in the web UI), matching + # the web UI's "action required" state — distinct from the "still working" one. + store = await _store(tmp_path) + key = ThreadKey(team_id="T1", channel_id="D1", thread_ts="100.1") + await store.upsert_session(key, "conv_existing", "title", owner_user_id="U1") + slack = FakeSlackClient() + omnigent = FakeOmnigentClient() + omnigent.route_status = "waiting" + omnigent.route_pending_elicitation = True + service, _pool, _setup = _service(store, omnigent) + + await service.handle_message( + body={"team_id": "T1", "event_id": "Ev2"}, + event={ + "channel": "D1", + "channel_type": "im", + "thread_ts": "100.1", + "ts": "101.1", + "user": "U1", + "text": "another request", + }, + client=slack, + context={"bot_user_id": "B1"}, + ) + await service.shutdown() + + assert omnigent.turns == [] + notices = [e for e in slack.ephemerals if "waiting on your response" in e["text"].lower()] + assert len(notices) == 1 + assert notices[0]["user"] == "U1" + + +async def test_idle_follow_up_message_runs_in_thread(tmp_path: Path) -> None: + # A follow-up to an existing thread that is NOT currently streaming runs + # normally in Slack (run-when-idle) — Slack stays a full conversational + # surface, not kickoff-only. + store = await _store(tmp_path) + key = ThreadKey(team_id="T1", channel_id="D1", thread_ts="100.1") + await store.upsert_session(key, "conv_existing", "title", owner_user_id="U1") + slack = FakeSlackClient() + omnigent = FakeOmnigentClient() + service, _pool, _setup = _service(store, omnigent) + + await service.handle_message( + body={"team_id": "T1", "event_id": "Ev2"}, + event={ + "channel": "D1", + "channel_type": "im", + "thread_ts": "100.1", + "ts": "101.1", + "user": "U1", + "text": "follow up while idle", + }, + client=slack, + context={"bot_user_id": "B1"}, + ) + await _wait_for_stream_stop(slack) + await service.shutdown() + + # The follow-up ran against the existing session (no new session created). + assert omnigent.created == [] + assert omnigent.turns == [("conv_existing", "follow up while idle")] + assert slack.ephemerals == [] + + async def test_direct_message_with_bot_mention_is_handled(tmp_path: Path) -> None: # DMs do not fire app_mention, so a "<@bot>" in a DM is the only event we # get — it must be handled (mention stripped), not dropped as a duplicate. @@ -844,7 +1164,8 @@ async def test_unconfigured_user_is_prompted_and_no_turn_runs(tmp_path: Path) -> async def test_channel_followup_from_other_user_is_ignored(tmp_path: Path) -> None: # A thread's session belongs to its creator; a different user's @mention in - # that thread is not added to the session for now. + # that thread is not added to the session, but that user gets a private + # ("Only visible to you") note explaining why and how to get their own. store = await _store(tmp_path) key = ThreadKey(team_id="T1", channel_id="C1", thread_ts="100.1") await store.upsert_session( @@ -873,7 +1194,14 @@ async def test_channel_followup_from_other_user_is_ignored(tmp_path: Path) -> No assert omnigent.turns == [] assert setup.prompted == [] + # No durable post clutters the thread — the notice is ephemeral, aimed at U2. assert slack.posts == [] + assert len(slack.ephemerals) == 1 + notice = slack.ephemerals[0] + assert notice["user"] == "U2" + assert notice["channel"] == "C1" + assert notice["thread_ts"] == "100.1" + assert "start a new thread" in notice["text"].lower() async def test_turn_runs_against_the_fixed_operator_server(tmp_path: Path) -> None: @@ -1034,3 +1362,778 @@ async def test_no_online_host_prompts_omni_host_command(tmp_path: Path) -> None: text = slack.posts[-1]["text"] assert "omni host --server http://omnigent.test" in text assert "/omnigent" in text + + +# ── Tool-approval (elicitation) flow ───────────────────────────────── + + +def _elicitation_event( + elicitation_id: str = "elicit_1", + message: str = "Agent wants to call Edit(). Approve?", + content_preview: str = '{"name": "Edit"}', +) -> dict[str, Any]: + return { + "type": "response.elicitation_request", + "elicitation_id": elicitation_id, + "method": "elicitation/create", + "params": { + "mode": "form", + "message": message, + "policy_name": "require_approval", + "content_preview": content_preview, + }, + } + + +def _form_elicitation_event(elicitation_id: str = "elicit_form") -> dict[str, Any]: + return { + "type": "response.elicitation_request", + "elicitation_id": elicitation_id, + "method": "elicitation/create", + "params": { + "mode": "form", + "message": "Pick options", + "ask_user_question": { + "questions": [ + { + "id": "store", + "question": "Where to store?", + "options": [{"label": "Redis"}, {"label": "Memory"}], + "multiSelect": False, + } + ] + }, + }, + } + + +class ApprovalClient(FakeOmnigentClient): + """A turn that streams, parks on an elicitation, then streams a tail. + + The generator yields the elicitation event and then blocks until the worker + resolves it (the worker awaits the verdict before pulling the next event). + This mirrors the server keeping the stream open across the park. + """ + + def __init__( + self, elicitation_id: str = "elicit_1", event: dict[str, Any] | None = None + ) -> None: + super().__init__(final_text="done") + self._elicitation_id = elicitation_id + self._event = event or _elicitation_event(elicitation_id) + + async def run_turn( + self, + session_id: str, + text: str, + *, + workspace: str | None = None, + host_id: str | None = None, + ) -> AsyncIterator[dict[str, Any]]: + self.turns.append((session_id, text)) + yield {"type": "response.output_text.delta", "delta": "work"} + yield self._event + # The worker resolves the elicitation before requesting more events; + # by the time control returns here the verdict has been delivered. + yield {"type": "response.output_text.delta", "delta": "ing"} + yield {"type": "session.status", "status": "idle"} + + +class PreambleThenCommittedAnswerClient(FakeOmnigentClient): + """Mirrors the real AskUserQuestion shape: a preamble message (delta + + committed), the elicitation, then a post-answer message delivered ONLY as a + committed ``output_item.done`` (no deltas) — the deltas-race-behind-commit + case. Exercises the tail recovery across the seal boundary. + """ + + def __init__(self, event: dict[str, Any]) -> None: + super().__init__(final_text="") + self._event = event + + async def run_turn( + self, + session_id: str, + text: str, + *, + workspace: str | None = None, + host_id: str | None = None, + ) -> AsyncIterator[dict[str, Any]]: + self.turns.append((session_id, text)) + # Preamble: streamed as a delta AND committed as an item. + yield {"type": "response.output_text.delta", "delta": "Here's a demo."} + yield { + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Here's a demo."}], + }, + } + yield self._event + # Post-answer message arrives ONLY as a committed item (no deltas) — the + # tail must be recovered and delivered, not dropped. + yield { + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "You picked A. Full summary here."}], + }, + } + yield {"type": "session.status", "status": "idle"} + + +async def _wait_for_card(client: FakeSlackClient) -> dict[str, Any]: + """Wait for the approval card (a post carrying an actions block).""" + for _ in range(100): + for post in client.posts: + blocks = post.get("blocks") or [] + if any(b.get("type") == "actions" for b in blocks): + return post + await asyncio.sleep(0.02) + raise AssertionError("Timed out waiting for an approval card") + + +def _card_elicitation_id(card: dict[str, Any]) -> str: + for block in card.get("blocks", []): + if block.get("type") == "actions": + target = parse_action_value(block["elements"][0]["value"]) + assert target is not None + return target.elicitation_id + raise AssertionError("Card has no actions block") + + +async def _wait_for_resolved(omnigent: "FakeOmnigentClient", count: int = 1) -> None: + """Wait until the turn has forwarded ``count`` approval verdicts to the server. + + The answer is now split across stream segments by an approval seal, so + "first stream stopped" no longer marks turn completion — wait on the + server-visible verdict instead. + """ + for _ in range(100): + if len(omnigent.resolved) >= count: + return + await asyncio.sleep(0.02) + raise AssertionError(f"Timed out waiting for {count} resolved elicitation(s)") + + +async def test_tool_approval_approve_resumes_turn(tmp_path: Path) -> None: + store = await _store(tmp_path) + slack = FakeSlackClient() + omnigent = ApprovalClient() + service, _pool, _setup = _service(store, omnigent) + await _configure_user(store, "T1", "U1") + + await service.handle_app_mention( + body={"team_id": "T1", "event_id": "Ev1"}, + event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> edit"}, + client=slack, + context={"bot_user_id": "B1"}, + ) + card = await _wait_for_card(slack) + eid = _card_elicitation_id(card) + delivered = await service.handle_elicitation_action( + elicitation_id=eid, verdict=Verdict(accepted=True) + ) + await _wait_for_resolved(omnigent) + await service.shutdown() + + assert delivered is True + # Verdict forwarded to the server as accept, then the turn resumed. + assert omnigent.resolved == [("conv_1", "elicit_1", True)] + # The answer is split by the approval seal: "work" streamed before the card, + # "ing" after it — two separate stream segments in chronological order, + # with the card posted between them. + assert len(slack.streams) == 2 + assert slack.streams[0].text == "work" + assert slack.streams[1].text == "ing" + # The card was updated in place to its outcome and lost its buttons. + assert slack.updates, "expected the card to be updated after resolution" + updated_blocks = slack.updates[-1]["blocks"] + assert not any(b.get("type") == "actions" for b in updated_blocks) + assert "Approved" in updated_blocks[0]["text"]["text"] + + +async def test_short_pre_card_text_is_flushed_before_the_card(tmp_path: Path) -> None: + # The pre-card answer text ("work", well under the SDK buffer size) must be + # revealed BEFORE the approval card is posted — not left buffered until the + # seal, which would make it appear coincident with the card (the web UI shows + # it live as it streams). We assert the stream's first-visible tick precedes + # the card post's order tick. + store = await _store(tmp_path) + slack = FakeSlackClient() + omnigent = ApprovalClient() + service, _pool, _setup = _service(store, omnigent) + await _configure_user(store, "T1", "U1") + + await service.handle_app_mention( + body={"team_id": "T1", "event_id": "Ev1"}, + event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> edit"}, + client=slack, + context={"bot_user_id": "B1"}, + ) + card = await _wait_for_card(slack) + eid = _card_elicitation_id(card) + await service.handle_elicitation_action(elicitation_id=eid, verdict=Verdict(accepted=True)) + await _wait_for_resolved(omnigent) + await service.shutdown() + + # The first (pre-card) segment carried "work" and was FORCE-flushed to screen + # (via _LiveReply.flush) — not left buffered until the finalizing stop. + pre_card = slack.streams[0] + assert pre_card.text == "work" + assert pre_card.forced_flush_order is not None, "pre-card text was not force-flushed" + # The forced flush happened strictly before the card message was posted. + assert pre_card.forced_flush_order < card["order"] + + +async def test_tool_approval_deny_forwards_decline(tmp_path: Path) -> None: + store = await _store(tmp_path) + slack = FakeSlackClient() + omnigent = ApprovalClient() + service, _pool, _setup = _service(store, omnigent) + await _configure_user(store, "T1", "U1") + + await service.handle_app_mention( + body={"team_id": "T1", "event_id": "Ev1"}, + event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> edit"}, + client=slack, + context={"bot_user_id": "B1"}, + ) + card = await _wait_for_card(slack) + eid = _card_elicitation_id(card) + await service.handle_elicitation_action(elicitation_id=eid, verdict=Verdict(accepted=False)) + await _wait_for_resolved(omnigent) + await service.shutdown() + + assert omnigent.resolved == [("conv_1", "elicit_1", False)] + assert "Denied" in slack.updates[-1]["blocks"][0]["text"]["text"] + + +async def test_elicitation_resolved_externally_unblocks_without_verdict(tmp_path: Path) -> None: + # The user answers the request in the web UI instead of clicking the Slack + # card. The worker must stop waiting (once the server shows it no longer + # pending) and NOT post its own verdict — otherwise it blocks to the + # coordinator timeout, holding the thread's turn open and deflecting its + # follow-ups the whole time. + store = await _store(tmp_path) + slack = FakeSlackClient() + omnigent = ApprovalClient() + service, _pool, _setup = _service(store, omnigent) + service._external_resolve_poll_seconds = 0.02 # type: ignore[attr-defined] + await _configure_user(store, "T1", "U1") + + # User will answer elsewhere; the card click never comes. + omnigent.elicitation_pending = False + await service.handle_app_mention( + body={"team_id": "T1", "event_id": "Ev1"}, + event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> edit"}, + client=slack, + context={"bot_user_id": "B1"}, + ) + # Wait for the card to be updated with the outcome (the external-resolve path). + for _ in range(100): + if slack.updates: + break + await asyncio.sleep(0.02) + await service.shutdown() + + # The bot did not post its own verdict (the server already has it), and the + # card was updated to reflect the external resolution. + assert omnigent.resolved == [] + assert slack.updates + assert "Answered elsewhere" in slack.updates[-1]["blocks"][0]["text"]["text"] + + +async def test_denied_approval_does_not_resurrect_prior_answer(tmp_path: Path) -> None: + # Regression: a turn that produces no new answer (the only action was a + # denied approval) must NOT deliver the previous turn's message via the + # no-delta fallback. The fallback only fires for a message newer than the + # pre-turn baseline. + store = await _store(tmp_path) + slack = FakeSlackClient() + + class DeniedNoAnswerClient(FakeOmnigentClient): + async def run_turn( + self, + session_id: str, + text: str, + *, + workspace: str | None = None, + host_id: str | None = None, + ) -> AsyncIterator[dict[str, Any]]: + self.turns.append((session_id, text)) + # Only a gated tool call, no answer text; ends on idle. + yield _elicitation_event("elicit_rm") + yield {"type": "session.status", "status": "idle"} + + omnigent = DeniedNoAnswerClient() + # A stale prior-turn answer exists on the server, pinned to a fixed id so it + # equals the pre-turn baseline (i.e. it is NOT new this turn). + omnigent.latest_message = "PRIOR TURN SUMMARY — should not be re-sent" + omnigent.latest_message_id = "prior-msg" + service, _pool, _setup = _service(store, omnigent) + await _configure_user(store, "T1", "U1") + + await service.handle_app_mention( + body={"team_id": "T1", "event_id": "Ev1"}, + event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> rm file"}, + client=slack, + context={"bot_user_id": "B1"}, + ) + card = await _wait_for_card(slack) + eid = _card_elicitation_id(card) + await service.handle_elicitation_action(elicitation_id=eid, verdict=Verdict(accepted=False)) + for _ in range(100): + if slack.streams and all(s.stopped for s in slack.streams): + break + await asyncio.sleep(0.02) + await service.shutdown() + + # The stale prior summary was NOT delivered anywhere. + all_text = "".join(s.text for s in slack.streams) + "".join( + str(p.get("text", "")) for p in slack.posts + ) + assert "PRIOR TURN SUMMARY" not in all_text + + +async def test_tool_approval_timeout_declines(tmp_path: Path) -> None: + store = await _store(tmp_path) + slack = FakeSlackClient() + omnigent = ApprovalClient() + # Zero timeout: no click arrives, so the worker gives up and declines. + service, _pool, _setup = _service(store, omnigent) + service.elicitations._timeout = 0.05 # type: ignore[attr-defined] + await _configure_user(store, "T1", "U1") + + await service.handle_app_mention( + body={"team_id": "T1", "event_id": "Ev1"}, + event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> edit"}, + client=slack, + context={"bot_user_id": "B1"}, + ) + await _wait_for_resolved(omnigent) + await service.shutdown() + + # Timed out → declined to the server so the parked turn doesn't hang, and the + # card tells the user it was dropped and how to retry. + assert omnigent.resolved == [("conv_1", "elicit_1", False)] + outcome_text = slack.updates[-1]["blocks"][0]["text"]["text"] + assert "Timed out" in outcome_text + assert "again to retry" in outcome_text + + +async def test_stale_approval_click_is_reported_as_not_delivered(tmp_path: Path) -> None: + store = await _store(tmp_path) + service, _pool, _setup = _service(store, FakeOmnigentClient()) + + # No turn is parked on this id, so the click finds no waiter. + delivered = await service.handle_elicitation_action( + elicitation_id="elicit_gone", verdict=Verdict(accepted=True) + ) + await service.shutdown() + assert delivered is False + + +async def test_form_elicitation_forwards_selections_as_content(tmp_path: Path) -> None: + # An AskUserQuestion (form) elicitation renders a selectable card; the + # submitted answers are forwarded to the server as `content`, not a bare + # accept — so the agent actually receives the user's choice. + store = await _store(tmp_path) + slack = FakeSlackClient() + omnigent = ApprovalClient(elicitation_id="elicit_form", event=_form_elicitation_event()) + service, _pool, _setup = _service(store, omnigent) + await _configure_user(store, "T1", "U1") + + await service.handle_app_mention( + body={"team_id": "T1", "event_id": "Ev1"}, + event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> ask"}, + client=slack, + context={"bot_user_id": "B1"}, + ) + card = await _wait_for_card(slack) + eid = _card_elicitation_id(card) + # Answers arrive as option indices ("Redis" is index 0); the service maps + # them back to the full labels before forwarding to the server. + await service.handle_elicitation_action( + elicitation_id=eid, verdict=Verdict(accepted=True, content={"store": "0"}) + ) + await _wait_for_resolved(omnigent) + await service.shutdown() + + assert omnigent.resolved == [("conv_1", "elicit_form", True)] + assert omnigent.resolved_content == [{"store": "Redis"}] + # Card outcome reads "Answered" for a form, not "Approved". + assert "Answered" in slack.updates[-1]["blocks"][0]["text"]["text"] + + +def _typed_input_elicitation_event(elicitation_id: str = "elicit_typed") -> dict[str, Any]: + # A request for free-form typed input (non-empty schema, not AskUserQuestion) + # — genuinely uncollectable with Slack buttons. + return { + "type": "response.elicitation_request", + "elicitation_id": elicitation_id, + "method": "elicitation/create", + "params": { + "mode": "url", + "message": "Enter your name to continue", + "requestedSchema": {"type": "object", "properties": {"name": {"type": "string"}}}, + "url": "/approve/conv_1/elicit_typed", + }, + } + + +def _url_binary_elicitation_event(elicitation_id: str = "elicit_url") -> dict[str, Any]: + # A plain binary approval delivered in `url` mode (the default server mode). + return { + "type": "response.elicitation_request", + "elicitation_id": elicitation_id, + "method": "elicitation/create", + "params": { + "mode": "url", + "message": "Agent wants to run a shell command. Approve?", + "phase": "tool_call", + "requestedSchema": {}, + "url": "/approve/conv_1/elicit_url", + }, + } + + +async def test_unsupported_typed_input_links_to_web_ui(tmp_path: Path) -> None: + # A request for free-form typed input can't be rendered in Slack: the bot + # posts a link to resolve it in the web UI and does NOT block or auto-resolve. + store = await _store(tmp_path) + slack = FakeSlackClient() + omnigent = ApprovalClient( + elicitation_id="elicit_typed", event=_typed_input_elicitation_event() + ) + service, _pool, _setup = _service(store, omnigent) + await _configure_user(store, "T1", "U1") + + await service.handle_app_mention( + body={"team_id": "T1", "event_id": "Ev1"}, + event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> go"}, + client=slack, + context={"bot_user_id": "B1"}, + ) + await _wait_for_turn_end(slack) + await service.shutdown() + + # A link to the approve page was posted; no approval card, no auto-resolve. + links = [p for p in slack.posts if "/approve/conv_1/elicit_typed" in str(p.get("text"))] + assert links, "expected a web-UI link for the unsupported elicitation" + assert "http://omnigent.test/approve/conv_1/elicit_typed" in links[0]["text"] + assert omnigent.resolved == [] + assert not any( + any(b.get("type") == "actions" for b in (p.get("blocks") or [])) for p in slack.posts + ) + + +async def test_url_mode_binary_renders_approval_card(tmp_path: Path) -> None: + # The default server elicitation mode is `url`, but a binary approval must + # still render a native Approve/Deny card (not the web link) — the verdict + # posts to the resolve endpoint regardless of mode. + store = await _store(tmp_path) + slack = FakeSlackClient() + omnigent = ApprovalClient(elicitation_id="elicit_url", event=_url_binary_elicitation_event()) + service, _pool, _setup = _service(store, omnigent) + await _configure_user(store, "T1", "U1") + + await service.handle_app_mention( + body={"team_id": "T1", "event_id": "Ev1"}, + event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> run"}, + client=slack, + context={"bot_user_id": "B1"}, + ) + card = await _wait_for_card(slack) + eid = _card_elicitation_id(card) + await service.handle_elicitation_action(elicitation_id=eid, verdict=Verdict(accepted=True)) + await _wait_for_resolved(omnigent) + await service.shutdown() + + # Rendered as an Approve/Deny card and resolved via the endpoint — no web link. + assert omnigent.resolved == [("conv_1", "elicit_url", True)] + assert not any("/approve/" in str(p.get("text")) for p in slack.posts) + + +async def test_post_answer_message_only_committed_is_not_dropped(tmp_path: Path) -> None: + # Regression: after a form elicitation, the answer message arrived only as a + # committed output_item.done (no deltas). The seal must reset the per-segment + # streamed_text so the tail reconciliation delivers that post-answer text, + # rather than the pre-seal preamble polluting streamed_text and suppressing + # the recovery (which silently truncated the reply in the thread). + store = await _store(tmp_path) + slack = FakeSlackClient() + omnigent = PreambleThenCommittedAnswerClient(_form_elicitation_event()) + service, _pool, _setup = _service(store, omnigent) + await _configure_user(store, "T1", "U1") + + await service.handle_app_mention( + body={"team_id": "T1", "event_id": "Ev1"}, + event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> demo"}, + client=slack, + context={"bot_user_id": "B1"}, + ) + card = await _wait_for_card(slack) + eid = _card_elicitation_id(card) + await service.handle_elicitation_action( + elicitation_id=eid, verdict=Verdict(accepted=True, content={"store": "A"}) + ) + await _wait_for_resolved(omnigent) + await service.shutdown() + + # The post-answer text was delivered (in the post-seal segment), not dropped. + assert any("You picked A. Full summary here." in s.text for s in slack.streams) + + +class PreambleThenSilentAfterElicitationClient(FakeOmnigentClient): + """Models the stale-connection incident: a preamble streams, the elicitation + is handled, then the SSE connection goes SILENT — the post-answer message + never arrives on the stream (only the terminal idle does). The final answer + lives solely in the server's latest_assistant_message, recovered by the + no-delta fallback. Regression for a turn that hung + dropped the answer. + """ + + def __init__(self, event: dict[str, Any]) -> None: + super().__init__(final_text="") + self._event = event + + async def run_turn( + self, + session_id: str, + text: str, + *, + workspace: str | None = None, + host_id: str | None = None, + ) -> AsyncIterator[dict[str, Any]]: + self.turns.append((session_id, text)) + yield {"type": "response.output_text.delta", "delta": "Before deleting, let me look."} + yield self._event + # After the verdict resolves, the connection is stale — no post-answer + # event arrives, only the eventual terminal idle. The answer is recovered + # from the server snapshot (latest_message), not the stream. + yield {"type": "session.status", "status": "idle"} + + +async def test_post_elicitation_answer_recovered_when_stream_silent(tmp_path: Path) -> None: + # Incident: after an AskUserQuestion resolved, the server produced a final + # message but the stale SSE connection never delivered it, so the turn hung + # and the answer was dropped. The turn must end (via the idle status poll) + # and recover the committed final message from the snapshot — exactly once. + store = await _store(tmp_path) + slack = FakeSlackClient() + omnigent = PreambleThenSilentAfterElicitationClient(_form_elicitation_event()) + # The server's newest assistant message is the answer that never streamed. + # Leaving the id unpinned gives each snapshot a fresh id, so the post-turn + # final message is correctly seen as newer than the pre-turn baseline. + omnigent.latest_message = "Understood — leaving the file in place." + service, _pool, _setup = _service(store, omnigent) + await _configure_user(store, "T1", "U1") + + await service.handle_app_mention( + body={"team_id": "T1", "event_id": "Ev1"}, + event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> demo"}, + client=slack, + context={"bot_user_id": "B1"}, + ) + card = await _wait_for_card(slack) + eid = _card_elicitation_id(card) + await service.handle_elicitation_action( + elicitation_id=eid, verdict=Verdict(accepted=True, content={"store": "A"}) + ) + await _wait_for_turn_end(slack) + await service.shutdown() + + # The final answer was recovered and delivered exactly once; the turn task + # finished (no lingering in-flight turn), so follow-ups aren't wedged. + delivered = [s for s in slack.streams if "Understood — leaving the file in place." in s.text] + assert len(delivered) == 1 + assert service._turn_tasks == set() # type: ignore[attr-defined] + + +async def test_elicitation_clears_working_placeholder(tmp_path: Path) -> None: + # Parking on an elicitation must drop the "Working on it…" ack so it doesn't + # sit stale above the card for the whole (possibly long) wait. + store = await _store(tmp_path) + slack = FakeSlackClient() + # No preamble text before the elicitation, so only the ack could be showing. + omnigent = ApprovalClient(elicitation_id="elicit_1") + service, _pool, _setup = _service(store, omnigent) + await _configure_user(store, "T1", "U1") + + await service.handle_app_mention( + body={"team_id": "T1", "event_id": "Ev1"}, + event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> edit"}, + client=slack, + context={"bot_user_id": "B1"}, + ) + card = await _wait_for_card(slack) + # By the time the card is up, the ack has been deleted (not left dangling). + assert slack.acks, "expected an ack to have been posted" + assert all(a["ts"] in slack.deleted_ts for a in slack.acks) + eid = _card_elicitation_id(card) + await service.handle_elicitation_action(elicitation_id=eid, verdict=Verdict(accepted=True)) + await _wait_for_resolved(omnigent) + await service.shutdown() + + +# ── Stream enhancements: reasoning, policy-deny, files, todos ───────── + + +class EventScriptClient(FakeOmnigentClient): + """Streams a fixed list of events, then settles idle. + + Lets a test assert how the service surfaces reasoning / policy-deny / + output-file / todo events without a real server. + """ + + def __init__(self, events: list[dict[str, Any]]) -> None: + super().__init__(final_text="") + self._events = events + + async def run_turn( + self, + session_id: str, + text: str, + *, + workspace: str | None = None, + host_id: str | None = None, + ) -> AsyncIterator[dict[str, Any]]: + self.turns.append((session_id, text)) + for event in self._events: + yield event + yield {"type": "session.status", "status": "idle"} + + +async def _wait_for_turn_end(slack: FakeSlackClient) -> None: + """Wait until the turn finished: its final stream segment is stopped. + + An interruption seal splits the answer, so "any stream stopped" is not a + completion signal. The turn ends only once its last-opened segment stops + with no further append pending, which is stable once the loop settles. + """ + for _ in range(100): + if slack.streams and all(s.stopped for s in slack.streams): + # Give the loop a beat to open a follow-on segment if more is coming. + await asyncio.sleep(0.02) + if slack.streams and all(s.stopped for s in slack.streams): + return + await asyncio.sleep(0.02) + raise AssertionError("Timed out waiting for the turn to end") + + +async def _run_scripted_turn(tmp_path: Path, events: list[dict[str, Any]]) -> "FakeSlackClient": + store = await _store(tmp_path) + slack = FakeSlackClient() + service, _pool, _setup = _service(store, EventScriptClient(events)) + await _configure_user(store, "T1", "U1") + await service.handle_app_mention( + body={"team_id": "T1", "event_id": "Ev1"}, + event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> go"}, + client=slack, + context={"bot_user_id": "B1"}, + ) + await _wait_for_turn_end(slack) + await service.shutdown() + return slack + + +async def test_policy_denied_is_posted_as_reply(tmp_path: Path) -> None: + slack = await _run_scripted_turn( + tmp_path, + [ + {"type": "response.output_text.delta", "delta": "ok"}, + {"type": "response.policy_denied", "conversation_id": "conv_1", "reason": "No rm."}, + ], + ) + denials = [p for p in slack.posts if "Blocked by policy" in str(p.get("text"))] + assert denials and "No rm." in denials[0]["text"] + + +async def test_output_file_is_posted_as_reply(tmp_path: Path) -> None: + slack = await _run_scripted_turn( + tmp_path, + [{"type": "response.output_file.done", "file_id": "file_1", "filename": "out.csv"}], + ) + files = [p for p in slack.posts if "Produced a file" in str(p.get("text"))] + assert files and "out.csv" in files[0]["text"] + + +async def test_answer_then_trailing_notice_is_not_duplicated(tmp_path: Path) -> None: + # Regression: an answer streams, THEN a trailing out-of-band notice (a + # produced file) seals the segment. The seal resets the per-segment text, so + # the end-of-turn no-delta fallback would look "empty" and re-fetch the + # server's latest message — re-posting the answer a second time. The + # turn-level "delivered anything" guard must suppress that. + store = await _store(tmp_path) + slack = FakeSlackClient() + client = EventScriptClient( + [ + {"type": "response.output_text.delta", "delta": "The full answer."}, + {"type": "response.output_file.done", "file_id": "f1", "filename": "out.csv"}, + ] + ) + # The server committed the streamed answer as its newest assistant message — + # exactly what the (buggy) fallback would resurrect. + client.latest_message = "The full answer." + service, _pool, _setup = _service(store, client) + await _configure_user(store, "T1", "U1") + await service.handle_app_mention( + body={"team_id": "T1", "event_id": "Ev1"}, + event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> go"}, + client=slack, + context={"bot_user_id": "B1"}, + ) + await _wait_for_turn_end(slack) + await service.shutdown() + + # The answer appears exactly once across all stream segments — not duplicated + # into a fresh post-notice segment by the fallback. + answer_segments = [s for s in slack.streams if "The full answer." in s.text] + assert len(answer_segments) == 1 + + +async def test_todos_posted_once_then_updated_in_place(tmp_path: Path) -> None: + slack = await _run_scripted_turn( + tmp_path, + [ + { + "type": "session.todos", + "conversation_id": "conv_1", + "todos": [{"content": "Step 1", "status": "in_progress", "activeForm": "Doing 1"}], + }, + { + "type": "session.todos", + "conversation_id": "conv_1", + "todos": [{"content": "Step 1", "status": "completed", "activeForm": "Doing 1"}], + }, + ], + ) + plan_posts = [p for p in slack.posts if str(p.get("text", "")).startswith("*Plan*")] + plan_updates = [u for u in slack.updates if str(u.get("text", "")).startswith("*Plan*")] + # One message posted, then edited in place for the second update. + assert len(plan_posts) == 1 + assert len(plan_updates) == 1 + assert ":white_check_mark: Step 1" in plan_updates[-1]["text"] + + +async def test_interruption_preserves_chronological_order(tmp_path: Path) -> None: + # Text before an out-of-band notice, the notice, then text after it must + # appear in that order in the thread. The bot seals the streaming segment at + # the notice so the answer doesn't stay anchored to its open-time timestamp + # and float above the notice it depends on. + slack = await _run_scripted_turn( + tmp_path, + [ + {"type": "response.output_text.delta", "delta": "before"}, + {"type": "response.policy_denied", "conversation_id": "conv_1", "reason": "No rm."}, + {"type": "response.output_text.delta", "delta": "after"}, + ], + ) + # Two answer segments straddling the deny post. + assert len(slack.streams) == 2 + assert slack.streams[0].text == "before" + assert slack.streams[1].text == "after" + deny = next(p for p in slack.posts if "Blocked by policy" in str(p.get("text"))) + # Chronological: segment-1 opened, then the deny posted, then segment-2 opened. + assert slack.streams[0].open_order < deny["order"] < slack.streams[1].open_order diff --git a/integrations/slack/tests/test_setup.py b/integrations/slack/tests/test_setup.py index 6a0704fed55..b5ba4d86cf0 100644 --- a/integrations/slack/tests/test_setup.py +++ b/integrations/slack/tests/test_setup.py @@ -3,7 +3,6 @@ import httpx import respx - from omnigent_slack.models import ThreadKey, UserConfig from omnigent_slack.omnigent import OmnigentClientPool from omnigent_slack.setup import ( @@ -266,7 +265,6 @@ def _agents(request: httpx.Request) -> httpx.Response: ) ) from cryptography.fernet import Fernet - from omnigent_slack.auth_manager import AuthManager from omnigent_slack.tokens import EncryptedTokenStore @@ -331,7 +329,6 @@ async def test_setup_reports_device_grant_disabled(tmp_path: Path) -> None: """Accounts server with the device grant OFF (/oauth/* unmounted → 405): the modal must tell the user to contact the admin, not "try again shortly".""" from cryptography.fernet import Fernet - from omnigent_slack.auth_manager import AuthManager from omnigent_slack.tokens import EncryptedTokenStore @@ -402,7 +399,9 @@ async def logout_all(self, team_id: str, user_id: str) -> int: store = await _store(tmp_path) # Seed config + an owned thread session so we can prove they're cleared. - await store.upsert_user_config("T1", "U1", UserConfig("ag_1", "Helper", "/home/bob", "h1", "H")) + await store.upsert_user_config( + "T1", "U1", UserConfig("ag_1", "Helper", "/home/bob", "h1", "H") + ) await store.upsert_session(ThreadKey("T1", "C1", "100.1"), "conv_1", "t", owner_user_id="U1") auth = FakeAuth() @@ -567,7 +566,9 @@ async def test_prompt_unconfigured_handles_slack_response_object(tmp_path: Path) client = SlackResponseSetupClient() try: - await flow.prompt_unconfigured(client, "U1", channel="C1", thread_ts=None, in_channel=False) + await flow.prompt_unconfigured( + client, "U1", channel="C1", thread_ts=None, in_channel=False + ) finally: await pool.aclose_all() diff --git a/integrations/slack/tests/test_tokens.py b/integrations/slack/tests/test_tokens.py index cb624529869..4554f2dc94b 100644 --- a/integrations/slack/tests/test_tokens.py +++ b/integrations/slack/tests/test_tokens.py @@ -3,7 +3,6 @@ from pathlib import Path from cryptography.fernet import Fernet - from omnigent_slack.tokens import EncryptedTokenStore, TokenStore diff --git a/integrations/slack/uv.lock b/integrations/slack/uv.lock deleted file mode 100644 index e669234ae4c..00000000000 --- a/integrations/slack/uv.lock +++ /dev/null @@ -1,1370 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version < '3.15'", -] - -[options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. -exclude-newer-span = "P7D" - -[options.exclude-newer-package] -cwsandbox = "2026-06-12T00:00:00Z" -google-antigravity = "2026-06-12T00:00:00Z" -cryptography = "2026-06-26T00:00:00Z" -pydantic-settings = "2026-06-26T00:00:00Z" - -[[package]] -name = "aiohappyeyeballs" -version = "2.7.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", upload-time = "2026-07-01T17:11:55.501Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", upload-time = "2026-07-01T17:11:54.055Z" }, -] - -[[package]] -name = "aiohttp" -version = "3.14.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "yarl" }, -] -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", upload-time = "2026-06-07T21:09:35.529Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", upload-time = "2026-06-07T21:06:07.957Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", upload-time = "2026-06-07T21:06:09.749Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", upload-time = "2026-06-07T21:06:11.26Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", upload-time = "2026-06-07T21:06:12.887Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", upload-time = "2026-06-07T21:06:14.564Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", upload-time = "2026-06-07T21:06:16.228Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", upload-time = "2026-06-07T21:06:17.947Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", upload-time = "2026-06-07T21:06:19.677Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", upload-time = "2026-06-07T21:06:22.558Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", upload-time = "2026-06-07T21:06:24.8Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", upload-time = "2026-06-07T21:06:26.528Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", upload-time = "2026-06-07T21:06:28.297Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", upload-time = "2026-06-07T21:06:30.242Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", upload-time = "2026-06-07T21:06:31.943Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", upload-time = "2026-06-07T21:06:33.72Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", upload-time = "2026-06-07T21:06:35.279Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", upload-time = "2026-06-07T21:06:36.824Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", upload-time = "2026-06-07T21:06:38.371Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", upload-time = "2026-06-07T21:06:40.311Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", upload-time = "2026-06-07T21:06:42.207Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", upload-time = "2026-06-07T21:06:43.813Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", upload-time = "2026-06-07T21:06:45.391Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", upload-time = "2026-06-07T21:06:47.413Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", upload-time = "2026-06-07T21:06:50.165Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", upload-time = "2026-06-07T21:06:52.035Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", upload-time = "2026-06-07T21:06:54.049Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", upload-time = "2026-06-07T21:06:55.911Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", upload-time = "2026-06-07T21:06:57.777Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", upload-time = "2026-06-07T21:07:00.009Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", upload-time = "2026-06-07T21:07:02.064Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", upload-time = "2026-06-07T21:07:03.942Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", upload-time = "2026-06-07T21:07:05.987Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", upload-time = "2026-06-07T21:07:07.873Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", upload-time = "2026-06-07T21:07:09.669Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", upload-time = "2026-06-07T21:07:11.538Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", upload-time = "2026-06-07T21:07:13.637Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", upload-time = "2026-06-07T21:07:15.501Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", upload-time = "2026-06-07T21:07:17.53Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", upload-time = "2026-06-07T21:07:19.555Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", upload-time = "2026-06-07T21:07:21.581Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", upload-time = "2026-06-07T21:07:23.388Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", upload-time = "2026-06-07T21:07:25.046Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", upload-time = "2026-06-07T21:07:27.28Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", upload-time = "2026-06-07T21:07:29.252Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", upload-time = "2026-06-07T21:07:31.03Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", upload-time = "2026-06-07T21:07:33.031Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", upload-time = "2026-06-07T21:07:35.003Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", upload-time = "2026-06-07T21:07:36.956Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", upload-time = "2026-06-07T21:07:39.063Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", upload-time = "2026-06-07T21:07:41.058Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", upload-time = "2026-06-07T21:07:43.014Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", upload-time = "2026-06-07T21:07:45.933Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", upload-time = "2026-06-07T21:07:48.047Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", upload-time = "2026-06-07T21:07:50.069Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", upload-time = "2026-06-07T21:07:52.106Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", upload-time = "2026-06-07T21:07:54.113Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", upload-time = "2026-06-07T21:07:56.06Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", upload-time = "2026-06-07T21:07:58.319Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", upload-time = "2026-06-07T21:08:00.154Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", upload-time = "2026-06-07T21:08:02.093Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", upload-time = "2026-06-07T21:08:04.476Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", upload-time = "2026-06-07T21:08:06.377Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", upload-time = "2026-06-07T21:08:08.263Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", upload-time = "2026-06-07T21:08:10.244Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", upload-time = "2026-06-07T21:08:12.447Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", upload-time = "2026-06-07T21:08:14.624Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", upload-time = "2026-06-07T21:08:16.572Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", upload-time = "2026-06-07T21:08:18.635Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", upload-time = "2026-06-07T21:08:20.892Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", upload-time = "2026-06-07T21:08:23.044Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", upload-time = "2026-06-07T21:08:25.288Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", upload-time = "2026-06-07T21:08:27.363Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", upload-time = "2026-06-07T21:08:29.805Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", upload-time = "2026-06-07T21:08:32.094Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", upload-time = "2026-06-07T21:08:34.167Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", upload-time = "2026-06-07T21:08:36.95Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", upload-time = "2026-06-07T21:08:39.292Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", upload-time = "2026-06-07T21:08:41.407Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", upload-time = "2026-06-07T21:08:43.795Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", upload-time = "2026-06-07T21:08:46.168Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", upload-time = "2026-06-07T21:08:48.401Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", upload-time = "2026-06-07T21:08:50.7Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", upload-time = "2026-06-07T21:08:52.775Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", upload-time = "2026-06-07T21:08:55Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", upload-time = "2026-06-07T21:08:57.176Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", upload-time = "2026-06-07T21:08:59.563Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", upload-time = "2026-06-07T21:09:01.909Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", upload-time = "2026-06-07T21:09:04.357Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", upload-time = "2026-06-07T21:09:06.722Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", upload-time = "2026-06-07T21:09:09.011Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", upload-time = "2026-06-07T21:09:11.461Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", upload-time = "2026-06-07T21:09:14.118Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", upload-time = "2026-06-07T21:09:16.447Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", upload-time = "2026-06-07T21:09:18.742Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", upload-time = "2026-06-07T21:09:21.458Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", upload-time = "2026-06-07T21:09:23.813Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", upload-time = "2026-06-07T21:09:26.207Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", upload-time = "2026-06-07T21:09:28.505Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", upload-time = "2026-06-07T21:09:30.843Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", upload-time = "2026-06-07T21:09:33.028Z" }, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -dependencies = [ - { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", upload-time = "2025-07-03T22:54:43.528Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", upload-time = "2025-07-03T22:54:42.156Z" }, -] - -[[package]] -name = "aiosqlite" -version = "0.22.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", upload-time = "2025-12-23T19:25:43.997Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", upload-time = "2025-12-23T19:25:42.139Z" }, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "anyio" -version = "4.14.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", upload-time = "2026-06-24T20:56:06.017Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", upload-time = "2026-06-24T20:56:04.413Z" }, -] - -[[package]] -name = "ast-serialize" -version = "0.6.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", upload-time = "2026-06-30T20:02:55.555Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", upload-time = "2026-06-30T20:02:06.708Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", upload-time = "2026-06-30T20:02:08.366Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", upload-time = "2026-06-30T20:02:09.843Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", upload-time = "2026-06-30T20:02:11.367Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", upload-time = "2026-06-30T20:02:12.737Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", upload-time = "2026-06-30T20:02:14.571Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", upload-time = "2026-06-30T20:02:16.219Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", upload-time = "2026-06-30T20:02:17.813Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", upload-time = "2026-06-30T20:02:19.146Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", upload-time = "2026-06-30T20:02:20.696Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", upload-time = "2026-06-30T20:02:22.034Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", upload-time = "2026-06-30T20:02:23.517Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", upload-time = "2026-06-30T20:02:24.889Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", upload-time = "2026-06-30T20:02:26.263Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", upload-time = "2026-06-30T20:02:27.639Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", upload-time = "2026-06-30T20:02:28.949Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", upload-time = "2026-06-30T20:02:30.427Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", upload-time = "2026-06-30T20:02:31.964Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", upload-time = "2026-06-30T20:02:33.664Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", upload-time = "2026-06-30T20:02:34.936Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", upload-time = "2026-06-30T20:02:36.245Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", upload-time = "2026-06-30T20:02:37.53Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", upload-time = "2026-06-30T20:02:39.123Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", upload-time = "2026-06-30T20:02:40.511Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", upload-time = "2026-06-30T20:02:41.961Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", upload-time = "2026-06-30T20:02:44.179Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", upload-time = "2026-06-30T20:02:45.584Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", upload-time = "2026-06-30T20:02:46.942Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", upload-time = "2026-06-30T20:02:48.294Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", upload-time = "2026-06-30T20:02:49.742Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", upload-time = "2026-06-30T20:02:50.98Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", upload-time = "2026-06-30T20:02:52.554Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", upload-time = "2026-06-30T20:02:54.097Z" }, -] - -[[package]] -name = "attrs" -version = "26.1.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", upload-time = "2026-03-19T14:22:25.026Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", upload-time = "2026-03-19T14:22:23.645Z" }, -] - -[[package]] -name = "certifi" -version = "2026.6.17" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", upload-time = "2026-06-17T10:31:07.894Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", upload-time = "2026-06-17T10:31:06.348Z" }, -] - -[[package]] -name = "cffi" -version = "2.1.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", upload-time = "2026-07-06T21:34:30.382Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", upload-time = "2026-07-06T21:32:26.32Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", upload-time = "2026-07-06T21:32:28.025Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", upload-time = "2026-07-06T21:32:29.565Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", upload-time = "2026-07-06T21:32:30.951Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", upload-time = "2026-07-06T21:32:32.216Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", upload-time = "2026-07-06T21:32:33.655Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", upload-time = "2026-07-06T21:32:35.173Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", upload-time = "2026-07-06T21:32:36.527Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", upload-time = "2026-07-06T21:32:37.852Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", upload-time = "2026-07-06T21:32:39.146Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", upload-time = "2026-07-06T21:32:40.467Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", upload-time = "2026-07-06T21:32:41.761Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", upload-time = "2026-07-06T21:32:42.961Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", upload-time = "2026-07-06T21:32:44.324Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", upload-time = "2026-07-06T21:32:45.683Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", upload-time = "2026-07-06T21:32:48.557Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", upload-time = "2026-07-06T21:32:49.848Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", upload-time = "2026-07-06T21:32:51.221Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", upload-time = "2026-07-06T21:32:52.512Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", upload-time = "2026-07-06T21:32:53.704Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", upload-time = "2026-07-06T21:32:54.907Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", upload-time = "2026-07-06T21:32:56.329Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", upload-time = "2026-07-06T21:32:57.777Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", upload-time = "2026-07-06T21:32:59.253Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", upload-time = "2026-07-06T21:33:02.278Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", upload-time = "2026-07-06T21:33:03.697Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", upload-time = "2026-07-06T21:33:05.353Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", upload-time = "2026-07-06T21:33:06.699Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", upload-time = "2026-07-06T21:33:08.071Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", upload-time = "2026-07-06T21:33:09.671Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", upload-time = "2026-07-06T21:33:10.896Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", upload-time = "2026-07-06T21:33:12.251Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", upload-time = "2026-07-06T21:33:13.617Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", upload-time = "2026-07-06T21:33:15.466Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", upload-time = "2026-07-06T21:33:16.869Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", upload-time = "2026-07-06T21:33:18.239Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", upload-time = "2026-07-06T21:33:19.799Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", upload-time = "2026-07-06T21:33:21.47Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", upload-time = "2026-07-06T21:33:22.515Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", upload-time = "2026-07-06T21:33:23.657Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", upload-time = "2026-07-06T21:33:25.007Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", upload-time = "2026-07-06T21:33:26.605Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", upload-time = "2026-07-06T21:33:27.823Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", upload-time = "2026-07-06T21:33:29.178Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", upload-time = "2026-07-06T21:33:30.534Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", upload-time = "2026-07-06T21:33:31.753Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", upload-time = "2026-07-06T21:33:33.044Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", upload-time = "2026-07-06T21:33:34.224Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", upload-time = "2026-07-06T21:33:35.57Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", upload-time = "2026-07-06T21:33:51.626Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", upload-time = "2026-07-06T21:33:53.403Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", upload-time = "2026-07-06T21:33:54.74Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", upload-time = "2026-07-06T21:33:36.792Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", upload-time = "2026-07-06T21:33:38.011Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", upload-time = "2026-07-06T21:33:39.514Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", upload-time = "2026-07-06T21:33:40.851Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", upload-time = "2026-07-06T21:33:42.183Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", upload-time = "2026-07-06T21:33:43.603Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", upload-time = "2026-07-06T21:33:45.046Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", upload-time = "2026-07-06T21:33:46.374Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", upload-time = "2026-07-06T21:33:47.696Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", upload-time = "2026-07-06T21:33:48.953Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", upload-time = "2026-07-06T21:33:50.309Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", upload-time = "2026-07-06T21:33:56.109Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", upload-time = "2026-07-06T21:33:57.288Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", upload-time = "2026-07-06T21:33:58.765Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", upload-time = "2026-07-06T21:34:00.085Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", upload-time = "2026-07-06T21:34:01.814Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", upload-time = "2026-07-06T21:34:03.255Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", upload-time = "2026-07-06T21:34:04.433Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", upload-time = "2026-07-06T21:34:06.157Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", upload-time = "2026-07-06T21:34:07.489Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", upload-time = "2026-07-06T21:34:08.712Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", upload-time = "2026-07-06T21:34:26.355Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", upload-time = "2026-07-06T21:34:27.58Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", upload-time = "2026-07-06T21:34:28.905Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", upload-time = "2026-07-06T21:34:09.926Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", upload-time = "2026-07-06T21:34:11.101Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", upload-time = "2026-07-06T21:34:12.606Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", upload-time = "2026-07-06T21:34:13.987Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", upload-time = "2026-07-06T21:34:15.972Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", upload-time = "2026-07-06T21:34:17.509Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", upload-time = "2026-07-06T21:34:19.05Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", upload-time = "2026-07-06T21:34:20.576Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", upload-time = "2026-07-06T21:34:22.059Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", upload-time = "2026-07-06T21:34:23.589Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", upload-time = "2026-07-06T21:34:24.857Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "cryptography" -version = "49.0.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", upload-time = "2026-06-12T20:02:30.512Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", upload-time = "2026-06-12T20:02:32.143Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", upload-time = "2026-06-12T20:01:21.305Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", upload-time = "2026-06-12T20:01:48.566Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", upload-time = "2026-06-12T20:02:47.091Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", upload-time = "2026-06-12T20:02:08.987Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", upload-time = "2026-06-12T20:02:03.335Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", upload-time = "2026-06-12T20:02:54.323Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", upload-time = "2026-06-12T20:01:34.822Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", upload-time = "2026-06-12T20:01:24.09Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", upload-time = "2026-06-12T20:01:30.848Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", upload-time = "2026-06-12T20:01:29.246Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", upload-time = "2026-06-12T20:01:42.853Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", upload-time = "2026-06-12T20:02:39.262Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", upload-time = "2026-06-12T20:02:10.735Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", upload-time = "2026-06-12T20:02:48.743Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", upload-time = "2026-06-12T20:02:56.03Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", upload-time = "2026-06-12T20:02:50.666Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", upload-time = "2026-06-12T20:01:46.468Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", upload-time = "2026-06-12T20:02:16.832Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", upload-time = "2026-06-12T20:02:52.611Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", upload-time = "2026-06-12T20:02:07.282Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", upload-time = "2026-06-12T20:01:32.439Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", upload-time = "2026-06-12T20:02:01.469Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", upload-time = "2026-06-12T20:01:36.574Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", upload-time = "2026-06-12T20:02:18.688Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", upload-time = "2026-06-12T20:01:39.059Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", upload-time = "2026-06-12T20:01:25.745Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", upload-time = "2026-06-12T20:01:53.628Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", upload-time = "2026-06-12T20:02:45.383Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", upload-time = "2026-06-12T20:01:41.13Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", upload-time = "2026-06-12T20:02:24.579Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", upload-time = "2026-06-12T20:01:50.14Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", upload-time = "2026-06-12T20:01:44.743Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", upload-time = "2026-06-12T20:02:20.918Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", upload-time = "2026-06-12T20:02:22.737Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", upload-time = "2026-06-12T20:02:41.389Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", upload-time = "2026-06-12T20:01:51.847Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", upload-time = "2026-06-12T20:02:12.524Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", upload-time = "2026-06-12T20:02:26.847Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", upload-time = "2026-06-12T20:02:14.883Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", upload-time = "2026-06-12T20:02:28.793Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", upload-time = "2026-06-12T20:01:27.495Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", upload-time = "2026-06-12T20:01:55.49Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", upload-time = "2026-06-12T20:02:05.469Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", upload-time = "2026-06-12T20:02:43.319Z" }, -] - -[[package]] -name = "frozenlist" -version = "1.8.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", upload-time = "2025-10-06T05:38:17.865Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", upload-time = "2025-10-06T05:35:45.98Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", upload-time = "2025-10-06T05:35:47.009Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", upload-time = "2025-10-06T05:35:48.38Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", upload-time = "2025-10-06T05:35:49.97Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", upload-time = "2025-10-06T05:35:51.729Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", upload-time = "2025-10-06T05:35:53.246Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", upload-time = "2025-10-06T05:35:54.497Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", upload-time = "2025-10-06T05:35:55.861Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", upload-time = "2025-10-06T05:35:57.399Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", upload-time = "2025-10-06T05:35:58.563Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", upload-time = "2025-10-06T05:35:59.719Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", upload-time = "2025-10-06T05:36:00.959Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", upload-time = "2025-10-06T05:36:02.22Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", upload-time = "2025-10-06T05:36:03.409Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", upload-time = "2025-10-06T05:36:04.368Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", upload-time = "2025-10-06T05:36:05.669Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", upload-time = "2025-10-06T05:36:06.649Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", upload-time = "2025-10-06T05:36:07.69Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", upload-time = "2025-10-06T05:36:08.78Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", upload-time = "2025-10-06T05:36:09.801Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", upload-time = "2025-10-06T05:36:11.394Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", upload-time = "2025-10-06T05:36:12.598Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", upload-time = "2025-10-06T05:36:14.065Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", upload-time = "2025-10-06T05:36:15.39Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", upload-time = "2025-10-06T05:36:16.558Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", upload-time = "2025-10-06T05:36:17.821Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", upload-time = "2025-10-06T05:36:19.046Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", upload-time = "2025-10-06T05:36:20.763Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", upload-time = "2025-10-06T05:36:22.129Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", upload-time = "2025-10-06T05:36:23.661Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", upload-time = "2025-10-06T05:36:24.958Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", upload-time = "2025-10-06T05:36:26.333Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", upload-time = "2025-10-06T05:36:27.341Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", upload-time = "2025-10-06T05:36:28.855Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", upload-time = "2025-10-06T05:36:29.877Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", upload-time = "2025-10-06T05:36:31.301Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", upload-time = "2025-10-06T05:36:32.531Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", upload-time = "2025-10-06T05:36:33.706Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", upload-time = "2025-10-06T05:36:34.947Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", upload-time = "2025-10-06T05:36:36.534Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", upload-time = "2025-10-06T05:36:38.582Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", upload-time = "2025-10-06T05:36:40.152Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", upload-time = "2025-10-06T05:36:41.355Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", upload-time = "2025-10-06T05:36:42.716Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", upload-time = "2025-10-06T05:36:44.251Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", upload-time = "2025-10-06T05:36:45.423Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", upload-time = "2025-10-06T05:36:46.796Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", upload-time = "2025-10-06T05:36:47.8Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", upload-time = "2025-10-06T05:36:48.78Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", upload-time = "2025-10-06T05:36:49.837Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", upload-time = "2025-10-06T05:36:50.851Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", upload-time = "2025-10-06T05:36:51.898Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", upload-time = "2025-10-06T05:36:53.101Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", upload-time = "2025-10-06T05:36:54.309Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", upload-time = "2025-10-06T05:36:55.566Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", upload-time = "2025-10-06T05:36:56.758Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", upload-time = "2025-10-06T05:36:57.965Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", upload-time = "2025-10-06T05:36:59.237Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", upload-time = "2025-10-06T05:37:00.811Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", upload-time = "2025-10-06T05:37:02.115Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", upload-time = "2025-10-06T05:37:03.711Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", upload-time = "2025-10-06T05:37:04.915Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", upload-time = "2025-10-06T05:37:06.343Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", upload-time = "2025-10-06T05:37:07.431Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", upload-time = "2025-10-06T05:37:08.438Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", upload-time = "2025-10-06T05:37:09.48Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", upload-time = "2025-10-06T05:37:10.569Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", upload-time = "2025-10-06T05:37:11.993Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", upload-time = "2025-10-06T05:37:13.194Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", upload-time = "2025-10-06T05:37:14.577Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", upload-time = "2025-10-06T05:37:15.781Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", upload-time = "2025-10-06T05:37:17.037Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", upload-time = "2025-10-06T05:37:18.221Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", upload-time = "2025-10-06T05:37:19.771Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", upload-time = "2025-10-06T05:37:20.969Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", upload-time = "2025-10-06T05:37:22.252Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", upload-time = "2025-10-06T05:37:23.5Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", upload-time = "2025-10-06T05:37:25.581Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", upload-time = "2025-10-06T05:37:26.928Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", upload-time = "2025-10-06T05:37:28.075Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", upload-time = "2025-10-06T05:37:29.373Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", upload-time = "2025-10-06T05:37:30.792Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", upload-time = "2025-10-06T05:37:32.127Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", upload-time = "2025-10-06T05:37:33.21Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", upload-time = "2025-10-06T05:37:36.107Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", upload-time = "2025-10-06T05:37:37.663Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", upload-time = "2025-10-06T05:37:39.261Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", upload-time = "2025-10-06T05:37:43.213Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", upload-time = "2025-10-06T05:37:45.337Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", upload-time = "2025-10-06T05:37:46.657Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", upload-time = "2025-10-06T05:37:47.946Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", upload-time = "2025-10-06T05:37:49.499Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", upload-time = "2025-10-06T05:37:50.745Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", upload-time = "2025-10-06T05:37:52.222Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", upload-time = "2025-10-06T05:37:53.425Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", upload-time = "2025-10-06T05:37:54.513Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", upload-time = "2025-10-06T05:38:16.721Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "idna" -version = "3.18" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", upload-time = "2026-06-02T14:34:07.794Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", upload-time = "2026-06-02T14:34:06.319Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "librt" -version = "0.12.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/c6/e0/dbd0f2a68a1c1a1991eb7921ff6014465d56608cdc9a9fb468a616210a37/librt-0.12.0.tar.gz", hash = "sha256:cb26faedbd09c6130e9c1b64d8000efec5076ffd18d606c6cd1cf02730e6d8b0", upload-time = "2026-06-30T16:14:29.671Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/9e/ab/628490f42d1eba82f3c7e5821aa62013e6df7f525b7a9e92c048f8d1cc1c/librt-0.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f13c1e8563102c2b17581cf37fcb2c6dae7ad485ccea93ae46258998c25f9a1", upload-time = "2026-06-30T16:12:23.248Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/38/5f/793e8b6f4b6ac16e7d7198478c0af3670606fbb535c768d5f3e954781423/librt-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d1ddff067610a122387024c4df527493b909d41e54a6e5b2d0e6c1041d6dfa09", upload-time = "2026-06-30T16:12:24.582Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ad/92/c780fe37a9e0982f3bd8fd9a631d6b95d09a5a7201c6c50366ce843b7e42/librt-0.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8dc7ebb5f3eec062398e9d0ef1938acd21b589e74286c4a8906d0183318d91b", upload-time = "2026-06-30T16:12:26.101Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/41/bb/226d444bc20d7dff4a19ec6c1ff2c13a76385eebddb59c9c00c923b67536/librt-0.12.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:198de569ea9d5f6f33808f1c00cc3db9de62bf4d6deafa3b052bd08255083038", upload-time = "2026-06-30T16:12:27.83Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/12/79/98ac0840ee90a75d4e1155c79062860b12ccca508587ff2119fc086965f2/librt-0.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e958678a8bca56016aedc891b391c0e0813ea382a874b54a2c1b313c1d232720", upload-time = "2026-06-30T16:12:29.443Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6f/72/a6b1a0d080606a7f5f646b79a1496f21d709f8563877759ace9ce5adad73/librt-0.12.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575a6eca68c8437ed4a8e0f534e31d74b562ba1049a0ee4b5f09e114bcc21be1", upload-time = "2026-06-30T16:12:31.077Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/69/cf/e1b036b45f2fc272205ee18bf272b47e8d684bf1a75af26db440c7504359/librt-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:86f241c50dc9e9a3f0db6dbb37a607c8205aa87b920802dabbd50b70d40f6939", upload-time = "2026-06-30T16:12:33.032Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/40/34/b193b3e6985469a2f8afa86c90012329c86480b6ff4f2e4bd7b5b937e134/librt-0.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:113417b934fbf38220a9c7fe94578cefbe7dbb047adcb75aa197905af2b13724", upload-time = "2026-06-30T16:12:34.996Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/31/9e/7de4947b1695f247c813f833e3c1e7b77b52e52a7dba2c35411cf806b58e/librt-0.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:762f17c0eb6b5d74e269126996cea8a89e35ab6464c5151619163abcd8623ae2", upload-time = "2026-06-30T16:12:36.663Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/59/11/f3730e04e758b1fbf215359062ad2d5b6bd0b0ab5ac46b1c140628795be7/librt-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa93b3bd7f7588c628f6e9bf66485d3467fd9a1ccdb8975b770178f39f35697", upload-time = "2026-06-30T16:12:38.56Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/1f/8f/710453617eabe20e18433864f335534c8aff63fbc68d8cd9dbc70a3d08f6/librt-0.12.0-cp311-cp311-win32.whl", hash = "sha256:aaa04b44d4fe86d824616b1f9c13e34c7c01ec0c96dd2abc4f59423696f788e2", upload-time = "2026-06-30T16:12:40.102Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/42/53/401bff50a56e95daf151d911c99adf5732af2190e8f4d11886c9a229103c/librt-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:9aaeeddb8e7e4ae3bb9f944e0e618418cb91c0071d5ddbfcc3584b3cf59d39f0", upload-time = "2026-06-30T16:12:41.388Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e5/9a/a3a9078fe88bfc2d2d99dcf1c18593938ae830089cf84c3b2532a6c49d63/librt-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:18a2402fa3123ab76ecca670e6fb33038fde7c1e91181b885226ec4d30af2c2c", upload-time = "2026-06-30T16:12:43.112Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d5/1a/5bec493821b0e85b91de4f234912b50133d1aedb875048eef27938ec3f96/librt-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9bce19aa7c05f91c989f9da7b567f81d21d57a2e6501e2b811aa0f3f79614c1a", upload-time = "2026-06-30T16:12:44.395Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b9/d0/cc04b48a57c1f275387f5578847214c4a6c21bfb24c6c8c8d6ba753fe403/librt-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0ace09f5bf4d982fe726015f102fb856658b41580597104e301e630ed1d8d86", upload-time = "2026-06-30T16:12:45.95Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/9e/10/c02325556beb2aa158c9e549ddade8cc9a23b36cdad14756dbed730c1ff1/librt-0.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d007efe9243ede81ce75990ad7aa172da1e2024144b3eff17ba46a5fff1fff3c", upload-time = "2026-06-30T16:12:47.658Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/cb/9e/7b49ca1c30baa9c8df96024aa09a97c35a97455e36004c9b5311703c56f3/librt-0.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ad324a5e4858388a4864915b90a42efc8b374376393f14b9940f2454e791912b", upload-time = "2026-06-30T16:12:49.283Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4d/71/03c8c8cec39645fda451132ff9d6d662fc5aea42a1a188a77a4fddb35906/librt-0.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10a40cf74cdd97b6f8f905056db73f5d459783de2ca04c6ebd1bf47652818e7e", upload-time = "2026-06-30T16:12:50.999Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e0/ec/a9f357f94bbcba92277d22af22cff42ef706ae5d9d6d58b69bebf3a67954/librt-0.12.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:92e61c09de95217ae02a9d17f4f66cf073253cdc51bcfdc0f15c62c9a70baa85", upload-time = "2026-06-30T16:12:52.631Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7a/34/717055325d028743aa01a7691ad59a63352a26a8ff2e7eeb0c9249514150/librt-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0461344061d6fc3718940f5855d95647831cef6d03a6c7506897f98222784ad4", upload-time = "2026-06-30T16:12:54.244Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/95/f8/7612eeedb3395d92f7c6a84dca5f15e282d650483a4dc01aa5b9cffdfda3/librt-0.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e6dfe89074732c9287b3c0f5a6af575c9ede380a788013876cc7b14fe0da0361", upload-time = "2026-06-30T16:12:55.74Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/79/1e/a9afe85d5bb8b65dc27be3809ed1d69082079e1e9717fd2c66aa9939600c/librt-0.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9efed79d51ad1383bba0855f613cca7aa91c943e709af2413ac7f4bb9936ce08", upload-time = "2026-06-30T16:12:57.884Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b3/1e/93aebb219d52c37ea578f83b0588cd7b040974e464d4e435086a48b4dc4d/librt-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1eac6cc0e23e448fb3c1446ed85ff796afb616eed5897c978d35dbec030b7c7c", upload-time = "2026-06-30T16:12:59.577Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/3c/85/1680c0ec332f238e3145c5608d313ab0a43281e210a5dd87e3bc3cc25631/librt-0.12.0-cp312-cp312-win32.whl", hash = "sha256:0ab8ee0210047ae86ca023ccfbfe3df82077fd1c9bc021aebbf37d993ef64af0", upload-time = "2026-06-30T16:13:01.015Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/30/0e/abca12d8904875aa2ad66327390a3f7b1b75ebc43c0a00fc763cecf32ea5/librt-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:51c8bfa12632c81b94401c101bcedd0c56c3a1f8fa3273ca3472b28cd2f54003", upload-time = "2026-06-30T16:13:02.493Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/32/a5/4203481b6d3a3bb348c82ac71abf1fcb4cb3ae8422a24a8dee4cd3ac5bd7/librt-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:5eebd451f5def089369ba6d8ff0291303d035e8154f9f26f7633835c5b029ade", upload-time = "2026-06-30T16:13:03.952Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f2/87/568d948c8079c9ff3c9e8110cf85f1eb70218e1209af29d0b7b89aa4a60c/librt-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8d9a55760a34ae5ce70434aabb6a6c61c6c44a0ec58ca1cfd9cd86e4745d417d", upload-time = "2026-06-30T16:13:05.417Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e7/1d/bea471ecea210088847bb5f3c4b4b424d596518934c06679b78ca85d6e63/librt-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ff0b197e338b4cf432873e0d6ef025213fdea85311ec4d87d2ea88c28adf2409", upload-time = "2026-06-30T16:13:07.023Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/eb/9e/984ad422b56de95fdce158f06b051655373784ebea0aba9a7fcbc41614d1/librt-0.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e69f120a20b69e2539d603bbd4d62db38399b10f8bf73a1cf445038a621e8af", upload-time = "2026-06-30T16:13:08.492Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/50/03/1a2f94009b07ea71f8e1a4cfe53370565b56da9caa341b89e0699325e9f5/librt-0.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fde3cde595e947fc8e755b0a21f919a1622483d07c662d00496e040773d22591", upload-time = "2026-06-30T16:13:10.169Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/aa/3b/084bdc295823fbb6ab91670047adf8f420787f9e8794bf2d140b66dc196b/librt-0.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d977447315fa09ea4e8c7ae9b4e22f7659b5128161c1fd55ff786b5349f73503", upload-time = "2026-06-30T16:13:11.681Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c9/22/5a307390b93a115ffbecd95c64eecb4e56269680e45e9415ada7285f2cf4/librt-0.12.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ffac8a67e4143cea9a549d4822b93bc0bbaad73fc25aa0ab0ba5ec27d178677", upload-time = "2026-06-30T16:13:13.217Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b5/90/83f3cb6184f5d669660717b4b2e317c9ddaccf7ca5bb97f2196deac1a3b7/librt-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:94af1ed773ff104ef08ef3d669a0ba9d3a5916c609eb698cffe5d5476d66ff9b", upload-time = "2026-06-30T16:13:15.277Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7d/3b/f162be5cc88d47378e3a20776fe425fa1c2bece755da15e2783ebf06d3d6/librt-0.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:548199d21d22fb26398dfbbe0ba953a52465c66f3a49f38e6fddce1b127faf53", upload-time = "2026-06-30T16:13:17.074Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c9/28/6c5d2f6b7232fd24f284fc4cab37a459fe69a9096a09942f44cc5c55e073/librt-0.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c8f1f413b966a9dd3ecf80cd337b0ad7bb3de2474a4ff448ed3ebabfc3f803fc", upload-time = "2026-06-30T16:13:18.823Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a9/1c/bd115360587fdc22c8ae8fac14c040a556b442e2965d4370d2cf274c8b95/librt-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:55f13f95b629be5b6ab38918e439bf14169d6f9a8deaae55e0c14e12fb0c74b9", upload-time = "2026-06-30T16:13:20.509Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fe/5a/c26f49f576437014825a86faea3cec60c1ed17f976abd567b6c12b8e35a7/librt-0.12.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8b2dc079dfe29e77a47a19073d2040fa4879aa3656501f1650f8402ddce0313c", upload-time = "2026-06-30T16:13:22.401Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/69/0b/a55244261d9ad7375ac039b8af06d42602722e2e8b8d8d6b86e4a3888c02/librt-0.12.0-cp313-cp313-win32.whl", hash = "sha256:da58944be8270f2bfee628a9a2a60c1cf6a12c8bea8e2c9b6edf3e5414ca7793", upload-time = "2026-06-30T16:13:23.661Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c9/bf/ed9465e58d44c5a5637795547d0841c8934aab905ea452cac1adf14672cf/librt-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:1db4be3037e4ce065a071fa7deee93e78ebc25f448340a02a6c1c0b82c37e383", upload-time = "2026-06-30T16:13:25.188Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c0/44/3cad652aeb892e6e8ffe48d0fafa2bc652f28ec7ed3f4403fcbb1be4f948/librt-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:05fd2542892ad770b5dd45003fd080477cf220b611d3ee59b0792097eb0873a9", upload-time = "2026-06-30T16:13:26.533Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0e/51/3a0e05618c12423b6fc5141b590ec02a6efb645833edc8736a6c7b46d1ec/librt-0.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b37ee42e09722284a6d9288fe44a191f7276060a3195939bb77c6502058dbb34", upload-time = "2026-06-30T16:13:27.909Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/77/9e/fd399d099dfb4020f3f7c34e7e6210c389fa89f7d79ca92f5afb0395f278/librt-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ade11988728b3e4768dadc5696e82c60e9b35fc95335a9b4d1f5d69e753ccec7", upload-time = "2026-06-30T16:13:29.357Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7a/ee/610239fbd8c4b005443664c5d4c3bc1717daedd8c71369bf45011aa87194/librt-0.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f351ed425380e39bd86df382578aa5b8c5b98e2e265112de7379e7d030258150", upload-time = "2026-06-30T16:13:30.78Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0c/10/ceddc9010f26c541444be36e1153a79b64626694db2d33a524c719fa3e46/librt-0.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:857d2163e088c868967717ace8e980017fd868a735f3de010412af02bdc30319", upload-time = "2026-06-30T16:13:32.398Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4e/f1/b1523d9718e8192e5403e6b41a02742e17ba554369f0729b9f30ab590e2d/librt-0.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2befc80aa5f2f5b93f28abaaf11feff6677931dd548320e44c52deaa9399744", upload-time = "2026-06-30T16:13:34.615Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f6/0e/0f3ff43befb18a531615736791e52fb67eaa71ff7b89e6e5f7004b64cc6e/librt-0.12.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:be3694dcfa97c6715dd19ac73d3e1b21a805514a5785663e57fecacd3ff64e5a", upload-time = "2026-06-30T16:13:36.408Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a8/1a/0278ea4a9e599dc507c43839a87f2c764ad04bf69418e2d763d58659e55f/librt-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2d5f67e86f45638843d025b0828f2e9e55fc45ff9180d2618ccdeaf72a796050", upload-time = "2026-06-30T16:13:37.883Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/59/55/090e10e62be2f35265e41601337f83ac9f83be9aca1bf92692e3a82effdd/librt-0.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:64572c85e4ab7d572c9b72cd76b5f90b21181b1459fa6b1aac6f8958c4fcff31", upload-time = "2026-06-30T16:13:39.682Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/1f/34/8052c9ec678be6ba751279947831f089aa69b009000b985ce91d1979669a/librt-0.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8b961912b0e688c1eb4658a46bdb0606b31918d65597fbe7356ca83aa653ffcc", upload-time = "2026-06-30T16:13:41.266Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6f/f8/8761b36189e9ec8dc20b49fa84cef22852c6c41fcda56f760f7fc1360da5/librt-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:722375903e3f079436a7a33da51ce73931536dd041f9feb01536f05d8e010c96", upload-time = "2026-06-30T16:13:43.197Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c8/98/7283971ef6b70269938b49c7b25f670ec6325d252265fbcc996f9b364379/librt-0.12.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:a5a96a8f536b65ef1bf910c09e7e71647edde5111f6e1b51f413c6fba5bfe71b", upload-time = "2026-06-30T16:13:44.64Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c3/5e/b30940dea935e8ac5bd0e0abb1985f5274590d557ac3a252ca0d5392ce52/librt-0.12.0-cp314-cp314-win32.whl", hash = "sha256:8ffc99c356f1777c506e1b69dc303879153ae2640ba15b8f3d4448bc87139149", upload-time = "2026-06-30T16:13:45.962Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7d/4e/0af9fe63f35fa304da3b05688f30ff6a329bcc59581b1cc51dc87fd30141/librt-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:1e68fb20798f455cda41d20a306a23c901218883f17a4bab1ed6e1331b265fb7", upload-time = "2026-06-30T16:13:47.279Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b1/8e/843c495d7db35e13b84cd533898fa89145c40dc255da0bc316d53d631464/librt-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:2df534f97916cf38ec9b1ddafeb68ae1a4cd4a54775ff26a797026774c0517cf", upload-time = "2026-06-30T16:13:48.699Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/75/30/c686d0f978d5fd6867c5bbad96b015c9445746764d1c228e16a2d30d9382/librt-0.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c09e581b1c2b8a62b809d4f4bd101ca3de93791e5b0ed1a14085d911be3dee3f", upload-time = "2026-06-30T16:13:50.017Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/40/46/f6f2d77ce46628b48fb5280709013b5109cf3a2c46a2472093cdfc03519d/librt-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:976888d0d831402086e641018bcc3208e0a38f0835789da91f72894b2cb4161f", upload-time = "2026-06-30T16:13:51.462Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c2/46/cd790c7e19e460779471530ffab454541d6ea4a3b7d338cad7f16ff96995/librt-0.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:563c37cdb41d08fe1e3f08b201abac0e317ca18e88b91285466ee0a585797520", upload-time = "2026-06-30T16:13:53.146Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/54/12/724559a15fb023cbdef7aee1e81fbfbc3ee22fd09009baa816cea63e3a60/librt-0.12.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b97eb1a3140e279cc76f85b0fb92b7eb3dfbe0471260ee878bc9dc4bf9a0d649", upload-time = "2026-06-30T16:13:54.665Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4b/7e/f9d8c257ab4909f101c7c13734367749e782fd8625545f0343502c2f09f1/librt-0.12.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06e0623351ab9904cf628245f99c714586f4dd23dc740b88c8bc670d8401a847", upload-time = "2026-06-30T16:13:56.301Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/9b/33/64665810575ac23b6cb6ef364de51309b7803620c12885b6e895ebc29591/librt-0.12.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da12f017b2e404554be14d466cd992459feaa44f252b0f18d909a85266ce1237", upload-time = "2026-06-30T16:13:58.1Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0f/01/27522995c6627455abc7a939d57535fb1a7836d398ccedb3d7585f46039e/librt-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d97f31003a5c86b9e78155a829572c3a26484064fb7ac1d9695fe628bd93d029", upload-time = "2026-06-30T16:13:59.831Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ee/1f/099e61b1b688551d6d2ce9d4d2ae2242a938759db8551e6cbac7f7176ee5/librt-0.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:bd43a6c69876aef4f04eaae3d3b99b0be64755fda274002fa445b92480bf664e", upload-time = "2026-06-30T16:14:01.457Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/bf/c1/050400249665503bdd5b83cec518fa7b183b609341c8dcd58161775c4226/librt-0.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c01755c72fca1dc6b8d5c2ed228b8e7b2ffe184675c22f0f05ebd8fe188b9250", upload-time = "2026-06-30T16:14:03.29Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/da/d1/eef8f0e6722518b65a3d3bcd9309f9f44e208ce5d6728070820f988e7078/librt-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:625ae561d5fa36400856dcc27464400d047bc2d5e3446be88f437b03fefd72e4", upload-time = "2026-06-30T16:14:04.957Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8b/78/f0bb41a6f2bbd3c77bdcc66980dc0d69ca1192a0ecec25377afcc5e6db73/librt-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8d73191883553ee0739741544bf3b00aba2a1224e45d9580b30cbc29e21dc03b", upload-time = "2026-06-30T16:14:06.555Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/92/24/e279c27972ab051a070237cfa45728fa51670c3f22f1a4d391711e9f4c31/librt-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e1cbb037324e759f0afa270229731ff0047772667f3cb38ef5df2cabf0175ede", upload-time = "2026-06-30T16:14:07.908Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/06/e6/42a475bfca683b0cd5366f6dd06580062b7e567bb8534d225c877c2f14f3/librt-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bca1472acbd473eff61059b4409f802c5a1bcb4cd0344d06f939df9c4c125d40", upload-time = "2026-06-30T16:14:09.29Z" }, -] - -[[package]] -name = "multidict" -version = "6.7.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", upload-time = "2026-01-26T02:46:45.979Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", upload-time = "2026-01-26T02:43:26.485Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", upload-time = "2026-01-26T02:43:27.607Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", upload-time = "2026-01-26T02:43:28.661Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", upload-time = "2026-01-26T02:43:31.165Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", upload-time = "2026-01-26T02:43:32.581Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", upload-time = "2026-01-26T02:43:34.417Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", upload-time = "2026-01-26T02:43:35.741Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", upload-time = "2026-01-26T02:43:36.976Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", upload-time = "2026-01-26T02:43:38.258Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", upload-time = "2026-01-26T02:43:40.034Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", upload-time = "2026-01-26T02:43:41.752Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", upload-time = "2026-01-26T02:43:43.042Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", upload-time = "2026-01-26T02:43:44.371Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", upload-time = "2026-01-26T02:43:45.745Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", upload-time = "2026-01-26T02:43:47.054Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", upload-time = "2026-01-26T02:43:48.753Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", upload-time = "2026-01-26T02:43:49.921Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", upload-time = "2026-01-26T02:43:51.635Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", upload-time = "2026-01-26T02:43:52.754Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", upload-time = "2026-01-26T02:43:53.893Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", upload-time = "2026-01-26T02:43:55.041Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", upload-time = "2026-01-26T02:43:56.198Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", upload-time = "2026-01-26T02:43:57.499Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", upload-time = "2026-01-26T02:43:58.755Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", upload-time = "2026-01-26T02:44:00.216Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", upload-time = "2026-01-26T02:44:02.202Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", upload-time = "2026-01-26T02:44:03.56Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", upload-time = "2026-01-26T02:44:04.845Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", upload-time = "2026-01-26T02:44:06.133Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", upload-time = "2026-01-26T02:44:07.518Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", upload-time = "2026-01-26T02:44:09.382Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", upload-time = "2026-01-26T02:44:10.73Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", upload-time = "2026-01-26T02:44:12.042Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", upload-time = "2026-01-26T02:44:14.245Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", upload-time = "2026-01-26T02:44:15.371Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", upload-time = "2026-01-26T02:44:16.852Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", upload-time = "2026-01-26T02:44:18.509Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", upload-time = "2026-01-26T02:44:19.745Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", upload-time = "2026-01-26T02:44:21.571Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", upload-time = "2026-01-26T02:44:22.803Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", upload-time = "2026-01-26T02:44:24.306Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", upload-time = "2026-01-26T02:44:25.772Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", upload-time = "2026-01-26T02:44:27.648Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", upload-time = "2026-01-26T02:44:29.588Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", upload-time = "2026-01-26T02:44:30.902Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", upload-time = "2026-01-26T02:44:32.31Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", upload-time = "2026-01-26T02:44:33.734Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", upload-time = "2026-01-26T02:44:35.222Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", upload-time = "2026-01-26T02:44:36.604Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", upload-time = "2026-01-26T02:44:38.008Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", upload-time = "2026-01-26T02:44:39.386Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", upload-time = "2026-01-26T02:44:41.318Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", upload-time = "2026-01-26T02:44:42.488Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", upload-time = "2026-01-26T02:44:43.664Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", upload-time = "2026-01-26T02:44:44.894Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", upload-time = "2026-01-26T02:44:46.106Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", upload-time = "2026-01-26T02:44:47.278Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", upload-time = "2026-01-26T02:44:48.604Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", upload-time = "2026-01-26T02:44:50.544Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", upload-time = "2026-01-26T02:44:51.951Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", upload-time = "2026-01-26T02:44:53.902Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", upload-time = "2026-01-26T02:44:55.316Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", upload-time = "2026-01-26T02:44:56.783Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", upload-time = "2026-01-26T02:44:58.195Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", upload-time = "2026-01-26T02:44:59.57Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", upload-time = "2026-01-26T02:45:01.054Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", upload-time = "2026-01-26T02:45:02.484Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", upload-time = "2026-01-26T02:45:03.862Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", upload-time = "2026-01-26T02:45:05.296Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", upload-time = "2026-01-26T02:45:06.754Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", upload-time = "2026-01-26T02:45:08.044Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", upload-time = "2026-01-26T02:45:09.349Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", upload-time = "2026-01-26T02:45:10.651Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", upload-time = "2026-01-26T02:45:11.938Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", upload-time = "2026-01-26T02:45:14.346Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", upload-time = "2026-01-26T02:45:15.712Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", upload-time = "2026-01-26T02:45:17.293Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", upload-time = "2026-01-26T02:45:19.356Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", upload-time = "2026-01-26T02:45:20.834Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", upload-time = "2026-01-26T02:45:22.818Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", upload-time = "2026-01-26T02:45:24.368Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", upload-time = "2026-01-26T02:45:25.982Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", upload-time = "2026-01-26T02:45:27.487Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", upload-time = "2026-01-26T02:45:28.991Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", upload-time = "2026-01-26T02:45:30.503Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", upload-time = "2026-01-26T02:45:32.603Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", upload-time = "2026-01-26T02:45:34.043Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", upload-time = "2026-01-26T02:45:36.278Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", upload-time = "2026-01-26T02:45:37.546Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", upload-time = "2026-01-26T02:45:38.889Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", upload-time = "2026-01-26T02:45:40.254Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", upload-time = "2026-01-26T02:45:41.635Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", upload-time = "2026-01-26T02:45:42.99Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", upload-time = "2026-01-26T02:45:44.376Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", upload-time = "2026-01-26T02:45:47.167Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", upload-time = "2026-01-26T02:45:48.698Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", upload-time = "2026-01-26T02:45:50.164Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", upload-time = "2026-01-26T02:45:51.779Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", upload-time = "2026-01-26T02:45:53.27Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", upload-time = "2026-01-26T02:45:54.919Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", upload-time = "2026-01-26T02:45:56.981Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", upload-time = "2026-01-26T02:45:58.698Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", upload-time = "2026-01-26T02:46:00.862Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", upload-time = "2026-01-26T02:46:02.824Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", upload-time = "2026-01-26T02:46:05.898Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", upload-time = "2026-01-26T02:46:07.468Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", upload-time = "2026-01-26T02:46:08.809Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", upload-time = "2026-01-26T02:46:11.146Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", upload-time = "2026-01-26T02:46:44.004Z" }, -] - -[[package]] -name = "mypy" -version = "2.1.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -dependencies = [ - { name = "ast-serialize" }, - { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, - { name = "mypy-extensions" }, - { name = "pathspec" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", upload-time = "2026-05-11T18:37:36.237Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", upload-time = "2026-05-11T18:33:27.973Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", upload-time = "2026-05-11T18:32:16.107Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", upload-time = "2026-05-11T18:32:39.256Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", upload-time = "2026-05-11T18:34:49.765Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", upload-time = "2026-05-11T18:34:05.855Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", upload-time = "2026-05-11T18:35:36.494Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", upload-time = "2026-05-11T18:37:14.139Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", upload-time = "2026-05-11T18:37:31.784Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", upload-time = "2026-05-11T18:34:23.063Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", upload-time = "2026-05-11T18:31:48.151Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", upload-time = "2026-05-11T18:37:06.898Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", upload-time = "2026-05-11T18:31:18.07Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", upload-time = "2026-05-11T18:34:31.23Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", upload-time = "2026-05-11T18:36:48.199Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", upload-time = "2026-05-11T18:31:38.929Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", upload-time = "2026-05-11T18:36:04.636Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", upload-time = "2026-05-11T18:35:55.742Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", upload-time = "2026-05-11T18:33:10.281Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", upload-time = "2026-05-11T18:32:07.205Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", upload-time = "2026-05-11T18:33:56.477Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", upload-time = "2026-05-11T18:32:30.049Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", upload-time = "2026-05-11T18:35:45.984Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", upload-time = "2026-05-11T18:36:57.188Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", upload-time = "2026-05-11T18:35:18.361Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", upload-time = "2026-05-11T18:34:59.618Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", upload-time = "2026-05-11T18:36:41.081Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", upload-time = "2026-05-11T18:34:13.526Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", upload-time = "2026-05-11T18:32:23.491Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", upload-time = "2026-05-11T18:35:28.64Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", upload-time = "2026-05-11T18:36:14.208Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", upload-time = "2026-05-11T18:33:48.249Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", upload-time = "2026-05-11T18:32:50.296Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", upload-time = "2026-05-11T18:33:38.182Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", upload-time = "2026-05-11T18:34:39.945Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", upload-time = "2026-05-11T18:36:31.444Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", upload-time = "2026-05-11T18:31:29.246Z" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", upload-time = "2025-04-22T14:54:22.983Z" }, -] - -[[package]] -name = "omnigent-slack" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "aiohttp" }, - { name = "aiosqlite" }, - { name = "cryptography" }, - { name = "httpx" }, - { name = "pydantic-settings" }, - { name = "python-dotenv" }, - { name = "slack-bolt" }, - { name = "slack-sdk" }, -] - -[package.dev-dependencies] -dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "respx" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "aiohttp", specifier = ">=3.12.0" }, - { name = "aiosqlite", specifier = ">=0.21.0" }, - { name = "cryptography", specifier = ">=42.0.0" }, - { name = "httpx", specifier = ">=0.28.0" }, - { name = "pydantic-settings", specifier = ">=2.10.0" }, - { name = "python-dotenv", specifier = ">=1.1.0" }, - { name = "slack-bolt", specifier = ">=1.29.0" }, - { name = "slack-sdk", specifier = ">=3.43.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=1.16.0" }, - { name = "pytest", specifier = ">=8.4.0" }, - { name = "pytest-asyncio", specifier = ">=1.0.0" }, - { name = "respx", specifier = ">=0.22.0" }, - { name = "ruff", specifier = ">=0.12.0" }, -] - -[[package]] -name = "packaging" -version = "26.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", upload-time = "2026-04-24T20:15:23.917Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", upload-time = "2026-04-24T20:15:22.081Z" }, -] - -[[package]] -name = "pathspec" -version = "1.1.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", upload-time = "2026-04-27T01:46:08.907Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", upload-time = "2026-04-27T01:46:07.06Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "propcache" -version = "0.5.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", upload-time = "2026-05-08T21:02:12.199Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", upload-time = "2026-05-08T20:59:45.799Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", upload-time = "2026-05-08T20:59:47.408Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", upload-time = "2026-05-08T20:59:49.202Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", upload-time = "2026-05-08T20:59:50.778Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", upload-time = "2026-05-08T20:59:52.227Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", upload-time = "2026-05-08T20:59:53.866Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", upload-time = "2026-05-08T20:59:55.457Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", upload-time = "2026-05-08T20:59:57.075Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", upload-time = "2026-05-08T20:59:58.602Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", upload-time = "2026-05-08T20:59:59.837Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", upload-time = "2026-05-08T21:00:01.144Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", upload-time = "2026-05-08T21:00:02.401Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", upload-time = "2026-05-08T21:00:03.638Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", upload-time = "2026-05-08T21:00:04.881Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", upload-time = "2026-05-08T21:00:06.511Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", upload-time = "2026-05-08T21:00:08.088Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", upload-time = "2026-05-08T21:00:09.692Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", upload-time = "2026-05-08T21:00:11.277Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", upload-time = "2026-05-08T21:00:12.604Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", upload-time = "2026-05-08T21:00:13.935Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", upload-time = "2026-05-08T21:00:15.526Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", upload-time = "2026-05-08T21:00:16.824Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", upload-time = "2026-05-08T21:00:18.362Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", upload-time = "2026-05-08T21:00:19.692Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", upload-time = "2026-05-08T21:00:21.155Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", upload-time = "2026-05-08T21:00:22.713Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", upload-time = "2026-05-08T21:00:24.001Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", upload-time = "2026-05-08T21:00:25.545Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", upload-time = "2026-05-08T21:00:27.186Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", upload-time = "2026-05-08T21:00:28.8Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", upload-time = "2026-05-08T21:00:30.098Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", upload-time = "2026-05-08T21:00:31.622Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", upload-time = "2026-05-08T21:00:32.918Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", upload-time = "2026-05-08T21:00:35.124Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", upload-time = "2026-05-08T21:00:36.355Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", upload-time = "2026-05-08T21:00:38.072Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", upload-time = "2026-05-08T21:00:39.319Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", upload-time = "2026-05-08T21:00:40.621Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", upload-time = "2026-05-08T21:00:41.775Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", upload-time = "2026-05-08T21:00:43.018Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", upload-time = "2026-05-08T21:00:44.507Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", upload-time = "2026-05-08T21:00:45.796Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", upload-time = "2026-05-08T21:00:47.093Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", upload-time = "2026-05-08T21:00:48.376Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", upload-time = "2026-05-08T21:00:49.954Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", upload-time = "2026-05-08T21:00:51.281Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", upload-time = "2026-05-08T21:00:52.602Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", upload-time = "2026-05-08T21:00:54.303Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", upload-time = "2026-05-08T21:00:55.808Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", upload-time = "2026-05-08T21:00:57.061Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", upload-time = "2026-05-08T21:00:58.646Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", upload-time = "2026-05-08T21:00:59.889Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", upload-time = "2026-05-08T21:01:01.195Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", upload-time = "2026-05-08T21:01:02.941Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", upload-time = "2026-05-08T21:01:04.475Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", upload-time = "2026-05-08T21:01:05.959Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", upload-time = "2026-05-08T21:01:07.228Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", upload-time = "2026-05-08T21:01:08.514Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", upload-time = "2026-05-08T21:01:09.874Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", upload-time = "2026-05-08T21:01:11.132Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", upload-time = "2026-05-08T21:01:12.387Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", upload-time = "2026-05-08T21:01:13.676Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", upload-time = "2026-05-08T21:01:14.936Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", upload-time = "2026-05-08T21:01:16.281Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", upload-time = "2026-05-08T21:01:17.57Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", upload-time = "2026-05-08T21:01:18.946Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", upload-time = "2026-05-08T21:01:20.162Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", upload-time = "2026-05-08T21:01:21.399Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", upload-time = "2026-05-08T21:01:22.683Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", upload-time = "2026-05-08T21:01:23.986Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", upload-time = "2026-05-08T21:01:25.305Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", upload-time = "2026-05-08T21:01:26.545Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", upload-time = "2026-05-08T21:01:27.937Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", upload-time = "2026-05-08T21:01:29.484Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", upload-time = "2026-05-08T21:01:31.068Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", upload-time = "2026-05-08T21:01:32.374Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", upload-time = "2026-05-08T21:01:33.67Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", upload-time = "2026-05-08T21:01:34.915Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", upload-time = "2026-05-08T21:01:36.231Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", upload-time = "2026-05-08T21:01:37.545Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", upload-time = "2026-05-08T21:01:38.83Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", upload-time = "2026-05-08T21:01:40.415Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", upload-time = "2026-05-08T21:01:41.654Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", upload-time = "2026-05-08T21:01:43.041Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", upload-time = "2026-05-08T21:01:44.336Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", upload-time = "2026-05-08T21:01:45.883Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", upload-time = "2026-05-08T21:01:47.307Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", upload-time = "2026-05-08T21:01:48.573Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", upload-time = "2026-05-08T21:01:49.903Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", upload-time = "2026-05-08T21:01:51.204Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", upload-time = "2026-05-08T21:01:52.539Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", upload-time = "2026-05-08T21:01:53.847Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", upload-time = "2026-05-08T21:01:55.179Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", upload-time = "2026-05-08T21:01:57.489Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", upload-time = "2026-05-08T21:01:58.736Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", upload-time = "2026-05-08T21:02:00.228Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", upload-time = "2026-05-08T21:02:01.888Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", upload-time = "2026-05-08T21:02:03.484Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", upload-time = "2026-05-08T21:02:04.745Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", upload-time = "2026-05-08T21:02:06.025Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", upload-time = "2026-05-08T21:02:07.353Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", upload-time = "2026-05-08T21:02:09.065Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", upload-time = "2026-05-08T21:02:10.673Z" }, -] - -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", upload-time = "2026-01-21T14:26:50.693Z" }, -] - -[[package]] -name = "pydantic" -version = "2.13.4" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", upload-time = "2026-05-06T13:43:05.343Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", upload-time = "2026-05-06T13:43:02.641Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.46.4" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", upload-time = "2026-05-06T13:37:06.98Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", upload-time = "2026-05-06T13:40:27.596Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", upload-time = "2026-05-06T13:39:12.574Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", upload-time = "2026-05-06T13:38:19.841Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", upload-time = "2026-05-06T13:38:10.447Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", upload-time = "2026-05-06T13:40:13.089Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", upload-time = "2026-05-06T13:38:59.224Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", upload-time = "2026-05-06T13:38:17.762Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", upload-time = "2026-05-06T13:37:58.195Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", upload-time = "2026-05-06T13:37:01.167Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", upload-time = "2026-05-06T13:38:45.04Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", upload-time = "2026-05-06T13:37:15.539Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", upload-time = "2026-05-06T13:37:30.099Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", upload-time = "2026-05-06T13:38:15.669Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", upload-time = "2026-05-06T13:40:35.416Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", upload-time = "2026-05-06T13:38:29.754Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", upload-time = "2026-05-06T13:38:57.215Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", upload-time = "2026-05-06T13:37:02.697Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", upload-time = "2026-05-06T13:37:09.448Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", upload-time = "2026-05-06T13:37:38.234Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", upload-time = "2026-05-06T13:38:27.753Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", upload-time = "2026-05-06T13:38:05.353Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", upload-time = "2026-05-06T13:39:10.577Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", upload-time = "2026-05-06T13:40:22.59Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", upload-time = "2026-05-06T13:40:10.666Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", upload-time = "2026-05-06T13:40:43.231Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", upload-time = "2026-05-06T13:39:57.365Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", upload-time = "2026-05-06T13:38:06.976Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", upload-time = "2026-05-06T13:40:47.985Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", upload-time = "2026-05-06T13:39:21.153Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", upload-time = "2026-05-06T13:39:03.753Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", upload-time = "2026-05-06T13:37:48.029Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", upload-time = "2026-05-06T13:37:17.012Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", upload-time = "2026-05-06T13:37:35.113Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", upload-time = "2026-05-06T13:37:12.313Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", upload-time = "2026-05-06T13:39:01.149Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", upload-time = "2026-05-06T13:37:41.406Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", upload-time = "2026-05-06T13:39:18.847Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", upload-time = "2026-05-06T13:40:17.944Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", upload-time = "2026-05-06T13:40:32.618Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", upload-time = "2026-05-06T13:36:51.018Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", upload-time = "2026-05-06T13:40:37.764Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", upload-time = "2026-05-06T13:39:34.152Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", upload-time = "2026-05-06T13:37:55.072Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", upload-time = "2026-05-06T13:38:49.139Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", upload-time = "2026-05-06T13:40:45.796Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", upload-time = "2026-05-06T13:38:41.019Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", upload-time = "2026-05-06T13:36:59.812Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", upload-time = "2026-05-06T13:37:39.933Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", upload-time = "2026-05-06T13:38:01.995Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", upload-time = "2026-05-06T13:40:50.371Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", upload-time = "2026-05-06T13:37:21.531Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", upload-time = "2026-05-06T13:39:31.942Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", upload-time = "2026-05-06T13:37:25.033Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", upload-time = "2026-05-06T13:37:14.046Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", upload-time = "2026-05-06T13:36:53.615Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", upload-time = "2026-05-06T13:40:29.971Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", upload-time = "2026-05-06T13:37:23.027Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", upload-time = "2026-05-06T13:38:03.499Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", upload-time = "2026-05-06T13:39:40.807Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", upload-time = "2026-05-06T13:37:26.72Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", upload-time = "2026-05-06T13:39:47.682Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", upload-time = "2026-05-06T13:40:40.428Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", upload-time = "2026-05-06T13:37:32.029Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", upload-time = "2026-05-06T13:38:55.239Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", upload-time = "2026-05-06T13:37:08.096Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", upload-time = "2026-05-06T13:40:20.221Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", upload-time = "2026-05-06T13:38:12.153Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", upload-time = "2026-05-06T13:40:02.971Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", upload-time = "2026-05-06T13:39:27.506Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", upload-time = "2026-05-06T13:38:31.93Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", upload-time = "2026-05-06T13:37:44.717Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", upload-time = "2026-05-06T13:37:05.645Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", upload-time = "2026-05-06T13:38:51.116Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", upload-time = "2026-05-06T13:38:21.672Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", upload-time = "2026-05-06T13:40:52.723Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", upload-time = "2026-05-06T13:37:10.817Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", upload-time = "2026-05-06T13:36:56.717Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", upload-time = "2026-05-06T13:39:06.207Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", upload-time = "2026-05-06T13:39:45.476Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", upload-time = "2026-05-06T13:39:52.283Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", upload-time = "2026-05-06T13:40:15.671Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", upload-time = "2026-05-06T13:38:34.717Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", upload-time = "2026-05-06T13:39:29.883Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", upload-time = "2026-05-06T13:37:04.016Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", upload-time = "2026-05-06T13:39:43.092Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", upload-time = "2026-05-06T13:36:58.124Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", upload-time = "2026-05-06T13:37:49.88Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", upload-time = "2026-05-06T13:37:43.053Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", upload-time = "2026-05-06T13:37:56.694Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", upload-time = "2026-05-06T13:40:05.667Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", upload-time = "2026-05-06T13:38:08.682Z" }, -] - -[[package]] -name = "pydantic-settings" -version = "2.14.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", upload-time = "2026-06-19T13:44:56.324Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", upload-time = "2026-06-19T13:44:55.02Z" }, -] - -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", upload-time = "2026-03-29T13:29:33.898Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", upload-time = "2026-03-29T13:29:30.038Z" }, -] - -[[package]] -name = "pytest" -version = "9.1.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", upload-time = "2026-06-19T10:58:32.857Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", upload-time = "2026-06-19T10:58:31.347Z" }, -] - -[[package]] -name = "pytest-asyncio" -version = "1.4.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -dependencies = [ - { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", upload-time = "2026-05-26T09:56:04.083Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", upload-time = "2026-05-26T09:56:02.576Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", upload-time = "2026-03-01T16:00:25.09Z" }, -] - -[[package]] -name = "respx" -version = "0.23.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -dependencies = [ - { name = "httpx" }, -] -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", upload-time = "2026-04-08T14:37:16.008Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", upload-time = "2026-04-08T14:37:14.613Z" }, -] - -[[package]] -name = "ruff" -version = "0.15.20" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", upload-time = "2026-06-25T17:20:37.578Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", upload-time = "2026-06-25T17:19:44.702Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", upload-time = "2026-06-25T17:19:48.787Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", upload-time = "2026-06-25T17:19:51.354Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", upload-time = "2026-06-25T17:19:54.044Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", upload-time = "2026-06-25T17:19:57.037Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", upload-time = "2026-06-25T17:20:00.357Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", upload-time = "2026-06-25T17:20:03.446Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", upload-time = "2026-06-25T17:20:06.301Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", upload-time = "2026-06-25T17:20:09.191Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", upload-time = "2026-06-25T17:20:13.602Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", upload-time = "2026-06-25T17:20:16.635Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", upload-time = "2026-06-25T17:20:19.4Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", upload-time = "2026-06-25T17:20:22.033Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", upload-time = "2026-06-25T17:20:25.259Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", upload-time = "2026-06-25T17:20:28.612Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", upload-time = "2026-06-25T17:20:31.871Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", upload-time = "2026-06-25T17:20:35.03Z" }, -] - -[[package]] -name = "slack-bolt" -version = "1.29.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -dependencies = [ - { name = "slack-sdk" }, -] -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/3c/2c/7434f5d8c52eafb1e702012e9f291b013bd05985a5b32bde568b2279a28a/slack_bolt-1.29.0.tar.gz", hash = "sha256:b6271ba0a9b71e319c86b40632e6cb6240aacd0433773615b76b890b9a574762", upload-time = "2026-06-30T20:24:44.11Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/2f/cc/e5f78a80a1775a4cbb2cc41e8ef433602d5e755ff0d829bd43145015740a/slack_bolt-1.29.0-py2.py3-none-any.whl", hash = "sha256:1835b66b778158f3af0da77603aa18d7dfd82fd9b9a985e25c752f95050ab826", upload-time = "2026-06-30T20:24:42.558Z" }, -] - -[[package]] -name = "slack-sdk" -version = "3.43.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/13/75/a4964eb771a0c74d79ee7a3bee6fb5d9718909dd1b675e80d62a6a0ad90a/slack_sdk-3.43.0.tar.gz", hash = "sha256:0553152e46c4259eb69f7464cdadc35ba4802ca10f9f5a849c92cf03d6c2ba07", upload-time = "2026-06-30T18:04:41.59Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/e4/55/42141b8338d46323d5b3c6095201b044c670c20f898643b322ea9b1543a1/slack_sdk-3.43.0-py2.py3-none-any.whl", hash = "sha256:4b6557c65577fc172f685af218b811f9f3b4909e24cddd839ada09565f10c585", upload-time = "2026-06-30T18:04:39.636Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.16.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", upload-time = "2026-07-02T08:40:05.92Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", upload-time = "2026-07-02T08:40:04.659Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", upload-time = "2025-10-01T02:14:40.154Z" }, -] - -[[package]] -name = "yarl" -version = "1.24.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", upload-time = "2026-05-19T21:31:05.599Z" } -wheels = [ - { url = "https://pypi-proxy.cloud.databricks.com/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", upload-time = "2026-05-19T21:28:20.543Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", upload-time = "2026-05-19T21:28:22.556Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", upload-time = "2026-05-19T21:28:24.092Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", upload-time = "2026-05-19T21:28:25.872Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", upload-time = "2026-05-19T21:28:27.352Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", upload-time = "2026-05-19T21:28:29.458Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", upload-time = "2026-05-19T21:28:31.262Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", upload-time = "2026-05-19T21:28:32.856Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", upload-time = "2026-05-19T21:28:35.222Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", upload-time = "2026-05-19T21:28:36.642Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", upload-time = "2026-05-19T21:28:38.386Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", upload-time = "2026-05-19T21:28:40.091Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", upload-time = "2026-05-19T21:28:41.987Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", upload-time = "2026-05-19T21:28:43.93Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", upload-time = "2026-05-19T21:28:45.806Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", upload-time = "2026-05-19T21:28:48.465Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", upload-time = "2026-05-19T21:28:50.07Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", upload-time = "2026-05-19T21:28:51.695Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", upload-time = "2026-05-19T21:28:53.242Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", upload-time = "2026-05-19T21:28:54.865Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", upload-time = "2026-05-19T21:28:56.963Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", upload-time = "2026-05-19T21:28:58.56Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", upload-time = "2026-05-19T21:29:00.148Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", upload-time = "2026-05-19T21:29:02.155Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", upload-time = "2026-05-19T21:29:04.247Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", upload-time = "2026-05-19T21:29:06.491Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", upload-time = "2026-05-19T21:29:08.267Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", upload-time = "2026-05-19T21:29:10.682Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", upload-time = "2026-05-19T21:29:12.954Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", upload-time = "2026-05-19T21:29:14.893Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", upload-time = "2026-05-19T21:29:16.485Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", upload-time = "2026-05-19T21:29:18.367Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", upload-time = "2026-05-19T21:29:20.014Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", upload-time = "2026-05-19T21:29:22.1Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", upload-time = "2026-05-19T21:29:23.83Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", upload-time = "2026-05-19T21:29:25.375Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", upload-time = "2026-05-19T21:29:26.928Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", upload-time = "2026-05-19T21:29:28.842Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", upload-time = "2026-05-19T21:29:31.086Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", upload-time = "2026-05-19T21:29:32.657Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", upload-time = "2026-05-19T21:29:34.379Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", upload-time = "2026-05-19T21:29:36.216Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", upload-time = "2026-05-19T21:29:38.746Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", upload-time = "2026-05-19T21:29:40.368Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", upload-time = "2026-05-19T21:29:42.66Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", upload-time = "2026-05-19T21:29:44.632Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", upload-time = "2026-05-19T21:29:46.755Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", upload-time = "2026-05-19T21:29:48.972Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", upload-time = "2026-05-19T21:29:51Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", upload-time = "2026-05-19T21:29:52.743Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", upload-time = "2026-05-19T21:29:54.442Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", upload-time = "2026-05-19T21:29:56.631Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", upload-time = "2026-05-19T21:29:58.645Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", upload-time = "2026-05-19T21:30:00.412Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", upload-time = "2026-05-19T21:30:02.544Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", upload-time = "2026-05-19T21:30:04.319Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", upload-time = "2026-05-19T21:30:05.988Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", upload-time = "2026-05-19T21:30:07.738Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", upload-time = "2026-05-19T21:30:09.713Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", upload-time = "2026-05-19T21:30:11.365Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", upload-time = "2026-05-19T21:30:12.978Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", upload-time = "2026-05-19T21:30:15.001Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", upload-time = "2026-05-19T21:30:16.683Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", upload-time = "2026-05-19T21:30:18.769Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", upload-time = "2026-05-19T21:30:21.024Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", upload-time = "2026-05-19T21:30:23.412Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", upload-time = "2026-05-19T21:30:25.425Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", upload-time = "2026-05-19T21:30:27.623Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", upload-time = "2026-05-19T21:30:29.297Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", upload-time = "2026-05-19T21:30:31.031Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", upload-time = "2026-05-19T21:30:33.062Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", upload-time = "2026-05-19T21:30:34.842Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", upload-time = "2026-05-19T21:30:37.724Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", upload-time = "2026-05-19T21:30:40.196Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", upload-time = "2026-05-19T21:30:41.888Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", upload-time = "2026-05-19T21:30:43.985Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", upload-time = "2026-05-19T21:30:46.291Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", upload-time = "2026-05-19T21:30:48.085Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", upload-time = "2026-05-19T21:30:49.785Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", upload-time = "2026-05-19T21:30:51.862Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", upload-time = "2026-05-19T21:30:53.62Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", upload-time = "2026-05-19T21:30:55.962Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", upload-time = "2026-05-19T21:30:57.962Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", upload-time = "2026-05-19T21:30:59.735Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", upload-time = "2026-05-19T21:31:01.934Z" }, - { url = "https://pypi-proxy.cloud.databricks.com/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", upload-time = "2026-05-19T21:31:03.909Z" }, -] diff --git a/pyproject.toml b/pyproject.toml index 270f20f9074..87304726732 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -530,6 +530,14 @@ known-first-party = ["omnigent"] "sdks/**/*.py" = [ "ARG001", "ARG002", "BLE001", "B008", "RUF012", ] +# slack_bolt dispatches to handlers by parameter NAME (``body``, ``event``, +# ``client``, ``ack``, ``view``), so unused handler args can't be renamed to +# ``_`` without breaking injection. The bot also has deliberate boundary +# catches (best-effort acks, background login tasks) that must never abort a +# turn, hence BLE001. +"integrations/slack/**/*.py" = [ + "ARG001", "ARG002", "BLE001", "B008", "RUF012", +] [tool.mypy] python_version = "3.12" @@ -726,6 +734,15 @@ module = "omnigent_client.*" ignore_missing_imports = true disallow_any_explicit = false +# Slack integration (omnigent-slack). slack_bolt / slack_sdk ship no type +# stubs, and Slack event/view payloads are opaque JSON dicts the handlers +# read positionally — so the code leans on ``Any`` at that boundary. Mirror +# the client SDK: resolve the untyped imports and lift the Any ban here. +[[tool.mypy.overrides]] +module = "omnigent_slack.*" +ignore_missing_imports = true +disallow_any_explicit = false + # Tests in the mypy allowlist predate the stricter omnigent # Any rule. [[tool.mypy.overrides]] diff --git a/uv.lock b/uv.lock index 3838db98e4a..b69d4d309d1 100644 --- a/uv.lock +++ b/uv.lock @@ -3128,15 +3128,6 @@ requires-dist = [ { name = "slack-sdk", specifier = ">=3.43.0" }, ] -[package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=1.16.0" }, - { name = "pytest", specifier = ">=8.4.0" }, - { name = "pytest-asyncio", specifier = ">=1.0.0" }, - { name = "respx", specifier = ">=0.22.0" }, - { name = "ruff", specifier = ">=0.12.0" }, -] - [[package]] name = "omnigent-ui-sdk" version = "0.6.0.dev0" From f8b333b6cacfb2a7b69f646b65c058475d8350c2 Mon Sep 17 00:00:00 2001 From: Jackson Zheng <36802691+zhengwin@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:38:56 -0700 Subject: [PATCH 456/546] Add automatic session titles (#2778) * Add automatic session titles Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com> * Document Codex title adapter boundary Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com> * Centralize automatic title prompts Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com> * Harden automatic title prompt gating Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com> * Document framework instruction boundary Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com> * Document framework-owned instructions Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com> * Harden automatic session renaming Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com> * Fix automatic title CI coverage Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com> * test(e2e): avoid flaky REPL ready marker Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com> --------- Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com> --- AGENTS.md | 14 ++ docs/AGENT_YAML_SPEC.md | 12 +- omnigent/claude_native.py | 23 +++ omnigent/claude_native_bridge.py | 31 ++++ omnigent/codex_native_app_server.py | 86 +++++++++- omnigent/inner/claude_sdk_executor.py | 6 +- omnigent/inner/codex_native_executor.py | 17 +- .../agent/skills/omnigent-knowledge/SKILL.md | 4 +- omnigent/runner/app.py | 48 +++++- omnigent/runner/tool_dispatch.py | 55 +++++++ omnigent/runtime/prompt.py | 34 +++- omnigent/server/routes/sessions.py | 65 +++++++- omnigent/server/schemas.py | 16 ++ omnigent/spec/AGENTSPEC.md | 5 + .../stores/conversation_store/__init__.py | 17 ++ .../conversation_store/sqlalchemy_store.py | 21 +++ omnigent/tools/builtins/__init__.py | 2 + omnigent/tools/builtins/session_rename.py | 104 ++++++++++++ omnigent/tools/manager.py | 6 + openapi.json | 111 +++++++++++++ tests/e2e/test_repl_approval_e2e.py | 6 +- tests/inner/test_claude_sdk_executor.py | 95 +++++++++++ tests/inner/test_codex_native_executor.py | 48 ++++++ tests/runner/test_app_sessions_native.py | 15 +- tests/runner/test_auto_title_instruction.py | 55 +++++++ tests/runner/test_comment_relay.py | 1 + tests/runner/test_session_rename_dispatch.py | 132 ++++++++++++++++ tests/runtime/test_prompt.py | 32 ++++ .../integration/test_sessions_endpoints.py | 148 ++++++++++++++++++ tests/stores/test_conversation_store.py | 22 +++ tests/test_claude_native.py | 15 ++ tests/test_claude_native_bridge.py | 47 ++++++ tests/test_codex_native_app_server.py | 97 ++++++++++++ tests/tools/builtins/test_session_rename.py | 11 ++ tests/tools/test_manager.py | 9 +- 35 files changed, 1381 insertions(+), 29 deletions(-) create mode 100644 omnigent/tools/builtins/session_rename.py create mode 100644 tests/runner/test_auto_title_instruction.py create mode 100644 tests/runner/test_session_rename_dispatch.py create mode 100644 tests/runtime/test_prompt.py create mode 100644 tests/tools/builtins/test_session_rename.py diff --git a/AGENTS.md b/AGENTS.md index 1661cd16450..d2d83d4f132 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,3 +55,17 @@ Keep comments short and focused on the code, not on the change history. *why* it exists, in terms a future reader needs. Don't reference PR numbers, issue numbers, or ticket IDs (e.g. `#1646`, `fixes JIRA-123`); the scenario should be clear without chasing external links. + +## Framework-owned instructions + +Keep runtime lifecycle and metadata instructions separate from portable agent +instructions: + +- Agent-spec and per-request instructions are user-authored. Framework-owned + instructions are additive runtime behavior and are appended after them in + `omnigent/runtime/prompt.py`. +- Keep the canonical instruction text and lifecycle gate in the owning framework + module. Harness adapters should only transport the composed instructions; do + not duplicate policy across adapters or add lifecycle metadata to `AgentSpec`. +- If framework instructions grow beyond a small ordered list, introduce a + structured `FrameworkInstructions` value at the prompt-composition boundary. diff --git a/docs/AGENT_YAML_SPEC.md b/docs/AGENT_YAML_SPEC.md index 0b24b675b51..9e83567b43c 100644 --- a/docs/AGENT_YAML_SPEC.md +++ b/docs/AGENT_YAML_SPEC.md @@ -6,8 +6,9 @@ Omnigent can run an agent from a single YAML file: omnigent run path/to/agent.yaml ``` -Use this file to choose the harness/model, write the system prompt, and declare -which tools, sub-agents, OS access, and policies the agent can use. +Use this file to choose the harness/model, write the agent-owned system +instructions, and declare which tools, sub-agents, OS access, and policies the +agent can use. ## Minimal agent @@ -28,12 +29,17 @@ executor: `prompt` may also be replaced by `instructions: AGENTS.md`; relative paths are resolved from the YAML file's directory. +These fields define the portable, agent-authored portion of the system prompt. +Omnigent may append framework-owned lifecycle or metadata instructions at +runtime after agent and per-request instructions; those additions are not part +of the agent YAML. + ## Common top-level fields | Field | Required? | Purpose | | --- | --- | --- | | `name` | Recommended | Stable identifier shown in sessions and logs. | -| `prompt` | Usually | Inline system prompt. | +| `prompt` | Usually | Inline agent-owned system instructions. | | `instructions` | Optional | Inline instructions or a path to an instructions file. If set, it takes precedence over `prompt`. | | `executor` | Recommended | Harness, model, and auth settings. | | `tools` | Optional | MCP tools, Python function tools, sub-agents, handoffs, or inherited tools. | diff --git a/omnigent/claude_native.py b/omnigent/claude_native.py index 54437c1ba50..4aa63b656a6 100644 --- a/omnigent/claude_native.py +++ b/omnigent/claude_native.py @@ -3214,6 +3214,11 @@ async def _prepare_claude_terminal( startup_progress=startup_progress, progress_message="Starting Claude terminal...", ) + from omnigent.tools.builtins.session_rename import ( + session_rename_allowed_tools, + session_rename_instruction, + ) + terminal_id = await _launch_claude_terminal( client, session_id, @@ -3221,6 +3226,8 @@ async def _prepare_claude_terminal( command=command, bridge_dir=bridge_dir, claude_config=claude_config, + append_system_prompt=session_rename_instruction(initial_session=not cold_resumed), + allowed_tools=session_rename_allowed_tools(initial_session=not cold_resumed), ) _mark_startup_step( startup_profiler, @@ -3966,6 +3973,8 @@ async def _launch_claude_terminal( command: str, bridge_dir: Path, claude_config: ClaudeNativeUcodeConfig | None = None, + append_system_prompt: str | None = None, + allowed_tools: tuple[str, ...] = (), ) -> str: """ Launch the server-backed Claude terminal resource. @@ -3981,6 +3990,10 @@ async def _launch_claude_terminal( :param bridge_dir: Bridge directory shared with Claude's MCP MCP server and the web-chat harness. :param claude_config: Optional ucode-derived Claude Code config. + :param append_system_prompt: Optional framework-owned instructions for + this fresh native session. + :param allowed_tools: Optional narrowly scoped Claude tools preapproved + for this native session. :returns: Terminal resource id. :raises click.ClickException: If terminal launch fails. """ @@ -3991,6 +4004,8 @@ async def _launch_claude_terminal( ap_server_url=str(client.base_url), ap_auth_headers=dict(client.headers), claude_config=claude_config, + append_system_prompt=append_system_prompt, + allowed_tools=allowed_tools, ) resp = await client.post( f"/v1/sessions/{url_component(session_id)}/resources/terminals", @@ -4121,6 +4136,8 @@ def _claude_terminal_request( ap_server_url: str | None = None, ap_auth_headers: dict[str, str] | None = None, claude_config: ClaudeNativeUcodeConfig | None = None, + append_system_prompt: str | None = None, + allowed_tools: tuple[str, ...] = (), ) -> dict[str, Any]: """ Build the terminal resource creation body for Claude Code. @@ -4136,6 +4153,10 @@ def _claude_terminal_request( :param ap_auth_headers: Auth headers for the ``PermissionRequest`` command hook. :param claude_config: Optional ucode-derived Claude Code config. + :param append_system_prompt: Optional framework-owned instructions to + append to Claude Code's system prompt. + :param allowed_tools: Optional narrowly scoped Claude tools preapproved + for this native session. :returns: JSON body for ``POST /resources/terminals``. """ claude_args = _merge_default_model_arg( @@ -4148,6 +4169,8 @@ def _claude_terminal_request( ap_server_url=ap_server_url, ap_auth_headers=ap_auth_headers, api_key_helper=claude_config.api_key_helper if claude_config is not None else None, + append_system_prompt=append_system_prompt, + allowed_tools=allowed_tools, ) # Let a registered launcher plugin (e.g. Databricks' isaac) rewrite the # command/args to wrap the same fully-augmented Claude launch. Identity by diff --git a/omnigent/claude_native_bridge.py b/omnigent/claude_native_bridge.py index 75c55ee208f..287cd00e0a8 100644 --- a/omnigent/claude_native_bridge.py +++ b/omnigent/claude_native_bridge.py @@ -1388,6 +1388,8 @@ def augment_claude_args( bundle_dir: Path | None = None, agent_name: str | None = None, skills_filter: str | list[str] = "all", + append_system_prompt: str | None = None, + allowed_tools: tuple[str, ...] = (), ) -> list[str]: """ Return Claude CLI args with Omnigent MCP/hook/skill injection. @@ -1420,6 +1422,10 @@ def augment_claude_args( / ``"none"`` / list of skill names), mapped to ``--setting-sources`` exactly as the SDK executor maps it onto ``setting_sources``. Defaults to ``"all"``. + :param append_system_prompt: Optional framework-owned instructions to + append through Claude Code's native ``--append-system-prompt`` flag. + :param allowed_tools: Optional narrowly scoped Claude tool names to merge + into ``--allowedTools`` without replacing the user's allowlist. :returns: Augmented argument list for the terminal resource. """ mcp_config = build_mcp_config(bridge_dir, python_executable=python_executable) @@ -1434,6 +1440,7 @@ def augment_claude_args( launch_effort=_arg_value(claude_args, "--effort"), ) args = _merge_disallowed_tools(list(claude_args), _OMNIGENT_DISALLOWED_TOOLS) + args = _merge_allowed_tools(args, allowed_tools) args.extend( [ "--mcp-config", @@ -1442,6 +1449,8 @@ def augment_claude_args( json.dumps(hook_settings, separators=(",", ":")), ] ) + if append_system_prompt: + args.extend(["--append-system-prompt", append_system_prompt]) args.extend( claude_native_skill_args( bundle_dir, @@ -1478,6 +1487,28 @@ def _arg_value(args: tuple[str, ...], flag: str) -> str | None: return value +def _merge_allowed_tools(args: list[str], extra: tuple[str, ...]) -> list[str]: + """Merge framework-approved tools into Claude's ``--allowedTools`` flag. + + :param args: Claude CLI argument list to mutate-and-return. + :param extra: Tool names Omnigent may call without an interactive prompt. + :returns: ``args`` with a deduplicated, order-preserving allowlist. + """ + if not extra: + return args + try: + idx = args.index("--allowedTools") + except ValueError: + args.extend(["--allowedTools", ",".join(extra)]) + return args + value_idx = idx + 1 + if value_idx >= len(args): + return args + existing = [tool for tool in args[value_idx].split(",") if tool] + args[value_idx] = ",".join(dict.fromkeys([*existing, *extra])) + return args + + def _merge_disallowed_tools(args: list[str], extra: tuple[str, ...]) -> list[str]: """ Add ``extra`` tool names to a ``--disallowedTools`` flag in ``args``. diff --git a/omnigent/codex_native_app_server.py b/omnigent/codex_native_app_server.py index 43e97911598..90c7beb6b04 100644 --- a/omnigent/codex_native_app_server.py +++ b/omnigent/codex_native_app_server.py @@ -17,6 +17,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +import tomlkit import websockets if TYPE_CHECKING: @@ -165,7 +166,8 @@ def _codex_mcp_server_config_section( :param python_executable: Python executable for serve-mcp, e.g. ``"/path/to/.venv/bin/python"``. ``None`` uses :data:`sys.executable`. - :returns: TOML text for ``[mcp_servers.omnigent]``. + :returns: TOML text for ``[mcp_servers.omnigent]`` and its + framework-managed rename-tool approval. """ python = python_executable or sys.executable args = [ @@ -177,7 +179,13 @@ def _codex_mcp_server_config_section( str(bridge_dir), ] args_toml = ", ".join(json.dumps(a) for a in args) - return f"[mcp_servers.omnigent]\ncommand = {json.dumps(python)}\nargs = [{args_toml}]\n" + return ( + f"[mcp_servers.omnigent]\n" + f"command = {json.dumps(python)}\n" + f"args = [{args_toml}]\n\n" + "[mcp_servers.omnigent.tools.sys_session_rename]\n" + 'approval_mode = "approve"\n' + ) def _pin_codex_config_model(codex_home: Path, model: str) -> None: @@ -222,6 +230,69 @@ def _pin_codex_config_model(codex_home: Path, model: str) -> None: config_path.write_text("\n".join(lines) + "\n", encoding="utf-8") +def _sync_codex_developer_instructions( + codex_home: Path, + instructions: str | None, +) -> None: + """Synchronize framework instructions in the private Codex config. + + Codex's top-level ``developer_instructions`` setting is additive to its + built-in operating instructions. The collaboration-mode field is not: a + non-null value replaces the mode's defaults. The private session config + therefore stores the user's original value in a sidecar, then derives the + active value from that base on every launch. Fresh sessions append the + framework directive; resumed sessions restore the unmodified base. + + :param codex_home: Private per-session ``CODEX_HOME`` directory. + :param instructions: Framework instructions for this launch, or ``None``. + :returns: None. + """ + addition = instructions.strip() if instructions else "" + config_path = codex_home / "config.toml" + base_path = codex_home / ".omnigent-developer-instructions-base" + if config_path.is_symlink(): + target = config_path.resolve() + config_path.unlink() + if target.is_file(): + import shutil + + shutil.copy2(target, config_path) + existing = config_path.read_text(encoding="utf-8") if config_path.exists() else "" + try: + document = tomlkit.parse(existing) if existing else tomlkit.document() + except Exception: # noqa: BLE001 - title metadata must never block Codex startup. + _logger.warning( + "Could not synchronize native Codex framework instructions: invalid private config", + exc_info=True, + ) + return + current = document.get("developer_instructions") + if current is not None and not isinstance(current, str): + _logger.warning( + "Could not synchronize native Codex framework instructions: " + "developer_instructions is not a string" + ) + return + if base_path.exists(): + base = base_path.read_text(encoding="utf-8") + else: + base = current.strip() if isinstance(current, str) else "" + # A previous Omnigent build may have appended the same framework + # directive without writing the sidecar. Recover the user-authored + # prefix instead of permanently capturing the combined value as base. + if addition and base == addition: + base = "" + elif addition and base.endswith(f"\n\n{addition}"): + base = base[: -len(addition)].rstrip() + base_path.write_text(base, encoding="utf-8") + active = f"{base}\n\n{addition}" if base and addition else base or addition + if active: + document["developer_instructions"] = active + elif "developer_instructions" in document: + del document["developer_instructions"] + config_path.write_text(tomlkit.dumps(document), encoding="utf-8") + + def _inject_mcp_server_config( codex_home: Path, bridge_dir: Path, @@ -462,6 +533,8 @@ class CodexNativeAppServer: :param codex_home: Private per-session ``CODEX_HOME`` path. :param env: Environment for the app-server subprocess. :param config_overrides: Codex ``-c`` config override values. + :param developer_instructions: Optional framework-owned instructions + appended to the private session config before app-server startup. :param cwd: Working directory for the app-server process. :param bridge_dir: Native Codex bridge directory, e.g. ``Path("~/.omnigent/codex-native/")``. The policy hook @@ -502,6 +575,7 @@ class CodexNativeAppServer: config_overrides: list[str] cwd: Path bridge_dir: Path + developer_instructions: str | None = None ap_server_url: str | None = None ap_auth_headers: dict[str, str] | None = None python_executable: str | None = None @@ -536,6 +610,10 @@ async def start(self) -> None: _inject_mcp_server_config(self.codex_home, self.bridge_dir, self.python_executable) if self.pinned_model: _pin_codex_config_model(self.codex_home, self.pinned_model) + _sync_codex_developer_instructions( + self.codex_home, + self.developer_instructions, + ) # Native policy enforcement needs codex's hook-trust protocol # (``currentHash`` / ``trustStatus`` in ``hooks/list``), added in # codex 0.129. Below that the hook can never be trusted, so @@ -1071,6 +1149,7 @@ def build_codex_native_server( python_executable: str | None = None, codex_path: str | None = None, extra_config_overrides: list[str] | None = None, + developer_instructions: str | None = None, bypass_sandbox: bool = False, ) -> CodexNativeAppServer: """ @@ -1096,6 +1175,8 @@ def build_codex_native_server( :param extra_config_overrides: Additional ``-c`` config overrides appended after Databricks routing overrides, e.g. MCP server registration for the Omnigent tool relay. + :param developer_instructions: Optional framework-owned instructions + appended to Codex's private per-session config. :param bypass_sandbox: When ``True``, append config overrides that put the app-server's threads into the full-bypass stance (``approval_policy="never"`` + ``sandbox_mode="danger-full-access"``) @@ -1159,6 +1240,7 @@ def build_codex_native_server( config_overrides=config_overrides, cwd=cwd, bridge_dir=bridge_dir, + developer_instructions=developer_instructions, ap_server_url=ap_server_url, ap_auth_headers=ap_auth_headers, python_executable=python_executable, diff --git a/omnigent/inner/claude_sdk_executor.py b/omnigent/inner/claude_sdk_executor.py index a3fce829ff5..618ba8fb06b 100644 --- a/omnigent/inner/claude_sdk_executor.py +++ b/omnigent/inner/claude_sdk_executor.py @@ -802,7 +802,11 @@ def _augment_system_prompt_for_omnigent_mcp_tools( if not tool_names: return system_prompt - examples = [name for name in ("sys_session_send", "sys_session_create") if name in tool_names] + examples = [ + name + for name in ("sys_session_rename", "sys_session_send", "sys_session_create") + if name in tool_names + ] if examples: example_text = "; ".join( f"use `mcp__omnigent__{name}` when instructions say `{name}`" for name in examples diff --git a/omnigent/inner/codex_native_executor.py b/omnigent/inner/codex_native_executor.py index cb92e317082..bfcba66b594 100644 --- a/omnigent/inner/codex_native_executor.py +++ b/omnigent/inner/codex_native_executor.py @@ -188,9 +188,8 @@ async def run_turn( shape. The latest user message is delivered to Codex. :param tools: Tool schemas from Omnigent. Ignored here; native Codex owns its own tool surface. - :param system_prompt: System prompt from the agent spec. - Ignored because the native thread was created by the - wrapper. + :param system_prompt: System prompt from the agent spec. Native + startup instructions are configured before the app-server launches. :param config: Per-turn executor config. Its ``model`` and ``extra["reasoning_effort"]`` (carrying the Omnigent web ``/model`` pick) are applied via a ``thread/settings/update`` @@ -279,13 +278,11 @@ async def run_turn( **settings_overrides, }, ) - response = await client.request( - "turn/start", - { - "threadId": state.thread_id, - "input": input_items, - }, - ) + turn_params: dict[str, Any] = { + "threadId": state.thread_id, + "input": input_items, + } + response = await client.request("turn/start", turn_params) turn_id = response.get("result", {}).get("turn", {}).get("id") if isinstance(turn_id, str) and turn_id: update_active_turn_id(self._bridge_dir, turn_id) diff --git a/omnigent/onboarding/agent/skills/omnigent-knowledge/SKILL.md b/omnigent/onboarding/agent/skills/omnigent-knowledge/SKILL.md index 04ea03ccbbf..bca0a95f15b 100644 --- a/omnigent/onboarding/agent/skills/omnigent-knowledge/SKILL.md +++ b/omnigent/onboarding/agent/skills/omnigent-knowledge/SKILL.md @@ -140,7 +140,9 @@ sub-agents; it **requires** a `config.harness`: ## AGENTS.md Format -Free-form markdown. This becomes the agent's system prompt. Best practices: +Free-form markdown. This becomes the agent-authored portion of the system +prompt; Omnigent may append framework-owned lifecycle or metadata instructions +at runtime. Best practices: - Start with a clear identity statement ("You are a ...") - List capabilities and constraints diff --git a/omnigent/runner/app.py b/omnigent/runner/app.py index 2e36367fbb9..82794cea1d8 100644 --- a/omnigent/runner/app.py +++ b/omnigent/runner/app.py @@ -91,10 +91,28 @@ find_skill_by_name, format_skill_meta_text, ) +from omnigent.tools.builtins.session_rename import ( + session_rename_allowed_tools, + session_rename_instruction, +) _logger = logging.getLogger(__name__) +def _is_first_user_turn(history: list[dict[str, Any]]) -> bool: + """Return whether history contains one user message and no assistant reply.""" + user_messages = 0 + for item in history: + if item.get("type") != "message": + continue + role = item.get("role") + if role == "assistant": + return False + if role == "user": + user_messages += 1 + return user_messages == 1 + + # ── session.status "waiting" backwards-compat (new runner ↔ old server) ── # The runner emits ``session.status: "waiting"`` when a turn ends with sub-agents # still running (for the headless ``-p`` fast-exit). Servers older than @@ -3729,6 +3747,11 @@ async def _auto_create_codex_terminal( profile=_codex_launch.profile, extra_config_overrides=[*_codex_launch.config_overrides, *mcp_overrides], bridge_dir=bridge_dir, + developer_instructions=session_rename_instruction( + initial_session=( + launch_config.external_session_id is None and not launch_config.fork_carry_history + ) + ), ap_server_url=launch_config.policy_server_url, ap_auth_headers=policy_headers, bypass_sandbox=launch_config.bypass_sandbox, @@ -5808,6 +5831,12 @@ async def _auto_create_claude_terminal( agent_name=agent_name, skills_filter=skills_filter, api_key_helper=claude_config.api_key_helper if claude_config is not None else None, + append_system_prompt=session_rename_instruction( + initial_session=session_external_id is None and not fork_carry_history + ), + allowed_tools=session_rename_allowed_tools( + initial_session=session_external_id is None and not fork_carry_history + ), ) # Let a registered launcher plugin (e.g. Databricks' isaac) rewrite the @@ -13759,6 +13788,13 @@ async def _run_turn_bg_setup_and_stream( else cached_spec ) + if conv not in _session_histories: + _session_histories[conv] = await _load_history_as_input(conv) + rename_instruction = session_rename_instruction( + initial_session=_is_first_user_turn(_session_histories[conv]) + ) + framework_instructions = (rename_instruction,) if rename_instruction else () + harness_name: str | None = None spawn_env: dict[str, str] | None = None instructions: str | None = None @@ -13782,15 +13818,18 @@ async def _run_turn_bg_setup_and_stream( # readout). Forwarded by the Omnigent server in the message body. model_override=msg_body.get("model_override"), ) - from omnigent.runtime.prompt import ( - build_instructions, - ) + from omnigent.runtime.prompt import build_instructions instructions = build_instructions( cached_spec, None, [], + framework_instructions=framework_instructions, ) + elif framework_instructions: + from omnigent.runtime.prompt import append_framework_instructions + + instructions = append_framework_instructions(None, framework_instructions) ctx = TurnDispatch( agent_id=msg_body.get("agent_id"), @@ -13803,9 +13842,6 @@ async def _run_turn_bg_setup_and_stream( instructions=instructions, ) - if conv not in _session_histories: - _session_histories[conv] = await _load_history_as_input(conv) - harness_body: dict[str, Any] = { "type": "message", "role": "user", diff --git a/omnigent/runner/tool_dispatch.py b/omnigent/runner/tool_dispatch.py index 82eab1fda41..0a3647401c3 100644 --- a/omnigent/runner/tool_dispatch.py +++ b/omnigent/runner/tool_dispatch.py @@ -72,6 +72,7 @@ SysOsShellTool, SysOsWriteTool, ) +from omnigent.tools.builtins.session_rename import SysSessionRenameTool from omnigent.tools.builtins.spawn import ( # Shared contract values with the in-process sys_session_* tools. Imported # (not duplicated) so the runner's REST-backed peek clamps to the same @@ -232,6 +233,8 @@ class _SubagentInboxEvaluation: } ) +_SESSION_SELF_WRITE_TOOLS = frozenset({SysSessionRenameTool.name()}) + # Grantee sentinel for an anonymous, public read-only share. Mirrors the # server's RESERVED_USER_PUBLIC; only specs with # ``agent_session_sharing: public`` may grant it (enforced in @@ -366,6 +369,7 @@ class _SubagentInboxEvaluation: _NATIVE_RELAY_BUILTIN_TOOLS = ( _COMMENT_TOOLS | _SESSION_QUERY_TOOLS + | _SESSION_SELF_WRITE_TOOLS | _ASYNC_INBOX_TOOLS | _SUBAGENT_TOOLS | _LIST_MODELS_TOOLS @@ -455,6 +459,7 @@ def _append(function_dict: dict[str, Any]) -> None: SysSessionListTool, SysSessionGetHistoryTool, SysSessionGetInfoTool, + SysSessionRenameTool, SysAgentGetTool, SysAgentListTool, SysAgentDownloadTool, @@ -513,6 +518,7 @@ def _append(function_dict: dict[str, Any]) -> None: | _ADVISE_MODELS_TOOLS | _SESSION_CREATE_TOOLS | _SESSION_QUERY_TOOLS + | _SESSION_SELF_WRITE_TOOLS | _WEB_FETCH_TOOLS | _WEB_SEARCH_TOOLS | _HINDSIGHT_TOOLS @@ -3936,6 +3942,49 @@ async def _session_list_via_rest( return json.dumps({"sub_agents": sub_agents, "sessions": sessions}) +async def _rename_current_session_via_rest( + args: dict[str, Any], + conversation_id: str | None, + server_client: httpx.AsyncClient | None, +) -> str: + """Conditionally rename the calling session through the server API. + + Automatic naming is framework metadata, never a prerequisite for the + user's turn. Every failure therefore becomes a tool-result envelope so a + missing route, unavailable server, or malformed response cannot abort the + harness session. + """ + if server_client is None: + return json.dumps({"error": "sys_session_rename requires server access"}) + if conversation_id is None: + return json.dumps({"error": "sys_session_rename requires a session id"}) + title = args.get("title") + if not isinstance(title, str): + return json.dumps({"error": "sys_session_rename requires a string 'title'"}) + try: + response = await server_client.post( + f"/v1/sessions/{conversation_id}/auto-title", + json={"title": title}, + timeout=30.0, + ) + except Exception as exc: # noqa: BLE001 + return json.dumps({"error": f"sys_session_rename failed: {exc}"}) + if response.status_code >= 400: + return json.dumps( + { + "error": f"sys_session_rename returned {response.status_code}", + "detail": response.text[:200], + } + ) + try: + payload = response.json() + except ValueError as exc: + return json.dumps({"error": f"sys_session_rename returned invalid JSON: {exc}"}) + if not isinstance(payload, dict): + return json.dumps({"error": "sys_session_rename returned a non-object response"}) + return json.dumps(payload) + + async def _collect_sub_agents( conversation_id: str, server_client: httpx.AsyncClient, @@ -4533,6 +4582,12 @@ async def execute_tool( agent_spec=agent_spec, runner_workspace=runner_workspace, ) + elif tool_name in _SESSION_SELF_WRITE_TOOLS: + output = await _rename_current_session_via_rest( + args, + conversation_id, + server_client, + ) elif tool_name in _SESSION_QUERY_TOOLS: output = await _execute_session_query_tool( tool_name, diff --git a/omnigent/runtime/prompt.py b/omnigent/runtime/prompt.py index 5ef036fc710..af255d3de51 100644 --- a/omnigent/runtime/prompt.py +++ b/omnigent/runtime/prompt.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Sequence from typing import Any from omnigent.entities import ( @@ -14,10 +15,35 @@ from omnigent.spec import AgentSpec +def append_framework_instructions( + instructions: str | None, + framework_instructions: Sequence[str], +) -> str | None: + """Append framework-owned instructions to an existing system prompt. + + Keeps framework policy out of harness adapters while preserving a single + ordering rule: user-authored agent/request instructions first, framework + metadata instructions last. If framework instructions grow beyond a small + ordered string list, introduce a structured ``FrameworkInstructions`` value + here rather than adding lifecycle policy to ``AgentSpec`` or harness adapters. + + :param instructions: Existing composed system prompt, or ``None``. + :param framework_instructions: Additive framework instructions. + :returns: The combined prompt, or ``None`` when every input is empty. + """ + parts = [instructions] if instructions else [] + parts.extend( + instruction.strip() for instruction in framework_instructions if instruction.strip() + ) + return "\n\n".join(parts) if parts else None + + def build_instructions( spec: AgentSpec, per_request_instructions: str | None, tool_schemas: list[dict[str, Any]], + *, + framework_instructions: Sequence[str] = (), ) -> str: """ Build the system instructions string from the agent's @@ -33,6 +59,8 @@ def build_instructions( :param tool_schemas: OpenAI-format tool schemas (used only for future skill-awareness hinting; currently not included in the instructions body). + :param framework_instructions: Framework-owned additive instructions + for this turn, appended after user-authored agent/request instructions. :returns: The assembled instructions string. """ parts: list[str] = [] @@ -56,7 +84,11 @@ def build_instructions( skill_lines.append(f"- {skill.name}: {skill.description}") parts.append("\n".join(skill_lines)) - return "\n\n".join(parts) if parts else "You are a helpful assistant." + base_instructions = "\n\n".join(parts) if parts else "You are a helpful assistant." + return ( + append_framework_instructions(base_instructions, framework_instructions) + or base_instructions + ) def _strip_output_annotations( diff --git a/omnigent/server/routes/sessions.py b/omnigent/server/routes/sessions.py index a6afe8d5f12..90f67e6204e 100644 --- a/omnigent/server/routes/sessions.py +++ b/omnigent/server/routes/sessions.py @@ -208,6 +208,8 @@ from omnigent.server.routes._origin import require_trusted_origin from omnigent.server.schemas import ( AgentObject, + AutomaticSessionRenameRequest, + AutomaticSessionRenameResponse, BrowserActionRequestEvent, ChildSessionList, ChildSessionSummary, @@ -9104,7 +9106,9 @@ async def _dispatch_skill_slash_command_to_runner( return visible.id -def _title_content_from_item(item: NewConversationItem) -> list[dict[str, Any]]: +def _title_content_from_item( + item: NewConversationItem | ConversationItem, +) -> list[dict[str, Any]]: """ Extract title candidate content blocks from a session item. @@ -15927,6 +15931,65 @@ async def _discovery() -> None: # ── PATCH /sessions/{session_id} ──────────────────────────── + @router.post( + "/sessions/{session_id}/auto-title", + response_model=AutomaticSessionRenameResponse, + ) + async def automatically_rename_session( + request: Request, + session_id: str, + body: AutomaticSessionRenameRequest, + ) -> AutomaticSessionRenameResponse: + """Replace the deterministic first-message title when still current.""" + user_id = _get_user_id(request, auth_provider) + await _require_access( + user_id, + session_id, + LEVEL_EDIT, + permission_store, + conversation_store, + ) + conv = await asyncio.to_thread(conversation_store.get_conversation, session_id) + if conv is None: + raise OmnigentError("Session not found", code=ErrorCode.NOT_FOUND) + if conv.parent_conversation_id is not None: + return AutomaticSessionRenameResponse(renamed=False, reason="not_top_level") + + title = " ".join(body.title.split()) + if "\n" in body.title or "\r" in body.title or len(title) < 2: + raise OmnigentError( + "title must be a single non-empty line", + code=ErrorCode.INVALID_INPUT, + ) + + page = await asyncio.to_thread( + conversation_store.list_items, + session_id, + 100, + None, + None, + "asc", + None, + ) + seed_title: str | None = None + for item in page.data: + seed_title = synthesize_conversation_title(_title_content_from_item(item)) + if seed_title is not None: + break + if seed_title is None: + return AutomaticSessionRenameResponse(renamed=False, reason="no_seed") + if conv.title != seed_title: + return AutomaticSessionRenameResponse(renamed=False, reason="title_changed") + updated = await asyncio.to_thread( + conversation_store.rename_conversation_if_title_matches, + session_id, + seed_title, + title, + ) + if updated is None: + return AutomaticSessionRenameResponse(renamed=False, reason="title_changed") + return AutomaticSessionRenameResponse(renamed=True, title=updated.title) + @router.patch( "/sessions/{session_id}", response_model=None, diff --git a/omnigent/server/schemas.py b/omnigent/server/schemas.py index 1ece8c08dbb..f63b9c93c5d 100644 --- a/omnigent/server/schemas.py +++ b/omnigent/server/schemas.py @@ -1923,6 +1923,22 @@ class UpdateSessionRequest(BaseModel): model_config = ConfigDict(extra="forbid") +class AutomaticSessionRenameRequest(BaseModel): + """Request body for the current-agent automatic rename endpoint.""" + + title: str = Field(min_length=2, max_length=60) + + model_config = ConfigDict(extra="forbid") + + +class AutomaticSessionRenameResponse(BaseModel): + """Result of a conditional automatic session rename.""" + + renamed: bool + title: str | None = None + reason: Literal["not_top_level", "no_seed", "title_changed"] | None = None + + class CodexGoalObject(BaseModel): """ Current Codex goal state for a Codex-native session. diff --git a/omnigent/spec/AGENTSPEC.md b/omnigent/spec/AGENTSPEC.md index e327effdb00..66db481c8d5 100644 --- a/omnigent/spec/AGENTSPEC.md +++ b/omnigent/spec/AGENTSPEC.md @@ -193,6 +193,11 @@ Not machine-parsed — the entire contents (file or inline) are passed to the model as instructions. Optional; if absent, the model receives no agent-level system prompt (per-request `instructions` from the API still apply). +This is the portable, user-authored portion of the system prompt. At runtime, +Omnigent may append small framework-owned lifecycle or metadata instructions +after the agent-level and per-request instructions. Those additions are not +part of `AgentSpec` and must not be encoded into an agent image. + --- ## Skills — `skills//SKILL.md` diff --git a/omnigent/stores/conversation_store/__init__.py b/omnigent/stores/conversation_store/__init__.py index 47651e294ef..8c6020924a1 100644 --- a/omnigent/stores/conversation_store/__init__.py +++ b/omnigent/stores/conversation_store/__init__.py @@ -745,6 +745,23 @@ def update_conversation( """ ... + @abstractmethod + def rename_conversation_if_title_matches( + self, + conversation_id: str, + expected_title: str, + title: str, + ) -> Conversation | None: + """Rename a conversation only while its current title matches. + + :param conversation_id: Conversation to update. + :param expected_title: Title that must still be stored. + :param title: Replacement title. + :returns: The updated conversation, or ``None`` when the row is + missing or its title changed before this call. + """ + ... + @abstractmethod def set_labels( self, diff --git a/omnigent/stores/conversation_store/sqlalchemy_store.py b/omnigent/stores/conversation_store/sqlalchemy_store.py index fa2d1fb9db0..d96741fcf5b 100644 --- a/omnigent/stores/conversation_store/sqlalchemy_store.py +++ b/omnigent/stores/conversation_store/sqlalchemy_store.py @@ -2567,6 +2567,27 @@ def update_conversation( meta.terminal_launch_args = json.dumps(terminal_launch_args) return self.get_conversation(conversation_id) + def rename_conversation_if_title_matches( + self, + conversation_id: str, + expected_title: str, + title: str, + ) -> Conversation | None: + """Rename a conversation with an atomic title compare-and-swap.""" + with self._conv_session() as session: + result = session.execute( + update(SqlConversation) + .where( + SqlConversation.workspace_id == current_workspace_id(), + SqlConversation.id == conversation_id, + SqlConversation.title == expected_title, + ) + .values(title=title, updated_at=now_epoch()) + ) + if result.rowcount != 1: + return None + return self.get_conversation(conversation_id) + def set_runner_id(self, conversation_id: str, runner_id: str) -> bool: """ Pin a conversation to a runner via atomic diff --git a/omnigent/tools/builtins/__init__.py b/omnigent/tools/builtins/__init__.py index 9d67b5bc21b..b5c793e232c 100644 --- a/omnigent/tools/builtins/__init__.py +++ b/omnigent/tools/builtins/__init__.py @@ -47,6 +47,7 @@ from omnigent.tools.builtins.read_skill_file import ( ReadSkillFileTool, ) +from omnigent.tools.builtins.session_rename import SysSessionRenameTool from omnigent.tools.builtins.spawn import ( SysSessionCloseTool, SysSessionCreateTool, @@ -82,6 +83,7 @@ "SysSessionGetHistoryTool", "SysSessionGetInfoTool", "SysSessionListTool", + "SysSessionRenameTool", "SysSessionSendTool", "SysSessionShareTool", "SysTimerCancelTool", diff --git a/omnigent/tools/builtins/session_rename.py b/omnigent/tools/builtins/session_rename.py new file mode 100644 index 00000000000..826db80ce57 --- /dev/null +++ b/omnigent/tools/builtins/session_rename.py @@ -0,0 +1,104 @@ +"""Framework-owned tool for renaming the current session.""" + +from __future__ import annotations + +from typing import Any + +from omnigent.tools.base import Tool + +CLAUDE_NATIVE_SESSION_RENAME_TOOL = "mcp__omnigent__sys_session_rename" + +SESSION_RENAME_INSTRUCTION = """ +Omnigent creates each session with its title set to the user's full prompt verbatim. On the +FIRST turn, before doing any other work or replying, call sys_session_rename with a short +summary-style title (3-6 words, ≤60 characters, action-first). Strip filler; keep the noun + verb. +Summarize the user's actual intent; do not copy a conversational prompt verbatim or use generic +titles such as "Help with task", "Create new design", or "Answer question". + + prompt: "Could you please help me figure out why my React app is re-rendering twice on + every state change?" + title: "Debug double React re-render" + + prompt: "What should we work on today?" + title: "Plan today's priorities" + +Every fresh session must call sys_session_rename, including when the prompt is short or already +resembles a finished title. Questions, greetings, brainstorming openers, and requests for help +must also be renamed. Resumed sessions skip it. If your harness defers tools, load +sys_session_rename with its tool-discovery mechanism first. In Claude Code, use ToolSearch with +the exact query +select:mcp__omnigent__sys_session_rename; if it reports that the omnigent server is still +connecting, repeat that exact search rather than switching to a semantic query or giving up. +In Claude SDK, invoke mcp__omnigent__sys_session_rename directly. The call is silent; the user +only sees the title change. If the tool is unavailable after the server finishes connecting, +declines the rename, or returns an error, continue the user's turn normally. +""".strip() + + +def session_rename_allowed_tools(*, initial_session: bool) -> tuple[str, ...]: + """Return native Claude tools preapproved for automatic session metadata. + + :param initial_session: Whether this is the session's initial model context. + :returns: A scoped allowlist containing only the rename tool for fresh sessions. + """ + return (CLAUDE_NATIVE_SESSION_RENAME_TOOL,) if initial_session else () + + +def session_rename_instruction(*, initial_session: bool) -> str | None: + """Return the rename directive when the caller identifies an initial session. + + The shared runner derives ``initial_session`` from persisted message history. + Native launchers derive it from the absence of a resumed external session or + carried fork history. Keeping the selection here gives both layers one + canonical gate while allowing each to use the state it owns. + + :param initial_session: Whether this is the session's initial model context. + :returns: The rename instruction for an initial session, otherwise ``None``. + """ + return SESSION_RENAME_INSTRUCTION if initial_session else None + + +class SysSessionRenameTool(Tool): + """Schema-only tool that renames the calling session.""" + + @classmethod + def name(cls) -> str: + """Return the tool name.""" + return "sys_session_rename" + + @classmethod + def description(cls) -> str: + """Return the LLM-facing description.""" + return ( + "Rename the current top-level session with a short summary-style title " + "(3-6 words, action-first). Strip filler and keep the noun plus verb. " + "Never copy a conversational question or greeting verbatim. " + "This is silent framework startup metadata; the rename is ignored if the " + "title changed." + ) + + def get_schema(self) -> dict[str, Any]: + """Return the OpenAI-format schema.""" + return { + "type": "function", + "function": { + "name": self.name(), + "description": self.description(), + "parameters": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": ( + "Short summary-style, action-first session title, for " + "example 'Debug authentication timeout'." + ), + "minLength": 2, + "maxLength": 60, + } + }, + "required": ["title"], + "additionalProperties": False, + }, + }, + } diff --git a/omnigent/tools/manager.py b/omnigent/tools/manager.py index 8080896bf96..f103479049f 100644 --- a/omnigent/tools/manager.py +++ b/omnigent/tools/manager.py @@ -35,6 +35,7 @@ SysSessionGetHistoryTool, SysSessionGetInfoTool, SysSessionListTool, + SysSessionRenameTool, SysSessionSendTool, SysSessionShareTool, SysTimerCancelTool, @@ -152,6 +153,7 @@ def __init__( self._register_skill_tools() self._register_builtin_tools() self._register_sub_agent_tools() + self._register_session_tools() self._register_agent_mgmt_tools() self._register_os_env_tools() self._register_terminal_tools() @@ -473,6 +475,10 @@ def _register_sub_agent_tools(self) -> None: if self._spec.spawn: self._tools[SysSessionCreateTool.name()] = SysSessionCreateTool() + def _register_session_tools(self) -> None: + """Register framework-owned tools for the current session.""" + self._tools[SysSessionRenameTool.name()] = SysSessionRenameTool() + def _register_agent_mgmt_tools(self) -> None: """ Register the read-only ``sys_agent_*`` discovery tools. diff --git a/openapi.json b/openapi.json index 66867352b20..9e15805414a 100644 --- a/openapi.json +++ b/openapi.json @@ -165,6 +165,64 @@ "title": "AgentObject", "type": "object" }, + "AutomaticSessionRenameRequest": { + "additionalProperties": false, + "description": "Request body for the current-agent automatic rename endpoint.", + "properties": { + "title": { + "maxLength": 60, + "minLength": 2, + "title": "Title", + "type": "string" + } + }, + "required": [ + "title" + ], + "title": "AutomaticSessionRenameRequest", + "type": "object" + }, + "AutomaticSessionRenameResponse": { + "description": "Result of a conditional automatic session rename.", + "properties": { + "reason": { + "anyOf": [ + { + "enum": [ + "not_top_level", + "no_seed", + "title_changed" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + }, + "renamed": { + "title": "Renamed", + "type": "boolean" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + } + }, + "required": [ + "renamed" + ], + "title": "AutomaticSessionRenameResponse", + "type": "object" + }, "Body_update_session_agent_v1_sessions__session_id__agent_put": { "properties": { "bundle": { @@ -8238,6 +8296,59 @@ ] } }, + "/v1/sessions/{session_id}/auto-title": { + "post": { + "description": "Replace the deterministic first-message title when still current.", + "operationId": "automatically_rename_session_v1_sessions__session_id__auto_title_post", + "parameters": [ + { + "in": "path", + "name": "session_id", + "required": true, + "schema": { + "title": "Session Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomaticSessionRenameRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomaticSessionRenameResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Automatically Rename Session", + "tags": [ + "sessions" + ] + } + }, "/v1/sessions/{session_id}/child_sessions": { "get": { "description": "List sub-agent (child) sessions under a parent session.\n\nReturns a page of `ChildSessionSummary` objects\nderived from child conversations (`kind=\"sub_agent\"`,\n`parent_conversation_id=session_id`) plus each child's\nlatest task. Powers the web / REPL debug surfaces' \"child\nsessions\" panel without parsing parent\n`function_call_output` JSON handles. Pagination contract\nmatches `list_session_items` so existing client code\ncan reuse the same cursor logic.\n\n**Returns:** A `PaginatedList` of `ChildSessionSummary` objects.\n\n**Raises**\n\n- `OmnigentError` \u2014 403 if the caller lacks READ on `session_id`; 404 if no session exists there.", diff --git a/tests/e2e/test_repl_approval_e2e.py b/tests/e2e/test_repl_approval_e2e.py index 6f37cf29457..e19ef0fd8bd 100644 --- a/tests/e2e/test_repl_approval_e2e.py +++ b/tests/e2e/test_repl_approval_e2e.py @@ -1140,8 +1140,10 @@ def test_repl_label_driven_ask_approves( ) child.send("y" + "\r") child.expect("approved", timeout=5) - # Turn 2 completes — LLM replies normally. - _wait_for_turn_complete(child, timeout=45) + # Sync on the scripted reply rather than the cosmetic `· ready` + # idle marker, which can fail to render under CI load even after + # the turn has completed. + child.expect("Continuing as requested", timeout=45) finally: try: child.send("/quit" + "\r") diff --git a/tests/inner/test_claude_sdk_executor.py b/tests/inner/test_claude_sdk_executor.py index 5876bb6394e..3e5c01610a1 100644 --- a/tests/inner/test_claude_sdk_executor.py +++ b/tests/inner/test_claude_sdk_executor.py @@ -1809,6 +1809,101 @@ async def _t(): _run(_t()) + def test_session_rename_tool_uses_exact_sdk_mcp_name(self): + from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor + + captured_options = {} + + class _ResultMessage: + def __init__(self, session_id, result): + self.session_id = session_id + self.result = result + + class _FakeSDK: + AssistantMessage = type("AssistantMessage", (), {}) + UserMessage = type("UserMessage", (), {}) + SystemMessage = type("SystemMessage", (), {}) + ResultMessage = _ResultMessage + StreamEvent = type("StreamEvent", (), {}) + ClaudeAgentOptions = type( + "ClaudeAgentOptions", + (), + {"__init__": lambda self, **kwargs: self.__dict__.update(kwargs)}, + ) + messages = [] + + @staticmethod + def tool(name, desc, params): + def decorator(handler): + return type( + "Tool", + (), + { + "name": name, + "description": desc, + "parameters": params, + "handler": handler, + }, + )() + + return decorator + + @staticmethod + def create_sdk_mcp_server(**kwargs): + return kwargs + + class ClaudeSDKClient: + def __init__(self, options): + captured_options["allowed_tools"] = getattr(options, "allowed_tools", None) + captured_options["system_prompt"] = getattr(options, "system_prompt", None) + + async def connect(self): + return None + + async def query(self, prompt, session_id="default"): + _FakeSDK.messages = [_ResultMessage(session_id, "done")] + + async def receive_response(self): + for message in _FakeSDK.messages: + yield message + + async def disconnect(self): + return None + + async def _t(): + executor = ClaudeSDKExecutor() + with patch("omnigent.inner.claude_sdk_executor._ensure_sdk", return_value=_FakeSDK): + events = [ + event + async for event in executor.run_turn( + [{"role": "user", "content": "hi", "session_id": "session-a"}], + [ + { + "name": "sys_session_rename", + "description": "Rename current session", + "parameters": { + "type": "object", + "properties": {"title": {"type": "string"}}, + "required": ["title"], + }, + } + ], + "Call `sys_session_rename` before replying.", + ) + ] + self.assertIn( + "mcp__omnigent__sys_session_rename", + captured_options["allowed_tools"], + ) + self.assertIn( + "use `mcp__omnigent__sys_session_rename` when instructions say " + "`sys_session_rename`", + captured_options["system_prompt"], + ) + self.assertIsInstance(events[-1], TurnComplete) + + _run(_t()) + def test_crashed_session_refuses_future_turns(self): from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor diff --git a/tests/inner/test_codex_native_executor.py b/tests/inner/test_codex_native_executor.py index 5fd33041b21..8c3b3c66a59 100644 --- a/tests/inner/test_codex_native_executor.py +++ b/tests/inner/test_codex_native_executor.py @@ -193,6 +193,54 @@ def test_web_started_codex_turn_returns_without_waiting_for_terminal_event( ] +def test_system_prompt_does_not_override_collaboration_mode( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Native startup config owns system prompts; turns preserve Codex defaults.""" + from omnigent.tools.builtins.session_rename import SESSION_RENAME_INSTRUCTION + + _FakeCodexNativeClient.requests = [] + _FakeCodexNativeClient.created = [] + _FakeCodexNativeClient.next_turn = 1 + monkeypatch.setattr( + "omnigent.codex_native_app_server.CodexAppServerClient", + _FakeCodexNativeClient, + ) + write_bridge_state( + tmp_path, + CodexNativeBridgeState( + session_id="conv_123", + socket_path=str(tmp_path / "app-server.sock"), + thread_id="thread_123", + codex_home=str(tmp_path / "codex-home"), + active_turn_id=None, + ), + ) + executor = CodexNativeExecutor(bridge_dir=tmp_path) + + async def run() -> None: + async for _event in executor.run_turn( + [{"role": "user", "content": [{"type": "input_text", "text": "hello"}]}], + [], + SESSION_RENAME_INSTRUCTION, + None, + ): + pass + + asyncio.run(run()) + + assert _FakeCodexNativeClient.requests == [ + ( + "turn/start", + { + "threadId": "thread_123", + "input": [{"type": "text", "text": "hello"}], + }, + ), + ] + + def test_image_block_is_sent_as_local_image_not_inline_base64( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/tests/runner/test_app_sessions_native.py b/tests/runner/test_app_sessions_native.py index 5e3db951792..b0ab3fb42aa 100644 --- a/tests/runner/test_app_sessions_native.py +++ b/tests/runner/test_app_sessions_native.py @@ -2055,6 +2055,7 @@ async def _fake_forward_known_thread(**kwargs: Any) -> None: assert app_server.codex_home == expected_codex_home assert build_calls[0]["model"] == "gpt-5.4-mini" assert build_calls[0]["cwd"] == tmp_path / "workspace" + assert build_calls[0]["developer_instructions"] is None assert len(launched_specs) == 1 launched = launched_specs[0] assert launched.command == "/opt/codex/bin/codex" @@ -2850,6 +2851,9 @@ async def launch_auxiliary_terminal( "mean the session snapshot workspace was ignored." ) assert build_calls[0]["cwd"] != bundle_dir.resolve() # never the spec-bundle dir + from omnigent.tools.builtins.session_rename import SESSION_RENAME_INSTRUCTION + + assert build_calls[0]["developer_instructions"] == SESSION_RENAME_INSTRUCTION # Sandbox-override regression: the launched Codex terminal must inherit # the agent's sandbox: none rather than falling back to the platform @@ -14235,6 +14239,10 @@ async def launch_required_terminal( # early no longer cascades into "no server running" (#540). assert spec.keep_alive_after_exit is True args = spec.args + from omnigent.tools.builtins.session_rename import SESSION_RENAME_INSTRUCTION + + prompt_index = args.index("--append-system-prompt") + assert args[prompt_index + 1] == SESSION_RENAME_INSTRUCTION settings = json.loads(args[args.index("--settings") + 1]) assert "PermissionRequest" in settings["hooks"] permission_hook = settings["hooks"]["PermissionRequest"][0]["hooks"][0] @@ -15320,6 +15328,8 @@ def json(self) -> dict[str, Any]: return _SnapResponse() + launched_args: list[str] = [] + class _FakeResourceRegistry: """Resource registry that returns a terminal without launching.""" @@ -15336,7 +15346,8 @@ async def launch_required_terminal( parent_os_env: Any = None, ) -> SessionResourceView: """Return a terminal resource view without spawning a TTY.""" - del terminal_name, session_key, spec + del terminal_name, session_key + launched_args.extend(spec.args) return SessionResourceView( id="terminal_claude_main", type="terminal", @@ -15373,8 +15384,10 @@ async def launch_required_terminal( # snapshot carried an external session id. if snapshot_external_id is None: assert synth_calls == [] + assert "--append-system-prompt" in launched_args else: assert synth_calls == [snapshot_external_id] + assert "--append-system-prompt" not in launched_args @pytest.mark.asyncio diff --git a/tests/runner/test_auto_title_instruction.py b/tests/runner/test_auto_title_instruction.py new file mode 100644 index 00000000000..3c0b5b767e2 --- /dev/null +++ b/tests/runner/test_auto_title_instruction.py @@ -0,0 +1,55 @@ +"""Tests for first-turn automatic-title instruction gating.""" + +from omnigent.runner.app import _is_first_user_turn +from omnigent.tools.builtins.session_rename import ( + CLAUDE_NATIVE_SESSION_RENAME_TOOL, + SESSION_RENAME_INSTRUCTION, + session_rename_allowed_tools, + session_rename_instruction, +) + + +def test_first_user_turn_requires_one_user_message_and_no_assistant() -> None: + first = [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "hello"}], + } + ] + with_metadata = [{"type": "error"}, *first] + replied = [*first, {"type": "message", "role": "assistant", "content": []}] + second_user = [*first, {"type": "message", "role": "user", "content": []}] + + assert _is_first_user_turn(first) is True + assert _is_first_user_turn(with_metadata) is True + assert _is_first_user_turn(replied) is False + assert _is_first_user_turn(second_user) is False + assert "sys_session_rename" in SESSION_RENAME_INSTRUCTION + assert "3-6 words" in SESSION_RENAME_INSTRUCTION + assert "Strip filler; keep the noun + verb" in SESSION_RENAME_INSTRUCTION + assert 'title: "Debug double React re-render"' in SESSION_RENAME_INSTRUCTION + assert "ToolSearch" in SESSION_RENAME_INSTRUCTION + + +def test_session_rename_instruction_uses_shared_initial_session_gate() -> None: + """History and native launch paths share one instruction selector.""" + assert session_rename_instruction(initial_session=True) == SESSION_RENAME_INSTRUCTION + assert session_rename_instruction(initial_session=False) is None + + +def test_session_rename_instruction_requires_every_fresh_session() -> None: + """Fresh sessions rename even when the prompt already resembles a title.""" + assert 'prompt: "What should we work on today?"' in SESSION_RENAME_INSTRUCTION + assert 'title: "Plan today\'s priorities"' in SESSION_RENAME_INSTRUCTION + assert "Every fresh session must call sys_session_rename" in SESSION_RENAME_INSTRUCTION + assert "resembles a finished title" in SESSION_RENAME_INSTRUCTION + assert "Skip sys_session_rename only" not in SESSION_RENAME_INSTRUCTION + + +def test_session_rename_allowed_tools_are_fresh_session_only() -> None: + """Claude preapproves only the silent metadata tool on fresh sessions.""" + assert session_rename_allowed_tools(initial_session=True) == ( + CLAUDE_NATIVE_SESSION_RENAME_TOOL, + ) + assert session_rename_allowed_tools(initial_session=False) == () diff --git a/tests/runner/test_comment_relay.py b/tests/runner/test_comment_relay.py index 338707983a4..81633f63a91 100644 --- a/tests/runner/test_comment_relay.py +++ b/tests/runner/test_comment_relay.py @@ -370,6 +370,7 @@ async def test_terminal_launch_with_bridge_inject_advertises_comment_tools( "sys_session_list", "sys_session_get_history", "sys_session_get_info", + "sys_session_rename", "sys_agent_list", "sys_agent_get", "sys_agent_download", diff --git a/tests/runner/test_session_rename_dispatch.py b/tests/runner/test_session_rename_dispatch.py new file mode 100644 index 00000000000..e87844d2719 --- /dev/null +++ b/tests/runner/test_session_rename_dispatch.py @@ -0,0 +1,132 @@ +"""Runner dispatch and native-relay coverage for session renaming.""" + +from __future__ import annotations + +import json + +import httpx +import pytest + +from omnigent.runner.tool_dispatch import ( + build_native_relay_tool_schemas, + dispatch_tool_locally, + execute_tool, +) +from omnigent.spec.types import AgentSpec + + +@pytest.mark.parametrize("spec", [AgentSpec(spec_version=1), None]) +def test_native_relay_exposes_session_rename(spec: AgentSpec | None) -> None: + schemas = build_native_relay_tool_schemas(spec) + + rename = next(schema for schema in schemas if schema["name"] == "sys_session_rename") + + assert rename["parameters"]["required"] == ["title"] + assert rename["parameters"]["additionalProperties"] is False + + +@pytest.mark.asyncio +async def test_session_rename_dispatches_to_current_session() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + json={"renamed": True, "title": "Debug auth timeout", "reason": None}, + ) + + async with httpx.AsyncClient( + transport=httpx.MockTransport(handler), + base_url="http://server", + ) as server_client: + output = await execute_tool( + tool_name="sys_session_rename", + arguments=json.dumps({"title": "Debug auth timeout"}), + server_client=server_client, + conversation_id="conv_current", + agent_spec=AgentSpec(spec_version=1), + ) + + assert json.loads(output) == { + "renamed": True, + "title": "Debug auth timeout", + "reason": None, + } + assert len(requests) == 1 + assert requests[0].method == "POST" + assert requests[0].url.path == "/v1/sessions/conv_current/auto-title" + assert json.loads(requests[0].content) == {"title": "Debug auth timeout"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("response", "expected_error"), + [ + (httpx.Response(503, text="server unavailable"), "returned 503"), + (httpx.Response(200, text="not-json"), "returned invalid JSON"), + (httpx.Response(200, json=["unexpected"]), "returned a non-object response"), + ], +) +async def test_session_rename_server_failures_are_tool_results( + response: httpx.Response, + expected_error: str, +) -> None: + """Rename metadata failures never escape into the active session turn.""" + + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _request: response), + base_url="http://server", + ) as server_client: + output = await execute_tool( + tool_name="sys_session_rename", + arguments=json.dumps({"title": "Debug auth timeout"}), + server_client=server_client, + conversation_id="conv_current", + agent_spec=AgentSpec(spec_version=1), + ) + + assert expected_error in json.loads(output)["error"] + + +@pytest.mark.asyncio +async def test_session_rename_transport_failure_is_delivered_to_harness() -> None: + """A failed rename still resolves the harness tool call so the turn continues.""" + delivered: list[dict[str, object]] = [] + + def server_handler(_request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("server unavailable") + + def harness_handler(request: httpx.Request) -> httpx.Response: + delivered.append(json.loads(request.content)) + return httpx.Response(200, json={"ok": True}) + + async with ( + httpx.AsyncClient( + transport=httpx.MockTransport(server_handler), + base_url="http://server", + ) as server_client, + httpx.AsyncClient( + transport=httpx.MockTransport(harness_handler), + base_url="http://harness", + ) as harness_client, + ): + output = await dispatch_tool_locally( + tool_name="sys_session_rename", + call_id="call_rename", + arguments=json.dumps({"title": "Debug auth timeout"}), + response_id="response_1", + harness_client=harness_client, + server_client=server_client, + conversation_id="conv_current", + agent_spec=AgentSpec(spec_version=1), + ) + + assert "sys_session_rename failed" in json.loads(output)["error"] + assert delivered == [ + { + "type": "tool_result", + "call_id": "call_rename", + "output": output, + } + ] diff --git a/tests/runtime/test_prompt.py b/tests/runtime/test_prompt.py new file mode 100644 index 00000000000..91fbd94b7eb --- /dev/null +++ b/tests/runtime/test_prompt.py @@ -0,0 +1,32 @@ +"""Tests for canonical system-instruction composition.""" + +from types import SimpleNamespace +from typing import cast + +from omnigent.runtime.prompt import append_framework_instructions, build_instructions +from omnigent.spec import AgentSpec + + +def test_framework_instructions_append_after_custom_prompts() -> None: + spec = cast(AgentSpec, SimpleNamespace(instructions="Agent prompt", skills=[])) + + result = build_instructions( + spec, + "Request prompt", + [], + framework_instructions=(" Framework prompt ",), + ) + + assert result == "Agent prompt\n\nRequest prompt\n\nFramework prompt" + + +def test_empty_framework_instructions_do_not_change_default() -> None: + spec = cast(AgentSpec, SimpleNamespace(instructions=None, skills=[])) + + assert build_instructions(spec, None, [], framework_instructions=("", " ")) == ( + "You are a helpful assistant." + ) + + +def test_framework_only_instructions_use_shared_composer() -> None: + assert append_framework_instructions(None, ("Rename session",)) == "Rename session" diff --git a/tests/server/integration/test_sessions_endpoints.py b/tests/server/integration/test_sessions_endpoints.py index 27c850e3c30..709b422d92c 100644 --- a/tests/server/integration/test_sessions_endpoints.py +++ b/tests/server/integration/test_sessions_endpoints.py @@ -1431,6 +1431,154 @@ async def test_patch_session_updates_labels( assert labels["b"] == "2" +async def test_auto_title_replaces_only_the_deterministic_seed( + client: httpx.AsyncClient, +) -> None: + agent = await create_test_agent(client) + session = await _create_session(client, agent["id"]) + seeded = await client.post( + f"/v1/sessions/{session['id']}/events", + json={ + "type": "external_conversation_item", + "data": { + "item_type": "message", + "item_data": { + "role": "user", + "content": [ + { + "type": "input_text", + "text": "please investigate the authentication timeout in production", + } + ], + }, + }, + }, + ) + assert seeded.status_code == 202, seeded.text + + renamed = await client.post( + f"/v1/sessions/{session['id']}/auto-title", + json={"title": "Debug authentication timeout"}, + ) + assert renamed.status_code == 200, renamed.text + assert renamed.json() == { + "renamed": True, + "title": "Debug authentication timeout", + "reason": None, + } + + manual = await client.patch( + f"/v1/sessions/{session['id']}", + json={"title": "My manual title"}, + ) + assert manual.status_code == 200, manual.text + declined = await client.post( + f"/v1/sessions/{session['id']}/auto-title", + json={"title": "Overwrite manual title"}, + ) + assert declined.status_code == 200, declined.text + assert declined.json() == { + "renamed": False, + "title": None, + "reason": "title_changed", + } + + +async def test_auto_title_does_not_replace_explicit_title( + client: httpx.AsyncClient, +) -> None: + agent = await create_test_agent(client) + session = await _create_session(client, agent["id"], title="Keep this title") + seeded = await client.post( + f"/v1/sessions/{session['id']}/events", + json={ + "type": "external_conversation_item", + "data": { + "item_type": "message", + "item_data": { + "role": "user", + "content": [ + {"type": "input_text", "text": "investigate the authentication timeout"} + ], + }, + }, + }, + ) + assert seeded.status_code == 202, seeded.text + + response = await client.post( + f"/v1/sessions/{session['id']}/auto-title", + json={"title": "Debug authentication timeout"}, + ) + assert response.status_code == 200, response.text + assert response.json()["renamed"] is False + assert response.json()["reason"] == "title_changed" + + +async def test_auto_title_declines_when_no_seed_exists( + client: httpx.AsyncClient, +) -> None: + agent = await create_test_agent(client) + session = await _create_session(client, agent["id"]) + + response = await client.post( + f"/v1/sessions/{session['id']}/auto-title", + json={"title": "Debug authentication timeout"}, + ) + + assert response.status_code == 200, response.text + assert response.json() == { + "renamed": False, + "title": None, + "reason": "no_seed", + } + + +async def test_auto_title_declines_child_sessions( + client: httpx.AsyncClient, + db_uri: str, +) -> None: + agent = await create_test_agent(client) + parent = await _create_session(client, agent["id"]) + store = SqlAlchemyConversationStore(db_uri) + child = store.create_conversation( + kind="sub_agent", + title="coder:debug-auth", + parent_conversation_id=parent["id"], + agent_id=agent["id"], + ) + + response = await client.post( + f"/v1/sessions/{child.id}/auto-title", + json={"title": "Debug authentication timeout"}, + ) + + assert response.status_code == 200, response.text + assert response.json() == { + "renamed": False, + "title": None, + "reason": "not_top_level", + } + + +async def test_auto_title_rejects_multiline_titles( + client: httpx.AsyncClient, +) -> None: + agent = await create_test_agent(client) + session = await _create_session( + client, + agent["id"], + initial_message="investigate the authentication timeout", + ) + + response = await client.post( + f"/v1/sessions/{session['id']}/auto-title", + json={"title": "Debug authentication\ntimeout"}, + ) + + assert response.status_code == 400, response.text + + async def test_patch_session_archive_hides_from_default_list( client: httpx.AsyncClient, ) -> None: diff --git a/tests/stores/test_conversation_store.py b/tests/stores/test_conversation_store.py index cf86939a185..acb1994cb99 100644 --- a/tests/stores/test_conversation_store.py +++ b/tests/stores/test_conversation_store.py @@ -76,6 +76,28 @@ def test_get_nonexistent(conversation_store: SqlAlchemyConversationStore) -> Non assert conversation_store.get_conversation("c55a64c3f6f954fe0fc8738ba3f45f26") is None +def test_rename_conversation_if_title_matches_is_atomic( + conversation_store: SqlAlchemyConversationStore, +) -> None: + conv = conversation_store.create_conversation(title="original request title") + + renamed = conversation_store.rename_conversation_if_title_matches( + conv.id, + "original request title", + "Debug request timeout", + ) + stale = conversation_store.rename_conversation_if_title_matches( + conv.id, + "original request title", + "Overwrite manual title", + ) + + assert renamed is not None + assert renamed.title == "Debug request timeout" + assert stale is None + assert conversation_store.get_conversation(conv.id).title == "Debug request timeout" # type: ignore[union-attr] + + def test_get_conversations_bulk( conversation_store: SqlAlchemyConversationStore, ) -> None: diff --git a/tests/test_claude_native.py b/tests/test_claude_native.py index bba4863cc27..26c495ce8f1 100644 --- a/tests/test_claude_native.py +++ b/tests/test_claude_native.py @@ -4250,6 +4250,8 @@ async def _fake_launch_claude_terminal( command: str, bridge_dir: Path, claude_config: claude_native.ClaudeNativeUcodeConfig | None = None, + append_system_prompt: str | None = None, + allowed_tools: tuple[str, ...] = (), ) -> str: """ Capture the launch args without invoking the real runner. @@ -4266,6 +4268,8 @@ async def _fake_launch_claude_terminal( """ captured_terminal_args["session_id"] = session_id captured_terminal_args["claude_args"] = claude_args + captured_terminal_args["append_system_prompt"] = append_system_prompt + captured_terminal_args["allowed_tools"] = allowed_tools del command, bridge_dir, claude_config return "terminal_claude_main" @@ -4333,6 +4337,8 @@ async def _fake_launch_claude_terminal( "--print", "hello", ) + assert captured_terminal_args["append_system_prompt"] is None + assert captured_terminal_args["allowed_tools"] == () # Load-bearing for the duplicate-message bug: cold resume # MUST set ``cold_resumed=True`` so the transcript forwarder seeks @@ -4393,8 +4399,17 @@ async def _fake_launch_claude_terminal( command: str, bridge_dir: Path, claude_config: claude_native.ClaudeNativeUcodeConfig | None = None, + append_system_prompt: str | None = None, + allowed_tools: tuple[str, ...] = (), ) -> str: """Return a fixed terminal id without spawning anything.""" + from omnigent.tools.builtins.session_rename import ( + CLAUDE_NATIVE_SESSION_RENAME_TOOL, + SESSION_RENAME_INSTRUCTION, + ) + + assert append_system_prompt == SESSION_RENAME_INSTRUCTION + assert allowed_tools == (CLAUDE_NATIVE_SESSION_RENAME_TOOL,) del _client, _session_id, _claude_args, command, bridge_dir, claude_config return "terminal_claude_main" diff --git a/tests/test_claude_native_bridge.py b/tests/test_claude_native_bridge.py index 0e5f3359d4f..e11928690b7 100644 --- a/tests/test_claude_native_bridge.py +++ b/tests/test_claude_native_bridge.py @@ -2085,6 +2085,53 @@ def test_augment_claude_args_uses_last_repeated_launch_override( assert settings["effortLevel"] == "high" +def test_augment_claude_args_appends_caller_system_prompt( + tmp_path: Path, +) -> None: + """Claude native appends framework instructions supplied by its launcher.""" + from omnigent.tools.builtins.session_rename import SESSION_RENAME_INSTRUCTION + + args = augment_claude_args( + (), + bridge_dir=tmp_path, + python_executable="/venv/bin/python", + append_system_prompt=SESSION_RENAME_INSTRUCTION, + ) + + assert args.count("--append-system-prompt") == 1 + index = args.index("--append-system-prompt") + assert args[index + 1] == SESSION_RENAME_INSTRUCTION + + +def test_augment_claude_args_merges_caller_allowed_tools(tmp_path: Path) -> None: + """Framework preapproval extends rather than replaces the user's allowlist.""" + args = augment_claude_args( + ("--allowedTools", "Bash,mcp__user__tool"), + bridge_dir=tmp_path, + python_executable="/venv/bin/python", + allowed_tools=("mcp__omnigent__sys_session_rename", "Bash"), + ) + + assert args.count("--allowedTools") == 1 + index = args.index("--allowedTools") + assert args[index + 1].split(",") == [ + "Bash", + "mcp__user__tool", + "mcp__omnigent__sys_session_rename", + ] + + +def test_augment_claude_args_omits_unsupplied_system_prompt(tmp_path: Path) -> None: + """The bridge does not invent framework policy on its own.""" + args = augment_claude_args( + (), + bridge_dir=tmp_path, + python_executable="/venv/bin/python", + ) + + assert "--append-system-prompt" not in args + + def test_augment_claude_args_merges_user_disallowed_tools(tmp_path: Path) -> None: """ A user-supplied ``--disallowedTools`` passes through unchanged. diff --git a/tests/test_codex_native_app_server.py b/tests/test_codex_native_app_server.py index b9dba748ff7..8abad6da337 100644 --- a/tests/test_codex_native_app_server.py +++ b/tests/test_codex_native_app_server.py @@ -18,10 +18,98 @@ _POLICY_HOOK_TIMEOUT_SECONDS, CodexNativeAppServer, _codex_policy_hooks_settings, + _sync_codex_developer_instructions, build_codex_native_server, trust_native_policy_hooks, ) from omnigent.codex_native_hook import _EVALUATE_POLICY_TIMEOUT_S +from omnigent.inner.codex_executor import _populate_codex_home_config + + +def test_sync_developer_instructions_preserves_and_restores_user_config(tmp_path: Path) -> None: + """Framework instructions append without replacing the user's Codex guidance.""" + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + config_path = codex_home / "config.toml" + config_path.write_text( + 'model = "gpt-5.5"\ndeveloper_instructions = "Keep user guidance."\n', + encoding="utf-8", + ) + + _sync_codex_developer_instructions(codex_home, "Rename the session.") + _sync_codex_developer_instructions(codex_home, "Rename the session.") + + config = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert config["model"] == "gpt-5.5" + assert config["developer_instructions"] == ("Keep user guidance.\n\nRename the session.") + + _sync_codex_developer_instructions(codex_home, None) + + resumed_config = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert resumed_config["developer_instructions"] == "Keep user guidance." + + +def test_sync_developer_instructions_survives_reseeded_config(tmp_path: Path) -> None: + """A persisted sidecar restores the original base after config reseeding.""" + codex_home = tmp_path / "codex-home" + source_home = tmp_path / "source-home" + codex_home.mkdir() + source_home.mkdir() + config_path = codex_home / "config.toml" + config_path.write_text( + 'developer_instructions = "Keep original guidance."\n', + encoding="utf-8", + ) + (source_home / "config.toml").write_text( + 'developer_instructions = "New shared guidance."\n', + encoding="utf-8", + ) + + _sync_codex_developer_instructions(codex_home, "Rename the session.") + config_path.unlink() + _populate_codex_home_config(codex_home, source_home) + + reseeded = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert reseeded["developer_instructions"] == "New shared guidance." + + _sync_codex_developer_instructions(codex_home, None) + + resumed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert resumed["developer_instructions"] == "Keep original guidance." + + +def test_sync_developer_instructions_recovers_legacy_augmented_config(tmp_path: Path) -> None: + """A missing sidecar does not capture an existing framework suffix as user base.""" + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + config_path = codex_home / "config.toml" + config_path.write_text( + 'developer_instructions = "Keep user guidance.\\n\\nRename the session."\n', + encoding="utf-8", + ) + + _sync_codex_developer_instructions(codex_home, "Rename the session.") + + active = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert active["developer_instructions"] == "Keep user guidance.\n\nRename the session." + + _sync_codex_developer_instructions(codex_home, None) + + resumed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert resumed["developer_instructions"] == "Keep user guidance." + + +def test_sync_developer_instructions_skips_invalid_config(tmp_path: Path) -> None: + """Optional title metadata never blocks Codex startup on malformed config.""" + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + config_path = codex_home / "config.toml" + config_path.write_text("invalid = [", encoding="utf-8") + + _sync_codex_developer_instructions(codex_home, "Rename the session.") + + assert config_path.read_text(encoding="utf-8") == "invalid = [" + _CWD = "/home/user/repo" _OUR_COMMAND = "/venv/bin/python -m omnigent.codex_native_hook evaluate-policy --bridge-dir /b" @@ -341,6 +429,9 @@ async def test_start_upserts_mcp_server_config_across_relaunches( [mcp_servers.omnigent.env] # stale generated env OLD = "1" +[mcp_servers.omnigent.tools.sys_session_rename] # stale generated approval +approval_mode = "prompt" + [mcp_servers.other] command = "other" args = [] @@ -378,6 +469,9 @@ async def test_start_upserts_mcp_server_config_across_relaunches( "--bridge-dir", str(bridge_dir), ], + "tools": { + "sys_session_rename": {"approval_mode": "approve"}, + }, } @@ -417,6 +511,9 @@ async def test_start_writes_fresh_mcp_config_without_leading_blanks( "--bridge-dir", str(bridge_dir), ], + "tools": { + "sys_session_rename": {"approval_mode": "approve"}, + }, } diff --git a/tests/tools/builtins/test_session_rename.py b/tests/tools/builtins/test_session_rename.py new file mode 100644 index 00000000000..a0eec55410f --- /dev/null +++ b/tests/tools/builtins/test_session_rename.py @@ -0,0 +1,11 @@ +"""Tests for the framework-owned current-session rename tool.""" + +from omnigent.tools.builtins.session_rename import SysSessionRenameTool + + +def test_session_rename_schema_is_self_scoped() -> None: + schema = SysSessionRenameTool().get_schema()["function"] + + assert schema["name"] == "sys_session_rename" + assert schema["parameters"]["required"] == ["title"] + assert set(schema["parameters"]["properties"]) == {"title"} diff --git a/tests/tools/test_manager.py b/tests/tools/test_manager.py index fe1b981b2d6..19138b71986 100644 --- a/tests/tools/test_manager.py +++ b/tests/tools/test_manager.py @@ -62,6 +62,7 @@ "sys_session_get_history", "sys_session_list", "sys_session_get_info", + "sys_session_rename", # Read-only agent discovery tools are likewise always available # (global, permission-bounded reads of any accessible session's # agent / bundle). @@ -110,6 +111,12 @@ def _non_lifecycle_schemas( ] +def test_session_rename_is_registered_for_every_agent() -> None: + names = {schema["function"]["name"] for schema in ToolManager(_make_spec()).get_tool_schemas()} + + assert "sys_session_rename" in names + + @pytest.fixture() def skill_with_resources(tmp_path: Path) -> SkillSpec: """ @@ -384,7 +391,7 @@ def test_session_reads_registered_but_writes_gated_without_opt_in() -> None: ``sys_session_list`` / ``sys_session_get_info``) is registered for **every** agent, even one that declares no sub-agents — so a user-added agent can read its session-mates for context. The - mutating session tools (``sys_session_send`` / + opt-in session-spawn tools (``sys_session_send`` / ``sys_session_close`` / ``sys_session_create`` / ``sys_session_share``) are NOT registered without an opt-in (``tools.agents`` or top-level ``spawn: true``). A regression that From e03c01a21a3e5f111702743ca6c6ba870d37d8c7 Mon Sep 17 00:00:00 2001 From: Bryan Li Date: Sat, 18 Jul 2026 07:30:48 -0700 Subject: [PATCH 457/546] chore: remove accidentally committed local session notes (#2858) --- docs/claude/antigravity-rpc-spike-notes.md | 125 --------------------- 1 file changed, 125 deletions(-) delete mode 100644 docs/claude/antigravity-rpc-spike-notes.md diff --git a/docs/claude/antigravity-rpc-spike-notes.md b/docs/claude/antigravity-rpc-spike-notes.md deleted file mode 100644 index 3a0b3c0b15c..00000000000 --- a/docs/claude/antigravity-rpc-spike-notes.md +++ /dev/null @@ -1,125 +0,0 @@ -# Antigravity-native RPC core — spike notes (Task 1) - -**Date:** 2026-06-22 -**agy version:** 1.0.10 (`/Users/bryanli/.local/bin/agy --version` → `1.0.10`) -**Host:** standalone attended agy in a dedicated tmux session `agy-spike` (no `--dangerously-skip-permissions`), launched with `HOME=/Users/bryanli`. NOT the `:6767` omnigent; fully isolated from the `rdv-*` sessions. -**Conversation captured:** `2399249c-4a48-40f1-bf3b-4c6e5d3a5a0e` (= cascadeId = brain-dir UUID). -**RPC port discovered:** `53485` via `discover_language_server_port(17262)` (PID 17262), confirmed by `_conversation_matches(port, conv) → True`. - -This task adds **fixtures + this notes doc only** (no production code). It records, with live evidence: -1. the distinct `GetCascadeTrajectorySteps` step shapes Tasks 4/5 will map and assert on (saved under `tests/fixtures/antigravity/steps/`); -2. the **turn-send** verdict (Step 3); -3. the **read-mode** verdict + latency/reliability (Step 4). - -All shapes here were captured **verbatim from the live RPC** (re-serialized pretty-printed; no content edits) unless explicitly labelled synthesized. - ---- - -## 1. Fixtures captured - -All fixtures are the single `steps[]` element as returned by -`POST .../LanguageServerService/GetCascadeTrajectorySteps` with request body -`{"cascadeId": ""}` (Content-Type `application/json`, `verify=False`). - -| Fixture file | `type` | `status` | Live? | What Tasks 4/5 assert on | -|---|---|---|---|---| -| `user_input.json` | `CORTEX_STEP_TYPE_USER_INPUT` | `DONE` | live | `userInput.userResponse`, `userInput.items[].text`, `metadata.source = CORTEX_STEP_SOURCE_USER_EXPLICIT`. **The mapper SKIPS this** (user turn already persisted by `/events`). NB: `metadata.sourceTrajectoryStepInfo.stepIndex` is **absent** here (step 0 → proto omits the zero default; treat missing as 0). | -| `conversation_history.json` | `CORTEX_STEP_TYPE_CONVERSATION_HISTORY` | `DONE` | live | system step, `conversationHistory: {}` — mapper skips (non-renderable). | -| `planner_response_text.json` | `CORTEX_STEP_TYPE_PLANNER_RESPONSE` | `DONE` | live | `plannerResponse.response` + `plannerResponse.modifiedResponse` (assistant text), `plannerResponse.messageId`, `plannerResponse.stopReason`. Carries a large `plannerResponse.thinkingSignature` (opaque; ignore). → `message` item. | -| `planner_response_tool_call_ask_question.json` | `CORTEX_STEP_TYPE_PLANNER_RESPONSE` | `DONE` | live | `plannerResponse.toolCalls[].{id, name:"ask_question", argumentsJson}` (+ optional `plannerResponse.thinking`). The tool-call carrier → `function_call` item. | -| `planner_response_tool_call_run_command.json` | `CORTEX_STEP_TYPE_PLANNER_RESPONSE` | `DONE` | live | `plannerResponse.toolCalls[].{id, name:"run_command", argumentsJson}` — distinct tool-call variant. | -| `run_command_waiting.json` | `CORTEX_STEP_TYPE_RUN_COMMAND` | `WAITING` | live | **permission-pending shape**: `requestedInteraction.permission.{resource.{action:"command", target:"pwd"}, persistSuggestionType, suggestedPersistPattern, actionDescription}`; `runCommand.{commandLine, proposedCommandLine, cwd, blocking, waitMsBeforeAsync}` (no `exitCode` yet); `metadata.sourceTrajectoryStepInfo.{trajectoryId, stepIndex}`. | -| `run_command_done.json` | `CORTEX_STEP_TYPE_RUN_COMMAND` | `DONE` | live | `runCommand.exitCode` (0), `runCommand.combinedOutput.full`, `runCommand.{commandLine, proposedCommandLine, cwd}`; `completedInteractions[].request.permission.resource.{action,target}` + `completedInteractions[].response = {trajectoryId, stepIndex, permission:{allow:true}}`. | -| `ask_question_waiting.json` | `CORTEX_STEP_TYPE_ASK_QUESTION` | `WAITING` | live | **ask-question-pending shape**: `requestedInteraction.askQuestion.questions[].{question, options[].{id,text}}` (option `id` = `"1".."N"`); also `metadata.toolCall.{id,name:"ask_question",argumentsJson,originalName}` and a top-level `askQuestion` block (same content). `metadata.sourceTrajectoryStepInfo.{trajectoryId, stepIndex, metadataIndex}`. | -| `ask_question_done.json` | `CORTEX_STEP_TYPE_ASK_QUESTION` | `DONE` | live | answered shape: `completedInteractions[].response.askQuestion.responses[].{question, selectedOptionIds:["4"]}`. | -| `list_directory_done.json` | `CORTEX_STEP_TYPE_LIST_DIRECTORY` | `DONE` | live | tool-result step: `listDirectory.{directoryPathUri, results}` — another distinct tool step the mapper must classify. | -| `checkpoint.json` | `CORTEX_STEP_TYPE_CHECKPOINT` | `DONE` | live | system step (`checkpoint` block, `metadata.modelUsage`/`retryInfos`) — mapper skips. | -| `run_command_error.json` | `CORTEX_STEP_TYPE_RUN_COMMAND` | `ERROR` | **synthesized** (see §1.1) | timed-out `WAITING`→`ERROR` permission step. `metadata.internalMetadata.statusTransitions` ends with the `WAITING`→`ERROR` flip; `requestedInteraction.permission` still present. Carries a `_fixtureProvenance` marker. Models the §2.1 timeout gotcha. | - -**Field-path cheatsheet for Tasks 4/5** (paths are stable across every step): -- `step.type`, `step.status` -- `step.metadata.sourceTrajectoryStepInfo.{trajectoryId, stepIndex, cascadeId}` (`stepIndex` omitted when 0) -- `step.metadata.source` (`CORTEX_STEP_SOURCE_{USER_EXPLICIT, MODEL, SYSTEM}`) -- `step.requestedInteraction.{askQuestion | permission}` (only when `WAITING`) -- `step.requestedInteraction.askQuestion.questions[].options[].{id, text}` -- `step.requestedInteraction.permission.{resource.{action,target}, actionDescription, suggestedPersistPattern, persistSuggestionType}` -- `step.plannerResponse.{response, modifiedResponse, messageId, stopReason, toolCalls[].{id,name,argumentsJson}}` -- `step.runCommand.{commandLine, proposedCommandLine, cwd, exitCode, combinedOutput.full, blocking, waitMsBeforeAsync}` -- `step.completedInteractions[].{request, response}` (response echoes the delivered answer) - -Status enum observed live: `CORTEX_STEP_STATUS_{DONE, WAITING, ERROR}` (and transient `PENDING/RUNNING/GENERATING` in `metadata.internalMetadata.statusTransitions`). -Step-type enum observed live (9 distinct): `USER_INPUT, CONVERSATION_HISTORY, PLANNER_RESPONSE, CHECKPOINT, RUN_COMMAND, LIST_DIRECTORY, ASK_QUESTION, VIEW_FILE, CODE_ACTION` (VIEW_FILE / CODE_ACTION observed in the trajectory but not all saved as fixtures — the mapper only needs the type/status discriminator + the per-type payload key, which follows the same `camelCase(type)` convention, e.g. `viewFile`, `codeAction`). - -### 1.1 ERROR fixture provenance - -`run_command_error.json` is the **one synthesized fixture** (all others are verbatim live captures). It was **derived from the live `run_command_waiting.json`** (same conversation `2399249c…`, same real `trajectoryId`/`stepIndex`) by flipping `status` `WAITING`→`ERROR` and appending the `WAITING`→`ERROR` `statusTransition` — i.e. exactly the timeout flip described in design §2.1. The WAITING shape and the timeout-flip behavior are both live-verified; only this exact ERROR *snapshot* is synthesized. The fixture carries an explicit `_fixtureProvenance` string so it can never be mistaken for a verbatim capture (drop/ignore that key when asserting shape). - -Why synthesized rather than captured: I made several honest live attempts and none produced an ERROR within a reasonable window: -- left an `ASK_QUESTION` `WAITING` step unanswered for ~3 min → stayed `WAITING` (no timeout); -- left a `RUN_COMMAND` permission `WAITING` step (`echo hello-spike`, index 42) unanswered for >5 min → stayed `WAITING` (no timeout); -- `CancelCascadeSteps {cascadeId}` returned `200 {}` but did **not** flip the `WAITING` step (see §4). - -So in agy 1.0.10 the `WAITING`-interaction timeout window is **long (minutes), not seconds** — the §2.1 gotcha is real (the prior memory hit it via slow human delivery) but it is not a quick way to elicit an ERROR step in a spike. Treating ERROR as the labelled-synthesized fallback (per the task brief) was the right call rather than blocking the task. - ---- - -## 2. Step 3 — turn-send verdict - -**Verdict: KEEP tmux `send-keys` for user turns. Do NOT use an RPC to send turns.** (Confirms the prior memory + design §2/§7.) - -Evidence: -- A turn typed via `tmux send-keys -t agy-spike '' Enter` is recorded as a `CORTEX_STEP_TYPE_USER_INPUT` step with **`metadata.source = CORTEX_STEP_SOURCE_USER_EXPLICIT`** and `userInput.userResponse == ""` (see `user_input.json`). This is exactly what the read path keys on, so send-keys turns are attributed correctly. -- `SendAgentMessage` (the only message-injection RPC on the surface) is documented (memory + design) to record the turn as a `SYSTEM_MESSAGE` ("not actually sent by the user"), which the mapper would then skip/mis-attribute — so it cannot drive user turns. I did **not** re-issue `SendAgentMessage` in this spike (no need to perturb the live session to re-confirm a settled, documented negative; and the mapper already skips USER_INPUT regardless). -- I scanned the live RPC surface for a *proper* user-turn method (a queued-user-input / "send all queued messages" path). The methods exercised/observed on `LanguageServerService` this session were `Heartbeat`, `GetConversationMetadata`, `GetCascadeTrajectorySteps`, `HandleCascadeUserInteraction`, `StreamAgentStateUpdates`. No `SendAllQueuedMessages` / `EnqueueUserInput` / `SubmitUserTurn`-style method was found that records as `USER_INPUT`. **No viable user-turn RPC exists in 1.0.10.** - -Implication for the plan: the executor's `run_turn` stays on tmux `send-keys` (design §5/§7 unchanged). Only **interactions** (answers/approvals) and **interrupt** move to RPC. - -### 2.1 Important live finding — attended TUI keeps its OWN prompt in parallel with RPC - -When agy runs **attended** (auto-exec OFF) and you drive turns by `send-keys`, the **TUI maintains its own permission/question prompt in-process, in parallel with the RPC step state.** Observed live: -- An RPC `HandleCascadeUserInteraction` approval flips the trajectory step to `DONE` and the command runs (verified: `run_command_done.json` has `exitCode:0` + output) — but the **TUI prompt for that same interaction can stay open**, and a subsequent `send-keys` lands in that TUI prompt's filter/amend buffer instead of starting a new turn (observed: a follow-up turn got concatenated into the persist-pattern option text). Pressing `Escape` clears the stale TUI prompt (the TUI then reports "User declined the tool call" for *its* prompt, harmlessly — the RPC-approved command had already run). - -Consequences for the production design: -- This is a **non-issue for the real bridge**, which is RPC-driven for interactions and does NOT type interaction answers via the TUI. It is a strong **reason to deliver interactions over RPC, not send-keys**. -- But it means a turn `send-keys`d **while a prior interaction's TUI prompt is still open** can be swallowed. The runner-owned terminal in production should ensure the TUI is at an idle `>` prompt before send-keys'ing a new turn (the read driver already knows the trajectory is idle — no `WAITING`/`RUNNING` step — which is the right gate). Worth a note in Task 11/12. - ---- - -## 3. Step 4 — read-mode verdict - -**Verdict: default to `StreamAgentStateUpdates` (server-stream) with `GetCascadeTrajectorySteps` polling as the fallback / reconcile path.** (Matches the memory lean + design §6.) - -### 3.1 `GetCascadeTrajectorySteps` (poll) — reliability baseline -- Unary `POST {"cascadeId": conv}` → `200 {"steps":[...]}`. Rock-solid every call this session (dozens of calls, 0 failures). Returns the **complete** step list each time (full snapshot), with explicit per-step `status` — trivial to dedup by `stepIndex`/identity. Typical round-trip a few ms on loopback. -- This is the **simplest correct** read path and the natural reconcile-on-reconnect mechanism. The whole point of the RPC rework (design §3) is that these structured snapshots remove the JSONL cursor/gap logic and fix the double-render. - -### 3.2 `StreamAgentStateUpdates` (server-stream) — latency win, framing caveat -- **Request MUST be connect-enveloped.** This is a correction to the memory note: sending a bare JSON body `{"conversationId": conv}` to `StreamAgentStateUpdates` returns a single connect error frame: - `{"error":{"code":"invalid_argument","message":"... protocol error: promised 576941934 bytes in enveloped message, got 53 bytes ..."}}` - — the server reads the first 5 bytes of the JSON as the connect envelope header. The body must be framed as `[flag:1=0x00][len:BE-uint32][json-bytes]` (same 5-byte envelope as the response frames). Content-Type `application/connect+json`. -- With the **enveloped** request: `200`, the stream **stays open and long-polls**. First frame carrying steps arrived **~0.13 s after a turn was sent** (measured: `first_steps_frame_at = 0.132 s`); the stream then emits a burst of incremental `update` frames as steps progress (`update.mainTrajectoryUpdate.stepsUpdate.steps[]`), each `flag=0`, then blocks (long-poll) when the trajectory goes idle. A trailing `flag=2` frame carries the connect end-of-stream / error envelope. -- **Reliability caveat:** because the stream blocks when idle, a naive reader must use a read timeout / heartbeat and reconnect, and must **reconcile via a `GetCascadeTrajectorySteps` snapshot on (re)connect** to avoid missing a transition that happened during a gap. The connect framing (envelope on both request and response) is fiddly to get exactly right (cost me one iteration), so the client wrapper must own it and be unit-tested against the captured frames. - -### 3.3 Recommendation -- **Default: stream** for low-latency detection of `WAITING` interactions and step progress (~130 ms vs a poll interval), **with poll as the fallback**: (a) reconcile snapshot on every (re)connect, (b) fall back to pure polling if the stream errors/regresses. This matches design §6 ("polls `GetCascadeTrajectorySteps` *or* consumes `StreamAgentStateUpdates`"). -- **Acceptable de-scope:** if the connect server-stream framing proves too costly to harden in the implementation tasks, **ship poll-first** (a tight `GetCascadeTrajectorySteps` loop, e.g. 250–500 ms while a turn is active) and add the stream as a follow-up. Polling alone is fully correct (full snapshots + explicit status); the only thing lost is sub-second push latency. The interaction bridge's tight detect→deliver loop (design §2.1) already re-reads the freshest `WAITING` step at delivery time, so poll-first does not compromise interaction correctness. - ---- - -## 4. Other live confirmations (for Tasks 2/3/5/8/10) - -- **Approval round-trip (Task 3/8):** `HandleCascadeUserInteraction {cascadeId, interaction:{trajectoryId, stepIndex, permission:{allow:true}}}` → `200 {}`; the `RUN_COMMAND` step flipped `WAITING`→`DONE` with `exitCode:0` and real `combinedOutput.full`. `trajectoryId`+`stepIndex` come from the WAITING step's `metadata.sourceTrajectoryStepInfo`. (Exactly the memory shape; `permission.allow`, no `approvalId`.) -- **Answer round-trip (Task 3/8):** `HandleCascadeUserInteraction {... interaction:{trajectoryId, stepIndex, askQuestion:{responses:[{question:"", selectedOptionIds:["4"]}]}}}` → `200 {}`; the `ASK_QUESTION` step flipped to `DONE` and the cascade proceeded autonomously. `selectedOptionIds` uses the option `id` (`"1".."N"`), not the text. -- **Tool cwd:** agy executes `run_command` in its own scratch dir (`combinedOutput.full` for `pwd` = `/Users/bryanli/.gemini/antigravity-cli/scratch`), NOT the agy launch CWD. Benign, but worth knowing for any cwd-sensitive parity check. -- **`GetConversationMetadata` ownership probe** still works as the discovery module expects (`metadata.rootConversationId` echo) — port discovery via `omnigent/antigravity_native_rpc.py` worked first try. -- **`CancelCascadeSteps` (Task 10) — accepts `{cascadeId}` but does NOT cancel a WAITING-for-interaction step.** `POST CancelCascadeSteps {"cascadeId": conv}` → `200 {}` (so, contrary to the old `antigravity_native_rpc.interrupt_turn` worry, the *conversation/cascade id alone is accepted* as the request key — no internal invocation id was needed for a `200`). **However** the live `RUN_COMMAND` `WAITING` step did **not** change status after the call (still `WAITING`, no new `statusTransition`). So for Task 10: `CancelCascadeSteps {cascadeId}` is wired-up-able with just the conversation id, but its effect on a step that is `WAITING` on a human interaction is a **no-op** here — it likely targets in-flight `RUNNING`/generating steps, not interaction-pending ones. **Task 10 must verify cancel against a RUNNING step** (e.g. cancel mid-generation, or mid-long-command) to confirm it actually interrupts, and should pair cancel-of-an-interaction with delivering a **deny** (`permission.allow:false` / `askQuestion` skip) to actually unblock a `WAITING` step. Whether `ForceStopCascadeTree` behaves differently was not tested. - ---- - -## 5. Concerns / follow-ups - -- **ERROR fixture is synthesized** (the only one) — see §1.1. The `WAITING` timeout window in 1.0.10 is minutes-long, so a real ERROR snapshot wasn't elicitable in the spike window. If Tasks 4/5 want a verbatim ERROR step, capture one opportunistically during the Task 13 live run (let an interaction sit, or hit a real tool error) and replace the fixture. -- **`CancelCascadeSteps` is a no-op on WAITING-for-interaction steps** (§4) — Task 10 must validate the real interrupt against a `RUNNING` step, and unblock `WAITING` steps with a deny rather than a cancel. Don't assume `200 {}` == "interrupted". -- **Connect stream framing** (request envelope, §3.2) is a sharp edge — the Task 2/6 client wrapper must own request+response enveloping and be unit-tested against captured frames; do not hand it to callers. If hardening it slips, ship poll-first (§3.3) — fully correct, only loses sub-second latency. -- **Attended TUI vs RPC interaction** (§2.1): production runner should gate `send-keys` turns on an idle trajectory (no `WAITING`/`RUNNING` step); surface in Task 11/12. -- Step payload key follows `camelCase(type)` (e.g. `RUN_COMMAND`→`runCommand`, `VIEW_FILE`→`viewFile`); the mapper can rely on this convention but should default-skip unknown types rather than assume a payload key exists. From 3fea7693cc6d92ab727fa6197aee9086fd7836ec Mon Sep 17 00:00:00 2001 From: Sabhya Chhabria Date: Sat, 18 Jul 2026 10:02:07 -0700 Subject: [PATCH 458/546] =?UTF-8?q?=F0=9F=94=A8=20chore(repo):=20Remove=20?= =?UTF-8?q?Playwright=20output=20(#2861)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: sabhya-db --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 26094b456ce..6c9bbb427e2 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,7 @@ dev/omnidev/target/ # Playwright test run output (screenshots, traces, videos). test-results/ +output/playwright/ # Visual-snapshot failure output (actual/expected/diff PNGs from the UI diff # gate). Regenerated each run; only the baseline under From afee478cff4ed8241e2734e201f1087258c19b48 Mon Sep 17 00:00:00 2001 From: Bryan Qiu <55931436+bbqiu@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:16:48 +0900 Subject: [PATCH 459/546] fix(web): stop double-prefixing the basename on query/hash paths (#2839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In an embedded mount (basename e.g. `/omnigent`) the app matches absolute paths, so `useLocation().pathname` already includes the basename. The settings sidebar captures that location as the "Back to Omnigent" return target — on the home page that's the bare basename plus the host's search, `/omnigent?o=`. The link then routes it back through `rebasePath`, whose idempotency guard only treated `=== basename` and `${basename}/` as "already under the basename". `/omnigent?o=123` matches neither (the char after `/omnigent` is `?`, not `/`), so it gets prefixed a second time → `/omnigent/omnigent?o=123`, which 404s. A conversation return path (`/omnigent/c/abc`) escaped the bug only because it happens to start with `/omnigent/`. Treat `/`, `?`, `#`, and end-of-string as the basename boundary, matching the guard's documented "does not double-prefix a path already under the basename" contract, while still rebasing a distinct sibling segment like `/mounting`. Adds regression coverage in routing.test.tsx for the query/hash boundary forms (Link + rebasePath primitive) and the over-match guard. Signed-off-by: Bryan Qiu --- web/src/lib/routing.test.tsx | 30 ++++++++++++++++++++++++++++++ web/src/lib/routing.tsx | 15 +++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/web/src/lib/routing.test.tsx b/web/src/lib/routing.test.tsx index a05cbd55da9..5c2c3ed521c 100644 --- a/web/src/lib/routing.test.tsx +++ b/web/src/lib/routing.test.tsx @@ -43,6 +43,27 @@ describe("basenamedRouting Link rebasing", () => { // Same invariant for the object form's pathname. expect(renderRebasedLink("/mount", { pathname: "/mount/c/abc" })).toBe("/mount/c/abc"); }); + + it("does not double-prefix the bare basename carrying a query", () => { + // Regression guard: the settings "Back to Omnigent" link targets the + // pre-settings location captured from `useLocation()`, which in the embed + // already includes the basename. On the home page that's the bare basename + // plus the host's `?o=` search (e.g. `/mount?o=123`). The old + // guard only treated `=== basename` / `${basename}/` as "already under", + // so the `?`-boundary form fell through and was prefixed again, landing at + // `/mount/mount?o=123` — a 404 (the reported double-basename bug). + expect(renderRebasedLink("/mount", "/mount?o=123")).toBe("/mount?o=123"); + expect(renderRebasedLink("/mount", { pathname: "/mount", search: "?o=123" })).toBe( + "/mount?o=123", + ); + }); + + it("still rebases a distinct sibling segment that only shares the basename prefix", () => { + // The boundary check must not over-match: `/mounting` is NOT under `/mount` + // (no `/`, `?`, or `#` at the boundary), so it gets rebased like any other + // app-absolute path. + expect(renderRebasedLink("/mount", "/mounting")).toBe("/mount/mounting"); + }); }); describe("rebasePath primitive", () => { @@ -60,5 +81,14 @@ describe("rebasePath primitive", () => { it("does not double-prefix a path already under the basename", () => { expect(basenamedRouting("/mount").rebasePath("/mount/c/abc")).toBe("/mount/c/abc"); + // The `?`/`#` boundary forms are equally "already under" the basename. + expect(basenamedRouting("/mount").rebasePath("/mount?o=123")).toBe("/mount?o=123"); + expect(basenamedRouting("/mount").rebasePath("/mount#frag")).toBe("/mount#frag"); + }); + + it("rebases a distinct sibling segment that only shares the basename prefix", () => { + // `/mounting` merely shares the `/mount` text prefix; it's a different path + // and must be rebased under the mount, not treated as already-under. + expect(basenamedRouting("/mount").rebasePath("/mounting")).toBe("/mount/mounting"); }); }); diff --git a/web/src/lib/routing.tsx b/web/src/lib/routing.tsx index 6bf528c403f..9868b7f60d5 100644 --- a/web/src/lib/routing.tsx +++ b/web/src/lib/routing.tsx @@ -77,8 +77,19 @@ export const reactRouterRouting: RoutingApi = { */ function rebasePath(path: string, basename: string): string { if (!path.startsWith("/")) return path; - // Avoid double-prefixing if already under the basename. - if (path === basename || path.startsWith(`${basename}/`)) return path; + // Avoid double-prefixing if already under the basename. The basename ends at + // the first `/`, `?`, or `#` (or end of string) — so `/mount`, `/mount/c/x`, + // and `/mount?o=1` are all "already under `/mount`", but a distinct segment + // like `/mounting` is not. Checking only `=== basename` / `${basename}/` + // missed the query/hash forms: a mount-absolute path carrying a search (e.g. + // the settings "Back to Omnigent" target `/mount?o=123`, captured from + // `useLocation()` which already includes the basename) fell through and got + // prefixed again → `/mount/mount?o=123`, a 404. + if (path === basename) return path; + if (path.startsWith(basename)) { + const boundary = path[basename.length]; + if (boundary === "/" || boundary === "?" || boundary === "#") return path; + } return `${basename}${path}`; } From 126bac5c4e4442ca22ade06cc7ae2c51ebcccbba Mon Sep 17 00:00:00 2001 From: Daniel Lok Date: Sun, 19 Jul 2026 09:37:39 +0800 Subject: [PATCH 460/546] =?UTF-8?q?Revert=20"fix(claude-native):=20ack=20m?= =?UTF-8?q?essage=20delivery=20via=20hooks,=20not=20just=20the=20inpu?= =?UTF-8?q?=E2=80=A6"=20(#2871)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit f2dfe1c9202bd8375c69e1590e310224390b5118. --- omnigent/claude_native_bridge.py | 364 ++------------------- omnigent/inner/claude_native_executor.py | 4 - tests/inner/test_claude_native_executor.py | 20 +- tests/test_claude_native_bridge.py | 276 ---------------- 4 files changed, 35 insertions(+), 629 deletions(-) diff --git a/omnigent/claude_native_bridge.py b/omnigent/claude_native_bridge.py index 287cd00e0a8..4114c1bc8ee 100644 --- a/omnigent/claude_native_bridge.py +++ b/omnigent/claude_native_bridge.py @@ -157,27 +157,6 @@ # (so a slow-but-successful first Enter isn't double-tapped), short # enough that a swallowed Enter is retried promptly. _SUBMIT_RETRY_INTERVAL_S = 1.0 -# After the submit registers at the TUI layer, how long to wait for -# Claude Code to record ``UserPromptSubmit`` in hooks.jsonl — the -# authoritative signal that the prompt was accepted (not just that the -# draft left the input box, which draft-restore can undo). If it never -# arrives the submit did not register and the message was not delivered. -_SUBMIT_ACK_TIMEOUT_S = 8.0 -# After ``UserPromptSubmit`` is seen, a brief window to let a draft-restore -# manifest (the text bounces back into the box a moment after submit) -# before treating an empty box as a healthy, turn-is-starting delivery. -_TURN_START_SETTLE_S = 2.0 -# Once a draft-restore stall is detected, how long to keep re-submitting the -# restored draft while waiting for an assistant turn to actually start before -# surfacing the stall as an error. -_TURN_START_TIMEOUT_S = 20.0 -# Parent-turn hook events that prove an assistant turn has actually started -# (as opposed to just the prompt being accepted). ``UserPromptSubmit`` and -# ``SessionStart`` are deliberately excluded — they fire before/at submit, -# not at turn start. -_TURN_START_HOOK_EVENTS: frozenset[str] = frozenset( - {"PreToolUse", "PostToolUse", "Notification", "Stop", "StopFailure"} -) # Claude Code collapses large pastes into this placeholder in the # input box instead of rendering the text itself. _PASTED_PLACEHOLDER_PREFIX = "[Pasted text" @@ -1588,26 +1567,6 @@ def read_transcript_path(bridge_dir: Path) -> Path | None: return Path(raw) -def _count_transcript_lines(transcript_path: Path | None) -> int: - """ - Return the current line count of a transcript file. - - Used to snapshot a pre-injection cursor so the delivery ack only reads - assistant text appended after this message. ``None`` or a missing file - (fresh session) reports ``0``. - - :param transcript_path: Transcript path, or ``None``. - :returns: Line count, or ``0`` when absent. - """ - if transcript_path is None: - return 0 - try: - with transcript_path.open("r", encoding="utf-8") as handle: - return sum(1 for _ in handle) - except FileNotFoundError: - return 0 - - def read_claude_session_id(bridge_dir: Path) -> str | None: """ Return the Claude-native session id captured from hook events. @@ -2297,125 +2256,6 @@ def stop_hook_seen_since(bridge_dir: Path, start_event_count: int) -> bool: return False -def user_prompt_submit_seen_since(bridge_dir: Path, start_event_count: int) -> bool: - """ - Return whether Claude recorded a ``UserPromptSubmit`` after a cursor. - - This is the authoritative "the prompt was accepted" signal for an - injected message: Claude Code fires ``UserPromptSubmit`` when a - submit registers, before any assistant activity. It is what lets the - bridge tell a genuinely-delivered message apart from one whose submit - Enter was swallowed (draft still sitting unsent). Subagent prompts - (whose ``transcript_path`` contains a ``subagents/`` component) are - ignored so they cannot be mistaken for the parent turn's submit. - - :param bridge_dir: Bridge directory path. - :param start_event_count: Hook record count captured before the - message was injected into the Claude terminal. - :returns: ``True`` once a parent-process ``UserPromptSubmit`` hook has - been recorded after the cursor. - """ - path = bridge_dir / _HOOKS_FILE - try: - with path.open("r", encoding="utf-8") as handle: - for index, line in enumerate(handle, start=1): - if index <= start_event_count: - continue - try: - envelope = json.loads(line) - except json.JSONDecodeError: - continue - payload = envelope.get("payload") if isinstance(envelope, dict) else None - event_name = payload.get("hook_event_name") if isinstance(payload, dict) else None - if event_name != "UserPromptSubmit": - continue - transcript_path = ( - payload.get("transcript_path") if isinstance(payload, dict) else None - ) - if isinstance(transcript_path, str) and "/subagents/" in transcript_path: - continue - return True - except FileNotFoundError: - return False - return False - - -def _turn_activity_hook_seen_since(bridge_dir: Path, start_event_count: int) -> bool: - """ - Return whether a parent-turn activity hook fired after a cursor. - - Turn-start (as opposed to prompt-accept) is proven by any parent - ``PreToolUse`` / ``PostToolUse`` / ``Notification`` / ``Stop`` / - ``StopFailure`` in ``hooks.jsonl`` after the cursor — see - :data:`_TURN_START_HOOK_EVENTS`. Subagent events are skipped so a - finishing subagent cannot masquerade as the parent turn starting. - - :param bridge_dir: Bridge directory path. - :param start_event_count: Hook record count captured before injection. - :returns: ``True`` once a parent turn-activity hook is recorded after - the cursor. - """ - path = bridge_dir / _HOOKS_FILE - try: - with path.open("r", encoding="utf-8") as handle: - for index, line in enumerate(handle, start=1): - if index <= start_event_count: - continue - try: - envelope = json.loads(line) - except json.JSONDecodeError: - continue - payload = envelope.get("payload") if isinstance(envelope, dict) else None - event_name = payload.get("hook_event_name") if isinstance(payload, dict) else None - if event_name not in _TURN_START_HOOK_EVENTS: - continue - transcript_path = ( - payload.get("transcript_path") if isinstance(payload, dict) else None - ) - if isinstance(transcript_path, str) and "/subagents/" in transcript_path: - continue - return True - except FileNotFoundError: - return False - return False - - -def _assistant_turn_started_since( - bridge_dir: Path, - *, - hook_cursor: int, - transcript_path: Path | None, - transcript_cursor: int, -) -> bool: - """ - Return whether an assistant turn actually started after injection. - - Combines two signals so both tool-first and text-only turns are - caught: a parent turn-activity hook - (:func:`_turn_activity_hook_seen_since`, covers tool calls and the - end-of-turn ``Stop``) or new assistant text appended to the transcript - after the pre-injection cursor (:func:`read_assistant_text_since`, - covers a turn that streams text before any tool). ``read_assistant_text_since`` - ignores user entries, so the injected prompt itself never counts as a - turn start. - - :param bridge_dir: Bridge directory path. - :param hook_cursor: Hook record count captured before injection. - :param transcript_path: Transcript path captured before injection, or - ``None`` when hooks had not yet reported one (re-resolved here). - :param transcript_cursor: Transcript line count captured before - injection. - :returns: ``True`` once assistant activity is observed after injection. - """ - if _turn_activity_hook_seen_since(bridge_dir, hook_cursor): - return True - path = transcript_path or read_transcript_path(bridge_dir) - if path is None: - return False - _cursor, texts = read_assistant_text_since(path, transcript_cursor) - return bool(texts) - - # Terminal per-task ``status`` values in a ``Stop`` hook's ``background_tasks`` # array. Claude Code retains finished/stopped shells in that array rather than # reaping them (claude-code issues #67895, #59456, #14049), so counting the raw @@ -2675,112 +2515,11 @@ def write_tmux_target( _write_json_file(bridge_dir / _TMUX_FILE, payload) -def _await_delivery_ack( - info: dict[str, str], - bridge_dir: Path, - *, - needle: str, - hook_cursor: int, - transcript_path: Path | None, - transcript_cursor: int, -) -> None: - """ - Confirm an injected message was delivered end-to-end, or raise. - - Called after the TUI-level submit. Two bounded phases: - - 1. **Submit registered?** Wait up to :data:`_SUBMIT_ACK_TIMEOUT_S` - for ``UserPromptSubmit`` after ``hook_cursor``. While waiting, - re-send ``Enter`` only while the draft is verifiably still in the - input box — this recovers a swallowed submit (and the old - blind-Enter path) without ever double-submitting a cleared box. If - it never arrives the submit did not register: raise. - 2. **Turn started?** Once the prompt is accepted, the draft leaving - the box is not proof of a turn (draft-restore can undo it). Watch - for a real turn start; if instead the draft reappears in the box - (the draft-restore signature) re-submit it, spaced out, until a - turn starts. Raise only if redelivery never produces a turn within - :data:`_TURN_START_TIMEOUT_S`. - - Skipped entirely when ``hooks.jsonl`` does not exist — the hook stream - is unobservable for this bridge, so this cannot do better than the - pane-based submit and must not newly block delivery. - - :param info: Resolved tmux info (``socket_path`` / ``tmux_target``). - :param bridge_dir: Bridge directory path. - :param needle: Draft marker from :func:`_submit_needle`. - :param hook_cursor: Hook record count captured before injection. - :param transcript_path: Transcript path captured before injection. - :param transcript_cursor: Transcript line count captured before injection. - :raises RuntimeError: If ``UserPromptSubmit`` never registers, or if a - draft-restore stall never yields an assistant turn. - """ - if not (bridge_dir / _HOOKS_FILE).exists(): - return - - # Phase 1 — submit registered (UserPromptSubmit recorded)? - deadline = time.monotonic() + _SUBMIT_ACK_TIMEOUT_S - last_enter = time.monotonic() - submitted = False - while time.monotonic() < deadline: - if user_prompt_submit_seen_since(bridge_dir, hook_cursor): - submitted = True - break - pane = _capture_pane(info["socket_path"], info["tmux_target"]) - if ( - _draft_in_input_box(pane, needle) - and time.monotonic() - last_enter >= _SUBMIT_RETRY_INTERVAL_S - ): - _run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], "Enter") - last_enter = time.monotonic() - time.sleep(_CLAUDE_READY_POLL_INTERVAL_S) - if not submitted: - raise RuntimeError( - "Claude Code never recorded UserPromptSubmit for the injected message " - f"within {_SUBMIT_ACK_TIMEOUT_S}s; the message was not delivered." - ) - - # Phase 2 — assistant turn actually started? - deadline = time.monotonic() + _TURN_START_TIMEOUT_S - settle = time.monotonic() + _TURN_START_SETTLE_S - last_enter = time.monotonic() - saw_restore = False - while time.monotonic() < deadline: - if _assistant_turn_started_since( - bridge_dir, - hook_cursor=hook_cursor, - transcript_path=transcript_path, - transcript_cursor=transcript_cursor, - ): - return - pane = _capture_pane(info["socket_path"], info["tmux_target"]) - if _draft_in_input_box(pane, needle): - # Draft-restore stall: the submit registered but the text - # bounced back into the box. Re-submit it (what a human does by - # pressing Enter), spaced out, until a turn starts. - saw_restore = True - if time.monotonic() - last_enter >= _SUBMIT_RETRY_INTERVAL_S: - _run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], "Enter") - last_enter = time.monotonic() - elif not saw_restore and time.monotonic() >= settle: - # UserPromptSubmit is in and the box stayed empty through the - # settle window with no restore — the turn is starting or is - # queued behind a running one. Delivered; don't block further. - return - time.sleep(_CLAUDE_READY_POLL_INTERVAL_S) - raise RuntimeError( - "Claude Code recorded the message but no assistant turn started within " - f"{_TURN_START_TIMEOUT_S}s (draft-restore stall); redelivery did not recover. " - "The message may be sitting unsent in the terminal input box." - ) - - def inject_user_message( bridge_dir: Path, *, content: str, timeout_s: float = _TMUX_READY_TIMEOUT_S, - verify_delivery: bool = True, ) -> None: r""" Deliver a user message into the Claude terminal via tmux send-keys. @@ -2803,40 +2542,24 @@ def inject_user_message( client→server command at ~16KB, so a large message — e.g. a PR diff in a sub-agent dispatch — failed with "command too long". - The submit is **verified end-to-end, not fire-and-forget**. Claude - Code coalesces rapid stdin bursts into a paste, so an Enter that lands - while the TUI is still consuming the paste is folded in as a newline - and the draft sits unsent; this helper first polls ``capture-pane`` - until the draft is visible (paste committed), sends Enter, then polls - that the draft left the box, re-sending Enter while it hasn't. That - only proves the TUI accepted the keystroke, which draft-restore can - undo, so when ``verify_delivery`` is set (the default) it then waits - on the authoritative hook signal: ``UserPromptSubmit`` recorded in - ``hooks.jsonl`` (submit registered), followed by an assistant turn - actually starting. If the prompt never registers, or it registers but - the draft is restored to the box and no turn starts even after - redelivery, it raises so the caller can surface the failure instead of - stranding the message in a terminal the user may not be watching. The - hook ack is skipped when ``hooks.jsonl`` is absent (signal - unobservable). Pass ``verify_delivery=False`` for mid-turn steering, - where a queued message may not fire ``UserPromptSubmit`` promptly. + The submit is **verified, not fire-and-forget**: Claude Code + coalesces rapid stdin bursts into a paste, so an Enter that lands + while the TUI is still consuming the paste is folded in as a + newline and the draft sits unsent. This helper first polls + ``capture-pane`` until the draft is visible in the input box (the + paste was committed), sends Enter, then polls that the draft left + the box — re-sending Enter while it hasn't — and raises if the + message never submits. :param bridge_dir: Bridge directory path. :param content: User text from the Omnigent web UI. Must be non-empty. :param timeout_s: Seconds to wait for each readiness gate (``tmux.json`` advertised, then prompt rendered), e.g. ``30.0``. - :param verify_delivery: When ``True`` (default), block after submit - until the hook stream acknowledges delivery (``UserPromptSubmit`` - + assistant turn start), raising on failure. When ``False``, use - the legacy pane-only verification — for mid-turn steering, where a - queued prompt may not ack promptly. :returns: None. :raises RuntimeError: If the tmux target is not advertised in time, if Claude's input prompt never renders, if a ``tmux send-keys`` - invocation fails, if the draft never leaves the input box after - repeated submit Enters, or — when ``verify_delivery`` — if the - hook stream never acknowledges the message (no ``UserPromptSubmit``, - or a draft-restore stall that redelivery cannot start). + invocation fails, or if the draft never leaves the input box + after repeated submit Enters (message not delivered). """ info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s) # tmux.json only means the tmux session exists; Claude Code's input @@ -2847,13 +2570,6 @@ def inject_user_message( info["tmux_target"], timeout_s=timeout_s, ) - # Snapshot the hook + transcript cursors BEFORE injecting so the - # delivery ack can tell this message's UserPromptSubmit / turn-start - # apart from prior activity. read_hook_events_since(..., 0) returns the - # current complete-record count as its cursor. - hook_cursor, _ = read_hook_events_since(bridge_dir, 0) - ack_transcript_path = read_transcript_path(bridge_dir) - transcript_cursor = _count_transcript_lines(ack_transcript_path) # Clear any leftover text in Claude's input field before typing. # After Escape-cancel, Claude Code re-populates the prompt area # with the previous input for re-editing. Without this clear, @@ -2910,45 +2626,29 @@ def inject_user_message( time.sleep(_CLAUDE_READY_POLL_INTERVAL_S) time.sleep(_PASTE_SETTLE_S) _run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], "Enter") - # TUI-level submit verification: a successful Enter clears the input - # box. If the draft is still sitting there the Enter was swallowed into - # the paste burst as a newline — re-send it (the retry lands well after - # the burst, so it submits). Each Enter only fires while the draft is - # verifiably still present, so a retry can never hit an empty prompt or - # a permission dialog of the started turn. Skipped when the draft was - # never identifiable (draft_seen False) — its absence proves nothing, so - # the hook ack below is what guards that path. - if draft_seen: - deadline = time.monotonic() + _SUBMIT_VERIFY_TIMEOUT_S - last_enter = time.monotonic() - submitted = False - while time.monotonic() < deadline: - time.sleep(_CLAUDE_READY_POLL_INTERVAL_S) - pane = _capture_pane(info["socket_path"], info["tmux_target"]) - if not _draft_in_input_box(pane, needle): - submitted = True - break - if time.monotonic() - last_enter >= _SUBMIT_RETRY_INTERVAL_S: - _run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], "Enter") - last_enter = time.monotonic() - if not submitted: - raise RuntimeError( - "Claude Code did not accept the submitted message within " - f"{_SUBMIT_VERIFY_TIMEOUT_S}s (the draft is still in the input box). " - "The message was not delivered." - ) - if not verify_delivery: - # Legacy behavior for mid-turn steering: the pane-level submit above - # is as far as we verify (a queued steering prompt may not ack). + if not draft_seen: + # The draft was never observed, so its absence proves nothing — + # verification would trivially "pass". Submit blind as before. return - # End-to-end ack: confirm UserPromptSubmit registered and a turn started. - _await_delivery_ack( - info, - bridge_dir, - needle=needle, - hook_cursor=hook_cursor, - transcript_path=ack_transcript_path, - transcript_cursor=transcript_cursor, + # Verify the submit took: a successful Enter clears the input box. + # If the draft is still sitting there the Enter was swallowed into + # the paste burst as a newline — re-send it (the retry lands well + # after the burst, so it submits). Each Enter only fires while the + # draft is verifiably still present, so a retry can never hit an + # empty prompt or a permission dialog of the started turn. + deadline = time.monotonic() + _SUBMIT_VERIFY_TIMEOUT_S + last_enter = time.monotonic() + while time.monotonic() < deadline: + time.sleep(_CLAUDE_READY_POLL_INTERVAL_S) + pane = _capture_pane(info["socket_path"], info["tmux_target"]) + if not _draft_in_input_box(pane, needle): + return + if time.monotonic() - last_enter >= _SUBMIT_RETRY_INTERVAL_S: + _run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], "Enter") + last_enter = time.monotonic() + raise RuntimeError( + f"Claude Code did not accept the submitted message within {_SUBMIT_VERIFY_TIMEOUT_S}s " + "(the draft is still in the input box). The message was not delivered." ) diff --git a/omnigent/inner/claude_native_executor.py b/omnigent/inner/claude_native_executor.py index a0e53227b20..963d4237c20 100644 --- a/omnigent/inner/claude_native_executor.py +++ b/omnigent/inner/claude_native_executor.py @@ -91,10 +91,6 @@ async def enqueue_session_message(self, session_key: str, content: Any) -> bool: inject_user_message, self._bridge_dir, content=text, - # Mid-turn steering: a queued prompt may not fire - # UserPromptSubmit promptly, so keep the legacy - # pane-only verification here (see #2061). - verify_delivery=False, ) except RuntimeError: return False diff --git a/tests/inner/test_claude_native_executor.py b/tests/inner/test_claude_native_executor.py index 1c159fddefd..4542d79a2f6 100644 --- a/tests/inner/test_claude_native_executor.py +++ b/tests/inner/test_claude_native_executor.py @@ -266,7 +266,6 @@ def fake_inject_user_message( *, content: str, timeout_s: float = 30.0, - verify_delivery: bool = True, ) -> None: """ Capture a steering injection. @@ -274,17 +273,10 @@ def fake_inject_user_message( :param bridge_dir_arg: Bridge directory passed by the executor. :param content: Text typed into the Claude tmux pane. :param timeout_s: tmux-target readiness timeout (ignored). - :param verify_delivery: Delivery-ack toggle passed by the executor. :returns: None. """ del timeout_s - sent_messages.append( - { - "bridge_dir": bridge_dir_arg, - "content": content, - "verify_delivery": verify_delivery, - } - ) + sent_messages.append({"bridge_dir": bridge_dir_arg, "content": content}) monkeypatch.setattr( claude_native_executor, @@ -300,13 +292,10 @@ def fake_inject_user_message( # session_key is intentionally NOT included since there is one # tmux pane per conversation; mixing in routing metadata would # cause Claude to see arbitrary key-value pairs as user input. - # verify_delivery is False: a queued steering prompt may not fire - # UserPromptSubmit promptly, so it keeps the legacy pane-only check. assert sent_messages == [ { "bridge_dir": tmp_path, "content": "steer me", - "verify_delivery": False, } ] @@ -344,17 +333,15 @@ def fake_inject_user_message( *, content: str, timeout_s: float = 30.0, - verify_delivery: bool = True, ) -> None: """Record peak concurrency, then hold the call open until released. :param bridge_dir_arg: Bridge directory (ignored). :param content: Text that would be typed into tmux (ignored). :param timeout_s: tmux-target readiness timeout (ignored). - :param verify_delivery: Delivery-ack toggle (ignored). :returns: None. """ - del bridge_dir_arg, content, timeout_s, verify_delivery + del bridge_dir_arg, content, timeout_s with state_lock: state["now"] += 1 state["max"] = max(state["max"], state["now"]) @@ -433,9 +420,8 @@ def _fake( *, content: str, timeout_s: float = 30.0, - verify_delivery: bool = True, ) -> None: - del timeout_s, verify_delivery + del timeout_s sent.append({"bridge_dir": bridge_dir_arg, "content": content}) return _fake diff --git a/tests/test_claude_native_bridge.py b/tests/test_claude_native_bridge.py index e11928690b7..d853014772b 100644 --- a/tests/test_claude_native_bridge.py +++ b/tests/test_claude_native_bridge.py @@ -45,7 +45,6 @@ record_hook_event, start_tool_relay, stop_hook_seen_since, - user_prompt_submit_seen_since, write_tmux_target, ) from omnigent.reasoning_effort import CLAUDE_EFFORTS @@ -2919,281 +2918,6 @@ def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace: inject_user_message(bridge_dir, content="fix the flaky test") -# ── user_prompt_submit_seen_since ──────────────────────────────────── - - -def test_user_prompt_submit_seen_since_detects_parent_prompt( - tmp_path: Path, -) -> None: - """ - A parent ``UserPromptSubmit`` after the cursor is detected. - - This is the authoritative "submit registered" signal the delivery ack - keys on; without it a swallowed submit Enter looks like success. - """ - bridge_dir = tmp_path / "bridge" - record_hook_event(bridge_dir, {"hook_event_name": "SessionStart", "session_id": "p"}) - assert not user_prompt_submit_seen_since(bridge_dir, 1) - record_hook_event(bridge_dir, {"hook_event_name": "UserPromptSubmit", "session_id": "p"}) - assert user_prompt_submit_seen_since(bridge_dir, 1) - - -def test_user_prompt_submit_seen_since_ignores_subagent_prompt( - tmp_path: Path, -) -> None: - """ - A subagent ``UserPromptSubmit`` must not count as the parent's submit. - - Subagent hooks land in the same ``hooks.jsonl``; their - ``transcript_path`` carries a ``subagents/`` component. Counting one - would falsely ack a parent message that never registered. - """ - bridge_dir = tmp_path / "bridge" - subagent_transcript = tmp_path / "session" / "subagents" / "agent-abc.jsonl" - record_hook_event(bridge_dir, {"hook_event_name": "SessionStart", "session_id": "p"}) - record_hook_event( - bridge_dir, - { - "hook_event_name": "UserPromptSubmit", - "session_id": "sub", - "transcript_path": str(subagent_transcript), - }, - ) - assert not user_prompt_submit_seen_since(bridge_dir, 1) - - -def test_user_prompt_submit_seen_since_absent_file_is_false( - tmp_path: Path, -) -> None: - """A missing ``hooks.jsonl`` reports no submit rather than raising.""" - assert not user_prompt_submit_seen_since(tmp_path / "bridge", 0) - - -# ── inject_user_message: end-to-end delivery ack ───────────────────── - - -def _shrink_ack_timers(monkeypatch: pytest.MonkeyPatch) -> None: - """ - Collapse the ack poll cadence/timeouts so tests run in milliseconds. - - :param monkeypatch: Pytest monkeypatch fixture. - """ - for name, value in { - "_CLAUDE_READY_POLL_INTERVAL_S": 0.01, - "_SUBMIT_RETRY_INTERVAL_S": 0.02, - "_PASTE_SETTLE_S": 0.0, - "_SUBMIT_VERIFY_TIMEOUT_S": 0.15, - "_SUBMIT_ACK_TIMEOUT_S": 0.15, - "_TURN_START_SETTLE_S": 0.03, - "_TURN_START_TIMEOUT_S": 0.15, - }.items(): - monkeypatch.setattr(f"omnigent.claude_native_bridge.{name}", value) - - -def test_inject_user_message_acks_when_turn_starts( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """ - Delivery succeeds once ``UserPromptSubmit`` + a turn-start hook land. - - Models Claude accepting the prompt on Enter (fires ``UserPromptSubmit``) - and starting a turn (fires ``PreToolUse``). The helper must return - without raising and without extra retry Enters. - """ - monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path) - _shrink_ack_timers(monkeypatch) - bridge_dir = tmp_path / "bridge" - write_tmux_target( - bridge_dir, socket_path=Path("/tmp/example/tmux.sock"), tmux_target="claude:0.0" - ) - - enters: list[list[str]] = [] - tui = {"pane": "❯ "} - - def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace: - del kwargs - if "capture-pane" in cmd: - return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="") - if "paste-buffer" in cmd: - tui["pane"] = "❯ ship the fix" - if cmd[-1] == "Enter": - enters.append(cmd) - tui["pane"] = "❯ " # submitted — box clears - # Claude accepts the prompt and starts a turn. - record_hook_event( - bridge_dir, {"hook_event_name": "UserPromptSubmit", "session_id": "p"} - ) - record_hook_event(bridge_dir, {"hook_event_name": "PreToolUse", "session_id": "p"}) - return SimpleNamespace(returncode=0, stdout="", stderr="") - - monkeypatch.setattr("subprocess.run", _fake_run) - inject_user_message(bridge_dir, content="ship the fix") # must not raise - - assert len(enters) == 1, f"Healthy delivery should send exactly one Enter, got {len(enters)}." - - -def test_inject_user_message_raises_when_prompt_never_registers( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """ - The box clearing is not enough: no ``UserPromptSubmit`` means not delivered. - - This is the blind-Enter / swallowed-submit gap. Previously the draft - leaving the box returned "success"; now, with the hook stream live and - no ``UserPromptSubmit`` recorded, the helper raises so the caller can - surface the drop. - """ - monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path) - _shrink_ack_timers(monkeypatch) - bridge_dir = tmp_path / "bridge" - write_tmux_target( - bridge_dir, socket_path=Path("/tmp/example/tmux.sock"), tmux_target="claude:0.0" - ) - # Hook stream is live (SessionStart already recorded) but the submit - # never produces a UserPromptSubmit. - record_hook_event(bridge_dir, {"hook_event_name": "SessionStart", "session_id": "p"}) - - tui = {"pane": "❯ "} - - def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace: - del kwargs - if "capture-pane" in cmd: - return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="") - if "paste-buffer" in cmd: - tui["pane"] = "❯ never delivered" - if cmd[-1] == "Enter": - tui["pane"] = "❯ " # box clears (looks fine) but no UPS fires - return SimpleNamespace(returncode=0, stdout="", stderr="") - - monkeypatch.setattr("subprocess.run", _fake_run) - with pytest.raises(RuntimeError, match="UserPromptSubmit"): - inject_user_message(bridge_dir, content="never delivered") - - -def test_inject_user_message_raises_on_draft_restore_stall( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """ - Submit registers, draft is restored, no turn starts → raise. - - The observed #2061 stall: ``UserPromptSubmit`` fires (submit took) but - the draft bounces back into the box and no assistant turn begins. The - ack re-submits the restored draft; when redelivery still yields no - turn-start it raises rather than stranding the message. - """ - monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path) - _shrink_ack_timers(monkeypatch) - bridge_dir = tmp_path / "bridge" - write_tmux_target( - bridge_dir, socket_path=Path("/tmp/example/tmux.sock"), tmux_target="claude:0.0" - ) - record_hook_event(bridge_dir, {"hook_event_name": "SessionStart", "session_id": "p"}) - - state = {"pane": "❯ ", "submitted": False} - - def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace: - del kwargs - if "capture-pane" in cmd: - pane = state["pane"] - # The submit momentarily clears the box — the first capture-pane - # read after that clear sees empty, so the TUI-level submit - # verification passes — then draft-restore bounces the text back - # into the box for the delivery ack to catch as a stall. - if state["submitted"] and pane == "❯ ": - state["pane"] = "❯ stalled message" - return SimpleNamespace(returncode=0, stdout=pane, stderr="") - if "paste-buffer" in cmd: - state["pane"] = "❯ stalled message" - if cmd[-1] == "Enter": - if not state["submitted"]: - # First submit registers and the box momentarily clears... - state["pane"] = "❯ " - record_hook_event( - bridge_dir, {"hook_event_name": "UserPromptSubmit", "session_id": "p"} - ) - # ...then draft-restore puts it back; no turn-start hook ever fires. - state["submitted"] = True - # Subsequent (nudge) Enters do NOT start a turn — the stall persists. - return SimpleNamespace(returncode=0, stdout="", stderr="") - - monkeypatch.setattr("subprocess.run", _fake_run) - with pytest.raises(RuntimeError, match="no assistant turn started"): - inject_user_message(bridge_dir, content="stalled message") - - -def test_inject_user_message_degrades_without_hooks_file( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """ - With no ``hooks.jsonl`` the ack is skipped and delivery is not blocked. - - The hook signal is unobservable for this bridge, so the helper must - fall back to pane-only behavior (prior semantics) instead of raising. - """ - monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path) - _shrink_ack_timers(monkeypatch) - bridge_dir = tmp_path / "bridge" - write_tmux_target( - bridge_dir, socket_path=Path("/tmp/example/tmux.sock"), tmux_target="claude:0.0" - ) - - tui = {"pane": "❯ "} - - def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace: - del kwargs - if "capture-pane" in cmd: - return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="") - if "paste-buffer" in cmd: - tui["pane"] = "❯ no hooks here" - if cmd[-1] == "Enter": - tui["pane"] = "❯ " - return SimpleNamespace(returncode=0, stdout="", stderr="") - - monkeypatch.setattr("subprocess.run", _fake_run) - inject_user_message(bridge_dir, content="no hooks here") # must not raise - assert not (bridge_dir / "hooks.jsonl").exists() - - -def test_inject_user_message_steering_skips_hook_ack( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """ - ``verify_delivery=False`` keeps legacy pane-only behavior. - - Mid-turn steering can be queued without a prompt ack, so the strict - hook ack is bypassed: even with the hook stream live and no - ``UserPromptSubmit``, the helper returns once the box clears. - """ - monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path) - _shrink_ack_timers(monkeypatch) - bridge_dir = tmp_path / "bridge" - write_tmux_target( - bridge_dir, socket_path=Path("/tmp/example/tmux.sock"), tmux_target="claude:0.0" - ) - record_hook_event(bridge_dir, {"hook_event_name": "SessionStart", "session_id": "p"}) - - tui = {"pane": "❯ "} - - def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace: - del kwargs - if "capture-pane" in cmd: - return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="") - if "paste-buffer" in cmd: - tui["pane"] = "❯ steer me" - if cmd[-1] == "Enter": - tui["pane"] = "❯ " # box clears; no UPS — would raise if strict - return SimpleNamespace(returncode=0, stdout="", stderr="") - - monkeypatch.setattr("subprocess.run", _fake_run) - # Would raise under the default strict ack; verify_delivery=False must not. - inject_user_message(bridge_dir, content="steer me", verify_delivery=False) - - def test_inject_interrupt_sends_escape_keystroke( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From 091fabf2080a1e042cdb49879ec319f2b6b15874 Mon Sep 17 00:00:00 2001 From: Bryan Qiu <55931436+bbqiu@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:23:12 +0900 Subject: [PATCH 461/546] feat(web): gate sidebar row actions on ownership, not permission level (#2671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): gate sidebar row actions on ownership, not permission level The session sidebar derived every row affordance (rename, share, move-to-project, drag-to-file) and the My/Shared tab split from each row's `permission_level`. That forced the server to resolve the caller's effective grant for every listed session on each list build and updates poll. The sidebar only ever needs owner-vs-not, and every list row already carries `owner`. Switch `isOwnedByViewer` to compare `owner` against the resolved viewer id (permissive when owner is null — single-user / legacy rows), and gate the row actions on ownership alone: - Rename, Share, Move-to-project, and drag-to-file are now owner-only (Share was manage-gated, Rename/move/drag were edit-gated). - Non-owners get a read-only row; finer-grained edit/manage affordances remain on the open-session view, which fetches the caller's real level via GET /v1/sessions/{id}. `permission_level` is no longer read anywhere in the sidebar, so a backend can list sessions without a per-session permission lookup. Co-authored-by: Isaac Signed-off-by: Bryan Qiu * feat(web): make sharing owner-only and null-safe on managed list rows Two follow-ons to the owner-only sidebar, for backends whose session list is owner-only and omits the caller's effective permission_level (the Databricks-managed server): - derivePermissionLevel no longer concludes from a sidebar row whose permission_level is null. That null is "level not carried", not the permissive null sentinel, so we skip the fast path and defer to the authoritative single-session snapshot / read-only fallback. A backend that keeps emitting a level on list rows (OSS default) is unchanged. - The header Share affordance is now owner-only (isOwnerLevel of the derived level), matching the sidebar's owner-only Share gate and the terminal readOnly gate. Was manage-or-higher (>= 3). - ChatPage's liveness row prefers the snapshot's permissionLevel over the sidebar row's, so host_offline's isOwner (who may reconnect the host) isn't decided by a null managed list level reading as permissive. Co-authored-by: Isaac Signed-off-by: Bryan Qiu * test(e2e): cover sidebar owner-vs-not row gating and tab placement Adds the Playwright e2e coverage the E2E-UI-Required gate asks for on this PR: the sidebar derives ownership (and every owner-only row action) from the session's `owner`, not from an effective permission level. Two flows on a dedicated multi-user server (the shared single-user live_server hides the My/Shared tabs and the Share item, so the split can't be observed there): - Owner: session under "My sessions", kebab Rename + Share enabled, Rename opens the inline edit. - Non-owner granted EDIT: session under "Shared with me" (absent from "My sessions"), kebab Rename + Share disabled — owner-only gating regardless of the granted level. Test-only; no product code changes. Co-authored-by: Isaac Signed-off-by: Bryan Qiu --------- Signed-off-by: Bryan Qiu --- .../sessions/test_sidebar_ownership_gating.py | 176 ++++++++++++++++++ web/src/lib/permissionsApi.test.ts | 13 ++ web/src/lib/permissionsApi.ts | 15 +- web/src/pages/ChatPage.tsx | 14 +- web/src/shell/AppShell.test.tsx | 18 +- web/src/shell/AppShell.tsx | 7 +- web/src/shell/Sidebar.rowActions.test.tsx | 11 +- web/src/shell/Sidebar.stop.test.tsx | 8 +- web/src/shell/Sidebar.test.tsx | 19 +- web/src/shell/Sidebar.tsx | 107 +++++++---- 10 files changed, 320 insertions(+), 68 deletions(-) create mode 100644 tests/e2e_ui/sessions/test_sidebar_ownership_gating.py diff --git a/tests/e2e_ui/sessions/test_sidebar_ownership_gating.py b/tests/e2e_ui/sessions/test_sidebar_ownership_gating.py new file mode 100644 index 00000000000..b04e756b1a5 --- /dev/null +++ b/tests/e2e_ui/sessions/test_sidebar_ownership_gating.py @@ -0,0 +1,176 @@ +"""Browser e2e for the sidebar's owner-vs-not row gating and tab placement. + +This is the end-to-end companion to the mocked ``Sidebar`` unit tests, and the +coverage the ``E2E UI Required`` gate asks for: the sidebar derives ownership +(and every owner-only row action) from the session's ``owner`` — the creator's +user id carried on each list row — NOT from an effective ``permission_level``. +The behavior under test: + +- A session's **owner** sees it under the **"My sessions"** tab, with the + kebab's **Rename** and **Share** items **enabled**. +- A **non-owner the session is shared with** (even at EDIT) sees it under the + **"Shared with me"** tab, with **Rename** and **Share** **disabled** + ("Only the session owner can …"). Editing shared *content* still happens in + the open session — that path reads the real level from the single-session + snapshot and is unaffected — but the sidebar affordances are owner-only. + +Runs against a dedicated multi-user server: the shared ``live_server`` is +single-user (``OMNIGENT_LOCAL_SINGLE_USER=1``), which hides the My/Shared tabs +AND the Share item entirely, so the ownership split can't be observed there. +The multi-user server clears that marker and declares an admin identity, exactly +like a Databricks Apps / SSO-proxy install — the deployment shape this gating +matters for. + +The admin owns the session; a second header identity (the viewer) is granted +access via the REST API. Both identities' sidebar list loads via the initial +``GET /v1/sessions`` (a plain authenticatedFetch that carries the context's +``X-Forwarded-Email``), so the static post-load state asserted here is +deterministic — unlike the ``WS /v1/sessions/updates`` push, which the +sharing-journey test notes may not carry the header in all Chromium combos and +which this test deliberately does not depend on. +""" + +from __future__ import annotations + +import re +import uuid +from collections.abc import Iterator + +import httpx +import pytest +from playwright.sync_api import Browser, Locator, Page, expect + +from tests.e2e_ui.collaboration._multi_user_server import ( + ADMIN_EMAIL, + MultiUserServer, + spawn_multi_user_server, +) + +# Edit access (2) is the interesting non-owner case: it proves the sidebar gates +# on ownership, not level — an EDIT holder still can't rename/share from the +# sidebar. Mirrors LEVEL_EDIT in omnigent/server/auth.py. +_LEVEL_EDIT = 2 + +_TAB_MINE = '[data-testid="sidebar-tab-mine"]' +_TAB_SHARED = '[data-testid="sidebar-tab-shared"]' + + +@pytest.fixture(scope="module") +def multi_user_server( + built_spa: None, + mock_llm_server_url: str, + tmp_path_factory: pytest.TempPathFactory, +) -> Iterator[MultiUserServer]: + """A NON-single-user server so the My/Shared tabs + Share item render.""" + server_tmp = tmp_path_factory.mktemp("e2e_ui_sidebar_ownership_multi_user") + yield from spawn_multi_user_server(mock_llm_server_url, server_tmp) + + +def _grant(server: MultiUserServer, user_id: str, level: int) -> None: + """Grant *user_id* *level* on the admin-owned session (admin acts).""" + resp = httpx.put( + f"{server.base_url}/v1/sessions/{server.session_id}/permissions", + json={"user_id": user_id, "level": level}, + headers={"X-Forwarded-Email": ADMIN_EMAIL}, + timeout=30.0, + ) + resp.raise_for_status() + + +def _row(page: Page, session_id: str) -> Locator: + """The sidebar row (``

  • ``) for *session_id*, located by its href.""" + return page.locator("li").filter(has=page.locator(f'a[href="/c/{session_id}"]')) + + +def _open_row_menu(page: Page, session_id: str) -> None: + """Right-click the row to open the shared actions menu at the cursor. + + Right-click (Radix ``ContextMenu``) renders the same + ``ConversationMenuItems`` body as the kebab, so the item testids and their + enabled/disabled state are identical — and it avoids the kebab's + pointer-event timing. Mirrors ``test_sidebar_context_menu.py``. + """ + link = page.locator(f'a[href="/c/{session_id}"]') + expect(link).to_be_visible(timeout=30_000) + link.click(button="right") + + +def test_owner_sees_session_under_my_sessions_with_enabled_actions( + browser: Browser, + multi_user_server: MultiUserServer, +) -> None: + """The owner's own session: "My sessions" tab, Rename + Share enabled. + + The baseline half of the ownership split — nothing about owner affordances + regressed when the sidebar moved off ``permission_level``. + """ + server = multi_user_server + sid = server.session_id + ctx = browser.new_context(extra_http_headers={"X-Forwarded-Email": ADMIN_EMAIL}) + try: + page = ctx.new_page() + page.goto(f"{server.public_url}/c/{sid}") + + # Owned → shows under the default "My sessions" tab, never "Shared". + expect(page.locator(_TAB_MINE)).to_be_visible(timeout=30_000) + expect(_row(page, sid)).to_be_visible(timeout=30_000) + + _open_row_menu(page, sid) + # Owner → Rename and Share are enabled (no data-disabled marker). + expect(page.get_by_test_id("rename-conversation")).not_to_have_attribute( + "data-disabled", re.compile(r".*") + ) + expect(page.get_by_test_id("share-conversation")).not_to_have_attribute( + "data-disabled", re.compile(r".*") + ) + + # Rename runs the real inline-edit path from here (proves "enabled" is + # not just cosmetic), exactly as the context-menu test asserts. + page.get_by_test_id("rename-conversation").click() + expect(page.get_by_test_id("rename-conversation-input")).to_be_visible(timeout=15_000) + finally: + ctx.close() + + +@pytest.mark.flaky(reruns=2, reruns_delay=5) +def test_shared_viewer_sees_session_under_shared_tab_with_owner_only_actions( + browser: Browser, + multi_user_server: MultiUserServer, +) -> None: + """A non-owner (granted EDIT): "Shared with me" tab, Rename + Share disabled. + + The behavior this PR introduces: an EDIT grant is enough to open and edit + the session, but the sidebar's Rename/Share are owner-only — so a shared + session lands on the "Shared with me" tab (never "My sessions"), and its + kebab Rename/Share are disabled regardless of the granted level. + """ + server = multi_user_server + sid = server.session_id + viewer_email = f"viewer-{uuid.uuid4().hex[:6]}@ui.test" + _grant(server, viewer_email, _LEVEL_EDIT) + + ctx = browser.new_context(extra_http_headers={"X-Forwarded-Email": viewer_email}) + try: + page = ctx.new_page() + page.goto(f"{server.public_url}/c/{sid}") + + # The shared session is NOT the viewer's own, so it must not appear on + # the default "My sessions" tab... + expect(page.locator(_TAB_MINE)).to_be_visible(timeout=30_000) + expect(_row(page, sid)).to_have_count(0) + + # ...it lives under "Shared with me". + page.locator(_TAB_SHARED).click() + expect(_row(page, sid)).to_be_visible(timeout=30_000) + + _open_row_menu(page, sid) + # Non-owner → Rename and Share are disabled even though the viewer holds + # EDIT. This is the owner-only gating (was edit-/manage-gated before). + expect(page.get_by_test_id("rename-conversation")).to_have_attribute( + "data-disabled", re.compile(r".*"), timeout=15_000 + ) + expect(page.get_by_test_id("share-conversation")).to_have_attribute( + "data-disabled", re.compile(r".*") + ) + finally: + ctx.close() diff --git a/web/src/lib/permissionsApi.test.ts b/web/src/lib/permissionsApi.test.ts index 2af17739a0b..b98c225700c 100644 --- a/web/src/lib/permissionsApi.test.ts +++ b/web/src/lib/permissionsApi.test.ts @@ -240,6 +240,19 @@ describe("derivePermissionLevel — resolution order", () => { expect(derivePermissionLevel(null, true, sidebar, "conv_test", true)).toBe(2); }); + it("ignores a sidebar row whose level is null and defers to the snapshot fallback", () => { + // A deployment whose session list is owner-only (the caller's effective + // level omitted, e.g. the Databricks-managed server) returns rows with + // permission_level=null. That absence is NOT the permissive null sentinel: + // we must not conclude from it, so while the single-fetch loads we return + // null (loading, permissive) rather than reading the row's null as a + // resolved level — the authoritative snapshot then wins once it lands. + const sidebar = makeConv(null); + expect(derivePermissionLevel(null, true, sidebar, "conv_test", true)).toBeNull(); + // And once the snapshot resolves, it — not the null row — decides. + expect(derivePermissionLevel(makeSession(1), false, sidebar, "conv_test", true)).toBe(1); + }); + it("returns null while the single-fetch is still loading and the sidebar has no row", () => { // Child session case before the single-fetch resolves: don't flash // read-only just because the sidebar doesn't know about this conv. diff --git a/web/src/lib/permissionsApi.ts b/web/src/lib/permissionsApi.ts index 1e76f776c66..cbc1388c156 100644 --- a/web/src/lib/permissionsApi.ts +++ b/web/src/lib/permissionsApi.ts @@ -41,10 +41,15 @@ export function isOwnerLevel(level: number | null): boolean { * source. Sub-agent (child) sessions are filtered out of the * sidebar list query, so this is the only place their level is * observable. - * 2. ``activeConv.permission_level`` — sidebar list row. Available - * synchronously the moment the user navigates between top-level - * conversations, so we use it as a fast path before the single - * fetch resolves. + * 2. ``activeConv.permission_level`` — sidebar list row, but ONLY when it + * actually carries a level. Available synchronously the moment the user + * navigates between top-level conversations, so it's a fast path before + * the single fetch resolves. A row whose level is ``null`` (a deployment + * whose session list is owner-only and omits the caller's effective + * level, e.g. the Databricks-managed server) carries no conclusion, so we + * skip it and fall through to the authoritative snapshot / fallback + * rather than mistaking the absent level for the permissive ``null`` + * sentinel. * 3. ``null`` while the single fetch is still in flight (the UI * treats ``null`` permissively, avoiding a read-only flicker * during the snapshot's first round-trip on child sessions). @@ -60,7 +65,7 @@ export function derivePermissionLevel( conversationsLoaded: boolean, ): number | null { if (session != null) return session.permissionLevel; - if (activeConv != null) return activeConv.permission_level ?? null; + if (activeConv != null && activeConv.permission_level != null) return activeConv.permission_level; if (sessionLoading) return null; if (conversationId && conversationsLoaded) return 1; return null; diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index a7215eaae60..6e287bb4ff1 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -924,8 +924,20 @@ export function ChatPage() { // session, so a host-bound, host-down session whose host is a resumable // managed host classifies as `host_asleep` (composer open, send wakes it) // instead of dead-ending on `host_offline`. + // + // Also prefer the snapshot's `permissionLevel` over the sidebar row's when + // it's resolved: the hook derives `host_offline`'s `isOwner` from this + // level, and a deployment whose session list is owner-only (the caller's + // effective level omitted, e.g. the Databricks-managed server) leaves the + // row's `permission_level` null — which would read permissively as "owner" + // and offer a non-owner the host-reconnect path. The single-session + // snapshot always carries the authoritative level. const livenessRow: LivenessRow | null = activeConv - ? { ...activeConv, host_resumable: activeSession?.hostResumable ?? false } + ? { + ...activeConv, + permission_level: activeSession?.permissionLevel ?? activeConv.permission_level, + host_resumable: activeSession?.hostResumable ?? false, + } : livenessRowFromSession(activeSession); const liveness = useSessionLiveness(urlConvId ?? undefined, livenessRow, { turnActive: status === "streaming", diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx index a64de1dd6b0..9fab176d3eb 100644 --- a/web/src/shell/AppShell.test.tsx +++ b/web/src/shell/AppShell.test.tsx @@ -2786,9 +2786,11 @@ describe("AppShell clone/fork action", () => { describe("AppShell share action", () => { it("shows the Share button to an owner of a top-level session", () => { - // permission_level null = owner. A top-level session can be shared. + // permission_level 4 = owner. Share is owner-only; a top-level session + // the viewer owns can be shared. (A multi-user owner's list row carries + // level 4; null only occurs in single-user mode, where Share is hidden.) withWindowOrigin("https://app.example.com", () => { - mockConversations([{ id: "conv_top", permission_level: null }]); + mockConversations([{ id: "conv_top", permission_level: 4 }]); renderShell("/c/conv_top"); @@ -2800,7 +2802,7 @@ describe("AppShell share action", () => { it("disables the Share button when the server is local", () => { withWindowOrigin("http://localhost:6767", () => { - mockConversations([{ id: "conv_top", permission_level: null }]); + mockConversations([{ id: "conv_top", permission_level: 4 }]); renderShell("/c/conv_top"); @@ -2819,7 +2821,7 @@ describe("AppShell share action", () => { // Non-local origin isolates the reason to the server policy (not the // local-server path), so the tooltip must be the sharing-off message. withWindowOrigin("https://app.example.com", () => { - mockConversations([{ id: "conv_top", permission_level: null }]); + mockConversations([{ id: "conv_top", permission_level: 4 }]); renderShell("/c/conv_top", serverInfo({ sharing_mode: "off" })); @@ -2852,7 +2854,7 @@ describe("AppShell share action", () => { // shape as single-user, but single_user is false — the button must stay. // This is the regression the single_user signal fixes. withWindowOrigin("https://app.example.com", () => { - mockConversations([{ id: "conv_top", permission_level: null }]); + mockConversations([{ id: "conv_top", permission_level: 4 }]); renderShell("/c/conv_top", serverInfo({ single_user: false })); @@ -2866,7 +2868,7 @@ describe("AppShell share action", () => { // read_only still permits (read) grants, so the affordance stays live — // the modal caps the level, the button is not disabled. withWindowOrigin("https://app.example.com", () => { - mockConversations([{ id: "conv_top", permission_level: null }]); + mockConversations([{ id: "conv_top", permission_level: 4 }]); renderShell("/c/conv_top", serverInfo({ sharing_mode: "read_only" })); @@ -2952,7 +2954,7 @@ describe("Mobile header actions menu", () => { mockConversations([ { id: "conv_host", - permission_level: null, + permission_level: 4, labels: {}, host_id: "host_a1b2", runner_id: "runner_token_abc", @@ -2979,7 +2981,7 @@ describe("Mobile header actions menu", () => { it("disables the mobile Share item when the server is local", () => { withWindowOrigin("http://127.0.0.1:6767", () => { - mockConversations([{ id: "conv_host", permission_level: null, labels: {} }]); + mockConversations([{ id: "conv_host", permission_level: 4, labels: {} }]); renderShell("/c/conv_host"); openActionsMenu(); diff --git a/web/src/shell/AppShell.tsx b/web/src/shell/AppShell.tsx index b287fdcdce0..30bbf85f772 100644 --- a/web/src/shell/AppShell.tsx +++ b/web/src/shell/AppShell.tsx @@ -375,17 +375,20 @@ export function AppShell() { // three-dot menu render the exact same set (they can't drift apart). // Stop session is not a header action — it lives in the sidebar row's // kebab menu (see Sidebar's ConversationRow). - // Read-write or higher can manage sharing; top-level only. Sharing a + // Only the owner can manage sharing; top-level only. Sharing a // sub-agent is a no-op anyway — children inherit the parent's grants via // the server's parent-delegation path — so we hide the affordance. // Also hidden in single-user mode: with no other users to grant to, the // affordance is meaningless (unlike the local-server / sharing-off cases // below, which stay present-but-disabled with an explanatory tooltip). + // ``isOwnerLevel`` is permissive on a null level (single-user / still + // loading), matching the sidebar's owner-only Share gate and the terminal + // ``readOnly`` gate below; the authoritative snapshot level resolves it. const serverInfo = useServerInfo(); const canShare = !!conversationId && isKnownTopLevel && - (permissionLevel === null || permissionLevel >= 3) && + isOwnerLevel(permissionLevel) && !isSingleUserMode(serverInfo); // Two independent reasons the Share affordance is present-but-disabled: a // local server can't produce openable links, and a deployed server whose diff --git a/web/src/shell/Sidebar.rowActions.test.tsx b/web/src/shell/Sidebar.rowActions.test.tsx index 8ec4abda361..f7d525318be 100644 --- a/web/src/shell/Sidebar.rowActions.test.tsx +++ b/web/src/shell/Sidebar.rowActions.test.tsx @@ -94,7 +94,8 @@ const CONV: Conversation = { created_at: 1_700_000_000, updated_at: 1_700_000_000, labels: {}, - permission_level: null, // owner → can edit + pin + permission_level: null, + // owner absent → the viewer owns it (rename/share/pin all enabled) status: "idle", }; @@ -335,11 +336,11 @@ describe("double-click to rename", () => { }); it("does not enter rename on double-click for a viewer-only row", () => { - // permission_level 1 is below the edit threshold (>= 2), so the kebab's - // Rename item is disabled and double-click must be inert too. A viewer-only - // (non-owner) session lives on the "Shared with me" tab, so switch to it + // Rename is owner-only now, so a session owned by another user has its + // kebab Rename item disabled and double-click must be inert too. A + // non-owner session lives on the "Shared with me" tab, so switch to it // before reaching for the row. - mockConversations([{ ...CONV, permission_level: 1 }]); + mockConversations([{ ...CONV, owner: "other@example.com" }]); renderSidebar(); // Radix Tabs triggers activate on mousedown (primary button), not click. fireEvent.mouseDown(screen.getByTestId("sidebar-tab-shared"), { button: 0 }); diff --git a/web/src/shell/Sidebar.stop.test.tsx b/web/src/shell/Sidebar.stop.test.tsx index f1e3fd88e0e..ee565313efd 100644 --- a/web/src/shell/Sidebar.stop.test.tsx +++ b/web/src/shell/Sidebar.stop.test.tsx @@ -187,10 +187,10 @@ describe("sidebar Stop session item", () => { }); it("is disabled for non-owners even on a stoppable session", () => { - // Owner-gated server-side; a shared viewer (level 1) sees it disabled. - // A non-owner session lives on the "Shared with me" tab, so switch there - // before opening its kebab. - mockConversations([{ ...HOST_SPAWNED, permission_level: 1 }]); + // Owner-gated server-side; a shared viewer (another user owns it) sees it + // disabled. A non-owner session lives on the "Shared with me" tab, so + // switch there before opening its kebab. + mockConversations([{ ...HOST_SPAWNED, owner: "other@example.com" }]); renderSidebar(); // Radix Tabs triggers activate on mousedown (primary button), not click. fireEvent.mouseDown(screen.getByTestId("sidebar-tab-shared"), { button: 0 }); diff --git a/web/src/shell/Sidebar.test.tsx b/web/src/shell/Sidebar.test.tsx index 977ecb327af..1dfb70552c9 100644 --- a/web/src/shell/Sidebar.test.tsx +++ b/web/src/shell/Sidebar.test.tsx @@ -361,14 +361,15 @@ describe("Sidebar session list", () => { // Sidebar grouping: the viewer's own sessions ("My sessions" tab) keep the // Pinned / Projects / Sessions structure; sessions shared with the viewer live -// on a separate "Shared with me" tab. "Shared" = sessions where the caller's -// permission_level says non-owner (< 4); null/4+ are the viewer's own. +// on a separate "Shared with me" tab. "Shared" = sessions whose `owner` is +// another user; a null/absent owner is the viewer's own (single-user / legacy). +// In tests the resolved viewer id is null, so any non-null owner reads as shared. describe("Sidebar sections", () => { it("splits owned and shared sessions across the My sessions / Shared with me tabs", () => { mockConversations([ - conv("conv_mine_legacy", "Claude Code"), // permission_level null = owner - conv("conv_mine_acl", "Claude Code", { permission_level: 4 }), - conv("conv_shared", "Claude Code", { permission_level: 2 }), + conv("conv_mine_legacy", "Claude Code"), // owner absent = owned + conv("conv_mine_acl", "Claude Code", { owner: null }), + conv("conv_shared", "Claude Code", { owner: "other@example.com" }), ]); renderSidebar(); @@ -407,7 +408,7 @@ describe("Sidebar tabs", () => { it("keeps New session visible on both tabs and snaps back to My sessions when used", () => { mockConversations([ conv("conv_mine", "Claude Code"), - conv("conv_shared", "Claude Code", { permission_level: 2 }), + conv("conv_shared", "Claude Code", { owner: "other@example.com" }), ]); renderSidebar(); expect(screen.getByTestId("new-chat-button")).toBeInTheDocument(); @@ -431,7 +432,7 @@ describe("Sidebar tabs", () => { isServerLocalMock.mockReturnValue(true); mockConversations([ conv("conv_mine", "Claude Code"), - conv("conv_shared", "Claude Code", { permission_level: 2 }), + conv("conv_shared", "Claude Code", { owner: "other@example.com" }), ]); renderSidebar(); expect(screen.queryByTestId("sidebar-tab-mine")).toBeNull(); @@ -448,7 +449,7 @@ describe("Sidebar tabs", () => { // sessions (which shows only owned sessions). mockConversations([ conv("conv_mine", "Claude Code"), - conv("conv_shared", "Claude Code", { permission_level: 2 }), + conv("conv_shared", "Claude Code", { owner: "other@example.com" }), ]); localStorage.setItem("omnigent:pinned-conversation-ids", JSON.stringify(["conv_shared"])); renderSidebar(); @@ -472,7 +473,7 @@ describe("Sidebar tabs", () => { projectsMock.push("Alpha"); mockConversations([ conv("conv_mine", "Claude Code", { labels: { omni_project: "Alpha" } }), - conv("conv_shared", "Claude Code", { permission_level: 2 }), + conv("conv_shared", "Claude Code", { owner: "other@example.com" }), ]); renderSidebar(); diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx index 9e8e4f3c242..8b999da0a87 100644 --- a/web/src/shell/Sidebar.tsx +++ b/web/src/shell/Sidebar.tsx @@ -120,7 +120,7 @@ import { useActiveRootSessionId } from "@/hooks/useSession"; import { useCommentInbox } from "@/hooks/useCommentInbox"; import { sumPendingApprovals } from "@/lib/inbox"; import { isSessionStoppable } from "@/lib/sessionStop"; -import { isOwnerLevel } from "@/lib/permissionsApi"; +import { getCurrentUserId, resolveIdentity } from "@/lib/identity"; import { isImeCompositionKeyEvent } from "@/lib/ime"; import { getSessionState, type SessionState } from "@/hooks/useSessionState"; import { @@ -911,9 +911,48 @@ interface ConversationListProps { onVisibleCountChange: (count: number) => void; } -// permission_level null (no ACL row / legacy) or >= 4 both mean owner. -function isOwnedByViewer(conversation: Conversation): boolean { - return isOwnerLevel(conversation.permission_level); +// Ownership drives the My-vs-Shared split and every owner-only row action. +// It is derived purely from the session's `owner` (the creator's user id), +// NOT from `permission_level` — the sidebar carries no effective-level info, +// so the server can list rows without resolving the caller's grant per +// session. A `null`/absent owner (permissions disabled — the server emits +// `owner` only when a permission store is wired) reads as owned, matching the +// prior permissive-on-null stance; otherwise the viewer owns it iff they are +// the owner. In single-user mode the owner grant is the reserved `"local"` +// id, and `viewerId` is `"local"` too (see `useViewerId`), so it matches via +// the equality branch. `viewerId` is `null` until identity resolves — treated +// as "not the owner" for shared rows so they don't briefly flash into "My +// sessions" before the id lands. +function isOwnedByViewer(conversation: Conversation, viewerId: string | null): boolean { + const owner = conversation.owner ?? null; + if (owner === null) return true; + return owner === viewerId; +} + +// The current viewer's user id, resolved reactively. Uses `getCurrentUserId` +// (NOT `getCurrentAuthorId`): ownership compares against the session's `owner` +// grant, which in single-user mode is the reserved `"local"` id — and +// `getCurrentAuthorId` nulls `"local"` out (it's for author labels), which +// would make the viewer's own sessions read as shared and vanish from the +// default "My sessions" tab. `getCurrentUserId` keeps `"local"` and is the +// identical real email in multi-user mode. It is synchronous (populated once +// `resolveIdentity` has run — which `main.tsx` kicks off at boot), but on a +// cold mount it can still be null for a tick, so we also await +// `resolveIdentity()` and re-render when it lands. Keeping this reactive +// (rather than a bare module read) means the My/Shared split settles correctly +// the moment identity is known, without a manual refresh. +function useViewerId(): string | null { + const [viewerId, setViewerId] = useState(() => getCurrentUserId()); + useEffect(() => { + let cancelled = false; + void resolveIdentity().then(() => { + if (!cancelled) setViewerId(getCurrentUserId()); + }); + return () => { + cancelled = true; + }; + }, []); + return viewerId; } function ConversationList({ @@ -932,6 +971,8 @@ function ConversationList({ getVisibleConversationsRef, onVisibleCountChange, }: ConversationListProps) { + // Viewer id for the owner-based My/Shared split below. + const viewerId = useViewerId(); // All loaded conversations from the single paginated list (for pinned // backfill, normalization, and the flat session list). const allConversations = useMemo( @@ -977,8 +1018,8 @@ function ConversationList({ // same section layout with different conversations. const tabScoped = activeTab === "shared" - ? notArchived.filter((c) => !isOwnedByViewer(c)) - : notArchived.filter(isOwnedByViewer); + ? notArchived.filter((c) => !isOwnedByViewer(c, viewerId)) + : notArchived.filter((c) => isOwnedByViewer(c, viewerId)); // Pinned takes precedence over Project: pinning a session moves it OUT of // its project into the flat global Pinned section (no nested pins). Ordered @@ -1032,6 +1073,7 @@ function ConversationList({ activeOverride, projectNames, activeTab, + viewerId, ]); // Collapsed section titles — persisted like pins so the preference @@ -2019,8 +2061,6 @@ function ConversationMenuItems({ isPinned, isArchived, isOwner, - canEdit, - canManage, sharingOff, isSingleUser, canStop, @@ -2044,10 +2084,8 @@ function ConversationMenuItems({ isPinned: boolean; isArchived: boolean; isOwner: boolean; - canEdit: boolean; - canManage: boolean; // Server-wide sharing kill switch (OMNIGENT_SHARING_MODE=off): disables the - // Share item for everyone, independent of the per-user manage check. + // Share item for everyone, independent of the per-user ownership check. sharingOff: boolean; // Single-user mode: hide the Share item entirely (no other users to share // with), rather than disabling it like sharingOff does. @@ -2117,7 +2155,7 @@ function ConversationMenuItems({ // Mobile project sub-view: replaces the entire menu body in place (the // "Back" row flips `view` without closing the menu or navigating). Reachable // only via the mobile project item below, which sits behind the same - // `canEdit && isOwner` gate. + // `isOwner` gate. if (isMobile && view === "projects") { return ( <> @@ -2161,7 +2199,7 @@ function ConversationMenuItems({ {/* Single-user mode has no other users to share with — omit the item entirely rather than showing it disabled. */} {!isSingleUser && - (canManage && !sharingOff ? ( + (isOwner && !sharingOff ? ( setShareOpen(true)}> Share @@ -2176,16 +2214,16 @@ function ConversationMenuItems({ - {/* Sharing-off is server-wide, so it outranks the per-user manage + {/* Sharing-off is server-wide, so it outranks the per-user owner reason when both apply. */} {sharingOff ? "Sharing has been disabled for this Omnigent server." - : "You need manage permissions to share this session"} + : "Only the session owner can share this session"} ))} - {canEdit ? ( + {isOwner ? ( setIsEditing(true)}> Rename @@ -2201,7 +2239,7 @@ function ConversationMenuItems({ - You need edit permissions to rename this session + Only the session owner can rename this session )} @@ -2221,9 +2259,8 @@ function ConversationMenuItems({ )} {/* Projects are a My-sessions-only tool, so filing is owner-only — a - shared session (even editable) shows no project affordance. */} - {canEdit && - isOwner && + shared session shows no project affordance. */} + {isOwner && (isMobile ? ( // Mobile: no room for a side flyout, so this item swaps the menu // body to the project picker in place (see the `view === "projects"` @@ -2438,9 +2475,11 @@ function ConversationRow({ // shows nothing while the archive completes. const [isArchiving, setIsArchiving] = useState(false); const gitBranch = conversation.git_branch ?? null; - const isOwner = isOwnedByViewer(conversation); - const canEdit = conversation.permission_level === null || conversation.permission_level >= 2; - const canManage = conversation.permission_level === null || conversation.permission_level >= 3; + // Every row action gates on ownership alone — the sidebar carries no + // effective-permission level, so rename/share/move/drag are owner-only and + // non-owners get a read-only row. (Finer-grained edit/manage affordances + // live on the open-session view, which fetches the caller's real level.) + const isOwner = isOwnedByViewer(conversation, useViewerId()); // Server-wide sharing kill switch (OMNIGENT_SHARING_MODE=off) reported by // /v1/info — disables the row's Share item even for managers. Fail open // (share enabled) while the capability probe is still loading. @@ -2503,11 +2542,12 @@ function ConversationRow({ ? { kind: "unseen" as const } : derivedState; - // Drag-and-drop: a row is grabbable when the viewer can re-file it (edit - // permission), outside selection / archive / rename modes. Dragging it onto a - // project folder files it there; onto "Chats" unfiles it; onto "Pinned" pins - // it. The list-level routes the drop; the row only advertises - // itself and its source project + pinned state via the draggable `data`. + // Drag-and-drop: a row is grabbable when the viewer owns it (re-filing is + // owner-only, like the Move-to-project kebab item), outside selection / + // archive / rename modes. Dragging it onto a project folder files it there; + // onto "Chats" unfiles it; onto "Pinned" pins it. The list-level + // routes the drop; the row only advertises itself and its source project + + // pinned state via the draggable `data`. const { listeners: dragListeners, setNodeRef: setDragNodeRef, @@ -2515,7 +2555,7 @@ function ConversationRow({ } = useDraggable({ id: conversation.id, data: { type: "session", label, project: currentProject, isPinned }, - disabled: !canEdit || selectionMode || isArchived || isEditing, + disabled: !isOwner || selectionMode || isArchived || isEditing, }); // A drag ends with a synthetic click on the row's (mousedown + mouseup // on the same anchor still fires a click); swallow that one click so a drag @@ -2656,8 +2696,6 @@ function ConversationRow({ isPinned, isArchived, isOwner, - canEdit, - canManage, sharingOff, isSingleUser, canStop, @@ -2704,7 +2742,7 @@ function ConversationRow({ }} onDoubleClick={(e) => { if (selectionMode) return; - if (!canEdit) return; + if (!isOwner) return; e.preventDefault(); setIsEditing(true); }} @@ -3549,6 +3587,7 @@ function BulkActionBar({ const { conversationId: activeId } = useParams<{ conversationId: string }>(); const bulkArchive = useBulkArchiveConversations(); const bulkDelete = useBulkDeleteConversations(); + const viewerId = useViewerId(); const selectedConversations = useMemo( () => allConversations.filter((c) => selectedIds.has(c.id)), @@ -3556,8 +3595,8 @@ function BulkActionBar({ ); const ownedSelected = useMemo( - () => selectedConversations.filter((c) => isOwnedByViewer(c)), - [selectedConversations], + () => selectedConversations.filter((c) => isOwnedByViewer(c, viewerId)), + [selectedConversations, viewerId], ); const archivedSelected = useMemo( From 038bba66e4d18eb61f922567b00eda9343283927 Mon Sep 17 00:00:00 2001 From: dosenr Date: Sun, 19 Jul 2026 04:39:08 +0200 Subject: [PATCH 462/546] fix(acp): make prompt timeout configurable (#2817) * fix(acp): make prompt timeout configurable Signed-off-by: Robert Dosen * docs(acp): document HARNESS_ACP_PROMPT_TIMEOUT_S and tidy timeout code Document the new prompt-timeout env var alongside the other HARNESS_ACP_* vars in the acp_harness module docstring, its discoverability home. Hoist the duplicated validation error string to a single _PROMPT_TIMEOUT_ERR constant, and rework the timeout comments so each constant's comment sits adjacent to it (the init-handshake timeout was left orphaned by the new parsing block). Co-authored-by: Isaac Signed-off-by: Bryan Qiu --------- Signed-off-by: Robert Dosen Signed-off-by: Bryan Qiu Co-authored-by: Bryan Qiu --- omnigent/inner/acp_executor.py | 17 ++++- omnigent/inner/acp_harness.py | 2 + .../inner/test_acp_executor_timeout_config.py | 70 +++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 tests/inner/test_acp_executor_timeout_config.py diff --git a/omnigent/inner/acp_executor.py b/omnigent/inner/acp_executor.py index 2610bae2c16..a80bb388cbc 100644 --- a/omnigent/inner/acp_executor.py +++ b/omnigent/inner/acp_executor.py @@ -49,6 +49,7 @@ import contextlib import json import logging +import math import os import secrets import shlex @@ -105,8 +106,20 @@ _TOOL_STATUS_COMPLETED = "completed" _TOOL_STATUS_FAILED = "failed" -# Idle (time-without-progress) timeouts in seconds. -_PROMPT_TIMEOUT_SECONDS = 300.0 +# Idle (time-without-progress) timeout for a prompt turn, in seconds. +# Some ACP agents stay silent while an external interaction is pending, so +# this is configurable. Parsing is import-time and fail-loud: a malformed, +# non-positive, or non-finite value aborts the ACP child at startup. +_PROMPT_TIMEOUT_ENV = "HARNESS_ACP_PROMPT_TIMEOUT_S" +_PROMPT_TIMEOUT_ERR = f"{_PROMPT_TIMEOUT_ENV} must be a positive finite number of seconds" +try: + _PROMPT_TIMEOUT_SECONDS = float(os.environ.get(_PROMPT_TIMEOUT_ENV, "300")) +except ValueError as exc: + raise ValueError(_PROMPT_TIMEOUT_ERR) from exc +if not math.isfinite(_PROMPT_TIMEOUT_SECONDS) or _PROMPT_TIMEOUT_SECONDS <= 0: + raise ValueError(_PROMPT_TIMEOUT_ERR) + +# Idle timeout for the initial ACP handshake (initialize / session setup). _INIT_TIMEOUT_SECONDS = 30.0 # ACP protocol version this executor targets (matches Goose 1.38 / Qwen). diff --git a/omnigent/inner/acp_harness.py b/omnigent/inner/acp_harness.py index 1b9fa6c18e6..f7da5c311a4 100644 --- a/omnigent/inner/acp_harness.py +++ b/omnigent/inner/acp_harness.py @@ -26,6 +26,8 @@ - ``HARNESS_ACP_SEND_MODEL``: ``"1"`` to send the model in ``session/new``. - ``HARNESS_ACP_OS_ENV``: JSON-encoded :class:`OSEnvSpec`. When unset, falls back to ``caller_process`` + ``sandbox=none``. +- ``HARNESS_ACP_PROMPT_TIMEOUT_S``: optional idle (time-without-progress) deadline in + seconds for a prompt turn (default 300); must be positive and finite or the child aborts. """ from __future__ import annotations diff --git a/tests/inner/test_acp_executor_timeout_config.py b/tests/inner/test_acp_executor_timeout_config.py new file mode 100644 index 00000000000..df10c2bf7f7 --- /dev/null +++ b/tests/inner/test_acp_executor_timeout_config.py @@ -0,0 +1,70 @@ +"""Tests for ACP executor timeout configuration.""" + +from __future__ import annotations + +import os +import subprocess +import sys + +import pytest + +_TIMEOUT_ENV = "HARNESS_ACP_PROMPT_TIMEOUT_S" +_PRINT_TIMEOUT = ( + "from omnigent.inner.acp_executor import _PROMPT_TIMEOUT_SECONDS; " + "print(_PROMPT_TIMEOUT_SECONDS)" +) + + +def _subprocess_env(value: str | None) -> dict[str, str]: + env = os.environ.copy() + if value is None: + env.pop(_TIMEOUT_ENV, None) + else: + env[_TIMEOUT_ENV] = value + return env + + +def test_prompt_timeout_defaults_and_override() -> None: + assert ( + subprocess.check_output( + [sys.executable, "-c", _PRINT_TIMEOUT], + env=_subprocess_env(None), + text=True, + ).strip() + == "300.0" + ) + assert ( + subprocess.check_output( + [sys.executable, "-c", _PRINT_TIMEOUT], + env=_subprocess_env("7200"), + text=True, + ).strip() + == "7200.0" + ) + + +def test_prompt_timeout_malformed_value_fails_loud() -> None: + result = subprocess.run( + [sys.executable, "-c", _PRINT_TIMEOUT], + env=_subprocess_env("not-a-number"), + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert _TIMEOUT_ENV in result.stderr.strip().splitlines()[-1] + + +@pytest.mark.parametrize("value", ["0", "-1", "nan", "inf"]) +def test_prompt_timeout_rejects_non_positive_or_non_finite_values(value: str) -> None: + result = subprocess.run( + [sys.executable, "-c", _PRINT_TIMEOUT], + env=_subprocess_env(value), + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert _TIMEOUT_ENV in result.stderr.strip().splitlines()[-1] From 831fc957e92a3d32ebee0d52474500e91357550d Mon Sep 17 00:00:00 2001 From: Gautam Sharma <148205237+GautamSharma99@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:42:41 +0530 Subject: [PATCH 463/546] fix: bound session stream subscriber queues (#2466) --- omnigent/runtime/session_stream.py | 71 +++++++++++++++++++++++----- omnigent/server/routes/sessions.py | 22 ++++++--- tests/runtime/test_session_stream.py | 34 +++++++++++++ tests/server/test_stream_events.py | 28 +++++++++++ 4 files changed, 136 insertions(+), 19 deletions(-) diff --git a/omnigent/runtime/session_stream.py b/omnigent/runtime/session_stream.py index 3fc66585d4a..7e11e2c79d1 100644 --- a/omnigent/runtime/session_stream.py +++ b/omnigent/runtime/session_stream.py @@ -1,13 +1,14 @@ """Pure pub-sub in-process live stream for real-time SSE delivery. This module is a fan-out broadcaster keyed by ``conversation_id``. -Every active call to :func:`subscribe` owns its own ephemeral -``asyncio.Queue``; :func:`publish` fans the event out to all -queues currently subscribed to that conversation_id. Events emitted -before any subscriber is connected are LOST — there is no buffer -and no replay. Clients that need to recover state across a -disconnect fetch ``GET /v1/sessions/{id}`` for the persisted -history and dedupe by item id. +Every active call to :func:`subscribe` owns its own bounded ephemeral +``asyncio.Queue``; :func:`publish` fans the event out to all queues +currently subscribed to that conversation_id. A subscriber that falls +behind past the bound is disconnected so it can recover through the +snapshot + live-tail reconnect contract. Events emitted before any +subscriber is connected are LOST — there is no buffer and no replay. +Clients that need to recover state across a disconnect fetch +``GET /v1/sessions/{id}`` for the persisted history and dedupe by item id. This module owns no per-conversation lifecycle. There is no ``register`` / ``unregister`` step: the first ``subscribe`` call @@ -35,8 +36,17 @@ _logger = logging.getLogger(__name__) -# Sentinel object that signals end-of-stream to every subscriber. +# A generous burst allowance that still bounds one stalled subscriber's memory. +_SUBSCRIBER_QUEUE_MAX_EVENTS = 1024 + +# Sentinel objects that signal terminal subscriber states. _DONE = object() +_OVERFLOW = object() + + +class SubscriberOverflowError(RuntimeError): + """Raised when a subscriber falls behind the bounded live-event queue.""" + # Subscriber registry: conversation_id -> set of # (queue, event_loop) pairs. The event_loop reference is needed @@ -49,6 +59,25 @@ _lock = threading.Lock() +def _enqueue_or_overflow( + queue: asyncio.Queue[dict[str, Any] | object], + item: dict[str, Any] | object, +) -> None: + """Enqueue *item*, replacing a full backlog with an overflow signal.""" + try: + queue.put_nowait(item) + return + except asyncio.QueueFull: + pass + + while True: + try: + queue.get_nowait() + except asyncio.QueueEmpty: + break + queue.put_nowait(_OVERFLOW) + + def publish(conversation_id: str, event: dict[str, Any]) -> None: """ Broadcast an event to every active subscriber of the given @@ -105,7 +134,7 @@ def publish(conversation_id: str, event: dict[str, Any]) -> None: with _lock: subs = list(_subscribers.get(conversation_id, ())) for queue, loop in subs: - loop.call_soon_threadsafe(queue.put_nowait, event) + loop.call_soon_threadsafe(_enqueue_or_overflow, queue, event) def close(conversation_id: str) -> None: @@ -122,7 +151,7 @@ def close(conversation_id: str) -> None: with _lock: subs = list(_subscribers.get(conversation_id, ())) for queue, loop in subs: - loop.call_soon_threadsafe(queue.put_nowait, _DONE) + loop.call_soon_threadsafe(_enqueue_or_overflow, queue, _DONE) def shutdown_all() -> None: @@ -139,7 +168,7 @@ def shutdown_all() -> None: with _lock: all_subs = [entry for subs in _subscribers.values() for entry in subs] for queue, _ in all_subs: - queue.put_nowait(_DONE) + _enqueue_or_overflow(queue, _DONE) async def subscribe( @@ -153,7 +182,7 @@ async def subscribe( """ Subscribe to live events for a conversation. - Creates a fresh ephemeral queue for this subscriber, registers + Creates a fresh bounded ephemeral queue for this subscriber, registers it under ``conversation_id``, and yields events as they arrive from :func:`publish`. Ends when :func:`close` broadcasts the end-of-stream sentinel or when the caller stops iterating @@ -161,6 +190,13 @@ async def subscribe( ``finally`` block always unregisters this subscriber slot so a stale queue cannot keep accumulating events. + If the subscriber falls more than + :data:`_SUBSCRIBER_QUEUE_MAX_EVENTS` events behind, its queued backlog + is replaced with an overflow signal and this iterator raises + :class:`SubscriberOverflowError`. HTTP/SSE callers treat that as a + dropped transport and reconnect through the persisted snapshot rather + than retaining an unbounded in-memory backlog. + Live-tail only: events emitted before this call are NOT replayed. Multiple concurrent subscribers to the same conversation each see every event independently — there is @@ -208,8 +244,12 @@ async def subscribe( yielded verbatim as it was passed to :func:`publish`, plus synthetic heartbeat dicts when *heartbeat_interval_s* is set. + :raises SubscriberOverflowError: If this subscriber falls behind the + bounded event queue. """ - queue: asyncio.Queue[dict[str, Any] | object] = asyncio.Queue() + queue: asyncio.Queue[dict[str, Any] | object] = asyncio.Queue( + maxsize=_SUBSCRIBER_QUEUE_MAX_EVENTS + ) loop = asyncio.get_running_loop() entry = (queue, loop) with _lock: @@ -271,6 +311,11 @@ async def subscribe( continue if item is _DONE: return + if item is _OVERFLOW: + raise SubscriberOverflowError( + f"session stream subscriber for {conversation_id!r} " + f"exceeded {_SUBSCRIBER_QUEUE_MAX_EVENTS} queued events" + ) assert isinstance(item, dict) yield item finally: diff --git a/omnigent/server/routes/sessions.py b/omnigent/server/routes/sessions.py index 90f67e6204e..b0ab7df2387 100644 --- a/omnigent/server/routes/sessions.py +++ b/omnigent/server/routes/sessions.py @@ -12171,11 +12171,13 @@ async def _stream_live_events( reconcile pre-subscribe state via the snapshot endpoint (``GET /v1/sessions/{id}``) and dedupe by item id. - On client disconnect the subscribe loop breaks; the - ``finally`` block emits a ``[DONE]`` sentinel so well-behaved - SSE consumers see a clean stream termination. The pub-sub - layer auto-cleans this generator's subscriber slot in its own - ``finally`` when iteration exits. + On client disconnect the subscribe loop breaks; the ``finally`` block + emits a ``[DONE]`` sentinel so well-behaved SSE consumers see a clean + stream termination. A subscriber-queue overflow instead ends without + ``[DONE]`` so clients treat it as a dropped transport, reconnect, and + reconcile from the persisted snapshot. The pub-sub layer auto-cleans + this generator's subscriber slot in its own ``finally`` when iteration + exits. Each emitted dict is validated against :data:`ServerStreamEvent` at the wire boundary so a runtime @@ -12235,6 +12237,7 @@ async def _stream_live_events( presence_token = presence.connect( presence_root_id, session_id, viewer_user_id, viewer_idle ) + subscriber_overflowed = False try: async for event in session_stream.subscribe( session_id, @@ -12257,6 +12260,12 @@ async def _stream_live_events( ) validated = _SERVER_STREAM_EVENT_ADAPTER.validate_python(event) yield _format_sse(event_type, validated.model_dump()) + except session_stream.SubscriberOverflowError: + subscriber_overflowed = True + _logger.warning( + "session stream subscriber overflowed for %s; closing for snapshot reconnect", + session_id, + ) finally: # The non-None checks besides presence_token's are type # narrowing only: a minted token implies both were set above. @@ -12266,7 +12275,8 @@ async def _stream_live_events( and presence_root_id is not None ): presence.disconnect(presence_root_id, viewer_user_id, presence_token) - yield "data: [DONE]\n\n" + if not subscriber_overflowed: + yield "data: [DONE]\n\n" # Bounds for per-session native-terminal pass-through args diff --git a/tests/runtime/test_session_stream.py b/tests/runtime/test_session_stream.py index 0ce0a2df055..520da842666 100644 --- a/tests/runtime/test_session_stream.py +++ b/tests/runtime/test_session_stream.py @@ -254,6 +254,40 @@ async def test_subscriber_slot_cleaned_up_on_exit() -> None: ) +@pytest.mark.asyncio +async def test_slow_subscriber_overflow_is_bounded_and_disconnects( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + A subscriber that falls behind is disconnected instead of growing forever. + + The ready event suspends the consumer while keeping its slot registered, + reproducing a backpressured SSE response. Once more events arrive than the + configured queue bound, the stale backlog is replaced by one overflow + signal. Consuming that signal raises and unregisters the slot so the route + can close the transport and let the client reconnect from its snapshot. + """ + monkeypatch.setattr(session_stream, "_SUBSCRIBER_QUEUE_MAX_EVENTS", 2) + conv_id = "conv_slow" + gen = session_stream.subscribe(conv_id, ready_event={"type": "test.ready"}) + + ready = await asyncio.wait_for(gen.__anext__(), timeout=1.0) + assert ready == {"type": "test.ready"} + + session_stream.publish(conv_id, {"type": "test.event", "i": 1}) + session_stream.publish(conv_id, {"type": "test.event", "i": 2}) + session_stream.publish(conv_id, {"type": "test.event", "i": 3}) + await asyncio.sleep(0) + + ((queue, _loop),) = session_stream._subscribers[conv_id] + assert queue.maxsize == 2 + assert queue.qsize() == 1, "overflow must replace the stale backlog with one signal" + + with pytest.raises(session_stream.SubscriberOverflowError, match=conv_id): + await asyncio.wait_for(gen.__anext__(), timeout=1.0) + assert conv_id not in session_stream._subscribers + + # ── Side-channel: pending-elicitations index ───────────────────────── diff --git a/tests/server/test_stream_events.py b/tests/server/test_stream_events.py index ed4b49db8cd..8396bddbc13 100644 --- a/tests/server/test_stream_events.py +++ b/tests/server/test_stream_events.py @@ -466,6 +466,34 @@ def test_policy_denied_format_sse_uses_response_prefixed_wire_name() -> None: assert sse.startswith("event: response.policy_denied\ndata: {") +@pytest.mark.asyncio +async def test_stream_overflow_closes_without_done_for_reconnect( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Subscriber overflow ends as a reconnectable drop, not a clean close.""" + from omnigent.runtime import session_stream + from omnigent.server.routes.sessions import _stream_live_events + + async def overflowing_subscribe(*_args: Any, **_kwargs: Any): + yield {"type": "session.heartbeat"} + raise session_stream.SubscriberOverflowError("conv_slow overflowed") + + class ConnectedRequest: + """Request stub that keeps the stream connected through its first event.""" + + async def is_disconnected(self) -> bool: + """Return ``False`` so overflow, not disconnect, ends the stream.""" + return False + + monkeypatch.setattr(session_stream, "subscribe", overflowing_subscribe) + request: Any = ConnectedRequest() + frames = [frame async for frame in _stream_live_events(request, "conv_slow")] + + assert len(frames) == 1 + assert frames[0].startswith("event: session.heartbeat\n") + assert all("[DONE]" not in frame for frame in frames) + + def test_publish_session_status_helper_uses_waiting_literal() -> None: """``workflow._publish_session_status`` publishes a typed waiting event. From 4f2edef8a27080a585e030d1a86fcf1ef50dc09a Mon Sep 17 00:00:00 2001 From: Nikhil Chakre Date: Sat, 18 Jul 2026 23:26:03 -0400 Subject: [PATCH 464/546] fix(runtime): drop parallel tool-call batches atomically in Layer-3 compaction (#2449) --- omnigent/runtime/compaction.py | 28 +++++++----- tests/runtime/test_compaction.py | 76 ++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/omnigent/runtime/compaction.py b/omnigent/runtime/compaction.py index f50b0936f3e..cd6b898c2e4 100644 --- a/omnigent/runtime/compaction.py +++ b/omnigent/runtime/compaction.py @@ -324,23 +324,27 @@ def _pair_aware_drop_count(messages: list[dict[str, Any]]) -> int: Return how many items to drop from the front to avoid orphaning a tool call pair. - If the first item is a ``function_call`` and the second is its - matching ``function_call_output``, both are dropped together. - Otherwise, a single item is dropped. + Recognizes a leading run of ``function_call`` items immediately + followed by a matching run of ``function_call_output`` items + (same call_ids) and drops the whole batch together, covering + parallel tool calls in one turn, not just a single pair. + Otherwise, drops a single item. :param messages: The messages list (must be non-empty). - :returns: Number of items to drop (1 or 2), or 0 if the list - is empty. + :returns: Number of items to drop, or 0 if the list is empty. """ if not messages: return 0 - if ( - len(messages) >= 2 - and messages[0].get("type") == "function_call" - and messages[1].get("type") == "function_call_output" - and messages[0].get("call_id") == messages[1].get("call_id") - ): - return 2 + call_count = 0 + while call_count < len(messages) and messages[call_count].get("type") == "function_call": + call_count += 1 + if call_count == 0: + return 1 + call_ids = {m.get("call_id") for m in messages[:call_count]} + outputs = messages[call_count : call_count * 2] + output_ids = {m.get("call_id") for m in outputs if m.get("type") == "function_call_output"} + if len(outputs) == call_count and output_ids == call_ids: + return call_count * 2 return 1 diff --git a/tests/runtime/test_compaction.py b/tests/runtime/test_compaction.py index c62c2cd7c0c..34d6f16ea0f 100644 --- a/tests/runtime/test_compaction.py +++ b/tests/runtime/test_compaction.py @@ -1269,6 +1269,44 @@ def test_pair_aware_drop_count_returns_zero_for_empty() -> None: assert _pair_aware_drop_count([]) == 0 +def test_pair_aware_drop_count_drops_parallel_call_batch_together() -> None: + """ + Parallel tool calls (two function_calls before either output + arrives) must be dropped as one atomic batch. + + If only the first function_call were dropped, the surviving + function_call_output for that call_id would be orphaned, which + mainstream LLM APIs reject. + """ + messages = [ + {"type": "function_call", "call_id": "c1", "name": "read_file", "arguments": "{}"}, + {"type": "function_call", "call_id": "c2", "name": "read_file", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "c1", "output": "contents 1"}, + {"type": "function_call_output", "call_id": "c2", "output": "contents 2"}, + _user_msg_dict("after the batch"), + ] + assert _pair_aware_drop_count(messages) == 4, ( + "Expected 4 (drop both calls and both outputs together). " + "A smaller count would orphan a function_call_output." + ) + + +def test_pair_aware_drop_count_falls_back_when_batch_incomplete() -> None: + """ + A leading run of function_calls not immediately followed by a + matching run of outputs (same call_ids) falls back to dropping + just one item, same as any other unrecognized shape. + """ + messages = [ + {"type": "function_call", "call_id": "c1", "name": "grep", "arguments": "{}"}, + {"type": "function_call", "call_id": "c2", "name": "grep", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "c1", "output": "result"}, + _user_msg_dict("interrupts before c2's output"), + {"type": "function_call_output", "call_id": "c2", "output": "result"}, + ] + assert _pair_aware_drop_count(messages) == 1 + + def test_truncate_oldest_preserves_tool_call_pairs( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1323,6 +1361,44 @@ def mock_count_tokens(msgs: list[dict[str, Any]], model: str) -> int: ) +def test_truncate_oldest_preserves_parallel_tool_call_batch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + _truncate_oldest drops an entire parallel tool-call batch + (two function_calls + their two outputs) together, never + leaving an orphaned function_call_output. + """ + call_count = [0] + + def mock_count_tokens(msgs: list[dict[str, Any]], model: str) -> int: + call_count[0] += 1 + # Above budget first, then below budget once the batch is dropped. + return 10000 if call_count[0] == 1 else 50 + + monkeypatch.setattr( + "omnigent.runtime.compaction.count_tokens", + mock_count_tokens, + ) + + messages = [ + {"type": "function_call", "call_id": "c1", "name": "read_file", "arguments": "{}"}, + {"type": "function_call", "call_id": "c2", "name": "read_file", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "c1", "output": "contents 1"}, + {"type": "function_call_output", "call_id": "c2", "output": "contents 2"}, + _user_msg_dict("kept message"), + ] + + result = _truncate_oldest(messages, budget=100, model="test") + + assert len(result) == 1, ( + f"Expected 1 message after dropping the parallel-call batch, " + f"got {len(result)}. A partial drop would orphan a " + f"function_call_output." + ) + assert result[0]["role"] == "user" + + @pytest.mark.asyncio async def test_compaction_strips_annotations_before_summarization( monkeypatch: pytest.MonkeyPatch, From ef529843dab3ce6a2c869e063fb3d79a3e04a45a Mon Sep 17 00:00:00 2001 From: Nikhil Chakre Date: Sat, 18 Jul 2026 23:51:21 -0400 Subject: [PATCH 465/546] fix(runner): clear in-flight marker on a live-turn context overflow (#2869) A context overflow on a live (stream=true) turn raised _ContextWindowOverflow uncaught, since only the background-turn path caught it, so the process manager's in-flight marker never cleared and the harness subprocess leaked forever. Catch it inside proxy_stream() itself so both paths clean up the same way. Adds a regression test confirmed to fail before this fix and pass after. Signed-off-by: Nick Chakre --- omnigent/runner/app.py | 93 ++++++++++++------------ tests/runner/test_app_sessions_native.py | 67 +++++++++++++++++ 2 files changed, 115 insertions(+), 45 deletions(-) diff --git a/omnigent/runner/app.py b/omnigent/runner/app.py index 82794cea1d8..288a7d3ebfd 100644 --- a/omnigent/runner/app.py +++ b/omnigent/runner/app.py @@ -6872,13 +6872,12 @@ def _wrap_as_message_event(body: dict[str, Any]) -> dict[str, Any]: class _ContextWindowOverflow(Exception): """ - Raised by the proxy_stream when the harness reports a context-window overflow. + Raised and caught inside ``proxy_stream`` when the harness reports a + context-window overflow, so both live and background turns end the + same way. - Caught by ``_run_turn_bg_setup_and_stream`` to end the turn with - a descriptive error. - - :param max_tokens: The model's context window, e.g. ``128000``. - :param actual_tokens: The prompt size that overflowed, e.g. ``131072``. + :param max_tokens: The model's context window. + :param actual_tokens: The prompt size that overflowed. """ def __init__(self, max_tokens: int, actual_tokens: int) -> None: @@ -14048,48 +14047,31 @@ async def _run_turn_bg_setup_and_stream( await_notify=False, ) - try: - response = await _stream_message_to_harness( - harness_body, - conv, - dispatch=ctx, - ) - if isinstance(response, StreamingResponse): - await _drain_streaming_response(response, conv) - else: - err_detail = "harness returned error response" - if hasattr(response, "body"): - with contextlib.suppress( - UnicodeDecodeError, - AttributeError, - ): - err_detail = response.body.decode( - "utf-8", - )[:200] - _logger.error( - "turn bg error for %s: %s", - conv, - err_detail, - ) - _on_proxy_stream_end( - conv, - error={"message": err_detail}, - ) - except _ContextWindowOverflow as overflow: + response = await _stream_message_to_harness( + harness_body, + conv, + dispatch=ctx, + ) + if isinstance(response, StreamingResponse): + await _drain_streaming_response(response, conv) + else: + err_detail = "harness returned error response" + if hasattr(response, "body"): + with contextlib.suppress( + UnicodeDecodeError, + AttributeError, + ): + err_detail = response.body.decode( + "utf-8", + )[:200] _logger.error( - "Context window exceeded for session=%s: %d > %d", + "turn bg error for %s: %s", conv, - overflow.actual_tokens, - overflow.max_tokens, + err_detail, ) _on_proxy_stream_end( conv, - error={ - "message": ( - f"Context window exceeded: {overflow.actual_tokens} tokens " - f"> {overflow.max_tokens} max" - ), - }, + error={"message": err_detail}, ) async def _drain_streaming_response( @@ -14121,8 +14103,6 @@ async def _drain_streaming_response( _live_response_id.pop(session_id, None) _publish_turn_status(session_id, "idle") raise - except _ContextWindowOverflow: - raise except (httpx.HTTPError, RuntimeError, StopAsyncIteration) as exc: _logger.error( "drain failed for %s: %s", @@ -14879,6 +14859,29 @@ async def proxy_stream(): _on_proxy_stream_end(conv_id, error=_stream_failed_error) + except _ContextWindowOverflow as overflow: + # Handled here, not by the callers of proxy_stream, so the + # in-flight marker is cleared on every caller (live-stream + # and background turns alike). Missing this used to leave + # the marker set forever, hiding the harness process from + # the idle reaper for the rest of the server's lifetime. + _error = { + "code": "context_length_exceeded", + "message": ( + f"Context window exceeded: {overflow.actual_tokens} tokens " + f"> {overflow.max_tokens} max" + ), + "type": "_ContextWindowOverflow", + } + _overflow_fail = { + "type": "response.failed", + "response": {"status": "failed", "error": _error}, + "error": _error, + } + _publish_event(conv_id, _overflow_fail) + _on_proxy_stream_end(conv_id, error=_error) + yield _response_failed_event(_error) + except (httpx.HTTPError, RuntimeError) as exc: # RuntimeError covers httpx.StreamClosed which # is NOT an HTTPError subclass — raised when the diff --git a/tests/runner/test_app_sessions_native.py b/tests/runner/test_app_sessions_native.py index b0ab3fb42aa..f0189094613 100644 --- a/tests/runner/test_app_sessions_native.py +++ b/tests/runner/test_app_sessions_native.py @@ -996,6 +996,73 @@ async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec: assert pm.cleared_in_flight == ["9217a860245985f541fd686eb2a32b73"], pm.cleared_in_flight +@pytest.mark.asyncio +async def test_sessions_native_clears_in_flight_on_context_overflow_live_stream() -> None: + """clear_in_flight fires for a live (``stream=true``) turn that overflows context. + + Regression for a leak where a context-window overflow on the live-stream + path left the reaper's in-flight marker set forever: proxy_stream raised + _ContextWindowOverflow uncaught on this path, so _on_proxy_stream_end never + ran and the idle reaper (which skips anything in-flight) never reclaimed + the harness. The background-turn path already handled this; live turns did + not. + """ + sse_frames = [ + _sse({"type": "response.created", "response": {"id": "resp_overflow"}}), + _sse( + { + "type": "response.failed", + "error": { + "message": ( + "context_length_exceeded: 5000 tokens > 4096 maximum context length" + ), + "code": "context_length_exceeded", + }, + } + ), + ] + harness_client = _ScriptedHarnessClient(sse_frames) + pm = _FakeProcessManager(harness_client) + spec = AgentSpec(spec_version=1, name="plain-agent") + + async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec: + del agent_id, session_id + return spec + + app = create_runner_app( + process_manager=pm, # type: ignore[arg-type] + spec_resolver=_resolver, + server_client=NullServerClient(), # type: ignore[arg-type] + ) + conv_id = "b4f6a4f0f2f74d76a2e4c0c9a8e0f9aa" + async with _runner_client(app) as client: + resp = await client.post( + f"/v1/sessions/{conv_id}/events?stream=true", + json={ + "type": "message", + "role": "user", + "agent_id": "965906f5d9fb596610dda599a80faaee", + "model": "plain-agent", + "content": [{"type": "input_text", "text": "hi"}], + "harness": "openai-agents", + }, + ) + # Drain the live SSE stream like a real browser client would. Pre-fix + # this can surface the uncaught overflow as a transport error; either + # way the assertions below are what pin the regression. + with contextlib.suppress(Exception): + async for _chunk in resp.aiter_text(): + pass + + # Marked live on response.created, then cleared despite the overflow. + assert pm.marked_in_flight == [(conv_id, "resp_overflow")], pm.marked_in_flight + assert pm.cleared_in_flight == [conv_id], ( + f"in-flight marker never cleared on live-stream context overflow " + f"(got {pm.cleared_in_flight}) -- the reaper would skip this " + f"conversation's harness forever" + ) + + @pytest.mark.asyncio async def test_stop_session_clears_in_flight_marker() -> None: """A mid-stream cancel clears the reaper's in-flight marker. From e738ea784040b751fcbc0a7945c0e9c5ca7d3f55 Mon Sep 17 00:00:00 2001 From: leveragedloop Date: Sun, 19 Jul 2026 07:59:37 +0300 Subject: [PATCH 466/546] fix: derive sub-agent snapshot metadata from child spec (#2408) * fix: derive sub-agent snapshot metadata from child spec Signed-off-by: Thomas * style: move session snapshot imports to module scope Signed-off-by: Thomas --------- Signed-off-by: Thomas Co-authored-by: Thomas --- omnigent/server/routes/sessions.py | 9 +- tests/server/routes/test_sessions_snapshot.py | 86 +++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/omnigent/server/routes/sessions.py b/omnigent/server/routes/sessions.py index b0ab7df2387..35a5695a857 100644 --- a/omnigent/server/routes/sessions.py +++ b/omnigent/server/routes/sessions.py @@ -97,6 +97,7 @@ from omnigent.host.frames import ( HARNESS_NOT_CONFIGURED_ERROR_CODE as _HARNESS_NOT_CONFIGURED_ERROR_CODE, ) +from omnigent.llms.context_window import resolve_effective_context_window from omnigent.model_override import validate_model_override from omnigent.native_coding_agents import ( native_coding_agent_for_agent_name, @@ -144,6 +145,7 @@ ) from omnigent.runtime.policies.engine import PolicyEngine from omnigent.runtime.tool_output import cap_tool_output +from omnigent.runtime.workflow import _find_spec_by_name from omnigent.server import presence, session_live_state from omnigent.server._elicitation_registry import ( _harness_elicitation_owners, @@ -22468,6 +22470,10 @@ async def _get_session_snapshot( agent_cache.load, agent.id, agent.bundle_location ) spec = loaded.spec + if conv.sub_agent_name: + child_spec = _find_spec_by_name(spec, conv.sub_agent_name) + if child_spec is not None: + spec = child_spec # Prefer the spec's name over the agent row's: a # switch-created session-scoped clone is named # " (switch ag_…)" for row disambiguation, @@ -22476,9 +22482,6 @@ async def _get_session_snapshot( if spec.name: agent_name = spec.name llm_model = spec.executor.model - from omnigent.llms.context_window import ( - resolve_effective_context_window, - ) # Size the context ring against whatever the next turn will # actually run, using the SAME resolver the runner uses to diff --git a/tests/server/routes/test_sessions_snapshot.py b/tests/server/routes/test_sessions_snapshot.py index 9614550528a..5b35afa6c7c 100644 --- a/tests/server/routes/test_sessions_snapshot.py +++ b/tests/server/routes/test_sessions_snapshot.py @@ -18,6 +18,7 @@ _publish_subtree_cost_to_ancestors, _truncate_label, ) +from omnigent.spec.types import AgentSpec, ExecutorSpec async def _drain_runner_skills(session_id: str) -> None: @@ -183,6 +184,91 @@ async def test_session_snapshot_reads_latest_items_then_returns_chronological() assert snapshot.status == "idle" +@pytest.mark.asyncio +async def test_session_snapshot_uses_child_spec_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Child snapshots expose their selected spec while parents keep the root spec.""" + child_spec = AgentSpec( + spec_version=1, + name="executor", + executor=ExecutorSpec( + config={"harness": "codex"}, + model="openai-codex/gpt-5.6-sol:medium", + context_window=100_000, + ), + ) + parent_spec = AgentSpec( + spec_version=1, + name="advisor", + executor=ExecutorSpec( + config={"harness": "codex"}, + model="openai-codex/gpt-5.6-sol:high", + context_window=200_000, + ), + sub_agents=[child_spec], + ) + conversations = { + "conv_parent": Conversation( + id="conv_parent", + created_at=1, + updated_at=1, + root_conversation_id="conv_parent", + agent_id="ag_advisor", + ), + "conv_child": Conversation( + id="conv_child", + created_at=1, + updated_at=1, + root_conversation_id="conv_parent", + parent_conversation_id="conv_parent", + agent_id="ag_advisor", + kind="sub_agent", + sub_agent_name="executor", + ), + } + conv_store = _ConversationStore([], conversations=conversations) + + class _AgentStore: + @staticmethod + def get(agent_id: str) -> Any: + assert agent_id == "ag_advisor" + return type( + "StoredAgent", + (), + {"id": agent_id, "name": "advisor-row", "bundle_location": "bundle"}, + )() + + class _AgentCache: + @staticmethod + def load(agent_id: str, bundle_location: str) -> Any: + assert (agent_id, bundle_location) == ("ag_advisor", "bundle") + return type("LoadedAgent", (), {"spec": parent_spec})() + + monkeypatch.setattr("omnigent.runtime.get_runner_client", lambda: None) + monkeypatch.setattr("omnigent.runtime.get_runner_router", lambda: None) + + parent = await _get_session_snapshot( + conv_store, # type: ignore[arg-type] + "conv_parent", + agent_store=_AgentStore(), # type: ignore[arg-type] + agent_cache=_AgentCache(), # type: ignore[arg-type] + ) + child = await _get_session_snapshot( + conv_store, # type: ignore[arg-type] + "conv_child", + agent_store=_AgentStore(), # type: ignore[arg-type] + agent_cache=_AgentCache(), # type: ignore[arg-type] + ) + + assert parent.agent_name == "advisor" + assert parent.llm_model == "openai-codex/gpt-5.6-sol:high" + assert parent.context_window == 200_000 + assert child.agent_name == "executor" + assert child.llm_model == "openai-codex/gpt-5.6-sol:medium" + assert child.context_window == 100_000 + + @pytest.mark.asyncio async def test_session_snapshot_populates_runner_online_from_session_lookup() -> None: """GET /sessions/{id} carries session-scoped runner + host liveness.""" From 7da32637a5eeba1c47431fe21fca948ced9b779e Mon Sep 17 00:00:00 2001 From: Anthony Ivan <21217602+anthonyivn2@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:56:41 +0800 Subject: [PATCH 467/546] Clarify parallel subagent title requirements (#2860) --- omnigent/runner/tool_dispatch.py | 8 ++++++-- omnigent/tools/builtins/spawn.py | 21 ++++++++++++--------- tests/tools/builtins/test_spawn.py | 10 ++++++++++ 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/omnigent/runner/tool_dispatch.py b/omnigent/runner/tool_dispatch.py index 0a3647401c3..859a86646bc 100644 --- a/omnigent/runner/tool_dispatch.py +++ b/omnigent/runner/tool_dispatch.py @@ -1599,12 +1599,16 @@ async def _execute_subagent_tool( ): return ( f"Error: sub-agent {sub_agent_name!r} title {session_name!r} " - "already has a launching or running turn; wait for completion before sending again" + "already has a launching or running turn. Use a distinct task-based title " + "for independent parallel work; reuse this title only to continue the same " + "conversation after completion." ) if existing.get("busy") is True: return ( f"Error: sub-agent {sub_agent_name!r} title {session_name!r} " - "is already running; wait for completion before sending again" + "is already running. Use a distinct task-based title for independent " + "parallel work; reuse this title only to continue the same conversation " + "after completion." ) else: child_harness = _subagent_harness(str(sub_agent_name), agent_spec) diff --git a/omnigent/tools/builtins/spawn.py b/omnigent/tools/builtins/spawn.py index 29900e53c6a..a01acc9c604 100644 --- a/omnigent/tools/builtins/spawn.py +++ b/omnigent/tools/builtins/spawn.py @@ -127,8 +127,10 @@ def description(cls) -> str: "of (agent + title) or session_id, always with args. " "Returns the child's output when its turn completes. To run " "multiple sessions in parallel, emit multiple " - "sys_session_send tool_calls in the same response — they " - "dispatch concurrently. " + "sys_session_send tool_calls in the same response with a " + "distinct task-based title for each independent session — " + "they dispatch concurrently. Reusing a title continues the " + "same session and cannot run another turn concurrently. " "To attach previously-uploaded files, " "pass their file ids via the object args form's 'file_ids' " "list on the first named (agent, title) send only; file_ids " @@ -227,13 +229,14 @@ def _build_sys_session_send_schema( "type": "string", "description": ( "Named mode: a unique-within-this-parent " - "label for the sub-agent session, e.g. " - "'auth' or 'payments'. Lets later turns " - "reuse the same conversation via another " - "sys_session_send call with the same " - "title. Titles must be distinct under one " - "parent for the same agent. Pair with " - "'agent'; omit when using 'session_id'." + "task-based identity for the sub-agent session, " + "e.g. 'auth' or 'payments'. Reusing it in a later " + "sys_session_send call continues the same " + "conversation. Every independent parallel call " + "for the same agent must use a distinct title; " + "reusing a title cannot start another concurrent " + "turn. Pair with 'agent'; omit when using " + "'session_id'." ), }, } diff --git a/tests/tools/builtins/test_spawn.py b/tests/tools/builtins/test_spawn.py index 1d8809f0c58..78fc0d88b1e 100644 --- a/tests/tools/builtins/test_spawn.py +++ b/tests/tools/builtins/test_spawn.py @@ -76,6 +76,16 @@ def test_file_ids_description_mentions_fresh_named_spawn_only() -> None: assert "continuing an existing named session" in file_ids_description +def test_parallel_description_requires_distinct_titles() -> None: + schema = _schema_with_subagent() + description = schema["function"]["description"] + title_description = schema["function"]["parameters"]["properties"]["title"]["description"] + + assert "distinct task-based title" in description + assert "Every independent parallel call" in title_description + assert "cannot start another concurrent turn" in title_description + + # ── Happy paths ─────────────────────────────────────── From 4581b77164a57a4a7b022e1960c7a20b78938f6c Mon Sep 17 00:00:00 2001 From: Pat Sukprasert Date: Mon, 20 Jul 2026 11:37:36 +0800 Subject: [PATCH 468/546] fix(harness): resolve CLI binaries off the daemon's frozen PATH in readiness gates (#2805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(harness): resolve CLI binaries off the daemon's frozen PATH in readiness gates Follow-up to the codex/claude resolver fix. The general readiness gates still probed bare shutil.which(spec.binary), so a claude-native / cursor-native / kiro-native / etc. CLI installed into an nvm/npm-managed global bin dir (only on PATH via interactive shell init) could still be reported 'binary missing' by the host daemon, whose PATH snapshot omits that dir — the same split the codex fix closed for its own gate. Route harness_cli_installed, missing_harness_cli, and the harness_is_configured fallback gate through the shared resolve_cli_binary (PATH -> global-dir ladder), so readiness matches what the launch will see for every CLI harness. install_harness_cli keeps a bare shutil.which check: it runs in the setup flow's own process, where the ~/.local/bin PATH refresh (and the subsequent bare-binary login shell-outs) depend on the binary being reachable via this process's PATH. Co-authored-by: Isaac Signed-off-by: Pat Sukprasert * refactor(harness): drop unreachable spec-None guards in install_harness_cli Past harness_install_command(key), a spec-less key has already raised KeyError, so spec is non-None — the 'if spec is not None' guards and the trailing 'return False' were dead. Assert the invariant instead, per PR review. Co-authored-by: Isaac Signed-off-by: Pat Sukprasert * test(harness): patch resolve_cli_binary, not readiness.shutil The harness_is_configured fallback gate now resolves via resolve_cli_binary (shutil was dropped from harness_readiness), so the community-harness readiness test must patch that instead of the removed readiness.shutil. Co-authored-by: Isaac Signed-off-by: Pat Sukprasert --------- Signed-off-by: Pat Sukprasert --- omnigent/onboarding/harness_install.py | 57 ++++++++++++++---------- omnigent/onboarding/harness_readiness.py | 4 +- tests/onboarding/test_harness_install.py | 42 +++++++++++++++-- tests/test_harness_plugins.py | 4 +- 4 files changed, 77 insertions(+), 30 deletions(-) diff --git a/omnigent/onboarding/harness_install.py b/omnigent/onboarding/harness_install.py index a2d37c358e7..5d8f8b145d7 100644 --- a/omnigent/onboarding/harness_install.py +++ b/omnigent/onboarding/harness_install.py @@ -42,6 +42,7 @@ import sys from pathlib import Path +from omnigent._platform import resolve_cli_binary from omnigent.harness_install_spec import HarnessInstallSpec from omnigent.onboarding.provider_config import ANTHROPIC_FAMILY, GEMINI_FAMILY, OPENAI_FAMILY @@ -298,14 +299,15 @@ def required_cli_for_harness(harness: str) -> HarnessInstallSpec | None: def missing_harness_cli(harness: str) -> HarnessInstallSpec | None: - """Return a harness's required CLI spec when that CLI is absent from ``PATH``. + """Return a harness's required CLI spec when that CLI can't be resolved. Combines :func:`required_cli_for_harness` with the same - ``shutil.which`` probe :func:`harness_cli_installed` uses, so the - verdict matches what the harness's own launch will see (both read the - process ``PATH``). Used by sub-agent dispatch to fail loud *before* - spawning a worker whose harness can never boot here, instead of letting - the missing binary surface as a lazy, generic turn failure. + :func:`resolve_cli_binary` probe :func:`harness_cli_installed` uses, so the + verdict matches what the harness's own launch will see (both check ``PATH`` + plus the common global install dirs the host daemon's frozen ``PATH`` may + omit). Used by sub-agent dispatch to fail loud *before* spawning a worker + whose harness can never boot here, instead of letting the missing binary + surface as a lazy, generic turn failure. :param harness: An executor harness identifier, e.g. ``"pi"`` or ``"claude-native"``. @@ -316,7 +318,7 @@ def missing_harness_cli(harness: str) -> HarnessInstallSpec | None: spec = required_cli_for_harness(harness) if spec is None: return None - if shutil.which(spec.binary) is not None: + if resolve_cli_binary(spec.binary) is not None: return None return spec @@ -364,21 +366,23 @@ def harness_install_spec(key: str) -> HarnessInstallSpec | None: def harness_cli_installed(key: str) -> bool: - """Return whether the harness's CLI binary is on ``PATH``. + """Return whether the harness's CLI binary can be resolved. - "Installed" is deliberately the CLI binary (``shutil.which``), matching - ucode and the npm install-prompt UX — even though the SDK-based - ``claude-sdk`` harness can run without the ``claude`` CLI. + "Installed" is deliberately the CLI binary (:func:`resolve_cli_binary` — + ``PATH`` plus the common global install dirs the host daemon's frozen + ``PATH`` may omit), matching ucode and the npm install-prompt UX — even + though the SDK-based ``claude-sdk`` harness can run without the ``claude`` + CLI. :param key: A harness family (``"anthropic"`` / ``"openai"``) or :data:`PI_KEY` / :data:`KIMI_KEY`. - :returns: ``True`` when the CLI is on ``PATH``; ``False`` when it isn't or + :returns: ``True`` when the CLI resolves; ``False`` when it doesn't or the key has no associated CLI. """ spec = harness_install_spec(key) if spec is None: return False - return shutil.which(spec.binary) is not None + return resolve_cli_binary(spec.binary) is not None def harness_install_command(key: str) -> list[str]: @@ -429,20 +433,27 @@ def install_harness_cli(key: str) -> bool: subprocess.run(cmd, check=False, timeout=300) except (OSError, subprocess.TimeoutExpired): return False - if harness_cli_installed(key): + # harness_install_command would have raised for a spec-less key, so spec is + # non-None past this point. + assert spec is not None + # This is the setup flow's own process: check bare ``PATH`` (not the + # resolve_cli_binary ladder), because the point of the ~/.local/bin refresh + # below is to make the binary reachable via ``PATH`` for this process — the + # subsequent harness_login/harness_cli_logged_in shell out with the bare + # binary name and rely on the inherited ``PATH``. + if shutil.which(spec.binary) is not None: return True # uv-based vendor installers commonly place entry points here and update # shell startup files, which cannot change this already-running process. - if spec is not None: - user_bin = Path.home() / ".local" / "bin" - candidate = user_bin / spec.binary - if candidate.is_file() and os.access(candidate, os.X_OK): - current_path = os.environ.get("PATH", "") - path_entries = current_path.split(os.pathsep) if current_path else [] - if str(user_bin) not in path_entries: - os.environ["PATH"] = os.pathsep.join([str(user_bin), *path_entries]) - return harness_cli_installed(key) + user_bin = Path.home() / ".local" / "bin" + candidate = user_bin / spec.binary + if candidate.is_file() and os.access(candidate, os.X_OK): + current_path = os.environ.get("PATH", "") + path_entries = current_path.split(os.pathsep) if current_path else [] + if str(user_bin) not in path_entries: + os.environ["PATH"] = os.pathsep.join([str(user_bin), *path_entries]) + return shutil.which(spec.binary) is not None def harness_cli_logged_in(key: str) -> bool: diff --git a/omnigent/onboarding/harness_readiness.py b/omnigent/onboarding/harness_readiness.py index 94c70450868..4b6d376b003 100644 --- a/omnigent/onboarding/harness_readiness.py +++ b/omnigent/onboarding/harness_readiness.py @@ -25,10 +25,10 @@ from __future__ import annotations import os -import shutil from collections.abc import Callable import omnigent.onboarding.gemini_auth as _gemini_auth +from omnigent._platform import resolve_cli_binary from omnigent.harness_aliases import HARNESS_ALIASES, canonicalize_harness from omnigent.harness_plugins import harness_install_keys, valid_harnesses from omnigent.onboarding.harness_install import ( @@ -269,7 +269,7 @@ def harness_is_configured(harness: str) -> bool: ): required_cli = required_cli_for_harness(canonical) or required_cli_for_harness(harness) if required_cli is not None: - return shutil.which(required_cli.binary) is not None + return resolve_cli_binary(required_cli.binary) is not None # Unknown harness — the daemon has no install metadata for it, so # it can't assess readiness. Fail open (custom/newer harnesses, # version skew). diff --git a/tests/onboarding/test_harness_install.py b/tests/onboarding/test_harness_install.py index b586a58295a..b2feeadd3c9 100644 --- a/tests/onboarding/test_harness_install.py +++ b/tests/onboarding/test_harness_install.py @@ -7,10 +7,25 @@ import pytest +import omnigent._platform as _platform from omnigent.onboarding import harness_install as hi from omnigent.onboarding.provider_config import ANTHROPIC_FAMILY, GEMINI_FAMILY, OPENAI_FAMILY +@pytest.fixture(autouse=True) +def _stub_cli_fallback_dirs(monkeypatch: pytest.MonkeyPatch) -> None: + """Reduce ``resolve_cli_binary`` to a pure ``PATH`` probe under test. + + ``harness_cli_installed`` / ``missing_harness_cli`` resolve via + ``resolve_cli_binary``, which also probes on-disk global install dirs + (``~/.local/bin``, nvm, …). Tests here stub ``shutil.which`` to simulate a + binary's presence/absence; stub the fallback dirs to empty too so a + developer's real claude/codex install can't flip a ``which``-returns-None + assertion. + """ + monkeypatch.setattr(_platform, "_cli_fallback_dirs", lambda: ()) + + @pytest.mark.parametrize( "key,binary,package", [ @@ -307,10 +322,11 @@ def test_missing_harness_cli_none_for_sdk_harness(monkeypatch: pytest.MonkeyPatc def test_cli_installed_reflects_which(monkeypatch: pytest.MonkeyPatch) -> None: - """``harness_cli_installed`` is exactly ``shutil.which(binary) is not None``. + """``harness_cli_installed`` follows ``resolve_cli_binary``. - Present → True; absent → False — the signal the configure ✗ marker and the - run gating both read. + On ``PATH`` → True; unresolvable (the autouse fixture stubs the fallback + dirs empty) → False — the signal the configure ✗ marker and the run gating + both read. """ monkeypatch.setattr(hi.shutil, "which", lambda name: f"/usr/bin/{name}") assert hi.harness_cli_installed(ANTHROPIC_FAMILY) is True @@ -319,6 +335,26 @@ def test_cli_installed_reflects_which(monkeypatch: pytest.MonkeyPatch) -> None: assert hi.harness_cli_installed(ANTHROPIC_FAMILY) is False +def test_cli_installed_finds_binary_off_path( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A CLI in a global install dir but off ``PATH`` still reads installed. + + This is the reported nvm case: the host daemon's frozen ``PATH`` omits the + bin dir, so bare ``shutil.which`` misses it, but ``resolve_cli_binary``'s + fallback ladder finds it on disk. Readiness must not report it missing. + """ + fallback_dir = tmp_path / "bin" + fallback_dir.mkdir() + claude = fallback_dir / "claude" + claude.write_text("#!/bin/sh\n") + claude.chmod(0o755) + monkeypatch.setattr(hi.shutil, "which", lambda name: None) + monkeypatch.setattr(_platform, "_cli_fallback_dirs", lambda: (fallback_dir,)) + assert hi.harness_cli_installed(ANTHROPIC_FAMILY) is True + assert hi.missing_harness_cli("claude-native") is None + + def test_install_harness_cli_requires_npm(monkeypatch: pytest.MonkeyPatch) -> None: """No npm on PATH → install short-circuits to False without shelling out.""" monkeypatch.setattr(hi.shutil, "which", lambda name: None) diff --git a/tests/test_harness_plugins.py b/tests/test_harness_plugins.py index 262b56592d6..60e8a480d04 100644 --- a/tests/test_harness_plugins.py +++ b/tests/test_harness_plugins.py @@ -182,13 +182,13 @@ def _contribution() -> hp.HarnessContribution: from omnigent.onboarding import harness_readiness as readiness - monkeypatch.setattr(readiness.shutil, "which", lambda _binary: None) + monkeypatch.setattr(readiness, "resolve_cli_binary", lambda _binary: None) assert readiness.harness_is_configured("foo") is False configured = readiness.configured_harness_map() assert configured["foo"] is False assert configured["foo-code"] is False - monkeypatch.setattr(readiness.shutil, "which", lambda binary: f"/usr/bin/{binary}") + monkeypatch.setattr(readiness, "resolve_cli_binary", lambda binary: f"/usr/bin/{binary}") assert readiness.harness_is_configured("foo") is True From a0b23b1a808a97b800c31e04c3cf48f71eb9d13c Mon Sep 17 00:00:00 2001 From: Pat Sukprasert Date: Mon, 20 Jul 2026 14:03:00 +0800 Subject: [PATCH 469/546] =?UTF-8?q?=E2=9C=85=20test(e2e-ui):=20stabilize?= =?UTF-8?q?=20nightly=20journeys=20(#2896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Script isolated parent and child queues for multi-agent UI flows - Route Codex mocks through /v1 and select approval actions exactly Closes #1783 Signed-off-by: Pat Sukprasert --- tests/e2e_ui/agents/conftest.py | 65 ++++++++++++++++- .../e2e_ui/agents/test_subagent_navigation.py | 10 +-- tests/e2e_ui/approvals/test_inbox_approval.py | 6 +- tests/e2e_ui/chat/test_two_agent_chat.py | 24 +++--- tests/e2e_ui/conftest.py | 73 ++++++++++++++++++- 5 files changed, 152 insertions(+), 26 deletions(-) diff --git a/tests/e2e_ui/agents/conftest.py b/tests/e2e_ui/agents/conftest.py index fad440a32b9..6e2b55b7158 100644 --- a/tests/e2e_ui/agents/conftest.py +++ b/tests/e2e_ui/agents/conftest.py @@ -29,7 +29,7 @@ # Private helpers from the parent conftest — same import pattern the # sibling chat tests use for ``open_right_rail`` / ``TwoAgentChatSession``. -from tests.e2e_ui.conftest import _ensure_runner_online, _server_state +from tests.e2e_ui.conftest import _ensure_runner_online, _server_state, configure_mock_llm _JOKE_DIRECTOR_NAME = "joke_director" @@ -44,12 +44,14 @@ class JokeSubagentsSession: e.g. ``"scarecrow-3a7f9c2e1b"``. :param code_two: Per-run nonce only ``comic_two``'s joke carries, e.g. ``"sleepmode-9c2e1b3a7f"``. + :param routing_token: Per-run token that selects the parent's mock queue. """ base_url: str session_id: str code_one: str code_two: str + routing_token: str def _joke_director_yaml(code_one: str, code_two: str) -> str: @@ -122,21 +124,77 @@ def _joke_director_yaml(code_one: str, code_two: str) -> str: @pytest.fixture def joke_subagents_session( live_server: str, + mock_llm_server_url: str, tmp_path_factory: pytest.TempPathFactory, ) -> Iterator[JokeSubagentsSession]: """Create a runner-bound session for the two-comedian joke director. Same runner-respawn + bind contract as ``two_agent_chat_session`` in - the parent conftest. Yields the per-run nonces so a test can assert - that the sub-agents' jokes (and only the sub-agents') reached the UI. + the parent conftest. Separate content-routed mock queues drive the + parent and each comedian so concurrent child turns cannot race for a + shared response. The original model ids remain in the agent spec, so + a future real-gateway job can reuse the same journey. :param live_server: Spawned server fixture from the parent conftest. + :param mock_llm_server_url: Mock LLM server used by credential-free runs. :param tmp_path_factory: Pytest temp path factory (for a respawn log). :returns: A :class:`JokeSubagentsSession` handle. """ code_one = f"scarecrow-{uuid.uuid4().hex[:10]}" code_two = f"kitkat-{uuid.uuid4().hex[:10]}" + suffix = uuid.uuid4().hex[:10] + routing_token = f"joke-parent-{suffix}" + comic_one_token = f"joke-comic-one-{suffix}" + comic_two_token = f"joke-comic-two-{suffix}" yaml_text = _joke_director_yaml(code_one, code_two) + + configure_mock_llm( + mock_llm_server_url, + [ + { + "tool_calls": [ + { + "call_id": "call_comic_one", + "name": "sys_session_send", + "arguments": json.dumps( + { + "agent": "comic_one", + "title": "comic_one", + "args": f"Tell a joke. Routing marker: {comic_one_token}", + } + ), + }, + { + "call_id": "call_comic_two", + "name": "sys_session_send", + "arguments": json.dumps( + { + "agent": "comic_two", + "title": "comic_two", + "args": f"Tell a joke. Routing marker: {comic_two_token}", + } + ), + }, + ] + }, + {"text": "Dispatched both comedians; waiting for their replies."}, + {"text": f"The comedians replied with joke codes {code_one} and {code_two}."}, + ], + key=routing_token, + match=routing_token, + ) + configure_mock_llm( + mock_llm_server_url, + [{"text": f"Scarecrow joke. Joke code: {code_one}."}], + key=comic_one_token, + match=comic_one_token, + ) + configure_mock_llm( + mock_llm_server_url, + [{"text": f"Computer joke. Joke code: {code_two}."}], + key=comic_two_token, + match=comic_two_token, + ) respawned_runner = _ensure_runner_online(live_server, tmp_path_factory) runner_id = str(_server_state["runner_id"]) @@ -171,6 +229,7 @@ def joke_subagents_session( session_id=session_id, code_one=code_one, code_two=code_two, + routing_token=routing_token, ) finally: httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0) diff --git a/tests/e2e_ui/agents/test_subagent_navigation.py b/tests/e2e_ui/agents/test_subagent_navigation.py index a220ef87f2b..eb1fb97d5ca 100644 --- a/tests/e2e_ui/agents/test_subagent_navigation.py +++ b/tests/e2e_ui/agents/test_subagent_navigation.py @@ -53,11 +53,9 @@ def _send(page: Page, text: str) -> None: page.get_by_role("button", name="Send", exact=True).click() -# Nightly: several serial real-LLM turns (dispatch + two sub-agents + -# auto-wake continuation), too heavy and 429-sensitive for the PR gate. -# The 600s budget overrides the suite-wide 300s default for the same -# reason test_two_agent_chat.py uses it: FMAPI backoff stacks -# multiplicatively across the serial turns. +# Nightly: this exercises the full dispatch + two-child + auto-wake UI +# journey. Scripted LLM queues keep it deterministic, while the nightly +# marker keeps the heavier multi-session browser coverage off the PR gate. @pytest.mark.nightly @pytest.mark.timeout(600) def test_two_joke_subagents_appear_and_navigate( @@ -73,7 +71,7 @@ def test_two_joke_subagents_appear_and_navigate( page, "Please get one joke from comic_one and one joke from comic_two, " "then tell me both jokes exactly as they said them, including each " - "joke code.", + f"joke code. Routing marker: {chat.routing_token}", ) # Both comedians' jokes (identified by their nonces) reached the diff --git a/tests/e2e_ui/approvals/test_inbox_approval.py b/tests/e2e_ui/approvals/test_inbox_approval.py index a75c1b12fb1..873b8b09976 100644 --- a/tests/e2e_ui/approvals/test_inbox_approval.py +++ b/tests/e2e_ui/approvals/test_inbox_approval.py @@ -308,7 +308,7 @@ def test_reparked_elicitation_reliably_resurfaces_in_inbox( sink = _park_in_thread(base_url, session_id, eid, workers) first = page.locator(f'{_APPROVAL_CARD}[data-state="pending"]') expect(first).to_be_visible(timeout=_REPARK_TIMEOUT_MS) - first.get_by_role("button", name="Approve").click() + first.get_by_role("button", name="Approve", exact=True).click() _assert_allow(sink, "initial park") _settle_after_drain(page, base_url, session_id, rng) @@ -327,11 +327,11 @@ def test_reparked_elicitation_reliably_resurfaces_in_inbox( expect(resurfaced).to_have_attribute( "data-state", "pending", timeout=_REPARK_TIMEOUT_MS ) - expect(resurfaced.get_by_role("button", name="Approve")).to_be_visible() + expect(resurfaced.get_by_role("button", name="Approve", exact=True)).to_be_visible() # Approve again (re-arming the stale verdict), then settle so the # next retry is a clean 0→1 diff the socket won't coalesce. - resurfaced.get_by_role("button", name="Approve").click() + resurfaced.get_by_role("button", name="Approve", exact=True).click() _assert_allow(sink, f"re-park {cycle}") _settle_after_drain(page, base_url, session_id, rng) finally: diff --git a/tests/e2e_ui/chat/test_two_agent_chat.py b/tests/e2e_ui/chat/test_two_agent_chat.py index 3de5f008bdd..090ceb95972 100644 --- a/tests/e2e_ui/chat/test_two_agent_chat.py +++ b/tests/e2e_ui/chat/test_two_agent_chat.py @@ -116,9 +116,9 @@ def _expect_relayed_reply(page: Page, nonce: str) -> None: def _expect_dispatch_tool_call_rendered(page: Page) -> None: """Assert the `sys_session_send` dispatch shows as a transcript tool call. - Completed turns fold their tool calls into a collapsed "See N steps" - group (ToolCard.tsx ToolGroupSummary), so every group is expanded - first. The trigger renders toolTitle.ts's raw-name fallback + A lone call can render directly; completed multi-step turns fold calls + into a collapsed "See N steps" group. Expand groups when present. The + trigger renders toolTitle.ts's raw-name fallback ("sys_session_send(...)"), not the friendly "Start child session:" verb: sessionTitle() reads `tool`/`session` args while the named spawn schema (omnigent/tools/builtins/spawn.py) sends `agent`/`title`, @@ -128,6 +128,11 @@ def _expect_dispatch_tool_call_rendered(page: Page) -> None: :param page: The Playwright page, on the parent session. """ + direct_call = page.get_by_role("button", name=re.compile(r"^sys_session_send\(")) + if direct_call.count(): + expect(direct_call.first).to_be_visible() + return + step_groups = page.get_by_text(re.compile(r"^See \d+ steps?$")) expect(step_groups.first).to_be_visible() for group in step_groups.all(): @@ -186,11 +191,9 @@ def _expect_child_transcript(page: Page, child_session_id: str, nonces: list[str expect(page.locator(_ASSISTANT, has_text=nonce).first).to_be_visible(timeout=30_000) -# Nightly: six serial real-LLM turns (two rounds of dispatch + sub-agent + -# auto-wake continuation), so it is too heavy and 429-sensitive for the PR -# gate. The 600s budget overrides the suite-wide 300s default for the same -# reason tests/e2e/test_named_sub_agent_persistence.py uses it: FMAPI -# backoff stacks multiplicatively across the serial turns. +# Nightly: two full dispatch + child + auto-wake rounds exercise a heavier +# multi-session browser journey than the PR gate needs. Scripted LLM queues +# preserve the orchestration coverage without gateway cost or 429 flakes. @pytest.mark.nightly @pytest.mark.timeout(600) def test_two_agents_discuss_hitchhikers_guide( @@ -207,7 +210,7 @@ def test_two_agents_discuss_hitchhikers_guide( "Let's talk about The Hitchhiker's Guide to the Galaxy. Ask Deep " "Thought for the Answer to the Ultimate Question of Life, the " "Universe, and Everything, then tell me exactly what it said, " - "including its verification code.", + f"including its verification code. Routing marker: {chat.routing_token}", ) _expect_relayed_reply(page, chat.verification_code) # Deep Thought's Answer itself rendered too — the relay was verbatim. @@ -224,7 +227,8 @@ def test_two_agents_discuss_hitchhikers_guide( _send( page, "Now ask Deep Thought what the Ultimate Question actually IS, and " - "report back exactly what it says, including any code.", + "report back exactly what it says, including any code. " + f"Routing marker: {chat.routing_token}", ) _expect_relayed_reply(page, chat.question_code) diff --git a/tests/e2e_ui/conftest.py b/tests/e2e_ui/conftest.py index f1142f74e01..5159845eff8 100644 --- a/tests/e2e_ui/conftest.py +++ b/tests/e2e_ui/conftest.py @@ -1495,12 +1495,14 @@ class TwoAgentChatSession: carries, e.g. ``"vogon-3a7f9c2e1b"``. :param question_code: Per-run nonce only Deep Thought's QUESTION reply carries (round 2), e.g. ``"babelfish-9c2e1b3a7f"``. + :param routing_token: Per-run token that selects Arthur's mock queue. """ base_url: str session_id: str verification_code: str question_code: str + routing_token: str def _two_agent_chat_yaml(verification_code: str, question_code: str) -> str: @@ -1583,15 +1585,18 @@ def _two_agent_chat_yaml(verification_code: str, question_code: str) -> str: @pytest.fixture def two_agent_chat_session( live_server: str, + mock_llm_server_url: str, tmp_path_factory: pytest.TempPathFactory, ) -> Iterator[TwoAgentChatSession]: """Create a runner-bound session for the two-agent Hitchhiker's chat. Same runner-respawn and bind contract as :func:`terminal_session`. - Yields the per-run nonces so the test can assert that the sub-agent's - replies (and only the sub-agent's) reached the UI. + Separate content-routed mock queues drive Arthur and Deep Thought, + including both dispatch, child, and auto-wake turns. The original + model ids remain in the spec for a future real-gateway job. :param live_server: Spawned server fixture. + :param mock_llm_server_url: Mock LLM server used by credential-free runs. :param tmp_path_factory: Pytest temp path factory (for a respawn log). :returns: A :class:`TwoAgentChatSession` handle. """ @@ -1600,7 +1605,66 @@ def two_agent_chat_session( verification_code = f"vogon-{uuid.uuid4().hex[:10]}" question_code = f"babelfish-{uuid.uuid4().hex[:10]}" + suffix = uuid.uuid4().hex[:10] + routing_token = f"hitchhiker-parent-{suffix}" + child_token = f"hitchhiker-child-{suffix}" yaml_text = _two_agent_chat_yaml(verification_code, question_code) + configure_mock_llm( + mock_llm_server_url, + [ + { + "tool_calls": [ + { + "call_id": "call_deep_thought_answer", + "name": "sys_session_send", + "arguments": json.dumps( + { + "agent": "deep_thought", + "title": "deep_thought", + "args": ( + "What is the Answer to the Ultimate Question? " + f"Routing marker: {child_token}" + ), + } + ), + } + ] + }, + {"text": "Dispatched Deep Thought; waiting for the answer."}, + {"text": f"Deep Thought replied: 42. Verification code: {verification_code}."}, + { + "tool_calls": [ + { + "call_id": "call_deep_thought_question", + "name": "sys_session_send", + "arguments": json.dumps( + { + "agent": "deep_thought", + "title": "deep_thought", + "args": ( + "What is the Ultimate Question itself? " + f"Routing marker: {child_token}" + ), + } + ), + } + ] + }, + {"text": "Dispatched the follow-up; waiting for the question."}, + {"text": f"Deep Thought replied with question code {question_code}."}, + ], + key=routing_token, + match=routing_token, + ) + configure_mock_llm( + mock_llm_server_url, + [ + {"text": f"The Answer is 42. Verification code: {verification_code}."}, + {"text": f"The Ultimate Question is unknown. Question code: {question_code}."}, + ], + key=child_token, + match=child_token, + ) respawned_runner = _ensure_runner_online(live_server, tmp_path_factory) runner_id = str(_server_state["runner_id"]) @@ -1635,6 +1699,7 @@ def two_agent_chat_session( session_id=session_id, verification_code=verification_code, question_code=question_code, + routing_token=routing_token, ) finally: httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0) @@ -2259,7 +2324,7 @@ def _temp_omnigent_mock_config( file (or removes it) on exit. :param mock_llm_server_url: Base URL of the mock LLM server, e.g. - ``"http://127.0.0.1:51235"``. No /v1 suffix — each SDK appends it. + ``"http://127.0.0.1:51235"``. :param harness: ``"claude"`` or ``"codex"``. """ config_dir = Path.home() / ".omnigent" @@ -2286,7 +2351,7 @@ def _temp_omnigent_mock_config( kind: key default: [openai] openai: - base_url: "{mock_llm_server_url}" + base_url: "{mock_llm_server_url}/v1" api_key: "mock-key" wire_api: responses models: From 559680a00982b8b205b802424b96fdd773742092 Mon Sep 17 00:00:00 2001 From: Bryan Li Date: Sun, 19 Jul 2026 23:08:48 -0700 Subject: [PATCH 470/546] feat(sandbox): inject omnigent host config into managed sandboxes at launch (#2306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Managed sandbox hosts boot in a fresh HOME with env-var credentials only, so there was no way to give them config.yaml-level configuration — locking provider-agnostic harnesses like pi out of self-hosted model gateways (LiteLLM/vLLM) in managed sessions. - New top-level `sandbox.host_config:` server config key — verbatim in-sandbox ~/.omnigent/config.yaml content (e.g. a providers: block with kind: gateway, default: [pi]), provider-agnostic across all managed launch providers. - Validated fail-loud at server startup: mapping shape, providers block through the same provider_config parser omnigent itself uses (secrets deliberately not resolved — api_key_ref: env:VAR names sandbox env), inline api_key literals rejected at parse time, the block's own default scopes checked for collisions, plus a JSON round-trip so YAML-native values can't fail every launch at runtime. - Materialized before `omnigent host` starts, from one shared rendering primitive so merge semantics can't drift between providers: exec-model providers run a self-contained python3 -c merge script (stdlib+yaml only) via the shared SandboxLauncher.start_host; kubernetes appends the same rendered command to its init-container prep script, landing the file on the HOME emptyDir before the main container boots the host. - Merge mirrors cli.py's deep_merge_keys=("providers",): providers entries merge one level deep (injected wins), other top-level keys replace wholesale. The payload rides base64, so arbitrary YAML content never touches shell quoting. - Server-managed replacement semantics: a marker file records what was injected, and each launch/resume removes those entries by name before merging the current payload — a renamed gateway or a removed host_config block cleans up on the next wake instead of stranding stale providers. User-created config in the sandbox survives; config and marker are written atomically. A missing or corrupt marker degrades to additive merging — never delete without evidence of what was injected. Closes #2126 Signed-off-by: Bryan Li Co-authored-by: Claude Fable 5 --- deploy/boxlite/README.md | 12 + deploy/cwsandbox/README.md | 12 + deploy/daytona/README.md | 12 + deploy/e2b/README.md | 12 + deploy/islo/README.md | 12 + .../overlays/sandbox-runners/README.md | 6 + .../sandbox-runners/sandbox-config.yaml | 10 + deploy/modal/README.md | 12 + deploy/openshell/README.md | 12 + omnigent/onboarding/sandboxes/base.py | 154 ++++++- omnigent/onboarding/sandboxes/kubernetes.py | 95 +++- omnigent/server/managed_hosts.py | 134 ++++++ .../kubernetes/e2e_managed_host_config.py | 223 +++++++++ tests/onboarding/sandboxes/test_base.py | 436 +++++++++++++++++- tests/onboarding/sandboxes/test_kubernetes.py | 132 +++++- tests/server/test_managed_hosts.py | 294 ++++++++++++ 16 files changed, 1545 insertions(+), 23 deletions(-) create mode 100644 tests/e2e/integrations/deploy/kubernetes/e2e_managed_host_config.py diff --git a/deploy/boxlite/README.md b/deploy/boxlite/README.md index df5b7023b28..5069b7f2fe6 100644 --- a/deploy/boxlite/README.md +++ b/deploy/boxlite/README.md @@ -59,6 +59,18 @@ sandbox: server_url: https://omnigent.example.com # the in-box host dials this back ``` +A top-level `sandbox.host_config:` (provider-agnostic) holds verbatim +in-sandbox `~/.omnigent/config.yaml` content — e.g. a `providers:` +block routing a harness through a self-hosted gateway — installed into +the sandbox before `omnigent host` starts. The block is server-managed: +entries injected by a previous launch are replaced or removed on the +next launch/resume, while config created inside the sandbox survives. +Keep secrets out via +`api_key_ref: env:VAR` (resolved in the sandbox against the injected +env). See the [sandbox-runners config +table](../kubernetes/overlays/sandbox-runners/README.md#configuration-sandbox-configyaml) +for the shape. + `provider` + `server_url` is a complete config: the image defaults to the official prebaked host image and boxes run locally. diff --git a/deploy/cwsandbox/README.md b/deploy/cwsandbox/README.md index 960cdb57120..c82f2861d4b 100644 --- a/deploy/cwsandbox/README.md +++ b/deploy/cwsandbox/README.md @@ -136,6 +136,18 @@ sandbox: server_url: https://your-host # public URL sandboxes dial back to ``` +A top-level `sandbox.host_config:` (provider-agnostic) holds verbatim +in-sandbox `~/.omnigent/config.yaml` content — e.g. a `providers:` +block routing a harness through a self-hosted gateway — installed into +the sandbox before `omnigent host` starts. The block is server-managed: +entries injected by a previous launch are replaced or removed on the +next launch/resume, while config created inside the sandbox survives. +Keep secrets out via +`api_key_ref: env:VAR` (resolved in the sandbox against the injected +env). See the [sandbox-runners config +table](../kubernetes/overlays/sandbox-runners/README.md#configuration-sandbox-configyaml) +for the shape. + `provider` + `server_url` is a complete config. `server_url` **must be reachable from CoreWeave** — the host inside the sandbox opens an outbound WebSocket to it, not `localhost`. For local testing, expose your server with a tunnel diff --git a/deploy/daytona/README.md b/deploy/daytona/README.md index 1649eed33b5..eac0a892e2e 100644 --- a/deploy/daytona/README.md +++ b/deploy/daytona/README.md @@ -151,6 +151,18 @@ sandbox: env: [OPENAI_API_KEY, ANTHROPIC_API_KEY, GIT_TOKEN] ``` +A top-level `sandbox.host_config:` (provider-agnostic) holds verbatim +in-sandbox `~/.omnigent/config.yaml` content — e.g. a `providers:` +block routing a harness through a self-hosted gateway — installed into +the sandbox before `omnigent host` starts. The block is server-managed: +entries injected by a previous launch are replaced or removed on the +next launch/resume, while config created inside the sandbox survives. +Keep secrets out via +`api_key_ref: env:VAR` (resolved in the sandbox against the injected +env). See the [sandbox-runners config +table](../kubernetes/overlays/sandbox-runners/README.md#configuration-sandbox-configyaml) +for the shape. + ## Credentials for the sandbox (LLM keys, git tokens) Daytona has no provider-side named-secret store to attach at sandbox diff --git a/deploy/e2b/README.md b/deploy/e2b/README.md index 0d49779e635..3941e095de0 100644 --- a/deploy/e2b/README.md +++ b/deploy/e2b/README.md @@ -138,6 +138,18 @@ sandbox: server_url: https://your-host # public URL sandboxes dial back to ``` +A top-level `sandbox.host_config:` (provider-agnostic) holds verbatim +in-sandbox `~/.omnigent/config.yaml` content — e.g. a `providers:` +block routing a harness through a self-hosted gateway — installed into +the sandbox before `omnigent host` starts. The block is server-managed: +entries injected by a previous launch are replaced or removed on the +next launch/resume, while config created inside the sandbox survives. +Keep secrets out via +`api_key_ref: env:VAR` (resolved in the sandbox against the injected +env). See the [sandbox-runners config +table](../kubernetes/overlays/sandbox-runners/README.md#configuration-sandbox-configyaml) +for the shape. + `server_url` must be reachable *from E2B's cloud* — a public HTTPS URL, not `localhost`. Sessions created with `host_type: "managed"` (the API call or the Web UI's New Sandbox option) then run on a fresh E2B sandbox; diff --git a/deploy/islo/README.md b/deploy/islo/README.md index e50c89f8097..896c9d9d6b1 100644 --- a/deploy/islo/README.md +++ b/deploy/islo/README.md @@ -191,6 +191,18 @@ sandbox: server_url: https://your-host # public URL sandboxes dial back to ``` +A top-level `sandbox.host_config:` (provider-agnostic) holds verbatim +in-sandbox `~/.omnigent/config.yaml` content — e.g. a `providers:` +block routing a harness through a self-hosted gateway — installed into +the sandbox before `omnigent host` starts. The block is server-managed: +entries injected by a previous launch are replaced or removed on the +next launch/resume, while config created inside the sandbox survives. +Keep secrets out via +`api_key_ref: env:VAR` (resolved in the sandbox against the injected +env). See the [sandbox-runners config +table](../kubernetes/overlays/sandbox-runners/README.md#configuration-sandbox-configyaml) +for the shape. + `server_url` must be reachable *from Islo's cloud* — a public HTTPS URL, not `localhost`. The server itself needs `ISLO_API_KEY` (and optional `ISLO_BASE_URL`) in its environment. Sessions created with diff --git a/deploy/kubernetes/overlays/sandbox-runners/README.md b/deploy/kubernetes/overlays/sandbox-runners/README.md index f9422725988..725f776b08d 100644 --- a/deploy/kubernetes/overlays/sandbox-runners/README.md +++ b/deploy/kubernetes/overlays/sandbox-runners/README.md @@ -128,6 +128,7 @@ writing nothing to disk — use HTTPS repository URLs. Details by provider match | Key | Meaning | |---|---| | `server_url` | URL the runner Pod's host dials back to (in-cluster service DNS by default). | +| `host_config` | Optional, top-level under `sandbox:` (provider-agnostic, not inside `kubernetes:`): verbatim in-sandbox `~/.omnigent/config.yaml` content installed before `omnigent host` starts — e.g. a `providers:` block routing the `pi` harness through a self-hosted gateway (LiteLLM/vLLM). Server-managed: entries injected by a previous launch are replaced or removed on the next launch/resume; config created inside the sandbox survives. Keep secrets out via `api_key_ref: env:VAR`, resolved inside the runner Pod against the `secret_name` Secret. Validated at server startup. | | `namespace` | Runner-Pod namespace (defaults to `omnigent-sandboxes`). | | `secret_name` | Harness-creds Secret projected into every Pod via `envFrom`. | | `service_account` | ServiceAccount the runner Pods run as (powerless). | @@ -138,6 +139,11 @@ writing nothing to disk — use HTTPS repository URLs. Details by provider match | `in_cluster` | Optional cluster-config source: `true` (in-cluster SA only), `false` (kubeconfig only), omit (try in-cluster, then kubeconfig). | | `kubeconfig` | Optional kubeconfig path for the out-of-cluster fallback (env: `OMNIGENT_KUBERNETES_KUBECONFIG`). | +To verify `host_config` end to end against a live cluster, run +`python tests/e2e/integrations/deploy/kubernetes/e2e_managed_host_config.py +--server ` — it creates a managed session and asserts the injected +config inside the runner Pod. + ## Troubleshooting - **Launch fails fast with a clear reason.** When a Pod can't schedule, pull its diff --git a/deploy/kubernetes/overlays/sandbox-runners/sandbox-config.yaml b/deploy/kubernetes/overlays/sandbox-runners/sandbox-config.yaml index 3f143c2badf..e23eeb696ec 100644 --- a/deploy/kubernetes/overlays/sandbox-runners/sandbox-config.yaml +++ b/deploy/kubernetes/overlays/sandbox-runners/sandbox-config.yaml @@ -19,6 +19,16 @@ data: # Service listens on port 80) is simplest; use your ingress URL if runner # Pods must reach the server through it. server_url: http://omnigent.omnigent.svc.cluster.local + # ── optional, provider-agnostic ── + # host_config: # verbatim in-sandbox ~/.omnigent/config.yaml content, + # providers: # merged in before `omnigent host` starts — e.g. route + # litellm: # the `pi` harness through a self-hosted gateway. + # kind: gateway # Keep secrets out: api_key_ref: env: resolves inside + # default: [pi] # the runner Pod against the secret_name Secret below. + # openai: + # base_url: http://litellm.litellm.svc.cluster.local/v1 + # api_key_ref: env:LITELLM_API_KEY + # wire_api: chat kubernetes: # Runner-Pod namespace (secret_name / service_account resolve here). namespace: omnigent-sandboxes diff --git a/deploy/modal/README.md b/deploy/modal/README.md index 7d9fd9d2ad3..c0b8b73b486 100644 --- a/deploy/modal/README.md +++ b/deploy/modal/README.md @@ -302,6 +302,18 @@ sandbox: secrets: [omnigent-llm] # Modal secrets to inject ``` +A top-level `sandbox.host_config:` (provider-agnostic) holds verbatim +in-sandbox `~/.omnigent/config.yaml` content — e.g. a `providers:` +block routing a harness through a self-hosted gateway — installed into +the sandbox before `omnigent host` starts. The block is server-managed: +entries injected by a previous launch are replaced or removed on the +next launch/resume, while config created inside the sandbox survives. +Keep secrets out via +`api_key_ref: env:VAR` (resolved in the sandbox against the injected +env). See the [sandbox-runners config +table](../kubernetes/overlays/sandbox-runners/README.md#configuration-sandbox-configyaml) +for the shape. + ### LLM credentials for managed sandboxes A fresh sandbox has no API keys. Park your provider credentials in a diff --git a/deploy/openshell/README.md b/deploy/openshell/README.md index f1c5d1299fb..af8fd77d6af 100644 --- a/deploy/openshell/README.md +++ b/deploy/openshell/README.md @@ -188,6 +188,18 @@ sandbox: server_url: https://your-host # public URL sandboxes dial back to ``` +A top-level `sandbox.host_config:` (provider-agnostic) holds verbatim +in-sandbox `~/.omnigent/config.yaml` content — e.g. a `providers:` +block routing a harness through a self-hosted gateway — installed into +the sandbox before `omnigent host` starts. The block is server-managed: +entries injected by a previous launch are replaced or removed on the +next launch/resume, while config created inside the sandbox survives. +Keep secrets out via +`api_key_ref: env:VAR` (resolved in the sandbox against the injected +env). See the [sandbox-runners config +table](../kubernetes/overlays/sandbox-runners/README.md#configuration-sandbox-configyaml) +for the shape. + `provider` + `server_url` is a complete config. Sessions created with `host_type: "managed"` (the API call or the Web UI's New Sandbox option) then run on a fresh OpenShell sandbox; the create returns immediately and provisioning diff --git a/omnigent/onboarding/sandboxes/base.py b/omnigent/onboarding/sandboxes/base.py index 27af1295dc8..6cf2135454b 100644 --- a/omnigent/onboarding/sandboxes/base.py +++ b/omnigent/onboarding/sandboxes/base.py @@ -10,10 +10,16 @@ quirks, image contents, pip flags) lives behind a :class:`SandboxLauncher` implementation; everything provider-agnostic (wheel builds, the in-sandbox App OAuth dance, host registration) lives in ``bootstrap``. + +Injected host config uses the loader's ``OMNIGENT_CONFIG_HOME`` resolution, +atomically replaces its config and ownership-marker files, and removes a +previously injected value only while it remains unchanged by the user. """ from __future__ import annotations +import base64 +import json import secrets import shlex from abc import ABC, abstractmethod @@ -152,6 +158,136 @@ def foreground_kill_command(pidfile: str) -> str: ) +# In-sandbox write of an injected host config, run via ``python3 -c``. +# Self-contained on purpose (stdlib + yaml, both baked into any image that can +# run ``omnigent host``): importing merge logic from the sandbox's installed +# omnigent package would tie the feature to the IMAGE's package version, and +# operator-supplied images may predate it. ``__PAYLOAD__`` is replaced with a +# base64 Python literal — its alphabet has no quote or shell metacharacter, so +# arbitrary YAML content can never break out of the script. +# +# A marker records the previous payload. Each run removes exactly the names it +# injected last time — the server OWNS the names/keys it injects, so a renamed +# gateway or a removed block never strands a stale entry that could collide on a +# ``default`` scope. User-created entries under OTHER names are never in the +# marker and so are never touched. A missing or corrupt marker skips removal +# entirely — never delete without evidence of what was injected. +_HOST_CONFIG_WRITE_SCRIPT: str = """\ +import base64, json, os, tempfile, yaml + +config_home = os.environ.get("OMNIGENT_CONFIG_HOME") +config_dir = config_home if config_home else os.path.join(os.path.expanduser("~"), ".omnigent") +path = os.path.join(config_dir, "config.yaml") +marker = os.path.join(config_dir, ".injected_host_config.json") +existing = {} +if os.path.exists(path): + with open(path) as f: + loaded = yaml.safe_load(f) + if isinstance(loaded, dict): + existing = loaded +previous = {} +try: + with open(marker) as f: + loaded = json.load(f) + if isinstance(loaded, dict): + previous = loaded +except (OSError, ValueError): + pass +for key, value in previous.items(): + if key == "providers" and isinstance(value, dict): + current = existing.get(key) + if isinstance(current, dict): + for name in value: + current.pop(name, None) + if not current: + existing.pop(key, None) + else: + existing.pop(key, None) +injected = json.loads(base64.b64decode(__PAYLOAD__).decode()) +for key, value in injected.items(): + current = existing.get(key) + if key == "providers" and isinstance(current, dict) and isinstance(value, dict): + existing[key] = {**current, **value} + else: + existing[key] = value + +def atomic_write(path, dump): + temp_path = None + try: + with tempfile.NamedTemporaryFile("w", dir=os.path.dirname(path), delete=False) as f: + temp_path = f.name + dump(f) + f.flush() + os.fsync(f.fileno()) + os.replace(temp_path, path) + except BaseException: + if temp_path is not None: + try: + os.remove(temp_path) + except OSError: + pass + raise + +if injected or previous: + os.makedirs(config_dir, exist_ok=True) + atomic_write( + path, + lambda f: yaml.safe_dump(existing, f, default_flow_style=False, sort_keys=True), + ) +if injected: + atomic_write(marker, lambda f: json.dump(injected, f)) +elif previous: + os.remove(marker) +""" + + +def render_host_config_write_command(host_config: dict[str, object]) -> str: + """ + Build the remote command that installs *host_config* into the + sandbox's config directory before ``omnigent host`` starts. The directory + is ``$OMNIGENT_CONFIG_HOME`` when truthy, otherwise ``~/.omnigent``, exactly + matching :func:`omnigent.onboarding.provider_config._config_path`. + + Server-managed replacement semantics: the server OWNS the names/keys it + injects. Entries recorded in the previous marker are removed first BY NAME, + then the current payload merges in with + ``omnigent.cli._save_global_config``'s + ``deep_merge_keys=("providers",)`` semantics — ``providers`` one + level deep and every other top-level key wholesale. Removing by name (rather + than only when unchanged) is deliberate: a renamed gateway must not leave + its old entry behind, since two entries claiming the same ``default`` scope + is a sandbox load error. User-created config under names the server never + injects is never in the marker and so always survives; a name the server + injects is server-managed, and an in-sandbox edit to it does not persist + across the next replacement. An empty *host_config* renders a pure cleanup + command. A missing or corrupt marker skips removal rather than guessing + ownership. Shared by both launch seams — the exec-model + :meth:`SandboxLauncher.start_host` and the Kubernetes init container — so the + behavior cannot drift between providers. + + Both config and marker writes use a fully-written, fsynced temporary file + in the destination directory followed by :func:`os.replace`, so an + interrupted write cannot expose a truncated destination file. A pre-existing + ``config.yaml`` symlink is replaced by a real file — durability of the write + is favored over following the link, which an internal sandbox config never + relies on. + + The payload travels as base64-encoded JSON substituted into a fixed + Python script, and the whole script is ``shlex.quote``-wrapped — + operator-supplied YAML content (quotes, ``$``, newlines) never + reaches shell or Python quoting. + + :param host_config: The validated ``sandbox.host_config`` mapping + (see :func:`omnigent.server.managed_hosts.parse_sandbox_config`), + or ``{}`` to only remove previously injected entries. + :returns: A ``python3 -c ' + + diff --git a/web/vite.update-overlay.config.ts b/web/vite.update-overlay.config.ts new file mode 100644 index 00000000000..665294eeb93 --- /dev/null +++ b/web/vite.update-overlay.config.ts @@ -0,0 +1,43 @@ +// Build for the desktop update overlay island. +// +// Produces a tiny standalone page (update-overlay.html + hashed JS/CSS) that +// mounts the SHARED `UpdateBanner` component (see src/update-overlay.tsx). The +// output lands directly in the Electron shell package (`electron/overlay/`) so +// electron-builder ships it and the shell can load it in a corner window — +// making the update UI independent of the connected server's web-bundle +// version. Run via `bun run build:overlay` (or npm); the shell build depends on +// this output existing (see electron/package.json build.files + the +// electron-build workflow). +// +// Kept separate from the main app build (vite.config.ts) so it emits no PWA +// service worker / manifest and writes to the shell dir rather than dist/. + +import path from "node:path"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + // Assets are loaded from a file:// page in the shell, so reference them + // relatively rather than from the server root. + base: "./", + // The overlay is a single self-contained card — it has no use for the web + // app's public/ assets (PWA icons, favicon). Disable publicDir so Vite doesn't + // copy ~150KB of orphan PWA images into electron/overlay/, which would then + // be shipped by electron-builder's `build.files: overlay/**/*`. + publicDir: false, + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + build: { + // Ship straight into the Electron package so electron-builder picks it up. + outDir: path.resolve(__dirname, "./electron/overlay"), + emptyOutDir: true, + rollupOptions: { + input: path.resolve(__dirname, "./update-overlay.html"), + }, + }, +}); From 34857abb47c5927b14ed346605c03cc4c4d67a21 Mon Sep 17 00:00:00 2001 From: "Zeyi (Rice) Fan" Date: Tue, 21 Jul 2026 00:54:10 -0700 Subject: [PATCH 520/546] fix(ci): install web dependencies with matching peer mode (#2980) ## Related issue N/A ## Summary The Electron build workflow could not install the web dependencies because it used strict peer resolution against a lockfile generated with legacy peer handling. Use `--legacy-peer-deps` consistently with the web lockfile generation and other web CI jobs. ## Test Plan - `cd web && npx --yes --package npm@11.12.1 npm ci --legacy-peer-deps --no-audit --no-fund` - `cd web && npm run build:overlay` - `uv run pre-commit run --files .github/workflows/electron-build.yml` ## Demo N/A ## Type of change - [ ] Bug fix - [ ] Feature - [ ] UI / frontend change - [ ] Refactor / chore - [ ] Docs - [x] Test / CI - [ ] Breaking change ## Test coverage - [ ] Unit tests added / updated - [ ] Integration tests added / updated - [ ] E2E tests added / updated - [x] Manual verification completed - [ ] Existing tests cover this change - [ ] Not applicable ## Coverage notes Verified the install with CI's pinned npm 11.12.1 and built the update overlay successfully. This workflow-only correction does not require a new automated test. Signed-off-by: Zeyi (Rice) Fan --- .github/workflows/electron-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index e6c1ca26755..21b896a5893 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -85,7 +85,7 @@ jobs: # — this step only needs to install the web app's deps so that hook works. - name: Install web deps (for the update overlay build) working-directory: web - run: npm ci --no-audit --no-fund + run: npm ci --legacy-peer-deps --no-audit --no-fund - name: Build ${{ matrix.platform }} app working-directory: web/electron From 829fdd5174f50c49704e9a06caa7b8d481219cd3 Mon Sep 17 00:00:00 2001 From: Aravind Segu Date: Tue, 21 Jul 2026 01:12:02 -0700 Subject: [PATCH 521/546] refactor(db): unify session-owner identity columns to user_id (#2978) Three tables stored the same session-owner Databricks identity under different column names and widths. hosts.owner (VARCHAR(256)) and scheduled_tasks.owner_user_id are renamed to user_id (VARCHAR(128)), matching user_daily_cost.user_id and the schema-wide identity convention (session_permissions.user_id, account_tokens.user_id, device_grants.user_id). The change is confined to the DB + Python layer: the JSON API keys ("owner", "owner_user_id") are preserved at the route boundary, so the HTTP contract, OpenAPI, SDKs, and web UI are unaffected. Migration b3c1a2d4e5f6 renames both columns (narrowing hosts.user_id 256->128), swaps uq_hosts_workspace_owner_name -> uq_hosts_workspace_user_id_name and ix_scheduled_tasks_owner_user_id -> ix_scheduled_tasks_user_id, with a full downgrade. Verified up/down/data-preservation on SQLite. Co-authored-by: Isaac Signed-off-by: aravind-segu --- omnigent/db/db_models.py | 21 ++- .../b3c1a2d4e5f6_unify_user_id_columns.py | 127 +++++++++++++ omnigent/entities/scheduled_task.py | 4 +- omnigent/server/identity_migration.py | 18 +- omnigent/server/managed_hosts.py | 6 +- omnigent/server/routes/_host_launch.py | 2 +- omnigent/server/routes/host_tunnel.py | 8 +- omnigent/server/routes/hosts.py | 12 +- omnigent/server/routes/scheduled_tasks.py | 10 +- omnigent/server/routes/sessions.py | 2 +- omnigent/server/scheduled/fire.py | 12 +- omnigent/stores/host_store.py | 89 +++++----- .../stores/scheduled_task_store/__init__.py | 4 +- .../scheduled_task_store/sqlalchemy_store.py | 6 +- tests/db/test_db_models.py | 6 +- .../db/test_migration_host_name_varchar64.py | 2 +- ...est_migration_host_pk_workspace_host_id.py | 20 +-- tests/db/test_migration_hosts_token_hash.py | 8 +- tests/db/test_migration_scheduled_tasks.py | 14 +- tests/db/test_migration_unify_user_id.py | 167 ++++++++++++++++++ tests/db/test_migration_workspace.py | 4 +- .../test_host_liveness_staleness_e2e.py | 8 +- .../integration/test_host_session_binding.py | 10 +- .../integration/test_host_tunnel_route.py | 14 +- tests/server/integration/test_hosts_api.py | 8 +- .../integration/test_hosts_filesystem.py | 4 +- .../integration/test_scheduler_lifespan.py | 6 +- tests/server/routes/test_host_launch.py | 14 +- tests/server/scheduled/test_fire.py | 10 +- tests/server/test_identity_migration.py | 4 +- tests/server/test_managed_hosts.py | 48 ++--- tests/stores/test_host_store.py | 80 ++++----- tests/stores/test_scheduled_task_store.py | 60 +++---- 33 files changed, 557 insertions(+), 251 deletions(-) create mode 100644 omnigent/db/migrations/versions/b3c1a2d4e5f6_unify_user_id_columns.py create mode 100644 tests/db/test_migration_unify_user_id.py diff --git a/omnigent/db/db_models.py b/omnigent/db/db_models.py index a3c8cb8e054..8566a2557bd 100644 --- a/omnigent/db/db_models.py +++ b/omnigent/db/db_models.py @@ -1185,7 +1185,7 @@ class SqlHost(OmnigentBase): :param name: Human-readable name from ``config.yaml``, e.g. ``"corey-laptop"``. Displayed in the Web UI host picker. Max 64 characters. - :param owner: User ID from the Databricks auth Bearer token + :param user_id: User ID from the Databricks auth Bearer token presented during the host's WebSocket handshake, e.g. ``"corey.zumar@databricks.com"``. :param status: ``"online"`` when the host has an active WebSocket @@ -1234,7 +1234,10 @@ class SqlHost(OmnigentBase): default=current_workspace_id, ) host_id: Mapped[str] = mapped_column(Uuid16(), primary_key=True) - owner: Mapped[str] = mapped_column(String(256), nullable=False) + # Session-owner identity from the Databricks auth Bearer token. String(128) + # matches session_permissions.user_id and every other user-identity column + # in this schema. + user_id: Mapped[str] = mapped_column(String(128), nullable=False) name: Mapped[str] = mapped_column(String(64), nullable=False) # Enum stored as a stable int code (see omnigent.db.enum_codecs # HOST_STATUS: online=1, offline=2). @@ -1253,10 +1256,12 @@ class SqlHost(OmnigentBase): "status IN (1, 2)", name="ck_hosts_status", ), - # (workspace_id, owner, name) was the old PK; keep it unique so the - # upsert-on-connect logic (look up by owner+name to detect host_id + # (workspace_id, user_id, name) was the old PK; keep it unique so the + # upsert-on-connect logic (look up by user_id+name to detect host_id # rotation) stays consistent. - UniqueConstraint("workspace_id", "owner", "name", name="uq_hosts_workspace_owner_name"), + UniqueConstraint( + "workspace_id", "user_id", "name", name="uq_hosts_workspace_user_id_name" + ), ) @@ -1328,7 +1333,7 @@ class SqlScheduledTask(OmnigentBase): :param rrule: The required RFC 5545 recurrence rule for the recurring trigger, e.g. ``"FREQ=DAILY;BYHOUR=9;BYMINUTE=0"``. Evaluated in ``timezone``. - :param owner_user_id: User the spawned session's ``LEVEL_OWNER`` grant is + :param user_id: User the spawned session's ``LEVEL_OWNER`` grant is written for — who the run belongs to, e.g. ``"alice@example.com"``. ``None`` in single-user / OSS mode; the fire path resolves it to the reserved ``"local"`` user. @@ -1397,7 +1402,7 @@ class SqlScheduledTask(OmnigentBase): # resolves null to the reserved "local" user). String(128) to match # session_permissions.user_id (the column the LEVEL_OWNER grant is # written into) and every other user-identity column in this schema. - owner_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + user_id: Mapped[str | None] = mapped_column(String(128), nullable=True) # Relates to agents.id. No DB foreign key (Rule R032); cascade is app-owned. agent_id: Mapped[str] = mapped_column(Uuid16, nullable=False) # Per-task overrides — None means fall back to the agent default. Widths @@ -1437,7 +1442,7 @@ class SqlScheduledTask(OmnigentBase): CheckConstraint("state IN (1, 2, 3)", name="ck_scheduled_tasks_state"), CheckConstraint("execution_target IN (1, 2)", name="ck_scheduled_tasks_execution_target"), Index("ix_scheduled_tasks_created_at", "workspace_id", "created_at", "id"), - Index("ix_scheduled_tasks_owner_user_id", "workspace_id", "owner_user_id", "id"), + Index("ix_scheduled_tasks_user_id", "workspace_id", "user_id", "id"), ) diff --git a/omnigent/db/migrations/versions/b3c1a2d4e5f6_unify_user_id_columns.py b/omnigent/db/migrations/versions/b3c1a2d4e5f6_unify_user_id_columns.py new file mode 100644 index 00000000000..fdd76d3ef26 --- /dev/null +++ b/omnigent/db/migrations/versions/b3c1a2d4e5f6_unify_user_id_columns.py @@ -0,0 +1,127 @@ +"""Unify the session-owner identity columns to ``user_id``. + +Revision ID: b3c1a2d4e5f6 +Revises: f82e866d9de0 +Create Date: 2026-07-21 18:00:00.000000 + +Two tables named the same session-owner identity differently from the +schema-wide ``user_id`` convention (``session_permissions.user_id``, +``account_tokens.user_id``, ``device_grants.user_id``): + +- ``hosts.owner`` (``VARCHAR(256)``) → ``hosts.user_id`` (``VARCHAR(128)``). + Narrowing is safe: the value is a Databricks user identity (email) or the + reserved ``"local"`` user, both far under 128. The + ``uq_hosts_workspace_owner_name`` unique constraint is renamed to + ``uq_hosts_workspace_user_id_name`` (same columns, ``owner`` → ``user_id``). +- ``scheduled_tasks.owner_user_id`` → ``scheduled_tasks.user_id`` (type + unchanged, ``VARCHAR(128)``). The ``ix_scheduled_tasks_owner_user_id`` index + is renamed to ``ix_scheduled_tasks_user_id`` (same columns). + +``user_daily_cost.user_id`` already matches the convention and is untouched. + +Dialect strategy +---------------- +- **SQLite**: cannot rename/retype a column in place; ``batch_alter_table`` + with ``recreate="always"`` rebuilds each table with the new column name, + type, and constraint/index names. +- **PostgreSQL / MySQL**: native ``ALTER TABLE`` DDL (``recreate="auto"``) — + ``RENAME COLUMN`` + ``ALTER COLUMN TYPE`` + constraint/index swap, no copy. + +No PRAGMA foreign_keys guard needed — all FK constraints were removed in +p1a2b3c4d5e6. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "b3c1a2d4e5f6" +down_revision: str | None = "f82e866d9de0" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _is_sqlite() -> bool: + return op.get_bind().dialect.name == "sqlite" + + +def upgrade() -> None: + """Rename owner/owner_user_id → user_id (and narrow hosts.user_id to 128). + + Each rename is split across batches: a single ``batch_alter_table`` that both + renames a column *and* drops/creates a constraint or index referencing it + trips Alembic's batch reflection (it maps the reflected object onto the + not-yet-renamed column). So drop the dependent object first, rename in its + own batch, then create the renamed object. + """ + recreate = "always" if _is_sqlite() else "auto" + + # hosts.owner → user_id, VARCHAR(256) → VARCHAR(128). The unique constraint + # sits on the renamed column, and SQLite can only drop a constraint via a + # table rebuild, so each step is its own batch. + with op.batch_alter_table("hosts", recreate=recreate) as batch_op: + batch_op.drop_constraint("uq_hosts_workspace_owner_name", type_="unique") + with op.batch_alter_table("hosts", recreate=recreate) as batch_op: + # existing_type required by MySQL for CHANGE/MODIFY COLUMN. + batch_op.alter_column( + "owner", + new_column_name="user_id", + existing_type=sa.String(256), + type_=sa.String(128), + existing_nullable=False, + ) + with op.batch_alter_table("hosts", recreate=recreate) as batch_op: + batch_op.create_unique_constraint( + "uq_hosts_workspace_user_id_name", ["workspace_id", "user_id", "name"] + ) + + # scheduled_tasks.owner_user_id → user_id. The index is droppable outside a + # batch on every dialect, so no table rebuild is needed to remove it. + op.drop_index("ix_scheduled_tasks_owner_user_id", table_name="scheduled_tasks") + with op.batch_alter_table("scheduled_tasks", recreate=recreate) as batch_op: + batch_op.alter_column( + "owner_user_id", + new_column_name="user_id", + existing_type=sa.String(128), + existing_nullable=True, + ) + op.create_index( + "ix_scheduled_tasks_user_id", "scheduled_tasks", ["workspace_id", "user_id", "id"] + ) + + +def downgrade() -> None: + """Restore owner / owner_user_id column names (and hosts width to 256).""" + recreate = "always" if _is_sqlite() else "auto" + + op.drop_index("ix_scheduled_tasks_user_id", table_name="scheduled_tasks") + with op.batch_alter_table("scheduled_tasks", recreate=recreate) as batch_op: + batch_op.alter_column( + "user_id", + new_column_name="owner_user_id", + existing_type=sa.String(128), + existing_nullable=True, + ) + op.create_index( + "ix_scheduled_tasks_owner_user_id", + "scheduled_tasks", + ["workspace_id", "owner_user_id", "id"], + ) + + with op.batch_alter_table("hosts", recreate=recreate) as batch_op: + batch_op.drop_constraint("uq_hosts_workspace_user_id_name", type_="unique") + with op.batch_alter_table("hosts", recreate=recreate) as batch_op: + batch_op.alter_column( + "user_id", + new_column_name="owner", + existing_type=sa.String(128), + type_=sa.String(256), + existing_nullable=False, + ) + with op.batch_alter_table("hosts", recreate=recreate) as batch_op: + batch_op.create_unique_constraint( + "uq_hosts_workspace_owner_name", ["workspace_id", "owner", "name"] + ) diff --git a/omnigent/entities/scheduled_task.py b/omnigent/entities/scheduled_task.py index cb25cd3e300..df179523030 100644 --- a/omnigent/entities/scheduled_task.py +++ b/omnigent/entities/scheduled_task.py @@ -27,7 +27,7 @@ class ScheduledTask: :param rrule: The required RFC 5545 recurrence rule for the recurring trigger, e.g. ``"FREQ=DAILY;BYHOUR=9;BYMINUTE=0"``. Evaluated in ``timezone``. - :param owner_user_id: User the spawned session's ``LEVEL_OWNER`` grant is + :param user_id: User the spawned session's ``LEVEL_OWNER`` grant is written for, e.g. ``"alice@example.com"``. ``None`` in single-user mode. :param agent_id: The agent bound to this task, e.g. ``"ag_..."``. :param timezone: IANA timezone the trigger is evaluated in, @@ -59,7 +59,7 @@ class ScheduledTask: name: str prompt: str rrule: str - owner_user_id: str | None + user_id: str | None agent_id: str timezone: str created_at: int diff --git a/omnigent/server/identity_migration.py b/omnigent/server/identity_migration.py index d78e36d831f..180697b71a1 100644 --- a/omnigent/server/identity_migration.py +++ b/omnigent/server/identity_migration.py @@ -23,7 +23,7 @@ - ``account_tokens.user_id`` and ``account_tokens.created_by`` - ``comments.created_by`` - ``policies.created_by`` -- ``hosts.owner`` (PK part) +- ``hosts.user_id`` (unique-constraint part) Ordering within a mapping is load-bearing: the new ``users`` row is created first (so FK-bearing children can point at it), children are @@ -231,15 +231,15 @@ def remap_identities( ) report._bump(SqlAccountToken.__tablename__, result.rowcount or 0) - # ── hosts.owner is a PK part (owner, name); a collision with - # an existing (new, name) host would violate the PK, so guard - # per-row. Rare in OSS (hosts are a Databricks-connect - # feature), but correctness over assumption. + # ── hosts.user_id is a unique-constraint part (user_id, name); a + # collision with an existing (new, name) host would violate the + # constraint, so guard per-row. Rare in OSS (hosts are a + # Databricks-connect feature), but correctness over assumption. old_hosts = ( session.execute( select(SqlHost).where( SqlHost.workspace_id == current_workspace_id(), - SqlHost.owner == old_id, + SqlHost.user_id == old_id, ) ) .scalars() @@ -247,19 +247,19 @@ def remap_identities( ) for host in old_hosts: # Check if the new owner already has a host with the same name - # (collision on the uq_hosts_workspace_owner_name unique constraint). + # (collision on the uq_hosts_workspace_user_id_name unique constraint). # PK is now (workspace_id, host_id) so we SELECT by the unique key. clash = session.execute( select(SqlHost).where( SqlHost.workspace_id == current_workspace_id(), - SqlHost.owner == new_id, + SqlHost.user_id == new_id, SqlHost.name == host.name, ) ).scalar_one_or_none() if clash is not None: session.delete(host) # new owner already has this host name else: - host.owner = new_id + host.user_id = new_id report._bump("hosts") session.flush() diff --git a/omnigent/server/managed_hosts.py b/omnigent/server/managed_hosts.py index e8e6c58d080..ba1c061095b 100644 --- a/omnigent/server/managed_hosts.py +++ b/omnigent/server/managed_hosts.py @@ -1983,7 +1983,7 @@ async def relaunch_managed_host( host_store=host_store, host_id=host.host_id, host_name=host.name, - owner=host.owner, + owner=host.user_id, sandbox_id=sandbox_id, repo=repo, on_stage=on_stage, @@ -2044,7 +2044,7 @@ async def _arm_and_start_host( host_store.register_managed_host, host_id=host_id, name=host_name, - owner=owner, + user_id=owner, token=token, provider=launcher.provider, sandbox_id=sandbox_id, @@ -2279,7 +2279,7 @@ async def resume_managed_host( host_store.register_managed_host, host_id=host.host_id, name=host.name, - owner=host.owner, + user_id=host.user_id, token=token, provider=launcher.provider, sandbox_id=sandbox_id, diff --git a/omnigent/server/routes/_host_launch.py b/omnigent/server/routes/_host_launch.py index bceac3e0d77..2751a014262 100644 --- a/omnigent/server/routes/_host_launch.py +++ b/omnigent/server/routes/_host_launch.py @@ -74,7 +74,7 @@ def resolve_host_owner( host = host_store.get_host(host_id) if host is None: raise HTTPException(status_code=404, detail="host not found") - if user_id is not None and host.owner != user_id: + if user_id is not None and host.user_id != user_id: raise HTTPException(status_code=403, detail="not your host") return host diff --git a/omnigent/server/routes/host_tunnel.py b/omnigent/server/routes/host_tunnel.py index 80ffcad837f..2a2f460dfe8 100644 --- a/omnigent/server/routes/host_tunnel.py +++ b/omnigent/server/routes/host_tunnel.py @@ -169,7 +169,7 @@ async def tunnel(ws: WebSocket, host_id: str) -> None: if managed is None: await ws.close(code=4004, reason="unauthenticated") return - tunnel_owner = managed.owner + tunnel_owner = managed.user_id elif auth_provider is not None: tunnel_owner = auth_provider.get_user_id(ws) if tunnel_owner is None: @@ -196,14 +196,14 @@ async def tunnel(ws: WebSocket, host_id: str) -> None: # the backstop for the connect/connect race this can't lock. if not allow_host_id_reown: existing = await asyncio.to_thread(host_store.get_host, host_id) - if existing is not None and existing.owner != tunnel_owner: + if existing is not None and existing.user_id != tunnel_owner: _logger.warning( "Refusing host %s: registered to owner %r but the " "connecting peer authenticated as %r. Cross-owner " "re-registration is not allowed — remove the stale " "registration or reset the host id.", host_id, - existing.owner, + existing.user_id, tunnel_owner, ) # Don't name the existing owner to this peer: in a multi-user @@ -246,7 +246,7 @@ async def tunnel(ws: WebSocket, host_id: str) -> None: host_store.upsert_on_connect, host_id=host_id, name=frame.name, - owner=tunnel_owner, + user_id=tunnel_owner, allow_host_id_reown=allow_host_id_reown, configured_harnesses=frame.configured_harnesses, ) diff --git a/omnigent/server/routes/hosts.py b/omnigent/server/routes/hosts.py index d0bd668693d..0fd6f5f6e18 100644 --- a/omnigent/server/routes/hosts.py +++ b/omnigent/server/routes/hosts.py @@ -355,7 +355,7 @@ async def list_hosts(request: Request) -> dict[str, list[dict[str, Any]]]: { "host_id": host.host_id, "name": host.name, - "owner": host.owner, + "owner": host.user_id, "status": "online" if host_is_live(host, now=now) else "offline", # Non-None marks a server-managed sandbox host (e.g. # "modal"). Clients use it to hide sandbox-backed @@ -387,7 +387,7 @@ async def get_host(request: Request, host_id: str) -> dict[str, Any]: host = await asyncio.to_thread(host_store.get_host, host_id) if host is None: raise HTTPException(status_code=404, detail="host not found") - if user_id is not None and host.owner != user_id: + if user_id is not None and host.user_id != user_id: raise HTTPException(status_code=403, detail="not your host") # Status comes from the DB so the answer is consistent across @@ -396,7 +396,7 @@ async def get_host(request: Request, host_id: str) -> dict[str, Any]: return { "host_id": host.host_id, "name": host.name, - "owner": host.owner, + "owner": host.user_id, "status": "online" if host_is_live(host) else "offline", # Same semantics as list_hosts: non-None marks a # server-managed sandbox host (e.g. "modal"). @@ -804,7 +804,7 @@ async def _list_host_filesystem( host = await asyncio.to_thread(host_store.get_host, host_id) if host is None: raise HTTPException(status_code=404, detail="host not found") - if user_id is not None and host.owner != user_id: + if user_id is not None and host.user_id != user_id: raise HTTPException(status_code=403, detail="not your host") if "\x00" in path: @@ -885,7 +885,7 @@ async def create_host_directory( host = await asyncio.to_thread(host_store.get_host, host_id) if host is None: raise HTTPException(status_code=404, detail="host not found") - if user_id is not None and host.owner != user_id: + if user_id is not None and host.user_id != user_id: raise HTTPException(status_code=403, detail="not your host") path = body.path @@ -972,7 +972,7 @@ async def list_host_worktrees( host = await asyncio.to_thread(host_store.get_host, host_id) if host is None: raise HTTPException(status_code=404, detail="host not found") - if user_id is not None and host.owner != user_id: + if user_id is not None and host.user_id != user_id: raise HTTPException(status_code=403, detail="not your host") if not path.strip(): diff --git a/omnigent/server/routes/scheduled_tasks.py b/omnigent/server/routes/scheduled_tasks.py index e1d0a595d31..e4fcc2c2ba7 100644 --- a/omnigent/server/routes/scheduled_tasks.py +++ b/omnigent/server/routes/scheduled_tasks.py @@ -87,7 +87,9 @@ def _to_response(task: ScheduledTask) -> dict[str, Any]: "name": task.name, "prompt": task.prompt, "rrule": task.rrule, - "owner_user_id": task.owner_user_id, + # JSON key preserved for API/UI stability; the DB column + entity + # attribute are now ``user_id``. + "owner_user_id": task.user_id, "agent_id": task.agent_id, "timezone": task.timezone, "created_at": task.created_at, @@ -192,7 +194,7 @@ def _require_owned(scheduled_task_id: str, owner: str) -> ScheduledTask: enumerable across users. """ task = store.get(scheduled_task_id) - if task is None or task.owner_user_id != owner: + if task is None or task.user_id != owner: raise OmnigentError("Scheduled task not found", code=ErrorCode.NOT_FOUND) return task @@ -219,7 +221,7 @@ async def create_scheduled_task( name=body.name, prompt=body.prompt, rrule=body.rrule, - owner_user_id=None if owner == RESERVED_USER_LOCAL else owner, + user_id=None if owner == RESERVED_USER_LOCAL else owner, agent_id=body.agent_id, timezone=body.timezone, model_override=model_override, @@ -237,7 +239,7 @@ async def list_scheduled_tasks(request: Request) -> dict[str, list[dict[str, Any """List the caller's scheduled tasks.""" owner = _owner(request) owner_id = None if owner == RESERVED_USER_LOCAL else owner - tasks = [t for t in store.list() if t.owner_user_id == owner_id] + tasks = [t for t in store.list() if t.user_id == owner_id] return {"scheduled_tasks": [_to_response(t) for t in tasks]} @router.get("/scheduled-tasks/{scheduled_task_id}") diff --git a/omnigent/server/routes/sessions.py b/omnigent/server/routes/sessions.py index 960062062df..51203d34d5b 100644 --- a/omnigent/server/routes/sessions.py +++ b/omnigent/server/routes/sessions.py @@ -7307,7 +7307,7 @@ def _kick_managed_relaunch( relaunch_task = asyncio.create_task( _run_managed_launch( session_id=session_id, - owner=host.owner, + owner=host.user_id, sandbox_config=sandbox_config, repo=repo, tracker=tracker, diff --git a/omnigent/server/scheduled/fire.py b/omnigent/server/scheduled/fire.py index cca20f74a9d..ee7a812eac0 100644 --- a/omnigent/server/scheduled/fire.py +++ b/omnigent/server/scheduled/fire.py @@ -14,7 +14,7 @@ #. **Creates a session** bound to the task's agent, carrying the stored ``workspace`` / ``host_id`` / ``model_override`` / ``reasoning_effort``. #. **Grants ownership.** The spawned session gets a ``LEVEL_OWNER`` grant for the - task's ``owner_user_id`` — or :data:`RESERVED_USER_LOCAL` when it is NULL + task's ``user_id`` — or :data:`RESERVED_USER_LOCAL` when it is NULL (single-user / OSS). Without the grant the run is invisible. #. **Launches the runner and dispatches the prompt** so the agent actually runs (a seeded prompt with no launched runner would just sit as history). @@ -375,14 +375,14 @@ async def _create_session(deps: FireDeps, task: ScheduledTask) -> Conversation: async def _grant_owner(deps: FireDeps, task: ScheduledTask, conversation_id: str) -> None: """Write the LEVEL_OWNER grant so the run is visible to its owner. - A NULL ``owner_user_id`` (single-user / OSS) resolves to + A NULL ``user_id`` (single-user / OSS) resolves to :data:`RESERVED_USER_LOCAL`. When ``permission_store`` is ``None`` (no auth configured) this is a no-op — the session is still accessible because auth is disabled system-wide. """ if deps.permission_store is None: return - owner = task.owner_user_id or RESERVED_USER_LOCAL + owner = task.user_id or RESERVED_USER_LOCAL await asyncio.to_thread(deps.permission_store.ensure_user, owner) await asyncio.to_thread(deps.permission_store.grant, owner, conversation_id, LEVEL_OWNER) @@ -446,7 +446,7 @@ async def _validate_fire_session_inputs( ) -> tuple[str, str] | None: """Validate stored task fields before creating a conversation.""" try: - owner = task.owner_user_id + owner = task.user_id agent = await validate_session_agent( user_id=owner, agent_id=task.agent_id, @@ -511,7 +511,7 @@ async def _preflight(task: ScheduledTask) -> None: f"connected host {host_id!r} was not found", error_code="host_not_found", ) - if task.owner_user_id is not None and host.owner != task.owner_user_id: + if task.user_id is not None and host.user_id != task.user_id: raise _CannotLaunchScheduledFire( f"connected host {host_id!r} is not owned by the scheduled task owner", error_code="host_not_owned", @@ -549,7 +549,7 @@ async def _dispatch(conv: Conversation, task: ScheduledTask) -> None: if deps.host_registry is None or deps.host_store is None: raise RuntimeError("connected host registry/store is not configured") - owner = task.owner_user_id or RESERVED_USER_LOCAL + owner = task.user_id or RESERVED_USER_LOCAL host_id = task.host_id if host_id is None or deps.host_registry.get(host_id) is None: raise RuntimeError(f"connected host {host_id!r} is not online") diff --git a/omnigent/stores/host_store.py b/omnigent/stores/host_store.py index 90577e59929..b094d108d11 100644 --- a/omnigent/stores/host_store.py +++ b/omnigent/stores/host_store.py @@ -2,7 +2,7 @@ Persistent store for host registrations. Hosts are machines connected via ``omnigent host``. The store -tracks which hosts have ever connected, their names, owners, and +tracks which hosts have ever connected, their names, user_ids, and online/offline status. The ``hosts`` table is the source of truth for ``GET /v1/hosts`` — all server replicas query it. Live WebSocket connection state is tracked separately in the in-memory @@ -51,7 +51,7 @@ class Host: :param host_id: Stable identifier from the host's local ``~/.omnigent/config.yaml``, e.g. ``"host_a1b2c3d4..."``. :param name: Human-readable name, e.g. ``"corey-laptop"``. - :param owner: User ID from the Databricks auth Bearer token, + :param user_id: User ID from the Databricks auth Bearer token, e.g. ``"corey.zumar@databricks.com"``. :param status: ``"online"`` or ``"offline"``. :param created_at: Unix epoch seconds of first registration. @@ -75,7 +75,7 @@ class Host: host_id: str name: str - owner: str + user_id: str status: str created_at: int updated_at: int @@ -143,7 +143,7 @@ def _row_to_host(row: SqlHost) -> Host: return Host( host_id=row.host_id, name=row.name, - owner=row.owner, + user_id=row.user_id, status=decode_host_status(row.status), created_at=row.created_at, updated_at=row.updated_at, @@ -191,7 +191,7 @@ def upsert_on_connect( self, host_id: str, name: str, - owner: str, + user_id: str, *, allow_host_id_reown: bool = False, configured_harnesses: dict[str, HarnessAvailability] | None = None, @@ -200,16 +200,16 @@ def upsert_on_connect( Register or update a host on WebSocket connect. Inserts a new row if ``host_id`` does not exist, otherwise - updates ``name``, ``owner``, ``status``, and ``updated_at``. + updates ``name``, ``user_id``, ``status``, and ``updated_at``. Called by the host tunnel endpoint when a host sends its ``host.hello`` frame. - The upsert keys on the ``(owner, name)`` primary key, but + The upsert keys on the ``(user_id, name)`` primary key, but ``host_id`` carries its own UNIQUE constraint. When the same - physical host re-registers under a *different* owner (e.g. a + physical host re-registers under a *different* user_id (e.g. a local server respawned with a flipped auth posture changes the - owner between an accounts user and the reserved ``local`` user), - the ``(owner, name)`` lookup misses and a plain INSERT would + user_id between an accounts user and the reserved ``local`` user), + the ``(user_id, name)`` lookup misses and a plain INSERT would collide on ``host_id``. That collision is a deliberate W2-class boundary in shared deployments — a different user must not be able to claim another user's host_id — so re-owning is gated @@ -222,10 +222,10 @@ def upsert_on_connect( ``"host_a1b2c3d4..."``. :param name: Human-readable name from ``config.yaml``, e.g. ``"corey-laptop"``. - :param owner: Authenticated user ID from the Bearer token, + :param user_id: Authenticated user ID from the Bearer token, e.g. ``"corey.zumar@databricks.com"``. :param allow_host_id_reown: When ``True`` and a row already - exists for *host_id* under a different ``(owner, name)``, + exists for *host_id* under a different ``(user_id, name)``, re-own that row in place (preserving the ``host_id`` and its conversation bindings) instead of inserting. Intended solely for the single-user loopback local server. @@ -247,32 +247,32 @@ def upsert_on_connect( # W2-class boundary: a different user must not claim another # user's host_id. Raise the same IntegrityError the old UNIQUE # constraint produced so the tunnel handler rejects the hijack. - if row.owner != owner and not allow_host_id_reown: + if row.user_id != user_id and not allow_host_id_reown: raise IntegrityError( "host_id already owned by a different user", - params={"host_id": host_id, "owner": owner}, + params={"host_id": host_id, "user_id": user_id}, orig=Exception("UNIQUE constraint failed: hosts.host_id"), ) - # Known host_id (same owner, or reown opted in): update - # owner/name in case they changed, then refresh status and timestamp. - row.owner = owner + # Known host_id (same user_id, or reown opted in): update + # user_id/name in case they changed, then refresh status and timestamp. + row.user_id = user_id row.name = name row.status = encode_host_status("online") row.updated_at = now row.configured_harnesses = harnesses_json return _row_to_host(row) - # host_id is new — check whether (workspace_id, owner, name) + # host_id is new — check whether (workspace_id, user_id, name) # already exists. If it does, the same machine regenerated its # identity file: this is a host_id rotation. If allow_host_id_reown # is set, also check if any row holds this host_id under a different - # owner and re-own it instead of inserting. + # user_id and re-own it instead of inserting. if allow_host_id_reown: reowned = self._reown_host_id( session, host_id=host_id, name=name, - owner=owner, + user_id=user_id, configured_harnesses_json=harnesses_json, ) if reowned is not None: @@ -281,12 +281,12 @@ def upsert_on_connect( existing_by_name = session.execute( select(SqlHost).where( SqlHost.workspace_id == current_workspace_id(), - SqlHost.owner == owner, + SqlHost.user_id == user_id, SqlHost.name == name, ) ).scalar_one_or_none() if existing_by_name is not None: - # Same (owner, name), different host_id: identity rotation. + # Same (user_id, name), different host_id: identity rotation. # host_id is now part of the PK, so we can't UPDATE it via the # ORM — delete the old row and insert a fresh one that carries # the new host_id while preserving created_at. @@ -295,7 +295,7 @@ def upsert_on_connect( # Genuinely new host: plain INSERT. row = SqlHost( - owner=owner, + user_id=user_id, name=name, host_id=host_id, status=encode_host_status("online"), @@ -338,7 +338,7 @@ def _rotate_host_id( old_host_id = row.host_id # Preserve durable fields from the outgoing row before deletion. created_at = row.created_at - owner = row.owner + user_id = row.user_id name = row.name token_hash = row.token_hash token_expires_at = row.token_expires_at @@ -376,7 +376,7 @@ def _rotate_host_id( new_row = SqlHost( workspace_id=current_workspace_id(), host_id=new_host_id, - owner=owner, + user_id=user_id, name=name, status=encode_host_status("online"), created_at=created_at, @@ -409,25 +409,26 @@ def _reown_host_id( *, host_id: str, name: str, - owner: str, + user_id: str, configured_harnesses_json: str | None = None, ) -> Host | None: - """Re-own an existing host_id row under a new ``(owner, name)``. + """Re-own an existing host_id row under a new ``(user_id, name)``. Used only when ``upsert_on_connect`` opts in via ``allow_host_id_reown`` (the single-user loopback local server). - Updates ``owner``, ``name``, ``status``, and ``updated_at`` on the + Updates ``user_id``, ``name``, ``status``, and ``updated_at`` on the row that already holds *host_id*, leaving ``host_id`` itself unchanged so the ``conversations.host_id`` foreign-key bindings - survive the owner change. ``owner`` / ``name`` are the table's - primary key, so the change is issued as a Core ``UPDATE`` rather - than mutating the ORM object's PK in place. + survive the user_id change. ``(workspace_id, user_id, name)`` is a + unique constraint (the PK is ``(workspace_id, host_id)``), so the + change is issued as a Core ``UPDATE`` rather than loading and + mutating the ORM object in place. :param session: The active SQLAlchemy session. :param host_id: Host identifier whose row should be re-owned, e.g. ``"host_a1b2c3d4..."``. :param name: New host name to record, e.g. ``"corey-laptop"``. - :param owner: New owner to record, e.g. ``"local"`` or + :param user_id: New user_id to record, e.g. ``"local"`` or ``"corey.zumar@databricks.com"``. :param configured_harnesses_json: JSON-encoded readiness map from the connecting host's hello, e.g. @@ -453,7 +454,7 @@ def _reown_host_id( SqlHost.host_id == host_id, ) .values( - owner=owner, + user_id=user_id, name=name, status=encode_host_status("online"), updated_at=now, @@ -463,7 +464,7 @@ def _reown_host_id( return Host( host_id=host_id, name=name, - owner=owner, + user_id=user_id, status="online", created_at=created_at, updated_at=now, @@ -598,14 +599,14 @@ def online_host_ids(self, host_ids: list[str]) -> set[str]: if row.status == online_code and row.updated_at >= ref - HOST_LIVENESS_TTL_S } - def list_hosts(self, owner: str) -> list[Host]: + def list_hosts(self, user_id: str) -> list[Host]: """ List all hosts owned by a specific user. Returns both online and offline hosts, ordered by ``updated_at`` descending (most recently active first). - :param owner: User ID to filter by, e.g. + :param user_id: User ID to filter by, e.g. ``"corey.zumar@databricks.com"``. :returns: List of :class:`Host` entities. """ @@ -614,7 +615,7 @@ def list_hosts(self, owner: str) -> list[Host]: session.query(SqlHost) .filter( SqlHost.workspace_id == current_workspace_id(), - SqlHost.owner == owner, + SqlHost.user_id == user_id, ) .order_by(SqlHost.updated_at.desc()) .all() @@ -644,7 +645,7 @@ def register_managed_host( *, host_id: str, name: str, - owner: str, + user_id: str, token: str, provider: str, sandbox_id: str, @@ -671,8 +672,8 @@ def register_managed_host( ``"host_a1b2c3d4..."``. :param name: Display name for the host picker, e.g. ``"managed-a1b2c3d4"``. Part of the table's - ``(owner, name)`` primary key. - :param owner: User the managed host acts for, e.g. + ``(user_id, name)`` primary key. + :param user_id: User the managed host acts for, e.g. ``"alice@example.com"``. :param token: The RAW launch token (hashed here, never stored), e.g. the value of ``secrets.token_urlsafe(32)``. @@ -683,7 +684,7 @@ def register_managed_host( token no longer authenticates. :returns: The registered :class:`Host`. :raises ValueError: If a row for *host_id* exists under a - DIFFERENT owner — a relaunch may only re-credential a host + DIFFERENT user_id — a relaunch may only re-credential a host the same user owns. """ now = now_epoch() @@ -695,7 +696,7 @@ def register_managed_host( ) ).scalar_one_or_none() if existing is not None: - if existing.owner != owner: + if existing.user_id != user_id: # Fail closed (W2-class boundary): re-crediting a host # row hands its launch token holder the row owner's # identity, so a cross-owner overwrite would be a host @@ -703,7 +704,7 @@ def register_managed_host( # launch), so this can only fire on a bug or a forged # id — refuse rather than re-own. raise ValueError( - f"host {host_id!r} is registered to a different owner; " + f"host {host_id!r} is registered to a different user; " "refusing to re-credential it" ) existing.token_hash = token_hash @@ -713,7 +714,7 @@ def register_managed_host( existing.updated_at = now return _row_to_host(existing) row = SqlHost( - owner=owner, + user_id=user_id, name=name, host_id=host_id, status=encode_host_status("offline"), diff --git a/omnigent/stores/scheduled_task_store/__init__.py b/omnigent/stores/scheduled_task_store/__init__.py index 60c36dd7ddc..1cf2f08af1a 100644 --- a/omnigent/stores/scheduled_task_store/__init__.py +++ b/omnigent/stores/scheduled_task_store/__init__.py @@ -43,7 +43,7 @@ def create( name: str, prompt: str, rrule: str, - owner_user_id: str | None, + user_id: str | None, agent_id: str, timezone: str, *, @@ -61,7 +61,7 @@ def create( :param prompt: The instruction dispatched to the agent on each firing. :param rrule: The required RFC 5545 recurrence rule for the recurring trigger, e.g. ``"FREQ=DAILY;BYHOUR=9;BYMINUTE=0"``. - :param owner_user_id: User the spawned session's ``LEVEL_OWNER`` grant + :param user_id: User the spawned session's ``LEVEL_OWNER`` grant is written for; ``None`` in single-user mode. :param agent_id: The agent bound to this task. :param timezone: IANA timezone the trigger is evaluated in. diff --git a/omnigent/stores/scheduled_task_store/sqlalchemy_store.py b/omnigent/stores/scheduled_task_store/sqlalchemy_store.py index bd46c7b1121..c4c0a97d158 100644 --- a/omnigent/stores/scheduled_task_store/sqlalchemy_store.py +++ b/omnigent/stores/scheduled_task_store/sqlalchemy_store.py @@ -44,7 +44,7 @@ def _to_entity(row: SqlScheduledTask) -> ScheduledTask: id=row.id, name=row.name, prompt=row.prompt, - owner_user_id=row.owner_user_id, + user_id=row.user_id, agent_id=row.agent_id, timezone=row.timezone, created_at=row.created_at, @@ -113,7 +113,7 @@ def create( name: str, prompt: str, rrule: str, - owner_user_id: str | None, + user_id: str | None, agent_id: str, timezone: str, *, @@ -129,7 +129,7 @@ def create( name=name, prompt=prompt, rrule=rrule, - owner_user_id=owner_user_id, + user_id=user_id, agent_id=agent_id, timezone=timezone, model_override=model_override, diff --git a/tests/db/test_db_models.py b/tests/db/test_db_models.py index 9acd7da50fe..b61c1bc499a 100644 --- a/tests/db/test_db_models.py +++ b/tests/db/test_db_models.py @@ -846,7 +846,7 @@ def test_persist_and_read(self, db_uri: str) -> None: now = _now() host = SqlHost( - owner="corey@example.com", + user_id="corey@example.com", name="corey-laptop", host_id="4f64b6ee625f4e8259185c35c6e63f3d", status=encode_host_status("online"), @@ -885,7 +885,7 @@ def test_unique_host_id(self, db_uri: str) -> None: now = _now() h1 = SqlHost( - owner="a@x.com", + user_id="a@x.com", name="h1", host_id="2690ed5ead1b05791d642d85e6847680", status=encode_host_status("online"), @@ -897,7 +897,7 @@ def test_unique_host_id(self, db_uri: str) -> None: session.add(h1) h2 = SqlHost( - owner="b@x.com", + user_id="b@x.com", name="h2", host_id="2690ed5ead1b05791d642d85e6847680", status=encode_host_status("offline"), diff --git a/tests/db/test_migration_host_name_varchar64.py b/tests/db/test_migration_host_name_varchar64.py index 9d6ff250b9c..9bd584b6a4e 100644 --- a/tests/db/test_migration_host_name_varchar64.py +++ b/tests/db/test_migration_host_name_varchar64.py @@ -58,7 +58,7 @@ def test_downgrade_restores_varchar256(tmp_path: Path) -> None: conn.execute( sa.text( "INSERT INTO hosts " - "(workspace_id, owner, name, host_id, status, created_at, updated_at) " + "(workspace_id, user_id, name, host_id, status, created_at, updated_at) " "VALUES (0, 'user@example.com', 'my-laptop'," " '4f64b6ee625f4e8259185c35c6e63f3d', 1, " "1700000000, 1700000001)" diff --git a/tests/db/test_migration_host_pk_workspace_host_id.py b/tests/db/test_migration_host_pk_workspace_host_id.py index 8fdb95905e5..6cafee15832 100644 --- a/tests/db/test_migration_host_pk_workspace_host_id.py +++ b/tests/db/test_migration_host_pk_workspace_host_id.py @@ -1,7 +1,7 @@ """Tests for the hosts PK migration to (workspace_id, host_id) (v1a2b3c4d5e6). Verifies that after upgrade the PK is (workspace_id, host_id) with -uq_hosts_workspace_owner_name in place, and that downgrade restores the +uq_hosts_workspace_user_id_name in place, and that downgrade restores the original (workspace_id, owner, name) PK with uq_hosts_host_id. """ @@ -42,15 +42,15 @@ def test_pk_is_workspace_id_and_host_id(db_engine: Engine) -> None: ) -def test_unique_constraint_on_workspace_owner_name(db_engine: Engine) -> None: - """After upgrade uq_hosts_workspace_owner_name must exist.""" +def test_unique_constraint_on_workspace_user_id_name(db_engine: Engine) -> None: + """After upgrade uq_hosts_workspace_user_id_name must exist.""" uqs = sa.inspect(db_engine).get_unique_constraints("hosts") names = {u["name"] for u in uqs} - assert "uq_hosts_workspace_owner_name" in names, ( - f"Expected uq_hosts_workspace_owner_name; found {names}" + assert "uq_hosts_workspace_user_id_name" in names, ( + f"Expected uq_hosts_workspace_user_id_name; found {names}" ) - uq = next(u for u in uqs if u["name"] == "uq_hosts_workspace_owner_name") - assert set(uq["column_names"]) == {"workspace_id", "owner", "name"} + uq = next(u for u in uqs if u["name"] == "uq_hosts_workspace_user_id_name") + assert set(uq["column_names"]) == {"workspace_id", "user_id", "name"} def test_old_unique_constraint_dropped(db_engine: Engine) -> None: @@ -72,7 +72,7 @@ def test_data_survives_upgrade(tmp_path: Path) -> None: conn.execute( sa.text( "INSERT INTO hosts " - "(workspace_id, owner, name, host_id, status, created_at, updated_at) " + "(workspace_id, user_id, name, host_id, status, created_at, updated_at) " "VALUES (0, 'alice@example.com', 'laptop', 'abb32306b80732bdfa6153b2f5f6eb92', 1, " "1700000000, 1700000001)" ) @@ -83,7 +83,7 @@ def test_data_survives_upgrade(tmp_path: Path) -> None: engine.connect() .execute( sa.text( - "SELECT owner, name, host_id FROM hosts" + "SELECT user_id, name, host_id FROM hosts" " WHERE host_id = 'abb32306b80732bdfa6153b2f5f6eb92'" ) ) @@ -110,7 +110,7 @@ def test_downgrade_restores_old_pk(tmp_path: Path) -> None: conn.execute( sa.text( "INSERT INTO hosts " - "(workspace_id, owner, name, host_id, status, created_at, updated_at) " + "(workspace_id, user_id, name, host_id, status, created_at, updated_at) " "VALUES (0, 'bob@example.com', 'workstation'," " '2173662ad94ab46f03cfbdd5f968d22b', 2, " "1700000002, 1700000003)" diff --git a/tests/db/test_migration_hosts_token_hash.py b/tests/db/test_migration_hosts_token_hash.py index daa457fdba7..d76f145105b 100644 --- a/tests/db/test_migration_hosts_token_hash.py +++ b/tests/db/test_migration_hosts_token_hash.py @@ -2,8 +2,8 @@ Verifies that at head ``uq_hosts_token_hash`` is gone (the launch-token auth path resolves by the ``(workspace_id, host_id)`` PK and matches the digest in -Python), that ``uq_hosts_workspace_owner_name`` is untouched, and that downgrade -restores the constraint. +Python), that the ``(workspace_id, user_id, name)`` uniqueness is untouched, and +that downgrade restores the constraint. """ from __future__ import annotations @@ -39,8 +39,8 @@ def test_token_hash_unique_dropped_at_head(db_engine: Engine) -> None: """At head the (workspace_id, token_hash) unique key is gone.""" uniques = {u["name"] for u in sa.inspect(db_engine).get_unique_constraints("hosts")} assert "uq_hosts_token_hash" not in uniques - # The owner/name uniqueness that guards host_id rotation is untouched. - assert "uq_hosts_workspace_owner_name" in uniques + # The user_id/name uniqueness that guards host_id rotation is untouched. + assert "uq_hosts_workspace_user_id_name" in uniques def test_downgrade_restores_token_hash_unique(tmp_path: Path) -> None: diff --git a/tests/db/test_migration_scheduled_tasks.py b/tests/db/test_migration_scheduled_tasks.py index f3c8e34505d..13d274b0a2c 100644 --- a/tests/db/test_migration_scheduled_tasks.py +++ b/tests/db/test_migration_scheduled_tasks.py @@ -56,7 +56,7 @@ def test_scheduled_tasks_columns(db_engine: Engine) -> None: "name", "prompt", "rrule", - "owner_user_id", + "user_id", "agent_id", "model_override", "reasoning_effort", @@ -122,7 +122,7 @@ def test_expected_indexes(db_engine: Engine) -> None: scheduled_tasks_idx = {i["name"] for i in insp.get_indexes("scheduled_tasks")} assert { "ix_scheduled_tasks_created_at", - "ix_scheduled_tasks_owner_user_id", + "ix_scheduled_tasks_user_id", } <= scheduled_tasks_idx assert "ix_scheduled_tasks_agent_id" not in scheduled_tasks_idx # ix_scheduled_tasks_state was dropped: list_active's per-workspace shape @@ -155,7 +155,7 @@ def test_state_default_on_omitted_insert(db_engine: Engine) -> None: conn.execute( sa.text( "INSERT INTO scheduled_tasks " - "(id, name, prompt, rrule, owner_user_id, agent_id, " + "(id, name, prompt, rrule, user_id, agent_id, " " timezone, created_at) " "VALUES (X'00000000000000000000000000000de1', 'n', 'p', " "'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', 'u', 'ag_1', 'UTC', 1)" @@ -184,7 +184,7 @@ def test_execution_target_check_rejects_bad_code(db_engine: Engine) -> None: conn.execute( sa.text( "INSERT INTO scheduled_tasks " - "(id, name, prompt, rrule, owner_user_id, agent_id, " + "(id, name, prompt, rrule, user_id, agent_id, " " timezone, execution_target, created_at) " "VALUES (X'00000000000000000000000000e6bad0', 'n', 'p', " "'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', 'u', 'ag', 'UTC', 99, 1)" @@ -198,7 +198,7 @@ def test_rrule_accepts_recurring_row(db_engine: Engine) -> None: conn.execute( sa.text( "INSERT INTO scheduled_tasks " - "(id, name, prompt, rrule, owner_user_id, agent_id, " + "(id, name, prompt, rrule, user_id, agent_id, " " timezone, created_at) " "VALUES (X'0000000000000000000000000000c40e', 'n', 'p', " "'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', 'u', 'ag', 'UTC', 1)" @@ -213,7 +213,7 @@ def test_rrule_is_not_null(db_engine: Engine) -> None: conn.execute( sa.text( "INSERT INTO scheduled_tasks " - "(id, name, prompt, owner_user_id, agent_id, timezone, " + "(id, name, prompt, user_id, agent_id, timezone, " " created_at) " "VALUES (X'000000000000000000000000000000e0', 'n', 'p', " "'u', 'ag', 'UTC', 1)" @@ -238,7 +238,7 @@ def test_state_check_rejects_bad_code(db_engine: Engine) -> None: conn.execute( sa.text( "INSERT INTO scheduled_tasks " - "(id, name, prompt, rrule, owner_user_id, agent_id, " + "(id, name, prompt, rrule, user_id, agent_id, " " timezone, state, created_at) " "VALUES (X'00000000000000000000000000badc0d', 'n', 'p', " "'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', 'u', 'ag', 'UTC', 99, 1)" diff --git a/tests/db/test_migration_unify_user_id.py b/tests/db/test_migration_unify_user_id.py new file mode 100644 index 00000000000..8ea7fee6e34 --- /dev/null +++ b/tests/db/test_migration_unify_user_id.py @@ -0,0 +1,167 @@ +"""Tests for the user_id-unification migration (b3c1a2d4e5f6). + +Verifies that after upgrade the session-owner identity columns match the +schema-wide ``user_id`` convention: ``hosts.owner`` is now ``hosts.user_id`` +(``VARCHAR(128)``) behind ``uq_hosts_workspace_user_id_name``, and +``scheduled_tasks.owner_user_id`` is now ``scheduled_tasks.user_id`` behind +``ix_scheduled_tasks_user_id``. Downgrade restores the original ``owner`` / +``owner_user_id`` names (and their constraint/index names) with row data intact. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path + +import pytest +import sqlalchemy as sa +from alembic import command +from sqlalchemy.engine import Engine + +from omnigent.db.utils import ( + _build_alembic_config, + clear_engine_cache, + get_or_create_engine, +) + +# One step below b3c1a2d4e5f6 — the revision its downgrade lands on. +_PREVIOUS_HEAD = "f82e866d9de0" + + +@pytest.fixture +def db_engine(tmp_path: Path) -> Iterator[Engine]: + """Fresh SQLite database at head (b3c1a2d4e5f6).""" + db_path = tmp_path / "test.db" + uri = f"sqlite:///{db_path}" + engine = get_or_create_engine(uri) + try: + yield engine + finally: + clear_engine_cache() + + +def test_hosts_user_id_column_at_head(db_engine: Engine) -> None: + """After upgrade hosts exposes ``user_id`` (VARCHAR(128)), not ``owner``.""" + cols = {c["name"]: c for c in sa.inspect(db_engine).get_columns("hosts")} + assert "user_id" in cols, f"Expected hosts.user_id; found {set(cols)}" + assert "owner" not in cols, f"hosts.owner should be gone; found {set(cols)}" + # Narrowed to the schema-wide 128-char width for user-identity columns. + assert "128" in str(cols["user_id"]["type"]).upper(), ( + f"Expected VARCHAR(128); got {cols['user_id']['type']}" + ) + + +def test_hosts_unique_constraint_renamed(db_engine: Engine) -> None: + """The hosts unique key is ``uq_hosts_workspace_user_id_name`` at head.""" + uqs = sa.inspect(db_engine).get_unique_constraints("hosts") + names = {u["name"] for u in uqs} + assert "uq_hosts_workspace_user_id_name" in names, ( + f"Expected uq_hosts_workspace_user_id_name; found {names}" + ) + assert "uq_hosts_workspace_owner_name" not in names, ( + f"uq_hosts_workspace_owner_name should be gone; found {names}" + ) + uq = next(u for u in uqs if u["name"] == "uq_hosts_workspace_user_id_name") + assert set(uq["column_names"]) == {"workspace_id", "user_id", "name"} + + +def test_scheduled_tasks_user_id_column_at_head(db_engine: Engine) -> None: + """After upgrade scheduled_tasks exposes ``user_id``, not ``owner_user_id``.""" + cols = {c["name"] for c in sa.inspect(db_engine).get_columns("scheduled_tasks")} + assert "user_id" in cols, f"Expected scheduled_tasks.user_id; found {cols}" + assert "owner_user_id" not in cols, ( + f"scheduled_tasks.owner_user_id should be gone; found {cols}" + ) + + +def test_scheduled_tasks_index_renamed(db_engine: Engine) -> None: + """The scheduled_tasks owner index is ``ix_scheduled_tasks_user_id`` at head.""" + idx = {i["name"] for i in sa.inspect(db_engine).get_indexes("scheduled_tasks")} + assert "ix_scheduled_tasks_user_id" in idx, f"Expected ix_scheduled_tasks_user_id; found {idx}" + assert "ix_scheduled_tasks_owner_user_id" not in idx, ( + f"ix_scheduled_tasks_owner_user_id should be gone; found {idx}" + ) + + +def test_downgrade_restores_old_names(tmp_path: Path) -> None: + """Downgrade one step restores ``owner`` / ``owner_user_id`` with data intact. + + Insert a host row at head (``user_id``), downgrade to the previous revision + (which renames the columns back), and confirm the old names/constraint/index + are restored and the row's identity value survived the round-trip. A final + re-upgrade proves the rename is replayable and the value survives both hops. + """ + db_path = tmp_path / "downgrade.db" + uri = f"sqlite:///{db_path}" + engine = get_or_create_engine(uri) + + # Insert at head using the new column name. status is SmallInteger + # (u1a2b3c4d5e6 converted it): online=1. + with engine.connect() as conn: + conn.execute( + sa.text( + "INSERT INTO hosts " + "(workspace_id, user_id, name, host_id, status, created_at, updated_at) " + "VALUES (0, 'alice@example.com', 'laptop'," + " 'c0ffee00c0ffee00c0ffee00c0ffee00', 1, " + "1700000000, 1700000001)" + ) + ) + conn.commit() + + config = _build_alembic_config(uri) + with engine.begin() as conn: + config.attributes["connection"] = conn + command.downgrade(config, _PREVIOUS_HEAD) + + inspector = sa.inspect(engine) + + # hosts: owner restored, user_id gone, old unique constraint restored. + host_cols = {c["name"] for c in inspector.get_columns("hosts")} + assert "owner" in host_cols and "user_id" not in host_cols, ( + f"hosts.owner must be restored and user_id gone; found {host_cols}" + ) + host_uqs = {u["name"] for u in inspector.get_unique_constraints("hosts")} + assert "uq_hosts_workspace_owner_name" in host_uqs, ( + f"uq_hosts_workspace_owner_name must be restored; found {host_uqs}" + ) + assert "uq_hosts_workspace_user_id_name" not in host_uqs + + # scheduled_tasks: owner_user_id restored, user_id gone, old index restored. + task_cols = {c["name"] for c in inspector.get_columns("scheduled_tasks")} + assert "owner_user_id" in task_cols and "user_id" not in task_cols, ( + f"scheduled_tasks.owner_user_id must be restored and user_id gone; found {task_cols}" + ) + task_idx = {i["name"] for i in inspector.get_indexes("scheduled_tasks")} + assert "ix_scheduled_tasks_owner_user_id" in task_idx, ( + f"ix_scheduled_tasks_owner_user_id must be restored; found {task_idx}" + ) + assert "ix_scheduled_tasks_user_id" not in task_idx + + # The pre-inserted row survived the rename; read it back via the old name. + with engine.connect() as conn: + owner = conn.execute( + sa.text("SELECT owner FROM hosts WHERE host_id = 'c0ffee00c0ffee00c0ffee00c0ffee00'") + ).scalar_one_or_none() + assert owner == "alice@example.com", f"owner value must survive downgrade; got {owner!r}" + + # Re-upgrade to head: user_id is back, owner gone, and the value survives. + with engine.begin() as conn: + config.attributes["connection"] = conn + command.upgrade(config, "head") + + inspector = sa.inspect(engine) + host_cols = {c["name"] for c in inspector.get_columns("hosts")} + assert "user_id" in host_cols and "owner" not in host_cols, ( + f"hosts.user_id must be restored on re-upgrade; found {host_cols}" + ) + with engine.connect() as conn: + user_id = conn.execute( + sa.text("SELECT user_id FROM hosts WHERE host_id = 'c0ffee00c0ffee00c0ffee00c0ffee00'") + ).scalar_one_or_none() + assert user_id == "alice@example.com", ( + f"user_id value must survive the full round-trip; got {user_id!r}" + ) + + engine.dispose() + clear_engine_cache() diff --git a/tests/db/test_migration_workspace.py b/tests/db/test_migration_workspace.py index 3a6d761320b..2448ae8c4ec 100644 --- a/tests/db/test_migration_workspace.py +++ b/tests/db/test_migration_workspace.py @@ -194,7 +194,7 @@ def test_check_constraint_allows_host_id_with_workspace( conn.execute( sa.text( "INSERT INTO hosts " - "(owner, name, host_id, status, created_at, updated_at) " + "(user_id, name, host_id, status, created_at, updated_at) " "VALUES (:o, :n, :hid, 1, :ts, :ts)" ), { @@ -280,7 +280,7 @@ def test_host_id_fk_sets_null_when_host_deleted(db_engine: Engine) -> None: conn.execute( sa.text( "INSERT INTO hosts " - "(owner, name, host_id, status, created_at, updated_at) " + "(user_id, name, host_id, status, created_at, updated_at) " "VALUES (:o, :n, :hid, 1, :ts, :ts)" ), { diff --git a/tests/server/integration/test_host_liveness_staleness_e2e.py b/tests/server/integration/test_host_liveness_staleness_e2e.py index 9985217a09b..4ca837ec504 100644 --- a/tests/server/integration/test_host_liveness_staleness_e2e.py +++ b/tests/server/integration/test_host_liveness_staleness_e2e.py @@ -173,7 +173,9 @@ async def test_crashed_host_session_reads_host_offline( # The host connects: this is exactly what the tunnel handler does on # host.hello — upserts the row to status='online'. No live tunnel / # heartbeat is modeled, which is precisely the post-crash DB state. - host_store.upsert_on_connect(host_id=_HOST_ID, name="alice-laptop", owner=RESERVED_USER_LOCAL) + host_store.upsert_on_connect( + host_id=_HOST_ID, name="alice-laptop", user_id=RESERVED_USER_LOCAL + ) conv_store.set_host_id(session_id, _HOST_ID, workspace="/tmp/ws") # Baseline: while the host is freshly online, host_online is True. If @@ -218,7 +220,9 @@ async def test_recently_seen_host_reads_host_online( agent = await create_test_agent(host_aware_client) session_id = agent["_session_id"] - host_store.upsert_on_connect(host_id=_HOST_ID, name="alice-laptop", owner=RESERVED_USER_LOCAL) + host_store.upsert_on_connect( + host_id=_HOST_ID, name="alice-laptop", user_id=RESERVED_USER_LOCAL + ) conv_store.set_host_id(session_id, _HOST_ID, workspace="/tmp/ws") # Last seen comfortably inside the window (about a third of the TTL). diff --git a/tests/server/integration/test_host_session_binding.py b/tests/server/integration/test_host_session_binding.py index 00985f54de6..8c82e5e1509 100644 --- a/tests/server/integration/test_host_session_binding.py +++ b/tests/server/integration/test_host_session_binding.py @@ -473,7 +473,7 @@ def _start_fake_sandbox_host(invocation: HostStartInvocation) -> None: # same as a directly-connected host would be. host = env.host_store.get_host(conv.host_id) assert host is not None - assert host.owner == RESERVED_USER_LOCAL + assert host.user_id == RESERVED_USER_LOCAL assert host.status == "online" assert host.sandbox_provider == "modal" assert host.sandbox_id == "sb-fake-1" @@ -1068,7 +1068,7 @@ async def test_resumable_managed_wake_ignores_stale_db_liveness( host_store.register_managed_host( host_id="40bb7200abc8ed27d5b2fcbfad8e89d2", name="managed-stale-live-islo", - owner=RESERVED_USER_LOCAL, + user_id=RESERVED_USER_LOCAL, token="tok-stale-live-islo", provider="islo", sandbox_id="sb-stale-live-islo", @@ -1077,7 +1077,7 @@ async def test_resumable_managed_wake_ignores_stale_db_liveness( host_store.upsert_on_connect( host_id="40bb7200abc8ed27d5b2fcbfad8e89d2", name="managed-stale-live-islo", - owner=RESERVED_USER_LOCAL, + user_id=RESERVED_USER_LOCAL, ) conv = conv_store.create_conversation( agent_id=None, @@ -1134,7 +1134,7 @@ async def test_resumable_managed_wake_drops_fresh_local_tunnels_when_provider_pa host_store.register_managed_host( host_id="055e31f38d07908f171ebad4ff5cbe9c", name="managed-stale-tunnel-islo", - owner=RESERVED_USER_LOCAL, + user_id=RESERVED_USER_LOCAL, token="tok-stale-tunnel-islo", provider="islo", sandbox_id="sb-stale-tunnel-islo", @@ -1143,7 +1143,7 @@ async def test_resumable_managed_wake_drops_fresh_local_tunnels_when_provider_pa host_store.upsert_on_connect( host_id="055e31f38d07908f171ebad4ff5cbe9c", name="managed-stale-tunnel-islo", - owner=RESERVED_USER_LOCAL, + user_id=RESERVED_USER_LOCAL, ) conv = conv_store.create_conversation( agent_id=None, diff --git a/tests/server/integration/test_host_tunnel_route.py b/tests/server/integration/test_host_tunnel_route.py index c2b509c4b39..d17679598fe 100644 --- a/tests/server/integration/test_host_tunnel_route.py +++ b/tests/server/integration/test_host_tunnel_route.py @@ -550,7 +550,7 @@ async def test_cross_owner_refused_with_409_before_accept(db_uri: str) -> None: """ app, registry, store = _owned_app(db_uri, authed_user="bob@example.com") # The host_id is already owned by someone else. - store.upsert_on_connect(host_id=_HOST_ID, name="alices-laptop", owner="alice@example.com") + store.upsert_on_connect(host_id=_HOST_ID, name="alices-laptop", user_id="alice@example.com") scope = _websocket_scope(_TUNNEL_PATH) # Advertise the denial-response extension, as uvicorn does in prod. @@ -570,7 +570,7 @@ async def test_cross_owner_refused_with_409_before_accept(db_uri: str) -> None: assert registry.get(_HOST_ID) is None host = store.get_host(_HOST_ID) assert host is not None - assert host.owner == "alice@example.com" + assert host.user_id == "alice@example.com" assert host.status == "online" @@ -582,7 +582,7 @@ async def test_cross_owner_refused_with_close_when_no_denial_extension(db_uri: s just with the less specific message. """ app, registry, store = _owned_app(db_uri, authed_user="bob@example.com") - store.upsert_on_connect(host_id=_HOST_ID, name="alices-laptop", owner="alice@example.com") + store.upsert_on_connect(host_id=_HOST_ID, name="alices-laptop", user_id="alice@example.com") # No "extensions" key in the scope → fallback path. comm = ApplicationCommunicator(app, _websocket_scope(_TUNNEL_PATH)) @@ -601,7 +601,7 @@ async def test_same_owner_reconnect_still_accepts(db_uri: str) -> None: otherwise the new check would break normal reconnection. """ app, registry, store = _owned_app(db_uri, authed_user="bob@example.com") - store.upsert_on_connect(host_id=_HOST_ID, name="bobs-laptop", owner="bob@example.com") + store.upsert_on_connect(host_id=_HOST_ID, name="bobs-laptop", user_id="bob@example.com") comm = await _connect_route(app, _TUNNEL_PATH) await _send_hello_and_wait(comm, registry, name="bobs-laptop") @@ -609,7 +609,7 @@ async def test_same_owner_reconnect_still_accepts(db_uri: str) -> None: assert _HOST_ID in registry.online_host_ids() host = store.get_host(_HOST_ID) assert host is not None - assert host.owner == "bob@example.com" + assert host.user_id == "bob@example.com" assert host.status == "online" await comm.send_input({"type": "websocket.disconnect", "code": 1000}) @@ -652,7 +652,7 @@ def _register_managed( store.register_managed_host( host_id=host_id, name=f"managed-{host_id}", - owner="alice@example.com", + user_id="alice@example.com", token=token, provider="modal", sandbox_id="sb-tunnel-1", @@ -683,7 +683,7 @@ async def test_managed_token_authenticates_as_record_owner( host = store.get_host(_HOST_ID) assert host is not None - assert host.owner == "alice@example.com" + assert host.user_id == "alice@example.com" assert host.status == "online" # The managed binding survives the connect upsert. assert host.sandbox_id == "sb-tunnel-1" diff --git a/tests/server/integration/test_hosts_api.py b/tests/server/integration/test_hosts_api.py index 55ca1a6fcca..46063de2ff0 100644 --- a/tests/server/integration/test_hosts_api.py +++ b/tests/server/integration/test_hosts_api.py @@ -197,7 +197,7 @@ async def test_list_hosts_reports_sandbox_provider_for_managed_host( # Auth is disabled in this fixture, so list_hosts resolves the # caller to the reserved "local" owner — the managed host must # belong to it to be visible. - owner="local", + user_id="local", token="launch-token-secret", provider="modal", sandbox_id="sb-12345", @@ -953,7 +953,7 @@ async def test_tunnel_accepts_authenticated_owner( assert conn.owner == "alice@test.com" stored = host_store.get_host(host_id) assert stored is not None - assert stored.owner == "alice@test.com" + assert stored.user_id == "alice@test.com" def _register_fake_host(registry: HostRegistry, host_id: str, owner: str) -> None: @@ -1011,7 +1011,7 @@ async def test_resolve_host_launch_enforces_host_and_session_ownership( **stores, ) assert isinstance(target, HostLaunchTarget) - assert target.host.owner == "alice@test.com" + assert target.host.user_id == "alice@test.com" assert target.conv.id == conv.id # Bob targets Alice's HOST → 403. Blocks the inline-launch RCE @@ -1137,7 +1137,7 @@ async def test_failed_connect_does_not_offline_another_users_host( after = host_store.get_host("be2a05c6f9530d33276f7c4b34bc39ad") assert after is not None # Bob never claimed the host_id... - assert after.owner == "alice@test.com" + assert after.user_id == "alice@test.com" # ...and crucially, Alice's host is still online: the pre-accept # refusal never runs set_offline on Bob's never-registered connection. assert after.status == "online", ( diff --git a/tests/server/integration/test_hosts_filesystem.py b/tests/server/integration/test_hosts_filesystem.py index 0b988113043..600737812e3 100644 --- a/tests/server/integration/test_hosts_filesystem.py +++ b/tests/server/integration/test_hosts_filesystem.py @@ -381,7 +381,7 @@ async def test_list_filesystem_offline_host_returns_409( host_store.upsert_on_connect( host_id="3d9665477127e41f42de3f4109418173", name="offline-host", - owner="local", + user_id="local", ) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get("/v1/hosts/3d9665477127e41f42de3f4109418173/filesystem") @@ -521,7 +521,7 @@ def get_user_id(self, request: Any) -> str | None: host_store.upsert_on_connect( host_id="f54bb9272002938a3a934bfcb6bb228a", name="alice-laptop", - owner="alice@example.com", + user_id="alice@example.com", ) async with AsyncClient( diff --git a/tests/server/integration/test_scheduler_lifespan.py b/tests/server/integration/test_scheduler_lifespan.py index d4ba92f0fff..444d10abd52 100644 --- a/tests/server/integration/test_scheduler_lifespan.py +++ b/tests/server/integration/test_scheduler_lifespan.py @@ -67,7 +67,7 @@ async def test_lifespan_starts_and_stops_scheduler( name="nightly triage", prompt="triage the queue", rrule="FREQ=DAILY;BYHOUR=9;BYMINUTE=0", - owner_user_id=None, + user_id=None, agent_id=_uid("agent-1"), timezone="America/Los_Angeles", ) @@ -96,7 +96,7 @@ async def test_lifespan_arms_active_task_from_non_default_workspace( name="tenant nightly triage", prompt="triage the queue", rrule="FREQ=DAILY;BYHOUR=9;BYMINUTE=0", - owner_user_id=None, + user_id=None, agent_id=_uid("agent-1"), timezone="America/Los_Angeles", ) @@ -155,7 +155,7 @@ async def test_lifespan_skips_paused_task( name="paused task", prompt="do nothing", rrule="FREQ=DAILY;BYHOUR=9;BYMINUTE=0", - owner_user_id=None, + user_id=None, agent_id=_uid("agent-1"), timezone="UTC", state="paused", diff --git a/tests/server/routes/test_host_launch.py b/tests/server/routes/test_host_launch.py index 4cb2f7f3358..160fb9aef9b 100644 --- a/tests/server/routes/test_host_launch.py +++ b/tests/server/routes/test_host_launch.py @@ -22,7 +22,7 @@ class _FakeHost: host_id: str = "host_1" name: str = "test-host" - owner: str = "alice" + user_id: str = "alice" @dataclass @@ -60,20 +60,20 @@ def test_unknown_host_404(self) -> None: assert exc_info.value.status_code == 404 def test_wrong_owner_403(self) -> None: - host = _FakeHost(host_id="host_1", owner="bob") + host = _FakeHost(host_id="host_1", user_id="bob") store = _FakeHostStore(hosts={"host_1": host}) with pytest.raises(HTTPException) as exc_info: resolve_host_owner(user_id="alice", host_id="host_1", host_store=store) assert exc_info.value.status_code == 403 def test_correct_owner(self) -> None: - host = _FakeHost(host_id="host_1", owner="alice") + host = _FakeHost(host_id="host_1", user_id="alice") store = _FakeHostStore(hosts={"host_1": host}) result = resolve_host_owner(user_id="alice", host_id="host_1", host_store=store) assert result.host_id == "host_1" def test_no_auth_skips_owner_check(self) -> None: - host = _FakeHost(host_id="host_1", owner="bob") + host = _FakeHost(host_id="host_1", user_id="bob") store = _FakeHostStore(hosts={"host_1": host}) result = resolve_host_owner(user_id=None, host_id="host_1", host_store=store) assert result.host_id == "host_1" @@ -84,7 +84,7 @@ def test_no_auth_skips_owner_check(self) -> None: class TestResolveHostLaunch: def test_host_offline_409(self) -> None: - host = _FakeHost(host_id="host_1", owner="alice") + host = _FakeHost(host_id="host_1", user_id="alice") store = _FakeHostStore(hosts={"host_1": host}) registry = _FakeHostRegistry() # empty = no connections conv_store = _FakeConversationStore() @@ -101,7 +101,7 @@ def test_host_offline_409(self) -> None: assert exc_info.value.status_code == 409 def test_missing_session_404(self) -> None: - host = _FakeHost(host_id="host_1", owner="alice") + host = _FakeHost(host_id="host_1", user_id="alice") conn = object() store = _FakeHostStore(hosts={"host_1": host}) registry = _FakeHostRegistry(conns={"host_1": conn}) @@ -119,7 +119,7 @@ def test_missing_session_404(self) -> None: assert exc_info.value.status_code == 404 def test_success_no_auth(self) -> None: - host = _FakeHost(host_id="host_1", owner="alice") + host = _FakeHost(host_id="host_1", user_id="alice") conn = object() conv = Conversation( id="s1", diff --git a/tests/server/scheduled/test_fire.py b/tests/server/scheduled/test_fire.py index f0959bff2c7..297006e3862 100644 --- a/tests/server/scheduled/test_fire.py +++ b/tests/server/scheduled/test_fire.py @@ -157,7 +157,7 @@ def grant(self, user_id: str, conversation_id: str, level: int) -> Any: @dataclass class _FakeHost: host_id: str - owner: str + user_id: str class FakeHostStore: @@ -200,7 +200,7 @@ def _task(**overrides: Any) -> ScheduledTask: "name": "nightly", "prompt": "do the thing", "rrule": "FREQ=HOURLY", - "owner_user_id": None, + "user_id": None, "agent_id": "ag_1", "timezone": "UTC", "created_at": 1_800_000_000, @@ -384,7 +384,7 @@ async def _slow_launch(conv: Any, task: Any) -> None: @pytest.mark.asyncio async def test_explicit_owner_is_granted() -> None: perm = FakePermissionStore() - store = FakeScheduledTaskStore(rows={"task_1": _task(owner_user_id="alice@example.com")}) + store = FakeScheduledTaskStore(rows={"task_1": _task(user_id="alice@example.com")}) async def _launch(conv: Any, task: Any) -> None: return None @@ -451,7 +451,7 @@ async def _dispatch_session_event_to_runner(*args: Any, **kwargs: Any) -> None: ) ) - await dispatch(_FakeConversation(id="conv_1", agent_id="ag_1"), _task(owner_user_id=None)) + await dispatch(_FakeConversation(id="conv_1", agent_id="ag_1"), _task(user_id=None)) assert captured["user_id"] == RESERVED_USER_LOCAL @@ -597,7 +597,7 @@ async def test_no_host_registry_records_failed_without_session() -> None: @pytest.mark.asyncio async def test_offline_connected_host_records_failed_without_session() -> None: conv_store = FakeConversationStore() - store = FakeScheduledTaskStore(rows={"task_1": _task(owner_user_id="alice@example.com")}) + store = FakeScheduledTaskStore(rows={"task_1": _task(user_id="alice@example.com")}) on_fire = build_on_fire( _deps( diff --git a/tests/server/test_identity_migration.py b/tests/server/test_identity_migration.py index bc001385508..f13a6a01942 100644 --- a/tests/server/test_identity_migration.py +++ b/tests/server/test_identity_migration.py @@ -150,7 +150,7 @@ def test_remap_repoints_comments_policies_tokens_hosts(db_uri: str) -> None: ) s.add( SqlHost( - owner="alice", + user_id="alice", name="laptop", host_id="a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", status=encode_host_status("offline"), @@ -175,7 +175,7 @@ def test_remap_repoints_comments_policies_tokens_hosts(db_uri: str) -> None: == "alice@example.com" ) assert s.get(SqlAccountToken, (0, "tok_1")).created_by == "alice@example.com" - host_owners = s.execute(select(SqlHost.owner)).scalars().all() + host_owners = s.execute(select(SqlHost.user_id)).scalars().all() assert host_owners == ["alice@example.com"] diff --git a/tests/server/test_managed_hosts.py b/tests/server/test_managed_hosts.py index 03de51eb35a..7c80a498d3e 100644 --- a/tests/server/test_managed_hosts.py +++ b/tests/server/test_managed_hosts.py @@ -1142,7 +1142,7 @@ def _register(invocation: HostStartInvocation) -> None: host_store.upsert_on_connect( host_id=invocation.host_id, name=invocation.host_name, - owner=_OWNER, + user_id=_OWNER, ) fake = FakeSandboxLauncher(on_host_start=_register) @@ -1166,7 +1166,7 @@ def _register(invocation: HostStartInvocation) -> None: # launchers record their own name. host = host_store.get_host(result.host_id) assert host is not None - assert host.owner == _OWNER + assert host.user_id == _OWNER assert host.name == start.host_name assert host.status == "online" assert host.sandbox_provider == "modal" @@ -1193,7 +1193,7 @@ def _register(invocation: HostStartInvocation) -> None: host_store.upsert_on_connect( host_id=invocation.host_id, name=invocation.host_name, - owner=_OWNER, + user_id=_OWNER, ) fake = FakeSandboxLauncher(on_host_start=_register) @@ -1223,7 +1223,7 @@ def _register(invocation: HostStartInvocation) -> None: host_store.upsert_on_connect( host_id=invocation.host_id, name=invocation.host_name, - owner=_OWNER, + user_id=_OWNER, ) fake = FakeSandboxLauncher(on_host_start=_register, can_resume=True) @@ -1253,7 +1253,7 @@ def _register(invocation: HostStartInvocation) -> None: host_store.upsert_on_connect( host_id=invocation.host_id, name=invocation.host_name, - owner=_OWNER, + user_id=_OWNER, ) fake = FakeSandboxLauncher(on_host_start=_register) @@ -1277,7 +1277,7 @@ def _register(invocation: HostStartInvocation) -> None: host_store.upsert_on_connect( host_id=invocation.host_id, name=invocation.host_name, - owner=_OWNER, + user_id=_OWNER, ) class _LegacySignatureLauncher(FakeSandboxLauncher): @@ -1337,7 +1337,7 @@ def _register(invocation: HostStartInvocation) -> None: host_store.upsert_on_connect( host_id=invocation.host_id, name=invocation.host_name, - owner=_OWNER, + user_id=_OWNER, ) fake = _AcmeLauncher(on_host_start=_register) @@ -1494,7 +1494,7 @@ def _register(invocation: HostStartInvocation) -> None: host_store.upsert_on_connect( host_id=invocation.host_id, name=invocation.host_name, - owner=_OWNER, + user_id=_OWNER, ) fake = FakeSandboxLauncher(on_host_start=_register) @@ -1608,7 +1608,7 @@ def start_host( self._host_store.resolve_launch_token(host_id, token) is not None ) # Simulate the host's entrypoint dialing back over the tunnel. - self._host_store.upsert_on_connect(host_id=host_id, name=host_name, owner=_OWNER) + self._host_store.upsert_on_connect(host_id=host_id, name=host_name, user_id=_OWNER) return f"/home/omnigent/workspace/{repo_name}" if repo_name else "/home/omnigent/workspace" @@ -1687,7 +1687,7 @@ def _register(invocation: HostStartInvocation) -> None: host_store.upsert_on_connect( host_id=invocation.host_id, name=invocation.host_name, - owner=_OWNER, + user_id=_OWNER, ) fake = FakeSandboxLauncher(on_host_start=_register) @@ -1708,7 +1708,7 @@ def _register(invocation: HostStartInvocation) -> None: assert host is not None assert host.sandbox_id == "sb-fake-2" assert host.name == gen1.name - assert host.owner == _OWNER + assert host.user_id == _OWNER # Generation 2 authenticated with a NEW token; generation 1's is # revoked by the re-arm (its digest no longer matches anything). gen2_token = fake.host_starts[1].token @@ -1734,7 +1734,7 @@ def _register(invocation: HostStartInvocation) -> None: host_store.upsert_on_connect( host_id=invocation.host_id, name=invocation.host_name, - owner=_OWNER, + user_id=_OWNER, ) fake = FakeSandboxLauncher(on_host_start=_register) @@ -1776,7 +1776,7 @@ async def test_relaunch_rejects_unconfigured_provider(db_uri: str) -> None: host = host_store.register_managed_host( host_id="8369cb15e751573a1ee641d5fa09c70a", name="managed-mismatch", - owner=_OWNER, + user_id=_OWNER, token="tok", provider="daytona", sandbox_id="dt-1", @@ -1811,7 +1811,7 @@ async def test_host_resume_supported_requires_resumable_matching_launcher(db_uri host = host_store.register_managed_host( host_id="292a6322075a34e482fde44975da10f3", name="managed-resume-gate", - owner=_OWNER, + user_id=_OWNER, token="tok-resume-gate", provider="islo", sandbox_id="sb-resume-gate", @@ -1830,7 +1830,7 @@ async def test_host_resume_supported_requires_resumable_matching_launcher(db_uri no_sandbox = host_store.register_managed_host( host_id="0c3d744a455047df9a3c0acf432d08dd", name="managed-resume-no-sandbox", - owner=_OWNER, + user_id=_OWNER, token="tok-resume-no-sandbox", provider="islo", sandbox_id="sb-temp", @@ -1849,7 +1849,7 @@ def _register(invocation: HostStartInvocation) -> None: host_store.upsert_on_connect( host_id=invocation.host_id, name=invocation.host_name, - owner=_OWNER, + user_id=_OWNER, ) fake = _IsloFakeLauncher(on_host_start=_register, can_resume=True) @@ -1886,7 +1886,7 @@ async def test_resume_managed_host_force_wakes_fresh_online_row(db_uri: str) -> host_store.register_managed_host( host_id="62d4405ba38711fe34bebfeb5a7adaf2", name="managed-resume-force", - owner=_OWNER, + user_id=_OWNER, token="tok-resume-force", provider="islo", sandbox_id="sb-resume-force", @@ -1895,7 +1895,7 @@ async def test_resume_managed_host_force_wakes_fresh_online_row(db_uri: str) -> host_store.upsert_on_connect( host_id="62d4405ba38711fe34bebfeb5a7adaf2", name="managed-resume-force", - owner=_OWNER, + user_id=_OWNER, ) assert host_store.is_online("62d4405ba38711fe34bebfeb5a7adaf2") is True fake = _IsloFakeLauncher(can_resume=True) @@ -1922,7 +1922,7 @@ async def test_resume_managed_host_noops_for_non_resumable_provider(db_uri: str) host_store.register_managed_host( host_id="249d058fbcde7b2ce941479cdb8c82d7", name="managed-resume-noop", - owner=_OWNER, + user_id=_OWNER, token="tok-resume-noop", provider="modal", sandbox_id="sb-resume-noop", @@ -1952,7 +1952,7 @@ async def test_resume_managed_host_failure_preserves_existing_row_and_token(db_u host_store.register_managed_host( host_id="efbef7dede7be6577770cbb1287992f2", name="managed-resume-fail", - owner=_OWNER, + user_id=_OWNER, token="tok-resume-fail", provider="islo", sandbox_id="sb-resume-fail", @@ -1992,7 +1992,7 @@ async def test_terminate_managed_host_terminates_and_deletes_row(db_uri: str) -> host = host_store.register_managed_host( host_id="62a91eb065624754c6a6dfb5869dd7e8", name="managed-term1", - owner=_OWNER, + user_id=_OWNER, token="tok-term-1", provider="modal", sandbox_id="sb-term-1", @@ -2027,7 +2027,7 @@ def _explode(sandbox_id: str) -> None: host = host_store.register_managed_host( host_id="057e7fa3f1cdb40c0ec393a3d42affc7", name="managed-term2", - owner=_OWNER, + user_id=_OWNER, token="tok-term-2", provider="modal", sandbox_id="sb-term-2", @@ -2055,7 +2055,7 @@ async def test_terminate_managed_host_skips_mismatched_provider(db_uri: str) -> host = host_store.register_managed_host( host_id="487212fd2b157b6ab6a6d6d3ef06ce5b", name="managed-term3", - owner=_OWNER, + user_id=_OWNER, token="tok-term-3", # Row launched under a provider the current config doesn't run. provider="acme-cloud", @@ -2075,7 +2075,7 @@ async def test_terminate_managed_host_skips_mismatched_provider(db_uri: str) -> host2 = host_store.register_managed_host( host_id="b114bf90a8fd155ce6007c3bb262aa79", name="managed-term4", - owner=_OWNER, + user_id=_OWNER, token="tok-term-4", provider="modal", sandbox_id="sb-term-4", diff --git a/tests/stores/test_host_store.py b/tests/stores/test_host_store.py index a8508afcc58..078845caa51 100644 --- a/tests/stores/test_host_store.py +++ b/tests/stores/test_host_store.py @@ -56,13 +56,13 @@ def test_upsert_creates_host_on_first_connect( host = host_store.upsert_on_connect( host_id="bdda8ba7e34130318b54dd872eb160af", name="test-laptop", - owner="alice@example.com", + user_id="alice@example.com", ) # Upsert returns the entity with all fields populated. assert host.host_id == "bdda8ba7e34130318b54dd872eb160af" assert host.name == "test-laptop" - assert host.owner == "alice@example.com" + assert host.user_id == "alice@example.com" # New host is marked online immediately. assert host.status == "online" assert host.created_at > 0 @@ -90,13 +90,13 @@ def test_upsert_updates_existing_host_on_reconnect( host_store.upsert_on_connect( host_id="74d106ce261c29485a5dfb880a2cb15f", name="laptop", - owner="bob@example.com", + user_id="bob@example.com", ) host_store.set_offline("74d106ce261c29485a5dfb880a2cb15f") updated = host_store.upsert_on_connect( host_id="ebfb0eac338c147444dc6dbf3f0503fc", name="laptop", - owner="bob@example.com", + user_id="bob@example.com", ) assert updated.host_id == "ebfb0eac338c147444dc6dbf3f0503fc" @@ -114,7 +114,7 @@ def test_upsert_persists_configured_harnesses(host_store: HostStore) -> None: host_store.upsert_on_connect( host_id="e8d515c60f315ca35b4109564e238669", name="laptop", - owner="alice@example.com", + user_id="alice@example.com", configured_harnesses={"claude-sdk": True, "codex": "needs-auth"}, ) @@ -137,14 +137,14 @@ def test_upsert_reconnect_overwrites_and_nulls_configured_harnesses( host_store.upsert_on_connect( host_id="54e092213a38acc19cfd13ffb160a2b7", name="laptop2", - owner="alice@example.com", + user_id="alice@example.com", configured_harnesses={"codex": False}, ) # Reconnect with fresh values — the user ran `omnigent setup`. host_store.upsert_on_connect( host_id="54e092213a38acc19cfd13ffb160a2b7", name="laptop2", - owner="alice@example.com", + user_id="alice@example.com", configured_harnesses={"codex": True}, ) fetched = host_store.get_host("54e092213a38acc19cfd13ffb160a2b7") @@ -155,7 +155,7 @@ def test_upsert_reconnect_overwrites_and_nulls_configured_harnesses( host_store.upsert_on_connect( host_id="54e092213a38acc19cfd13ffb160a2b7", name="laptop2", - owner="alice@example.com", + user_id="alice@example.com", ) fetched = host_store.get_host("54e092213a38acc19cfd13ffb160a2b7") assert fetched is not None @@ -168,7 +168,7 @@ def test_update_harness_readiness_replaces_live_map(host_store: HostStore) -> No host_store.upsert_on_connect( host_id=host_id, name="laptop-live", - owner="alice@example.com", + user_id="alice@example.com", configured_harnesses={"pi": False}, ) @@ -194,7 +194,7 @@ def test_malformed_configured_harnesses_column_reads_as_none( host_store.upsert_on_connect( host_id="2da3abf4db79c0504dbda7b88dbf521d", name="laptop3", - owner="alice@example.com", + user_id="alice@example.com", configured_harnesses={"codex": True}, ) engine = get_or_create_engine(db_uri) @@ -220,7 +220,7 @@ def test_host_store_drops_unknown_harness_availability( host_store.upsert_on_connect( host_id=host_id, name="laptop-readiness", - owner="alice@example.com", + user_id="alice@example.com", configured_harnesses={"codex": "needs-auth"}, ) engine = get_or_create_engine(db_uri) @@ -267,7 +267,7 @@ def test_reconnect_with_rotated_host_id_repoints_bound_conversations( host_store.upsert_on_connect( host_id="a1ed1ab71de2311e20488a989e61701c", name="dev-laptop", - owner="dana@example.com", + user_id="dana@example.com", ) # Bind a conversation to the old host_id (workspace is required by # the ck_conversations_workspace_required_for_host check constraint). @@ -280,7 +280,7 @@ def test_reconnect_with_rotated_host_id_repoints_bound_conversations( updated = host_store.upsert_on_connect( host_id="b1b5efd7dfc33b5a6241f1866ffb00e6", name="dev-laptop", - owner="dana@example.com", + user_id="dana@example.com", ) assert updated.host_id == "b1b5efd7dfc33b5a6241f1866ffb00e6" @@ -317,7 +317,7 @@ def test_reown_host_id_across_owner_change_preserves_conversation_binding( host_store.upsert_on_connect( host_id="a0c8ab2431b35377abb4232febeded94", name="laptop", - owner="admin@example.com", + user_id="admin@example.com", allow_host_id_reown=True, ) conv = conversations.create_conversation( @@ -328,21 +328,21 @@ def test_reown_host_id_across_owner_change_preserves_conversation_binding( reowned = host_store.upsert_on_connect( host_id="a0c8ab2431b35377abb4232febeded94", name="laptop", - owner="local", + user_id="local", allow_host_id_reown=True, ) assert reowned.host_id == "a0c8ab2431b35377abb4232febeded94" - assert reowned.owner == "local" + assert reowned.user_id == "local" assert reowned.status == "online" # The conversation binding survives the owner change (host_id unchanged). rebound = conversations.get_conversation(conv.id) assert rebound is not None assert rebound.host_id == "a0c8ab2431b35377abb4232febeded94" # Exactly one row for this host_id — re-owned, not duplicated. - online = host_store.list_hosts(owner="local") + online = host_store.list_hosts(user_id="local") assert [h.host_id for h in online] == ["a0c8ab2431b35377abb4232febeded94"] - assert host_store.list_hosts(owner="admin@example.com") == [] + assert host_store.list_hosts(user_id="admin@example.com") == [] def test_reown_disabled_rejects_foreign_owner_claiming_host_id( @@ -360,21 +360,21 @@ def test_reown_disabled_rejects_foreign_owner_claiming_host_id( from sqlalchemy.exc import IntegrityError host_store.upsert_on_connect( - host_id="5d23e459b50e20479abf5d3fa8e2f936", name="alice-box", owner="alice@example.com" + host_id="5d23e459b50e20479abf5d3fa8e2f936", name="alice-box", user_id="alice@example.com" ) with pytest.raises(IntegrityError): host_store.upsert_on_connect( host_id="5d23e459b50e20479abf5d3fa8e2f936", name="bob-box", - owner="bob@example.com", + user_id="bob@example.com", ) # Alice still owns host_x; Bob got nothing. - assert [h.owner for h in host_store.list_hosts(owner="alice@example.com")] == [ + assert [h.user_id for h in host_store.list_hosts(user_id="alice@example.com")] == [ "alice@example.com" ] - assert host_store.list_hosts(owner="bob@example.com") == [] + assert host_store.list_hosts(user_id="bob@example.com") == [] def test_set_offline(host_store: HostStore) -> None: @@ -387,7 +387,7 @@ def test_set_offline(host_store: HostStore) -> None: host_store.upsert_on_connect( host_id="7b463227e479b3a677307588a5d9e44f", name="laptop", - owner="carol@example.com", + user_id="carol@example.com", ) host_store.set_offline("7b463227e479b3a677307588a5d9e44f") @@ -506,7 +506,7 @@ def test_online_host_ids_returns_only_live_hosts( include the offline host. """ # Distinct (host_id, name) per row — the hosts unique constraint is - # (workspace_id, owner, name), so reusing one name would collide. + # (workspace_id, user_id, name), so reusing one name would collide. host_store.upsert_on_connect( "2fd786c75c03cfbbec099a6820c08b62", "laptop-live", "alice@example.com" ) @@ -557,7 +557,7 @@ def test_host_is_live_boundary_is_inclusive() -> None: at_ttl = Host( host_id="85816a8fc5fccd5874bf61da46a4c0ef", name="laptop", - owner="a@example.com", + user_id="a@example.com", status="online", created_at=now, updated_at=now - HOST_LIVENESS_TTL_S, @@ -565,7 +565,7 @@ def test_host_is_live_boundary_is_inclusive() -> None: just_past = Host( host_id="85816a8fc5fccd5874bf61da46a4c0ef", name="laptop", - owner="a@example.com", + user_id="a@example.com", status="online", created_at=now, updated_at=now - HOST_LIVENESS_TTL_S - 1, @@ -703,7 +703,7 @@ def test_register_managed_host_and_resolve_token_roundtrip(db_uri: str) -> None: store.register_managed_host( host_id="e932ccae9eeb8f2a86f7ebfc5089c28d", name="managed-m1", - owner="alice@example.com", + user_id="alice@example.com", token="raw-launch-token-1", provider="modal", sandbox_id="sb-m1", @@ -714,7 +714,7 @@ def test_register_managed_host_and_resolve_token_roundtrip(db_uri: str) -> None: assert resolved is not None assert resolved.host_id == "e932ccae9eeb8f2a86f7ebfc5089c28d" assert resolved.name == "managed-m1" - assert resolved.owner == "alice@example.com" + assert resolved.user_id == "alice@example.com" assert resolved.sandbox_provider == "modal" assert resolved.sandbox_id == "sb-m1" # Pre-registered, not yet connected. @@ -731,7 +731,7 @@ def test_resolve_launch_token_rejects_unknown_and_expired(db_uri: str) -> None: store.register_managed_host( host_id="e5e05ec590da46a0e27bb138d343ffe7", name="managed-m2", - owner="alice@example.com", + user_id="alice@example.com", token="raw-launch-token-2", provider="modal", sandbox_id="sb-m2", @@ -760,7 +760,7 @@ def test_register_managed_host_relaunch_rotates_credential(db_uri: str) -> None: first = store.register_managed_host( host_id="a687a760841c785578a03f4677f8db3c", name="managed-m3", - owner="alice@example.com", + user_id="alice@example.com", token="generation-1-token", provider="modal", sandbox_id="sb-gen1", @@ -770,7 +770,7 @@ def test_register_managed_host_relaunch_rotates_credential(db_uri: str) -> None: second = store.register_managed_host( host_id="a687a760841c785578a03f4677f8db3c", name="managed-m3", - owner="alice@example.com", + user_id="alice@example.com", token="generation-2-token", provider="modal", sandbox_id="sb-gen2", @@ -804,7 +804,7 @@ def test_managed_columns_survive_connect(db_uri: str) -> None: store.register_managed_host( host_id="d55a61010459cea88ed2af0fe916139b", name="managed-m4", - owner="alice@example.com", + user_id="alice@example.com", token="raw-launch-token-4", provider="modal", sandbox_id="sb-m4", @@ -814,7 +814,7 @@ def test_managed_columns_survive_connect(db_uri: str) -> None: connected = store.upsert_on_connect( host_id="d55a61010459cea88ed2af0fe916139b", name="managed-m4", - owner="alice@example.com", + user_id="alice@example.com", ) assert connected.status == "online" @@ -837,7 +837,7 @@ def test_delete_host_removes_row_and_revokes_token(db_uri: str) -> None: store.register_managed_host( host_id="dcf4eb5fc0b04985ec45f79cfda95566", name="managed-m5", - owner="alice@example.com", + user_id="alice@example.com", token="raw-launch-token-5", provider="modal", sandbox_id="sb-m5", @@ -866,7 +866,7 @@ def test_revoke_launch_token_keeps_row_but_stops_resolution(db_uri: str) -> None store.register_managed_host( host_id="f59827fa9468170e62cf28104d2a5251", name="managed-revoke", - owner="alice@example.com", + user_id="alice@example.com", token="raw-launch-token-revoke", provider="modal", sandbox_id="sb-revoke", @@ -907,7 +907,7 @@ def test_managed_host_raw_token_never_stored(db_uri: str) -> None: store.register_managed_host( host_id="64c92d9b75006275f995c5041380a170", name="managed-m6", - owner="alice@example.com", + user_id="alice@example.com", token="raw-launch-token-6", provider="modal", sandbox_id="sb-m6", @@ -935,18 +935,18 @@ def test_register_managed_host_refuses_cross_owner_recredential(db_uri: str) -> store.register_managed_host( host_id="58f80f7592c6a72ba121eb5aedde8a82", name="managed-m7", - owner="alice@example.com", + user_id="alice@example.com", token="alice-token-7", provider="modal", sandbox_id="sb-m7", token_expires_at=now_epoch() + 3600, ) - with pytest.raises(ValueError, match="different owner"): + with pytest.raises(ValueError, match="different user"): store.register_managed_host( host_id="58f80f7592c6a72ba121eb5aedde8a82", name="managed-m7-bob", - owner="bob@example.com", + user_id="bob@example.com", token="bob-token-7", provider="modal", sandbox_id="sb-m7-bob", @@ -957,7 +957,7 @@ def test_register_managed_host_refuses_cross_owner_recredential(db_uri: str) -> # became valid. resolved = store.resolve_launch_token("58f80f7592c6a72ba121eb5aedde8a82", "alice-token-7") assert resolved is not None - assert resolved.owner == "alice@example.com" + assert resolved.user_id == "alice@example.com" assert resolved.sandbox_id == "sb-m7" # Bob's token never armed Alice's host: it does not match the stored digest. assert store.resolve_launch_token("58f80f7592c6a72ba121eb5aedde8a82", "bob-token-7") is None diff --git a/tests/stores/test_scheduled_task_store.py b/tests/stores/test_scheduled_task_store.py index 20572abd680..2942aa2d30a 100644 --- a/tests/stores/test_scheduled_task_store.py +++ b/tests/stores/test_scheduled_task_store.py @@ -18,7 +18,7 @@ # scheduled_tasks.id / scheduled_task_runs.id / scheduled_task_id are Uuid16 # columns (16 raw bytes), read back as bare 32-char hex strings. ``_uid`` maps a # readable seed to a deterministic bare-hex UUID so tests stay legible while the -# store still round-trips real UUIDs. agent_id / owner_user_id / conversation_id +# store still round-trips real UUIDs. agent_id / user_id / conversation_id # stay plain strings — those columns are still ``String``. def _uid(seed: str) -> str: """Deterministic bare 32-char hex UUID string from a short readable seed.""" @@ -47,7 +47,7 @@ def test_create_returns_scheduled_task_with_all_fields( name="nightly triage", prompt="Triage the inbox", rrule="FREQ=DAILY;BYHOUR=9;BYMINUTE=0", - owner_user_id="alice@example.com", + user_id="alice@example.com", agent_id=_uid("ag_abc"), timezone="America/Los_Angeles", model_override="claude-opus-4-7", @@ -60,7 +60,7 @@ def test_create_returns_scheduled_task_with_all_fields( assert task.name == "nightly triage" assert task.prompt == "Triage the inbox" assert task.rrule == "FREQ=DAILY;BYHOUR=9;BYMINUTE=0" - assert task.owner_user_id == "alice@example.com" + assert task.user_id == "alice@example.com" assert task.agent_id == _uid("ag_abc") assert task.timezone == "America/Los_Angeles" assert task.model_override == "claude-opus-4-7" @@ -83,7 +83,7 @@ def test_create_minimal_defaults(store: SqlAlchemyScheduledTaskStore) -> None: name="minimal", prompt="do a thing", rrule="FREQ=MINUTELY", - owner_user_id="bob@example.com", + user_id="bob@example.com", agent_id=_uid("ag_min"), timezone="UTC", ) @@ -110,7 +110,7 @@ def test_state_round_trips_as_string(store: SqlAlchemyScheduledTaskStore) -> Non name="n", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", state=name, @@ -127,7 +127,7 @@ def test_create_rejects_invalid_state(store: SqlAlchemyScheduledTaskStore) -> No name="n", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", state="bogus", @@ -141,7 +141,7 @@ def test_update_host_id_reads_back(store: SqlAlchemyScheduledTaskStore) -> None: name="n", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", ) @@ -158,7 +158,7 @@ def test_update_state_reads_back(store: SqlAlchemyScheduledTaskStore) -> None: name="n", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", ) @@ -177,7 +177,7 @@ def test_create_recurring_task(store: SqlAlchemyScheduledTaskStore) -> None: name="recurring", prompt="p", rrule="FREQ=DAILY;BYHOUR=9;BYMINUTE=0", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", ) @@ -191,7 +191,7 @@ def test_update_changes_rrule(store: SqlAlchemyScheduledTaskStore) -> None: name="n", prompt="p", rrule="FREQ=DAILY;BYHOUR=9;BYMINUTE=0", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", ) @@ -207,7 +207,7 @@ def test_get_returns_created_task(store: SqlAlchemyScheduledTaskStore) -> None: name="n", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag_1"), timezone="UTC", ) @@ -239,7 +239,7 @@ def test_list_orders_by_created_at_then_id(store: SqlAlchemyScheduledTaskStore) name="a", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", ) @@ -248,7 +248,7 @@ def test_list_orders_by_created_at_then_id(store: SqlAlchemyScheduledTaskStore) name="b", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", ) @@ -263,7 +263,7 @@ def test_list_active_excludes_non_active(store: SqlAlchemyScheduledTaskStore) -> name="active", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", state="active", @@ -274,7 +274,7 @@ def test_list_active_excludes_non_active(store: SqlAlchemyScheduledTaskStore) -> name=other_state, prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", state=other_state, @@ -293,7 +293,7 @@ def test_list_active_all_workspaces_includes_tenant_tasks( name="tenant", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", ) @@ -303,7 +303,7 @@ def test_list_active_all_workspaces_includes_tenant_tasks( name="paused", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", state="paused", @@ -324,7 +324,7 @@ def test_update_changes_fields_and_stamps_updated_at(store: SqlAlchemyScheduledT name="before", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", ) @@ -353,7 +353,7 @@ def test_update_noop_leaves_updated_at_none(store: SqlAlchemyScheduledTaskStore) name="n", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", ) @@ -377,7 +377,7 @@ def test_delete_removes_task(store: SqlAlchemyScheduledTaskStore) -> None: name="n", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", ) @@ -400,7 +400,7 @@ def test_create_run_and_list_runs(store: SqlAlchemyScheduledTaskStore) -> None: name="n", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", ) @@ -440,7 +440,7 @@ def test_list_runs_scoped_to_task(store: SqlAlchemyScheduledTaskStore) -> None: name=rid, prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", ) @@ -470,7 +470,7 @@ def test_run_status_round_trips_as_string(store: SqlAlchemyScheduledTaskStore) - name="n", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", ) @@ -492,7 +492,7 @@ def test_create_run_rejects_invalid_status_name(store: SqlAlchemyScheduledTaskSt name="n", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", ) @@ -515,7 +515,7 @@ def test_update_host_id_can_be_cleared_to_null(store: SqlAlchemyScheduledTaskSto name="n", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", host_id=_uid("host_abc"), @@ -538,7 +538,7 @@ def test_update_last_run_conversation_id_can_be_cleared_to_null( name="n", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", ) @@ -560,7 +560,7 @@ def test_update_omitting_nullable_param_leaves_field_unchanged( name="n", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", host_id=_uid("host_keep"), @@ -582,7 +582,7 @@ def test_update_clearing_already_null_field_is_noop_for_updated_at( name="n", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", ) @@ -602,7 +602,7 @@ def test_delete_also_removes_associated_runs(store: SqlAlchemyScheduledTaskStore name="n", prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", ) @@ -631,7 +631,7 @@ def test_delete_does_not_remove_other_tasks_runs(store: SqlAlchemyScheduledTaskS name=tid, prompt="p", rrule="FREQ=MINUTELY", - owner_user_id="u", + user_id="u", agent_id=_uid("ag"), timezone="UTC", ) From 5ff4c9d2b85c7614076106606683d362ef0bf31d Mon Sep 17 00:00:00 2001 From: Rahul Ravindranathan <70488221+rahulrav1@users.noreply.github.com> Date: Tue, 21 Jul 2026 02:00:54 -0700 Subject: [PATCH 522/546] feat(scheduled tasks): make workspace/host optional on create (#2946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(scheduled tasks): make workspace/host optional on create Many scheduled tasks do no code work — research, summaries, chat-only — so requiring a workspace and a connected host at create time is wrong. Make both optional on CREATE. No schema/migration change: the DB columns are already nullable. - routes/scheduled_tasks.py: CreateScheduledTaskRequest.workspace and host_id become optional (still reject empty strings). The router's _validate_launch_inputs skips connected-host workspace validation when BOTH are unset and returns a null canonical workspace; supplying just one of the pair is still an error. PATCH is unchanged — it still cannot null an already-set workspace/host_id. - scheduled/fire.py: a fired task with neither host nor workspace creates a default/no-workspace session and seeds its prompt as the opening user turn (the no-host analog of the connected-host launch+dispatch), instead of recording a failed run. A task that pins a host_id (with or without a workspace) stays on the honest connected-host path and still records a skipped/failed run when that host is missing or offline. - tools/builtins/scheduled_tasks.py: drop workspace/host_id from the sys_scheduled_task_create required list; they remain optional properties. Normal POST /v1/sessions is unchanged — the shared session-create validation and the sessions route still require a workspace. Signed-off-by: Rahul Ravindranathan * fix(scheduled tasks): resolve owner's live host when host unset (rework) Rework of the optional-workspace/host semantics: an unset host_id no longer means "run hostless" — it means "run on the owner's live host, whichever it is". The prompt always runs on real compute. - Unset host_id: resolve the owner's most-recently-active ONLINE host at fire time (host_store.list_hosts(owner) + host_registry; v1 first-online tiebreak). No online host, or no host store/registry, records a failed run (no_online_host / host_registry_unavailable) — never a silent no-op. - Unset workspace: default to the host's HOME, canonicalized to an absolute realpath via a host.stat of '~' (_resolve_default_workspace). The stored conversation row never holds a literal '~'; an unresolvable HOME records a failed run (default_workspace_unresolved). - Removed the hostless seed-prompt dispatch path; every fire goes through connected-host launch+dispatch. Resolution produces an effective task (dataclasses.replace) threaded through preflight/validate/create/dispatch and is never written back to the stored row. - Pinned-host tasks are unchanged (offline still skipped/failed); the API partial-binding rejection and PATCH rules are unchanged. Fixes two /review MAJOR findings from the rework: - literal '~' persisted where an absolute realpath is contracted → now a canonical absolute path via host.stat. - os_env.cwd boundary bypassed for a defaulted workspace → workspace validation is gated on the resolved effective.workspace, so a defaulted HOME outside a boundary-pinned agent records a failed run, matching POST /v1/sessions. Tests: 101 passed across the scheduled fire/routes/tool-dispatch and scheduler-lifespan suites; ruff clean. Signed-off-by: Rahul Ravindranathan * docs(scheduled tasks): correct optional host/workspace wording to resolve-live-host Doc-only. The tool description, workspace/host_id schema property text, and the route request comment + _validate_launch_inputs docstring still described the pre-rework hostless design ('fires as a default/no-workspace session', 'omit both for research/summaries/chat-only', 'needs neither a workspace nor a connected host'). After the rework an unset host_id RESOLVES the owner's online host at fire time (a failed run is recorded if none is online) and an unset workspace defaults to that host's home dir — it is not hostless. Reword the surface text to match. No logic change. Signed-off-by: Rahul Ravindranathan * feat(scheduled tasks): allow pinned host without workspace (default to host HOME) Workspace is now ALWAYS optional. A task may pin a host but omit the workspace — e.g. a task that only talks to an MCP (PagerDuty, etc.) needs no code directory. The workspace defaults to the launch host's home directory whether the host was pinned OR resolved from the owner's live hosts at fire time. The four combos: - host none + workspace none → resolve owner's live host, default workspace to HOME. - host set + workspace set → run there (workspace validated at create). - host set + workspace none → run on the pinned host, default workspace to HOME. (was 400; now allowed — the fix.) - host none + workspace set → still 400 (a path with no machine is meaningless). - routes/scheduled_tasks.py _validate_launch_inputs: short-circuit to a null canonical workspace whenever workspace is None (host set or not), skipping validate_existing_host_workspace (which raises on a null workspace). Only workspace-without-host stays a 400. Agent + model/effort validation still run. - scheduled/fire.py _resolve_effective_task: the HOME default already applies to a pinned host (host_id kept, workspace resolved to canonical HOME); docstring clarified that a pinned host is not re-resolved. - tools/builtins/scheduled_tasks.py: tool + property text note workspace is always optional and a host may be pinned without one. Shared _session_create_validation.py / sessions.py untouched — normal POST /v1/sessions still requires a workspace. Signed-off-by: Rahul Ravindranathan * fix(scheduled tasks): check pinned-host ownership before stat RPC When a task pinned host_id but omitted the workspace, _resolve_effective_task issued a host.stat of '~' to the pinned host to derive the default workspace BEFORE the ownership check (which lived in the preflight, run after resolution). A task pinning another owner's online host would thus dispatch a stat RPC to a host it doesn't own on every fire — the preflight then correctly rejected it (host_not_owned, no session, path not leaked), but the RPC had already gone out. Reorder, not new validation: extract the existence + ownership check into a shared _authorize_pinned_host helper (a local host_store.get_host read — no RPC to the host) and call it for a PINNED host before _resolve_default_workspace. The preflight reuses the same helper. A resolved host (host_id was unset) is by construction the owner's own, so its path is unchanged and not double-checked. Single-user / auth-disabled (owner_user_id None) behavior is unchanged — the owner check is skipped, matching the preflight. Net: for a pinned host, ownership is authorized before any RPC reaches it; owned/valid hosts behave exactly as before. Signed-off-by: Rahul Ravindranathan * fix(scheduled tasks): authorize pinned host at create even when workspace omitted _validate_launch_inputs returned early the moment workspace was None, before any host authorization ran. So a scheduled-task create/PATCH with host_id set but no workspace persisted the host_id without verifying the caller owns it or that it exists (200), and a bad reference only surfaced as a failed run at fire time. Authorize a pinned host (existence + ownership) BEFORE the workspace-None early return, reusing the same resolve_host_owner the workspace-present branch already calls inside validate_existing_host_workspace (whose semantics fire.py:_authorize_pinned_host mirrors) so create-time and fire-time authorization cannot drift. It is a LOCAL store read only — no host.stat / workspace RPC — preserving the no-workspace contract (workspace defaults to host HOME at fire time). Single-user / auth-disabled mode still skips the owner check (existence is still enforced), matching the fire path. A nonexistent host now 404s and a non-owned host 403s at create; PATCH is covered via the shared helper. Updates the test that asserted the old 200, adds nonexistent/non-owned create cases and a PATCH-adds-host case, and keeps the fire-path late-failure backstop tests. Co-authored-by: Isaac Signed-off-by: Rahul Ravindranathan * style: ruff-format test_desktop_update.py (whole-repo pre-commit gate) Signed-off-by: Rahul Ravindranathan --------- Signed-off-by: Rahul Ravindranathan --- omnigent/server/routes/scheduled_tasks.py | 60 ++- omnigent/server/scheduled/fire.py | 221 +++++++++-- omnigent/tools/builtins/scheduled_tasks.py | 23 +- tests/e2e_ui/desktop/test_desktop_update.py | 3 - .../test_scheduled_task_tool_dispatch.py | 9 +- .../test_scheduled_tasks_routes.py | 136 ++++++- tests/server/scheduled/test_fire.py | 353 +++++++++++++++++- 7 files changed, 758 insertions(+), 47 deletions(-) diff --git a/omnigent/server/routes/scheduled_tasks.py b/omnigent/server/routes/scheduled_tasks.py index e4fcc2c2ba7..677821c40bb 100644 --- a/omnigent/server/routes/scheduled_tasks.py +++ b/omnigent/server/routes/scheduled_tasks.py @@ -13,6 +13,7 @@ from __future__ import annotations +import asyncio import logging import uuid from typing import Any @@ -25,6 +26,7 @@ from omnigent.errors import ErrorCode, OmnigentError from omnigent.server.auth import RESERVED_USER_LOCAL, AuthProvider from omnigent.server.routes._auth_helpers import require_user +from omnigent.server.routes._host_launch import resolve_host_owner from omnigent.server.routes._session_create_validation import ( validate_existing_host_workspace, validate_session_agent, @@ -49,8 +51,15 @@ class CreateScheduledTaskRequest(BaseModel): timezone: str = "UTC" model_override: str | None = None reasoning_effort: str | None = None - workspace: str = Field(min_length=1) - host_id: str = Field(min_length=1) + # Optional: no PINNED host/workspace. When both are unset the fire path + # resolves the owner's online host at fire time and defaults the workspace to + # that host's home directory (a failed run is recorded if none is online) — + # it does not run hostless. ``min_length=1`` still rejects an empty string + # (the field is unset via omission / null, not ""), mirroring the PATCH + # request. PATCH still cannot null an already-set workspace/host_id (see + # ``UpdateScheduledTaskRequest``). + workspace: str | None = Field(default=None, min_length=1) + host_id: str | None = Field(default=None, min_length=1) class UpdateScheduledTaskRequest(BaseModel): @@ -158,12 +167,21 @@ async def _validate_launch_inputs( *, owner: str, agent_id: str, - host_id: str, - workspace: str, + host_id: str | None, + workspace: str | None, model_override: str | None, reasoning_effort: str | None, - ) -> tuple[str, str | None, str | None]: - """Validate inputs that scheduled tasks persist into future sessions.""" + ) -> tuple[str | None, str | None, str | None]: + """Validate inputs that scheduled tasks persist into future sessions. + + Workspace is always optional. When it is unset the canonical workspace + persists as ``None`` and the fire path defaults it to the launch host's + home directory — this holds whether the host was pinned or is resolved + from the owner's live hosts at fire time. Only a workspace pinned WITHOUT + a host is an error (a path with no machine is meaningless). When both a + host and a workspace are supplied, the workspace is validated against the + host boundary here so a bad pin fails fast at create. + """ user_id = None if owner == RESERVED_USER_LOCAL else owner agent = await validate_session_agent( user_id=user_id, @@ -176,6 +194,36 @@ async def _validate_launch_inputs( model_override=model_override, reasoning_effort=reasoning_effort, ) + if workspace is None: + # No pinned workspace: the fire path defaults it to the launch host's + # HOME, so there is nothing to validate against the host boundary + # here (a bare host with no workspace is allowed). But a PINNED host + # must still be authorized at create — existence + ownership — even + # without a workspace, so a non-owned / nonexistent host reference + # fails fast with a clean 4xx instead of persisting and only + # surfacing as a failed run at fire time. This is a LOCAL store read + # (no host.stat / workspace RPC), via the same resolve_host_owner the + # workspace-present branch below uses inside + # validate_existing_host_workspace — and whose semantics + # fire.py:_authorize_pinned_host mirrors — so create-time and + # fire-time host authorization cannot drift. When user_id is None + # (single-user / auth disabled) resolve_host_owner skips the owner + # check, matching the fire path and the rest of the server. + if host_id is not None: + host_store = getattr(request.app.state, "host_store", None) + if host_store is not None: + await asyncio.to_thread( + resolve_host_owner, + user_id=user_id, + host_id=host_id, + host_store=host_store, + ) + return None, validated_model, validated_effort + if host_id is None: + raise OmnigentError( + "host_id required when workspace is set", + code=ErrorCode.INVALID_INPUT, + ) canonical_workspace = await validate_existing_host_workspace( user_id=user_id, host_id=host_id, diff --git a/omnigent/server/scheduled/fire.py b/omnigent/server/scheduled/fire.py index ee7a812eac0..cba496ddceb 100644 --- a/omnigent/server/scheduled/fire.py +++ b/omnigent/server/scheduled/fire.py @@ -8,11 +8,15 @@ #. **Re-reads the row.** The armed timer is never trusted: the row is re-read by id, and a row that vanished (deleted between arming and firing) or is no longer ``active`` (paused/deleted) is a logged no-op. -#. **Validates the launch target.** Scheduled tasks currently support - connected-host execution only; missing host/workspace or an unreachable host - is recorded as a failed/skipped run instead of a running run. -#. **Creates a session** bound to the task's agent, carrying the stored - ``workspace`` / ``host_id`` / ``model_override`` / ``reasoning_effort``. +#. **Resolves and validates the launch target.** A task that pinned no + ``host_id`` resolves the owner's most-recently-active live host at fire time; + a task that pinned no ``workspace`` (research / summaries / chat-only) starts + the runner in the host's home directory. A pinned host that is missing or + offline — and an owner with no live host at all — records a failed/skipped + run instead of a running run. +#. **Creates a session** bound to the task's agent, carrying the resolved + ``workspace`` / ``host_id`` and the stored ``model_override`` / + ``reasoning_effort``. #. **Grants ownership.** The spawned session gets a ``LEVEL_OWNER`` grant for the task's ``user_id`` — or :data:`RESERVED_USER_LOCAL` when it is NULL (single-user / OSS). Without the grant the run is invisible. @@ -45,7 +49,7 @@ import time import uuid from collections.abc import Awaitable, Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Any from omnigent.db.db_models import workspace_scope @@ -66,6 +70,15 @@ # a timeout leaves an owner-visible session the runner can still pick up later. _RUNNER_CONNECT_TIMEOUT_S = 30.0 +# The path stat'd on the resolved host to derive a fallback workspace for a task +# that pinned no workspace (research / summaries / chat-only). The runner still +# needs a real cwd and the DB check constraint +# ``ck_conversations_workspace_required_for_host`` requires a workspace once a +# host is bound. Only the host knows its own ``HOME``, so the server sends this +# tilde and stores the absolute ``canonical_path`` the host resolves it to (never +# the literal ``~`` — see ``_resolve_default_workspace``). +_DEFAULT_WORKSPACE = "~" + # Strong references to in-flight background fire tasks. ``loop.create_task`` holds # only a weak reference, so without this a fire could be garbage-collected # mid-flight; each task is discarded from the set when it completes. @@ -239,7 +252,33 @@ async def _run_fire_for_task( ) return - input_error = _validate_connected_host_inputs(task) + # Resolve the effective launch target. An unset ``host_id`` means "you + # didn't pin WHICH host", not "run hostless": resolve the owner's live + # host at fire time. An unset ``workspace`` (research / summaries / + # chat-only) defaults to the host's home directory so the runner still + # has a real cwd. If no live host can be resolved, this records a + # failed run — the same honest behavior as a pinned host that is offline. + # + # ``task`` stays the source of truth for the persisted row; ``effective`` + # carries the resolved host_id / defaulted workspace through preflight, + # validation, create, and dispatch WITHOUT writing them back to the row + # (the next fire re-resolves the live host). + try: + effective = await _resolve_effective_task(deps, task) + except _CannotLaunchScheduledFire as exc: + _logger.warning("scheduled fire: task %s cannot launch: %s", task.id, exc) + await _record_run( + deps, + task, + None, + scheduled_at, + status="failed", + error=str(exc), + error_code=exc.error_code, + ) + return + + input_error = _validate_connected_host_inputs(effective) if input_error is not None: error, error_code = input_error _logger.warning("scheduled fire: task %s cannot run: %s", task.id, error) @@ -256,7 +295,7 @@ async def _run_fire_for_task( if preflight is not None: try: - await preflight(task) + await preflight(effective) except _CannotLaunchScheduledFire as exc: _logger.warning("scheduled fire: task %s cannot launch: %s", task.id, exc) await _record_run( @@ -270,8 +309,16 @@ async def _run_fire_for_task( ) return + # Validate the RESOLVED host/workspace. ``effective.workspace`` is always + # an absolute realpath by this point — a caller-supplied path or the + # canonicalized default (HOME). Gating on ``effective.workspace`` (not the + # stored ``task.workspace``) means the agent's ``os_env.cwd`` boundary is + # enforced even for a defaulted workspace, exactly as ``POST /v1/sessions`` + # does — an agent that pins an absolute cwd outside HOME records a failed + # run instead of silently launching outside its declared boundary. + validate_workspace = preflight is not None and effective.workspace is not None validation_error = await _validate_fire_session_inputs( - deps, task, validate_workspace=preflight is not None + deps, effective, validate_workspace=validate_workspace ) if validation_error is not None: error, error_code = validation_error @@ -288,7 +335,7 @@ async def _run_fire_for_task( return try: - conv = await _create_session(deps, task) + conv = await _create_session(deps, effective) except Exception: _logger.exception("scheduled fire: failed to create session for task %s", task.id) await _record_run( @@ -322,7 +369,7 @@ async def _run_fire_for_task( return try: - await dispatch(conv, task) + await dispatch(conv, effective) except Exception: # The session + grant are already persisted and owner-visible, so a # launch/dispatch failure still records a run — just a failed one. @@ -348,6 +395,118 @@ async def _run_fire_for_task( _logger.exception("scheduled fire: task %s failed", task.id) +async def _resolve_effective_task(deps: FireDeps, task: ScheduledTask) -> ScheduledTask: + """Resolve the host/workspace the fire actually launches against. + + A task may omit ``host_id`` (run on the owner's live host, whichever it is) + and/or ``workspace`` (a task that does no code work — e.g. an MCP-only task). + This returns a copy of *task* with those holes filled for this one fire: + + * ``host_id`` unset → the owner's most-recently-active ONLINE host. No live + host (or no host store/registry) raises :class:`_CannotLaunchScheduledFire` + so the caller records a failed run instead of silently no-oping. + * ``workspace`` unset → the launch host's home directory, canonicalized to an + absolute realpath via a ``host.stat`` round-trip, so the runner launches + with a real cwd and the stored row never holds a literal ``~``. This HOME + default applies whether the host was pinned or resolved above. + + A pinned ``host_id`` is left untouched — not re-resolved — and its liveness is + enforced by the existing preflight, not here. The resolved values are never + written back to the stored row; the next fire re-resolves the live host. + """ + host_id = task.host_id + if host_id is None: + host_id = await _resolve_owner_host(deps, task) + workspace = task.workspace + if workspace is None: + # Authorize a PINNED host's ownership BEFORE the home-dir stat below. + # ``_resolve_default_workspace`` issues a ``host.stat`` RPC to the host, + # and the ownership check otherwise lives in the preflight, which runs + # AFTER resolution — so a task pinning another owner's host would dispatch + # a stat to a host it doesn't own before being rejected. A host resolved + # above (``task.host_id`` was None) is by construction the owner's own, so + # only the pinned case needs this pre-RPC check. + if task.host_id is not None: + await _authorize_pinned_host(deps, task, host_id) + # Canonicalize the host's home dir to an ABSOLUTE realpath rather than + # persisting the literal ``~``. ``conv.workspace`` is contracted to be an + # already-resolved absolute path (many consumers do plain ``Path`` math / + # ``startswith('/')`` on it without expanding ``~``), so a stat round-trip + # here mirrors how the normal session-create path stores canonical_path. + workspace = await _resolve_default_workspace(deps, host_id) + if host_id is task.host_id and workspace is task.workspace: + return task + return replace(task, host_id=host_id, workspace=workspace) + + +async def _resolve_owner_host(deps: FireDeps, task: ScheduledTask) -> str: + """Pick the owner's most-recently-active online host for an unpinned task. + + ``list_hosts`` returns the owner's hosts most-recently-active first and + includes offline ones, so the first that is live in the registry is the + natural default. First-online is the v1 tiebreak. + """ + if deps.host_store is None or deps.host_registry is None: + raise _CannotLaunchScheduledFire( + "connected host registry/store is not configured", + error_code="host_registry_unavailable", + ) + owner = task.user_id or RESERVED_USER_LOCAL + hosts = await asyncio.to_thread(deps.host_store.list_hosts, owner) + for host in hosts: + if deps.host_registry.get(host.host_id) is not None: + return host.host_id + raise _CannotLaunchScheduledFire( + "no online host is available for the scheduled task owner", + error_code="no_online_host", + ) + + +async def _resolve_default_workspace(deps: FireDeps, host_id: str) -> str: + """Canonicalize the host's home directory to an absolute realpath. + + Sends a ``host.stat`` for :data:`_DEFAULT_WORKSPACE` (``~``) to the resolved + host — the host expands the tilde against its own ``HOME`` and returns the + absolute ``canonical_path``, the same value the normal session-create path + stores. Raises :class:`_CannotLaunchScheduledFire` if the host is gone or + can't resolve its home dir, so the caller records an honest failed run. + """ + from omnigent.server.routes._workspace_validation import ( + WorkspaceValidationError, + _ask_host_stat, + ) + + if deps.host_registry is None: + raise _CannotLaunchScheduledFire( + "connected host registry is not configured", + error_code="host_registry_unavailable", + ) + host_conn = deps.host_registry.get(host_id) + if host_conn is None: + raise _CannotLaunchScheduledFire( + f"connected host {host_id!r} is not online on this server", + error_code="host_offline", + ) + try: + stat = await _ask_host_stat( + host_registry=deps.host_registry, + host_conn=host_conn, + path=_DEFAULT_WORKSPACE, + ) + except WorkspaceValidationError as exc: + raise _CannotLaunchScheduledFire( + f"could not resolve a default workspace on host {host_id!r}: {exc}", + error_code="default_workspace_unresolved", + ) from exc + canonical = stat.get("canonical_path") + if not stat.get("exists") or not isinstance(canonical, str): + raise _CannotLaunchScheduledFire( + f"host {host_id!r} did not resolve a home directory for the default workspace", + error_code="default_workspace_unresolved", + ) + return canonical + + async def _create_session(deps: FireDeps, task: ScheduledTask) -> Conversation: """Create a conversation bound to the task's agent, carrying the stored spec.""" # Connected-host, existing-workspace runs create the conversation directly. @@ -493,6 +652,33 @@ def _validate_connected_host_inputs(task: ScheduledTask) -> tuple[str, str] | No return None +async def _authorize_pinned_host(deps: FireDeps, task: ScheduledTask, host_id: str) -> None: + """Verify a host belongs to the task owner (local store read, no host RPC). + + Shared by the preflight and by :func:`_resolve_effective_task`'s pre-stat + check so a task pinning another owner's host is rejected before any RPC + reaches that host. ``get_host`` is a local DB lookup — it never contacts the + host. When ``user_id`` is ``None`` (single-user / auth disabled) the owner + check is skipped, matching the preflight and the rest of the server. + """ + if deps.host_store is None: + raise _CannotLaunchScheduledFire( + "connected host registry/store is not configured", + error_code="host_registry_unavailable", + ) + host = await asyncio.to_thread(deps.host_store.get_host, host_id) + if host is None: + raise _CannotLaunchScheduledFire( + f"connected host {host_id!r} was not found", + error_code="host_not_found", + ) + if task.user_id is not None and host.user_id != task.user_id: + raise _CannotLaunchScheduledFire( + f"connected host {host_id!r} is not owned by the scheduled task owner", + error_code="host_not_owned", + ) + + def _make_connected_host_preflight(deps: FireDeps) -> ConnectedHostPreflight: """Build a preflight check for the connected-host execution target.""" @@ -505,17 +691,8 @@ async def _preflight(task: ScheduledTask) -> None: host_id = task.host_id assert host_id is not None # guarded by _validate_connected_host_inputs - host = await asyncio.to_thread(deps.host_store.get_host, host_id) - if host is None: - raise _CannotLaunchScheduledFire( - f"connected host {host_id!r} was not found", - error_code="host_not_found", - ) - if task.user_id is not None and host.user_id != task.user_id: - raise _CannotLaunchScheduledFire( - f"connected host {host_id!r} is not owned by the scheduled task owner", - error_code="host_not_owned", - ) + # Existence + ownership (local store read; no RPC to the host). + await _authorize_pinned_host(deps, task, host_id) if deps.host_registry.get(host_id) is None: raise _CannotLaunchScheduledFire( f"connected host {host_id!r} is not online on this server", diff --git a/omnigent/tools/builtins/scheduled_tasks.py b/omnigent/tools/builtins/scheduled_tasks.py index d1db8b02945..4199edab80b 100644 --- a/omnigent/tools/builtins/scheduled_tasks.py +++ b/omnigent/tools/builtins/scheduled_tasks.py @@ -39,9 +39,13 @@ def description(cls) -> str: return ( "Create a scheduled task: a saved prompt that runs an agent session " "on a recurring schedule (RRULE). Provide the agent to run, the " - "prompt to send it, the recurrence rule, a connected host, and an " - "existing workspace on that host. The task fires automatically on " - "its schedule until deleted." + "prompt to send it, and the recurrence rule. The workspace is always " + "optional and defaults to the launch host's home directory (fine for " + "MCP-only / chat tasks that touch no code directory). Optionally PIN " + "a connected host and/or a workspace on it; with no pinned host it " + "runs on your live host at fire time (the owner must have an online " + "host then, else the run is recorded as failed). The task fires " + "automatically on its schedule until deleted." ) def get_schema(self) -> dict[str, Any]: @@ -91,16 +95,23 @@ def get_schema(self) -> dict[str, Any]: }, "workspace": { "type": "string", - "description": "Existing absolute path where the run's runner starts.", + "description": ( + "Optional existing absolute path where the run's runner " + "starts. Omit to default to the launch host's home " + "directory (whether the host is pinned or resolved)." + ), }, "host_id": { "type": "string", "description": ( - "Connected host to run on, from the current workspace's host list." + "Optional PIN of a connected host to run on, from the " + "current workspace's host list. Omit to run on the owner's " + "online host at fire time; a failed run is recorded if none " + "is online." ), }, }, - "required": ["name", "prompt", "rrule", "agent_id", "workspace", "host_id"], + "required": ["name", "prompt", "rrule", "agent_id"], "additionalProperties": False, }, }, diff --git a/tests/e2e_ui/desktop/test_desktop_update.py b/tests/e2e_ui/desktop/test_desktop_update.py index f35bb0888fa..d18f1f5e04a 100644 --- a/tests/e2e_ui/desktop/test_desktop_update.py +++ b/tests/e2e_ui/desktop/test_desktop_update.py @@ -120,6 +120,3 @@ def test_settings_updates_section_check_and_mode( check_button.click() page.wait_for_function("() => window.__omniUpdate.calls.includes('check')") assert "check" in _bridge_calls(page) - - - diff --git a/tests/runner/test_scheduled_task_tool_dispatch.py b/tests/runner/test_scheduled_task_tool_dispatch.py index b2b2d77f4e4..7875a94a981 100644 --- a/tests/runner/test_scheduled_task_tool_dispatch.py +++ b/tests/runner/test_scheduled_task_tool_dispatch.py @@ -196,15 +196,20 @@ def test_tools_registered_without_spec_optin() -> None: assert names >= _ALL_NAMES -def test_create_tool_schema_matches_connected_host_scope() -> None: +def test_create_tool_schema_makes_workspace_and_host_optional() -> None: from omnigent.tools.builtins.scheduled_tasks import SysScheduledTaskCreateTool schema = SysScheduledTaskCreateTool().get_schema()["function"]["parameters"] properties = schema["properties"] + # workspace / host_id stay available as optional properties (a task that + # does code work still pins them), but are no longer required — a + # no-workspace research / summary / chat-only task omits both. assert "workspace" in properties assert "host_id" in properties assert "base_branch" not in properties - assert set(schema["required"]) >= {"workspace", "host_id"} + assert "workspace" not in schema["required"] + assert "host_id" not in schema["required"] + assert set(schema["required"]) == {"name", "prompt", "rrule", "agent_id"} def test_update_tool_schema_allows_connected_host_changes() -> None: diff --git a/tests/server/integration/test_scheduled_tasks_routes.py b/tests/server/integration/test_scheduled_tasks_routes.py index 14bad440271..52e9a981799 100644 --- a/tests/server/integration/test_scheduled_tasks_routes.py +++ b/tests/server/integration/test_scheduled_tasks_routes.py @@ -56,6 +56,7 @@ async def _validate_workspace(**kwargs: object) -> str: @pytest.fixture() def auth_app(runtime_init: None, db_uri: str, tmp_path: Path) -> FastAPI: from omnigent.server.auth import UnifiedAuthProvider + from omnigent.stores.host_store import HostStore artifact_store = LocalArtifactStore(str(tmp_path / "artifacts")) return create_app( @@ -66,10 +67,24 @@ def auth_app(runtime_init: None, db_uri: str, tmp_path: Path) -> FastAPI: agent_cache=AgentCache(artifact_store=artifact_store, cache_dir=tmp_path / "cache"), permission_store=SqlAlchemyPermissionStore(db_uri), scheduled_task_store=SqlAlchemyScheduledTaskStore(db_uri), + # A real host store so pinned-host create authorization (existence + + # ownership) resolves against actual host rows. Without it, + # ``app.state.host_store`` is None and the route skips the check. + host_store=HostStore(db_uri), auth_provider=UnifiedAuthProvider(source="header"), ) +def _register_host(app: FastAPI, host_id: str, owner: str) -> None: + """Persist a host owned by ``owner`` so the pinned-host owner check resolves. + + A local store row is all the create-time authorization needs — it never + contacts the host (no ``host.stat`` / workspace RPC in the no-workspace + path), so the host does not need to be online in the registry. + """ + app.state.host_store.upsert_on_connect(host_id, f"{owner}-laptop", owner) + + @pytest_asyncio.fixture() async def auth_client( auth_app: FastAPI, @@ -144,6 +159,40 @@ async def test_create_lists_and_gets(auth_client: httpx.AsyncClient, db_uri: str assert got.json()["id"] == task_id +async def test_create_no_workspace_task_persists_null_host_and_workspace( + auth_client: httpx.AsyncClient, db_uri: str +) -> None: + """A task that does no code work omits workspace + host_id; the row persists + both as null and the connected-host workspace validation is skipped.""" + _make_user(db_uri) + body = _create_body() + del body["workspace"] + del body["host_id"] + resp = await auth_client.post("/v1/scheduled-tasks", json=body, headers=_headers()) + assert resp.status_code == 200, resp.text + created = resp.json() + assert created["workspace"] is None + assert created["host_id"] is None + task_id = created["id"] + + # The null binding survives a round-trip read. + got = await auth_client.get(f"/v1/scheduled-tasks/{task_id}", headers=_headers()) + assert got.status_code == 200 + assert got.json()["workspace"] is None + assert got.json()["host_id"] is None + + +async def test_create_rejects_workspace_without_host( + auth_client: httpx.AsyncClient, db_uri: str +) -> None: + """A workspace with no host is a broken binding, not a no-workspace task.""" + _make_user(db_uri) + body = _create_body() + del body["host_id"] + resp = await auth_client.post("/v1/scheduled-tasks", json=body, headers=_headers()) + assert resp.status_code == 400, resp.text + + async def test_create_rejects_invalid_rrule(auth_client: httpx.AsyncClient, db_uri: str) -> None: _make_user(db_uri) # FREQ=SECONDLY fires far below the 1-hour floor. @@ -202,16 +251,95 @@ async def test_create_rejects_relative_workspace( assert resp.status_code == 400, resp.text -async def test_create_rejects_missing_connected_host_inputs( +async def test_create_pinned_host_without_workspace_persists_null_workspace( + auth_app: FastAPI, auth_client: httpx.AsyncClient, db_uri: str +) -> None: + """A pinned host with NO workspace is allowed (e.g. an MCP-only task) WHEN + the caller owns the host: the row persists the host and a null workspace, and + the connected-host workspace RPC is skipped. The fire path defaults the + workspace to host HOME. Ownership is still authorized at create (local read), + so an owned/existing host is required — see the rejection tests below.""" + _make_user(db_uri) + _register_host(auth_app, "4b653f6031f35d168cc0b37caa1306d1", "alice@example.com") + body = _create_body() + del body["workspace"] + resp = await auth_client.post("/v1/scheduled-tasks", json=body, headers=_headers()) + assert resp.status_code == 200, resp.text + created = resp.json() + assert created["host_id"] == "4b653f6031f35d168cc0b37caa1306d1" + assert created["workspace"] is None + + got = await auth_client.get(f"/v1/scheduled-tasks/{created['id']}", headers=_headers()) + assert got.status_code == 200 + assert got.json()["host_id"] == "4b653f6031f35d168cc0b37caa1306d1" + assert got.json()["workspace"] is None + + +async def test_create_pinned_host_without_workspace_rejects_nonexistent_host( auth_client: httpx.AsyncClient, db_uri: str ) -> None: + """A pinned host with NO workspace that references a NONEXISTENT host is + rejected at create (404) instead of persisting an unvalidated host that only + fails at fire time. No host was registered, so the owner check 404s.""" _make_user(db_uri) + body = _create_body() + del body["workspace"] # host_id set, no workspace → the fixed authz gap + resp = await auth_client.post("/v1/scheduled-tasks", json=body, headers=_headers()) + assert resp.status_code == 404, resp.text + + +async def test_create_pinned_host_without_workspace_rejects_nonowned_host( + auth_app: FastAPI, auth_client: httpx.AsyncClient, db_uri: str +) -> None: + """A pinned host with NO workspace owned by ANOTHER user is rejected at + create (403) — create-time authorization mirrors the fire-path owner check so + a caller cannot persist a reference to a host they do not own.""" + _make_user(db_uri, email="alice@example.com") + _make_user(db_uri, email="bob@example.com") + # The host belongs to bob; alice pins it with no workspace. + _register_host(auth_app, "4b653f6031f35d168cc0b37caa1306d1", "bob@example.com") + body = _create_body() + del body["workspace"] resp = await auth_client.post( - "/v1/scheduled-tasks", - json=_create_body(host_id=None), + "/v1/scheduled-tasks", json=body, headers=_headers("alice@example.com") + ) + assert resp.status_code == 403, resp.text + + +async def test_patch_add_host_without_workspace_authorizes_owner( + auth_app: FastAPI, auth_client: httpx.AsyncClient, db_uri: str +) -> None: + """PATCH shares ``_validate_launch_inputs``: adding a host_id with no + workspace authorizes the pin. An owned host succeeds; a non-owned host is + rejected (403).""" + _make_user(db_uri, email="alice@example.com") + _make_user(db_uri, email="bob@example.com") + # Start from a no-host, no-workspace task (a valid MCP-only task). + body = _create_body() + del body["workspace"] + del body["host_id"] + created = (await auth_client.post("/v1/scheduled-tasks", json=body, headers=_headers())).json() + task_id = created["id"] + + # PATCH in a host alice owns, still no workspace → 200. + _register_host(auth_app, "aaaa1111bbbb2222cccc3333dddd4444", "alice@example.com") + ok = await auth_client.patch( + f"/v1/scheduled-tasks/{task_id}", + json={"host_id": "aaaa1111bbbb2222cccc3333dddd4444"}, headers=_headers(), ) - assert resp.status_code == 422, resp.text + assert ok.status_code == 200, ok.text + assert ok.json()["host_id"] == "aaaa1111bbbb2222cccc3333dddd4444" + assert ok.json()["workspace"] is None + + # PATCH in a host bob owns → 403 (not authorized), no drift from the fire path. + _register_host(auth_app, "eeee5555ffff6666aaaa7777bbbb8888", "bob@example.com") + denied = await auth_client.patch( + f"/v1/scheduled-tasks/{task_id}", + json={"host_id": "eeee5555ffff6666aaaa7777bbbb8888"}, + headers=_headers("alice@example.com"), + ) + assert denied.status_code == 403, denied.text async def test_create_rejects_unsupported_public_fields( diff --git a/tests/server/scheduled/test_fire.py b/tests/server/scheduled/test_fire.py index 297006e3862..79d3098fa68 100644 --- a/tests/server/scheduled/test_fire.py +++ b/tests/server/scheduled/test_fire.py @@ -167,6 +167,11 @@ def __init__(self, hosts: dict[str, _FakeHost] | None = None) -> None: def get_host(self, host_id: str) -> _FakeHost | None: return self.hosts.get(host_id) + def list_hosts(self, owner: str) -> list[_FakeHost]: + # Mirrors the real store: most-recently-active first. Insertion order in + # the dict stands in for that ordering here. + return [h for h in self.hosts.values() if h.user_id == owner] + class FakeHostRegistry: def __init__(self, online: set[str] | None = None) -> None: @@ -553,16 +558,67 @@ async def _launch(conv: Any, task: Any) -> None: @pytest.mark.asyncio -async def test_missing_execution_inputs_record_failed_without_session() -> None: +async def test_unset_host_resolves_owner_online_host_and_runs() -> None: + """An unset host_id means 'run on the owner's live host', not 'run hostless': + the fire resolves the owner's online host, creates a session bound to it, and + records a run.""" + perm = FakePermissionStore() conv_store = FakeConversationStore() - store = FakeScheduledTaskStore(rows={"task_1": _task(host_id=None, workspace=None)}) + store = FakeScheduledTaskStore( + rows={"task_1": _task(user_id="alice@example.com", host_id=None, workspace="/repo")} + ) + launched: list[Any] = [] + + async def _launch(conv: Any, task: Any) -> None: + launched.append((conv, task)) + + on_fire = build_on_fire( + _deps( + store, + permission_store=perm, + conversation_store=conv_store, + host_store=FakeHostStore({"host_9": _FakeHost("host_9", "alice@example.com")}), + host_registry=FakeHostRegistry(online={"host_9"}), + ), + launch_dispatch=_launch, + ) + await on_fire(0, "task_1") + await _drain() + + # The session bound to the RESOLVED host (not None), carrying the workspace. + assert len(conv_store.created) == 1 + assert conv_store.created[0]["host_id"] == "host_9" + assert conv_store.created[0]["workspace"] == "/repo" + # The dispatch saw the resolved host on its effective task. + assert len(launched) == 1 + assert launched[0][1].host_id == "host_9" + # A running run was recorded; the stored row keeps its null host_id. + assert len(store.runs) == 1 + assert store.runs[0]["status"] == "running" + assert store._rows["task_1"].host_id is None + + +@pytest.mark.asyncio +async def test_unset_host_no_online_host_records_failed() -> None: + """An unset host_id with no live host is an honest failure, not a no-op: it + records a failed run with the no_online_host code and creates no session.""" + conv_store = FakeConversationStore() + store = FakeScheduledTaskStore( + rows={"task_1": _task(user_id="alice@example.com", host_id=None, workspace=None)} + ) launched: list[Any] = [] async def _launch(conv: Any, task: Any) -> None: launched.append(conv) on_fire = build_on_fire( - _deps(store, conversation_store=conv_store), + _deps( + store, + conversation_store=conv_store, + # Owner has a host, but it is offline (not in the registry). + host_store=FakeHostStore({"host_9": _FakeHost("host_9", "alice@example.com")}), + host_registry=FakeHostRegistry(online=set()), + ), launch_dispatch=_launch, ) await on_fire(0, "task_1") @@ -572,10 +628,299 @@ async def _launch(conv: Any, task: Any) -> None: assert conv_store.created == [] assert len(store.runs) == 1 assert store.runs[0]["status"] == "failed" - assert store.runs[0]["error_code"] == "missing_host_id" + assert store.runs[0]["error_code"] == "no_online_host" assert store.runs[0]["conversation_id"] is None +@pytest.mark.asyncio +async def test_no_workspace_resolved_host_launches_with_canonical_home( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A task with no workspace still launches: the fire resolves the host's home + dir to an ABSOLUTE realpath (never the literal '~') and stores that.""" + conv_store = FakeConversationStore() + store = FakeScheduledTaskStore( + rows={"task_1": _task(user_id="alice@example.com", host_id=None, workspace=None)} + ) + launched: list[Any] = [] + + async def _launch(conv: Any, task: Any) -> None: + launched.append((conv, task)) + + # The default-workspace resolution is a host.stat round-trip; stub it to the + # canonical home path the host would return so the fire path is exercised + # without a live host tunnel. + async def _fake_resolve(deps: Any, host_id: str) -> str: + assert host_id == "host_9" + return "/home/alice" + + monkeypatch.setattr(fire_mod, "_resolve_default_workspace", _fake_resolve) + + on_fire = build_on_fire( + _deps( + store, + conversation_store=conv_store, + host_store=FakeHostStore({"host_9": _FakeHost("host_9", "alice@example.com")}), + host_registry=FakeHostRegistry(online={"host_9"}), + ), + launch_dispatch=_launch, + ) + await on_fire(0, "task_1") + await _drain() + + # Resolved host + absolute canonical workspace (not the literal '~'). + assert len(conv_store.created) == 1 + assert conv_store.created[0]["host_id"] == "host_9" + assert conv_store.created[0]["workspace"] == "/home/alice" + assert launched[0][1].workspace == "/home/alice" + assert len(store.runs) == 1 + assert store.runs[0]["status"] == "running" + + +@pytest.mark.asyncio +async def test_pinned_host_no_workspace_defaults_to_host_home( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A task that PINS a host but omits the workspace launches on that pinned + host with the workspace defaulted to its canonical HOME — the pinned host is + NOT re-resolved to some other live host.""" + conv_store = FakeConversationStore() + store = FakeScheduledTaskStore( + rows={"task_1": _task(user_id="alice@example.com", host_id="host_pinned", workspace=None)} + ) + launched: list[Any] = [] + + async def _launch(conv: Any, task: Any) -> None: + launched.append((conv, task)) + + async def _fake_resolve(deps: Any, host_id: str) -> str: + # Defaulting runs against the PINNED host, not a re-resolved one. + assert host_id == "host_pinned" + return "/home/alice" + + monkeypatch.setattr(fire_mod, "_resolve_default_workspace", _fake_resolve) + + on_fire = build_on_fire( + _deps( + store, + conversation_store=conv_store, + host_store=FakeHostStore( + { + "host_pinned": _FakeHost("host_pinned", "alice@example.com"), + "host_other": _FakeHost("host_other", "alice@example.com"), + } + ), + host_registry=FakeHostRegistry(online={"host_pinned", "host_other"}), + ), + launch_dispatch=_launch, + ) + await on_fire(0, "task_1") + await _drain() + + assert len(conv_store.created) == 1 + assert conv_store.created[0]["host_id"] == "host_pinned" + assert conv_store.created[0]["workspace"] == "/home/alice" + assert launched[0][1].host_id == "host_pinned" + assert launched[0][1].workspace == "/home/alice" + assert len(store.runs) == 1 + assert store.runs[0]["status"] == "running" + + +@pytest.mark.asyncio +async def test_pinned_nonowned_host_no_workspace_rejected_before_stat( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Pinning ANOTHER owner's online host with no workspace fails host_not_owned + WITHOUT dispatching the default-workspace stat RPC to the non-owned host — + ownership is authorized before any RPC reaches it.""" + conv_store = FakeConversationStore() + store = FakeScheduledTaskStore( + rows={"task_1": _task(user_id="alice@example.com", host_id="host_bob", workspace=None)} + ) + + resolve_calls: list[str] = [] + + async def _spy_resolve(deps: Any, host_id: str) -> str: + resolve_calls.append(host_id) + return "/home/bob" + + monkeypatch.setattr(fire_mod, "_resolve_default_workspace", _spy_resolve) + + on_fire = build_on_fire( + _deps( + store, + conversation_store=conv_store, + # The pinned host is online but owned by bob, not alice. + host_store=FakeHostStore({"host_bob": _FakeHost("host_bob", "bob@example.com")}), + host_registry=FakeHostRegistry(online={"host_bob"}), + ) + ) + await on_fire(0, "task_1") + await _drain() + + # Rejected on ownership; NO stat RPC dispatched to the non-owned host. + assert resolve_calls == [] + assert conv_store.created == [] + assert len(store.runs) == 1 + assert store.runs[0]["status"] == "failed" + assert store.runs[0]["error_code"] == "host_not_owned" + assert store.runs[0]["conversation_id"] is None + + +@pytest.mark.asyncio +async def test_no_workspace_unresolvable_home_records_failed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If the host can't resolve its home dir, the fire records an honest failed + run rather than launching with a bogus workspace.""" + conv_store = FakeConversationStore() + store = FakeScheduledTaskStore( + rows={"task_1": _task(user_id="alice@example.com", host_id=None, workspace=None)} + ) + + async def _boom(deps: Any, host_id: str) -> str: + raise fire_mod._CannotLaunchScheduledFire( + "home dir unresolved", error_code="default_workspace_unresolved" + ) + + monkeypatch.setattr(fire_mod, "_resolve_default_workspace", _boom) + + on_fire = build_on_fire( + _deps( + store, + conversation_store=conv_store, + host_store=FakeHostStore({"host_9": _FakeHost("host_9", "alice@example.com")}), + host_registry=FakeHostRegistry(online={"host_9"}), + ) + ) + await on_fire(0, "task_1") + await _drain() + + assert conv_store.created == [] + assert len(store.runs) == 1 + assert store.runs[0]["status"] == "failed" + assert store.runs[0]["error_code"] == "default_workspace_unresolved" + + +@pytest.mark.asyncio +async def test_defaulted_workspace_is_boundary_validated( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The resolved default HOME workspace is validated against the agent's + os_env.cwd boundary, exactly like a caller-supplied one — the check is gated + on the RESOLVED workspace, not the (null) stored value. A boundary failure + records a failed run and creates no session.""" + conv_store = FakeConversationStore() + store = FakeScheduledTaskStore( + rows={"task_1": _task(user_id="alice@example.com", host_id=None, workspace=None)} + ) + + async def _fake_resolve(deps: Any, host_id: str) -> str: + return "/home/alice" + + seen: dict[str, Any] = {} + + async def _fake_validate(deps: Any, task: Any, *, validate_workspace: bool): + # Record that the boundary check was requested for the resolved workspace. + seen["validate_workspace"] = validate_workspace + seen["workspace"] = task.workspace + if validate_workspace: + return ("workspace is outside the agent boundary", "invalid_input") + return None + + monkeypatch.setattr(fire_mod, "_resolve_default_workspace", _fake_resolve) + monkeypatch.setattr(fire_mod, "_validate_fire_session_inputs", _fake_validate) + + # No launch_dispatch override → the real preflight runs, so validation is on. + on_fire = build_on_fire( + _deps( + store, + conversation_store=conv_store, + host_store=FakeHostStore({"host_9": _FakeHost("host_9", "alice@example.com")}), + host_registry=FakeHostRegistry(online={"host_9"}), + ) + ) + await on_fire(0, "task_1") + await _drain() + + # The boundary check ran against the resolved absolute workspace. + assert seen["validate_workspace"] is True + assert seen["workspace"] == "/home/alice" + # The boundary failure was recorded honestly; no session was created. + assert conv_store.created == [] + assert len(store.runs) == 1 + assert store.runs[0]["status"] == "failed" + assert store.runs[0]["error_code"] == "invalid_input" + + +@pytest.mark.asyncio +async def test_no_host_store_records_failed_when_host_unset() -> None: + """No host store/registry configured + an unset host is an honest failure.""" + conv_store = FakeConversationStore() + store = FakeScheduledTaskStore(rows={"task_1": _task(host_id=None, workspace=None)}) + + on_fire = build_on_fire( + _deps(store, conversation_store=conv_store, host_store=None, host_registry=None), + ) + await on_fire(0, "task_1") + await _drain() + + assert conv_store.created == [] + assert len(store.runs) == 1 + assert store.runs[0]["status"] == "failed" + assert store.runs[0]["error_code"] == "host_registry_unavailable" + assert store.runs[0]["conversation_id"] is None + + +@pytest.mark.asyncio +async def test_resolve_default_workspace_returns_canonical_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The default workspace is the host's stat'd canonical home path, not '~'.""" + import omnigent.server.routes._workspace_validation as wsv + + captured: dict[str, Any] = {} + + async def _fake_stat(*, host_registry: Any, host_conn: Any, path: str) -> dict[str, Any]: + captured["path"] = path + return { + "status": "ok", + "exists": True, + "type": "directory", + "canonical_path": "/home/alice", + } + + monkeypatch.setattr(wsv, "_ask_host_stat", _fake_stat) + deps = _deps( + FakeScheduledTaskStore(), + host_registry=FakeHostRegistry(online={"host_9"}), + ) + result = await fire_mod._resolve_default_workspace(deps, "host_9") + assert result == "/home/alice" + # The server sends the tilde; the host expands it (server never expands ~). + assert captured["path"] == "~" + + +@pytest.mark.asyncio +async def test_resolve_default_workspace_raises_when_home_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A stat that returns no canonical path is an honest launch failure.""" + import omnigent.server.routes._workspace_validation as wsv + + async def _fake_stat(*, host_registry: Any, host_conn: Any, path: str) -> dict[str, Any]: + return {"status": "ok", "exists": False, "type": None, "canonical_path": None} + + monkeypatch.setattr(wsv, "_ask_host_stat", _fake_stat) + deps = _deps( + FakeScheduledTaskStore(), + host_registry=FakeHostRegistry(online={"host_9"}), + ) + with pytest.raises(fire_mod._CannotLaunchScheduledFire) as excinfo: + await fire_mod._resolve_default_workspace(deps, "host_9") + assert excinfo.value.error_code == "default_workspace_unresolved" + + @pytest.mark.asyncio async def test_no_host_registry_records_failed_without_session() -> None: conv_store = FakeConversationStore() From f35726a9b96ba3e003d955b123f06a22c59c2df4 Mon Sep 17 00:00:00 2001 From: "Zeyi (Rice) Fan" Date: Tue, 21 Jul 2026 02:03:55 -0700 Subject: [PATCH 523/546] fix(ci): format desktop update test (#2982) ## Related issue N/A ## Summary Main's lint workflow failed because the desktop update E2E test retained extra trailing blank lines. Apply Ruff's formatting so the all-files pre-commit check remains clean. ## Test Plan - `.venv/bin/pre-commit run --all-files --show-diff-on-failure` ## Demo N/A ## Type of change - [ ] Bug fix - [ ] Feature - [ ] UI / frontend change - [ ] Refactor / chore - [ ] Docs - [x] Test / CI - [ ] Breaking change ## Test coverage - [ ] Unit tests added / updated - [ ] Integration tests added / updated - [ ] E2E tests added / updated - [ ] Manual verification completed - [ ] Existing tests cover this change - [x] Not applicable ## Coverage notes Formatting-only correction; the full all-files pre-commit suite passes. Signed-off-by: Zeyi (Rice) Fan From 8d151104788989092df370fd77ca751d1fac8058 Mon Sep 17 00:00:00 2001 From: simtsc <2539637+simtsc@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:11:06 +0800 Subject: [PATCH 524/546] perf(web): lazy-load Shiki so it leaves the main bundle (#2886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(web): lazy-load Shiki so it leaves the main bundle Shiki's engine (including its WASM regex engine) was pulled into the app's main entry chunk even when no code block ever rendered. Two eager importers kept it there: code-block.tsx and the @streamdown/code highlighter plugin wired into chat markdown via streamdown-security.ts. Defer both. code-block.tsx now imports shiki at highlight time inside its existing per-language cached getHighlighter helper. A new lazyCodePlugin wraps @streamdown/code, satisfying Streamdown's CodeHighlighterPlugin contract (default themes synchronously; highlight() returns null until the engine loads, then resolves tokens through the callback) while deferring the @streamdown/code import — and with it shiki — to the first highlight call. Rendering, theming, language handling, and public APIs are unchanged. Shiki now splits into a separate on-demand chunk: the main entry chunk drops from 4,551.81 kB to 4,356.25 kB (~196 kB raw, ~60 kB gzip), and Vite no longer reports the ineffective-dynamic-import warning. Signed-off-by: simtsc <2539637+simtsc@users.noreply.github.com> * test(web): prove lazy Shiki highlighting through Streamdown + harden callback Address cross-vendor review of the lazy-Shiki change. Verified lazyCodePlugin matches Streamdown's real consumption contract: HighlightedCodeBlockBody runs highlight() inside a useEffect and stores the result via setState — `let r=o.highlight({...}, c=>{i(c)}); r&&i(r);` (streamdown/dist/highlighted-body-OFNGDK62.js). Returning null keeps the raw code in state; the callback calls setState, forcing a re-render with the highlighted tokens. The highlighted body is itself React.lazy + Suspense (chunk-BO2N2NFS.js), so raw text paints first and highlighting streams in. So the null-then-callback path reliably produces highlighted output. - Add streamdownCodeHighlight.test.tsx: renders MessageResponse (which uses STREAMDOWN_PLUGINS with code: lazyCodePlugin) on a fenced code block, asserts raw code shows immediately, then waits for the lazy @streamdown/code import + callback and asserts multiple per-token colored spans appear (Streamdown colors tokens via the --sdm-c CSS custom property). - Harden highlight() against double callback invocation with a fire-once guard so the callback runs exactly once whether the real plugin resolves via its return value (sync cache hit) or its own callback. Add a unit test asserting the callback fires exactly once. - Clarify supportsLanguage: Streamdown has zero call sites for it/ getSupportedLanguages, and highlight() falls back to "text" for unknown languages, so the optimistic pre-load answer is safe. Signed-off-by: simtsc <2539637+simtsc@users.noreply.github.com> * test(e2e): assert chat code blocks lazy-load Shiki highlighting Regression guard for the lazy-Shiki change: seeds a deterministic assistant message with a fenced code block and asserts the observable syntax-highlighted token spans appear once the on-demand Shiki import resolves, proving highlighting survives the deferral. Signed-off-by: simtsc <2539637+simtsc@users.noreply.github.com> * style: apply ruff format to lazy-Shiki e2e test `ruff format` collapses the multi-line `wait_for_function` string concat onto one line; matches the pre-commit CI fix so the check passes. Co-authored-by: Isaac * test(ui-snapshot): wait for lazy Shiki highlight before chat capture The lazy-Shiki change defers `@streamdown/code`, so the fenced code block first paints raw and only re-renders with syntax-highlighted token spans once the on-demand import resolves. The visual snapshot was capturing the pre-highlight frame, drifting from the committed (highlighted) baseline and failing the UI Snapshot gate. Wait for the `--sdm-c` token spans (same signal the lazy-Shiki e2e test uses) before capture so the render is highlighted and matches the existing baseline — no baseline regen needed. Co-authored-by: Isaac * test(ui-snapshot): update chat baseline for lazy-Shiki render The lazy-Shiki change defers `@streamdown/code`; in the pinned headless Playwright renderer the fenced code block paints uncolored even after the token spans mount (confirmed across two CI runs — the DOM wait added last commit does not repaint the colors at capture). Highlighting works in a real browser, so this is a snapshot-environment artifact, not a UX regression. Adopt the CI-rendered baseline (byte-identical to the gate's render) so the visual gate matches, and keep the token-span wait so the capture is the settled post-import DOM rather than a mid-tokenization frame. Co-authored-by: Isaac * test(ui-snapshot): fix chat snapshot flake on lazy Shiki highlight The chat baseline flaked between highlighted and raw code renders. The lazy `@streamdown/code` import mounts the colored token spans a frame before the browser composites their colors, so waiting on span presence raced the paint — the screenshot sometimes caught the raw frame. Wait until the tokens resolve more than one distinct computed color (the raw fallback is a uniform `inherit`), then flush two animation frames so the colors are painted before capture. Restore the highlighted baseline as the correct target (a prior commit had adopted a raced raw render). Co-authored-by: Isaac --------- Signed-off-by: simtsc <2539637+simtsc@users.noreply.github.com> Co-authored-by: Daniel Lok --- .../chat/test_code_block_highlighting.py | 93 +++++++++++++++++++ tests/e2e_ui/visual/test_chat_snapshot.py | 25 +++++ .../ai-elements/code-block.test.tsx | 34 +++++++ web/src/components/ai-elements/code-block.tsx | 13 ++- .../ai-elements/lazyCodePlugin.test.ts | 54 +++++++++++ .../components/ai-elements/lazyCodePlugin.ts | 78 ++++++++++++++++ .../ai-elements/streamdown-security.ts | 4 +- .../streamdownCodeHighlight.test.tsx | 45 +++++++++ 8 files changed, 339 insertions(+), 7 deletions(-) create mode 100644 tests/e2e_ui/chat/test_code_block_highlighting.py create mode 100644 web/src/components/ai-elements/code-block.test.tsx create mode 100644 web/src/components/ai-elements/lazyCodePlugin.test.ts create mode 100644 web/src/components/ai-elements/lazyCodePlugin.ts create mode 100644 web/src/components/ai-elements/streamdownCodeHighlight.test.tsx diff --git a/tests/e2e_ui/chat/test_code_block_highlighting.py b/tests/e2e_ui/chat/test_code_block_highlighting.py new file mode 100644 index 00000000000..8fbdb67ae9e --- /dev/null +++ b/tests/e2e_ui/chat/test_code_block_highlighting.py @@ -0,0 +1,93 @@ +"""E2E: chat code blocks become syntax-highlighted via the lazy Shiki path. + +Regression guard for the lazy-Shiki change. Shiki's highlighter engine is no +longer eagerly bundled into the main entry chunk; the Streamdown ``code`` +plugin imports it on demand the first time a fenced code block renders. This +test proves the user-facing behavior survives that deferral: a fenced code +block in an assistant message still becomes syntax-highlighted once the lazy +import resolves. + +A deterministic assistant message (seeded via the ``external_assistant_message`` +event — no LLM run) carries a fenced ``ts`` block. Streamdown renders each +highlighted token as its own ```` carrying a per-token color through the +``--sdm-c`` CSS custom property; the raw, pre-highlight path emits a single +uncolored line span. So the test asserts the observable behavior: + + - **After highlighting:** more than one ``span[style*="--sdm-c"]`` token span + appears, and distinct tokens (the ``const`` keyword and the ``42`` literal) + are among them. + +Highlighting is async (the engine is lazily imported), so the token spans are +awaited with Playwright's ``expect(...).to_have_count`` / ``to_be_visible`` +timeouts rather than a fixed sleep. +""" + +from __future__ import annotations + +from collections.abc import Iterator + +import httpx +import pytest +from playwright.sync_api import Page, expect + +_AGENT_NAME = "hello_world" +_CODE_BODY = '[data-streamdown="code-block-body"]' +# Streamdown emits one span per highlighted token, each carrying its Shiki color +# via the `--sdm-c` custom property. The raw (pre-highlight) path has no such +# spans, so their presence is the signal that the lazy engine tokenized. +_TOKEN_SPANS = '[data-streamdown="code-block-body"] span[style*="--sdm-c"]' + +# Fenced ``ts`` block whose highlighted output splits into multiple colored +# tokens, including a distinct `const` keyword and `42` numeric literal. +_MESSAGE_TEXT = "Here is a snippet:\n\n```ts\nconst answer = 42;\n```\n" + + +@pytest.fixture +def highlight_session(seeded_session: tuple[str, str]) -> Iterator[tuple[str, str]]: + """Seed a runner-bound session with a fenced ``ts`` code-block reply. + + Reuses :func:`seeded_session` (a ``hello_world`` session already bound to the + spawned runner) and appends a deterministic assistant bubble via + ``external_assistant_message`` so no LLM turn runs. + + :param seeded_session: ``(base_url, session_id)`` for a runner-bound session. + :returns: the same ``(base_url, session_id)`` after the reply is seeded. + """ + base_url, session_id = seeded_session + event_resp = httpx.post( + f"{base_url}/v1/sessions/{session_id}/events", + json={ + "type": "external_assistant_message", + "data": {"agent": _AGENT_NAME, "text": _MESSAGE_TEXT}, + }, + timeout=10.0, + ) + event_resp.raise_for_status() + yield (base_url, session_id) + + +def test_code_block_becomes_syntax_highlighted( + page: Page, + highlight_session: tuple[str, str], +) -> None: + """A fenced code block is syntax-highlighted after the lazy Shiki import.""" + base_url, session_id = highlight_session + page.goto(f"{base_url}/c/{session_id}") + + # The assistant bubble and its rendered code block must mount first. + body = page.locator(_CODE_BODY).first + expect(body).to_be_visible(timeout=30_000) + + # The lazy @streamdown/code import + tokenization resolves asynchronously and + # re-renders the block with per-token colored spans. Wait for more than one. + token_spans = page.locator(_TOKEN_SPANS) + expect(token_spans.first).to_be_visible(timeout=30_000) + page.wait_for_function( + "() => document.querySelectorAll('" + _TOKEN_SPANS.replace("'", "\\'") + "').length > 1", + timeout=30_000, + ) + + # Distinct tokens land in their own highlighted spans: the `const` keyword + # and the `42` literal are both syntax-highlighted. + expect(token_spans.filter(has_text="const").first).to_be_visible(timeout=30_000) + expect(token_spans.filter(has_text="42").first).to_be_visible(timeout=30_000) diff --git a/tests/e2e_ui/visual/test_chat_snapshot.py b/tests/e2e_ui/visual/test_chat_snapshot.py index 92b3682757b..1cee8e79ef2 100644 --- a/tests/e2e_ui/visual/test_chat_snapshot.py +++ b/tests/e2e_ui/visual/test_chat_snapshot.py @@ -140,6 +140,11 @@ _DONE_SSE = "data: [DONE]\n\n" _BUBBLE = '[data-testid="message-bubble"]' +# Shiki loads lazily, so the fenced block paints raw first and re-renders with +# per-token spans (colored via the `--sdm-c` custom property) once the import +# resolves. Span *presence* races the paint — the spans mount a frame before +# their colors are composited — so the capture waits on the computed colors. +_TOKEN_SPANS = '[data-streamdown="code-block-body"] span[style*="--sdm-c"]' @pytest.mark.visual @@ -188,6 +193,26 @@ def test_chat_conversation_matches_baseline( # No live turn is in flight, so the working shimmer must be absent. expect(page.locator('[data-testid="working-indicator"]')).to_have_count(0) + # Shiki loads lazily: the colored token spans mount a frame after the block + # first paints raw. Wait until the tokens resolve more than one distinct + # color (the raw fallback is uniform `inherit`), then flush two animation + # frames so those colors are composited before the screenshot — waiting on + # span presence alone races the paint and captured a raw frame. + page.wait_for_function( + """(selector) => { + const spans = Array.from(document.querySelectorAll(selector)); + if (spans.length < 2) return false; + const colors = new Set(spans.map((el) => getComputedStyle(el).color)); + return colors.size > 1; + }""", + arg=_TOKEN_SPANS, + timeout=30_000, + ) + page.evaluate( + "() => new Promise((resolve) => " + "requestAnimationFrame(() => requestAnimationFrame(resolve)))" + ) + # Settle web fonts + kill the blinking caret (both time-dependent). settle_for_snapshot(page) diff --git a/web/src/components/ai-elements/code-block.test.tsx b/web/src/components/ai-elements/code-block.test.tsx new file mode 100644 index 00000000000..b484f92c51e --- /dev/null +++ b/web/src/components/ai-elements/code-block.test.tsx @@ -0,0 +1,34 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { CodeBlock } from "./code-block"; + +afterEach(cleanup); + +describe("CodeBlock — lazy Shiki highlighting", () => { + it("renders the raw code immediately before the highlighter loads", () => { + render(); + + // Raw tokens render synchronously so the code is visible without waiting + // for the lazily-imported Shiki engine. + expect(screen.getByText(/const answer = 42;/)).toBeTruthy(); + }); + + it("highlights the code with Shiki after the lazy import resolves", async () => { + const { container } = render(); + + // Once the dynamically-imported highlighter tokenizes the code, Shiki + // splits the source into per-token spans with inline colors. The raw + // pre-highlight path renders each line as a single span with no color. + await waitFor( + () => { + const colored = container.querySelectorAll("span[style*='color']"); + expect(colored.length).toBeGreaterThan(1); + }, + { timeout: 10000 }, + ); + + // The keyword and the literal end up in distinct tokens. + expect(screen.getByText("const")).toBeTruthy(); + expect(screen.getByText("42")).toBeTruthy(); + }); +}); diff --git a/web/src/components/ai-elements/code-block.tsx b/web/src/components/ai-elements/code-block.tsx index aa8b17aa943..db9f6a6a74a 100644 --- a/web/src/components/ai-elements/code-block.tsx +++ b/web/src/components/ai-elements/code-block.tsx @@ -23,7 +23,6 @@ import { useState, } from "react"; import type { BundledLanguage, BundledTheme, HighlighterGeneric, ThemedToken } from "shiki"; -import { createHighlighter } from "shiki"; // Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline // oxlint-disable-next-line eslint(no-bitwise) @@ -149,10 +148,14 @@ const getHighlighter = ( return cached; } - const highlighterPromise = createHighlighter({ - langs: [language], - themes: ["github-light", "github-dark"], - }); + // Import Shiki's engine lazily so its core (incl. the WASM regex engine) + // stays out of the main bundle and only loads when a code block renders. + const highlighterPromise = import("shiki").then(({ createHighlighter }) => + createHighlighter({ + langs: [language], + themes: ["github-light", "github-dark"], + }), + ); highlighterCache.set(language, highlighterPromise); return highlighterPromise; diff --git a/web/src/components/ai-elements/lazyCodePlugin.test.ts b/web/src/components/ai-elements/lazyCodePlugin.test.ts new file mode 100644 index 00000000000..abc1baec318 --- /dev/null +++ b/web/src/components/ai-elements/lazyCodePlugin.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import { lazyCodePlugin } from "./lazyCodePlugin"; + +describe("lazyCodePlugin — deferred Shiki engine", () => { + it("satisfies the Streamdown code-highlighter plugin contract", () => { + expect(lazyCodePlugin.name).toBe("shiki"); + expect(lazyCodePlugin.type).toBe("code-highlighter"); + }); + + it("returns default themes synchronously before the engine loads", () => { + // getThemes() is called on the render path, so it must resolve without + // waiting for the lazily-imported @streamdown/code module. + expect(lazyCodePlugin.getThemes()).toEqual(["github-light", "github-dark"]); + }); + + it("returns null on the first highlight and resolves tokens via callback", async () => { + const result = await new Promise<{ tokens: unknown[][] }>((resolve) => { + // First call must be non-blocking: null now, real tokens through the + // callback once the engine finishes loading. + const immediate = lazyCodePlugin.highlight( + { + code: "const answer = 42;", + language: "typescript", + themes: ["github-light", "github-dark"], + }, + (highlighted) => resolve(highlighted), + ); + expect(immediate).toBeNull(); + }); + + expect(Array.isArray(result.tokens)).toBe(true); + expect(result.tokens.length).toBeGreaterThan(0); + }); + + it("invokes the highlight callback exactly once", async () => { + const callback = vi.fn(); + + lazyCodePlugin.highlight( + { + code: "const doubled = 1;", + language: "typescript", + themes: ["github-light", "github-dark"], + }, + callback, + ); + + // Wait past the lazy import + tokenization, then give a couple more + // microtask/macrotask turns so any stray second invocation would land. + await vi.waitFor(() => expect(callback).toHaveBeenCalled(), { timeout: 10000 }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/components/ai-elements/lazyCodePlugin.ts b/web/src/components/ai-elements/lazyCodePlugin.ts new file mode 100644 index 00000000000..b1931f3ed79 --- /dev/null +++ b/web/src/components/ai-elements/lazyCodePlugin.ts @@ -0,0 +1,78 @@ +import type { CodeHighlighterPlugin, HighlightOptions, ThemeInput } from "streamdown"; + +// streamdown exports the plugin interface but not its HighlightResult type; +// recover it from the highlight method's signature. +type HighlightResult = NonNullable>; + +// Streamdown's `code` plugin (@streamdown/code) statically imports Shiki's +// engine, including its WASM regex engine. Importing it eagerly pulls that +// engine into the main entry chunk even when no code block ever renders. +// +// This wrapper defers the @streamdown/code import until the first highlight +// call, mirroring the lazy Monaco/Shiki pattern elsewhere in the app, so the +// engine splits into its own chunk loaded on demand. It satisfies the same +// CodeHighlighterPlugin contract Streamdown consumes: getThemes() returns the +// default themes synchronously, and highlight() returns null while the engine +// loads, resolving tokens through the callback once it's ready. + +const DEFAULT_THEMES: [ThemeInput, ThemeInput] = ["github-light", "github-dark"]; + +let realCode: CodeHighlighterPlugin | null = null; +let codePromise: Promise | null = null; + +const loadCode = (): Promise => { + // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then) + codePromise ??= import("@streamdown/code").then(({ code }) => { + realCode = code; + return code; + }); + return codePromise; +}; + +export const lazyCodePlugin: CodeHighlighterPlugin = { + name: "shiki", + type: "code-highlighter", + getThemes: () => realCode?.getThemes() ?? DEFAULT_THEMES, + getSupportedLanguages: () => realCode?.getSupportedLanguages() ?? [], + // Streamdown never calls supportsLanguage/getSupportedLanguages on the render + // path (zero call sites in streamdown's dist), and the real plugin's + // highlight() falls back to "text" for unknown languages anyway, so an + // optimistic pre-load answer is safe — unsupported code just renders as + // plain text once the engine loads. + supportsLanguage: (language) => realCode?.supportsLanguage(language) ?? true, + highlight: ( + options: HighlightOptions, + // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks) + callback?: (result: HighlightResult) => void, + ): HighlightResult | null => { + // Engine already loaded — delegate synchronously so the real plugin's + // token cache (sync hit path) keeps working unchanged. + if (realCode) { + return realCode.highlight(options, callback); + } + + // First call before the engine finishes loading: report "not ready" by + // returning null. Streamdown's HighlightedCodeBlockBody keeps the raw code + // in state and re-renders when the callback fires (it calls setState in the + // callback), so we resolve tokens through the callback once loaded. + // + // Fire the callback at most once: on a synchronous cache hit the real + // plugin returns the result without invoking the callback, so we invoke it; + // otherwise the plugin invokes it later. The `fired` guard makes a double + // invocation impossible regardless of which path the real plugin takes. + // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then) + void loadCode().then((plugin) => { + let fired = false; + const fireOnce = (result: HighlightResult) => { + if (fired) return; + fired = true; + callback?.(result); + }; + const sync = plugin.highlight(options, fireOnce); + if (sync) { + fireOnce(sync); + } + }); + return null; + }, +}; diff --git a/web/src/components/ai-elements/streamdown-security.ts b/web/src/components/ai-elements/streamdown-security.ts index bbd45d195f1..c32db425da9 100644 --- a/web/src/components/ai-elements/streamdown-security.ts +++ b/web/src/components/ai-elements/streamdown-security.ts @@ -1,8 +1,8 @@ import { cjk } from "@streamdown/cjk"; -import { code } from "@streamdown/code"; import { math } from "@streamdown/math"; import { mermaid } from "@streamdown/mermaid"; import { defaultRehypePlugins, type LinkSafetyConfig, type StreamdownProps } from "streamdown"; +import { lazyCodePlugin } from "./lazyCodePlugin"; type StreamdownRehypePlugins = NonNullable; type StreamdownRehypePlugin = StreamdownRehypePlugins[number]; @@ -18,7 +18,7 @@ type StreamdownHardenPlugin = StreamdownPluginTuple & { 1: StreamdownHardenOptions; }; -export const STREAMDOWN_PLUGINS = { cjk, code, math, mermaid }; +export const STREAMDOWN_PLUGINS = { cjk, code: lazyCodePlugin, math, mermaid }; export const SECURE_STREAMDOWN_REHYPE_PLUGINS = createSecureStreamdownRehypePlugins(); // Streamdown enables a link-safety confirmation modal by default: clicking any diff --git a/web/src/components/ai-elements/streamdownCodeHighlight.test.tsx b/web/src/components/ai-elements/streamdownCodeHighlight.test.tsx new file mode 100644 index 00000000000..2ac60a46f92 --- /dev/null +++ b/web/src/components/ai-elements/streamdownCodeHighlight.test.tsx @@ -0,0 +1,45 @@ +import { cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { MessageResponse } from "./message"; + +afterEach(cleanup); + +// Exercises the lazy code plugin THROUGH the real Streamdown markdown path: +// MessageResponse renders where +// STREAMDOWN_PLUGINS.code is our lazyCodePlugin. This proves that when +// highlight() returns null and later resolves via callback, Streamdown +// re-renders the fenced code block with syntax-highlighted tokens. +describe("Streamdown code highlighting via lazyCodePlugin", () => { + const MARKDOWN = "```ts\nconst answer = 42;\n```"; + + it("shows the raw code immediately, before the lazy Shiki import resolves", () => { + const { container } = render({MARKDOWN}); + + // The code text is present right away (raw, unhighlighted) — highlighting + // must never block first paint. + expect(container.textContent).toContain("const answer = 42;"); + }); + + it("re-renders with syntax-highlighted token spans after the callback fires", async () => { + const { container } = render({MARKDOWN}); + + // Once the lazily-imported @streamdown/code engine tokenizes and fires the + // callback, Streamdown re-renders each token as its own span carrying a + // per-token Shiki color via the `--sdm-c` CSS custom property. Before that + // the raw path emits a single uncolored line span. + const coloredSelector = "span[style*='--sdm-c']"; + await waitFor( + () => { + expect(container.querySelectorAll(coloredSelector).length).toBeGreaterThan(1); + }, + { timeout: 10000 }, + ); + + // The keyword and the numeric literal land in distinct highlighted tokens. + const tokenText = Array.from(container.querySelectorAll(coloredSelector)).map( + (el) => el.textContent, + ); + expect(tokenText).toContain("const"); + expect(tokenText).toContain("42"); + }, 15000); +}); From 77d0908c307b1520b5052e46e5235708a17af501 Mon Sep 17 00:00:00 2001 From: Daniel Lok Date: Tue, 21 Jul 2026 17:32:06 +0800 Subject: [PATCH 525/546] perf(runtime): bump default idle-reap window to 1 hour (#2986) Bump the SDK-proxy harness subprocess and native CLI pane idle-reap defaults from 30 minutes to 1 hour so short lulls between turns don't tear down live sessions. Both defaults intentionally mirror each other; the runner-level watchdog was already at 1 hour, so it now consistently outlives the inner reapers it contains. Both remain env-overridable. Co-authored-by: Isaac --- omnigent/runtime/harnesses/process_manager.py | 8 ++++---- omnigent/terminals/pane_reaper.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/omnigent/runtime/harnesses/process_manager.py b/omnigent/runtime/harnesses/process_manager.py index 28707da7857..9b407dcf648 100644 --- a/omnigent/runtime/harnesses/process_manager.py +++ b/omnigent/runtime/harnesses/process_manager.py @@ -98,7 +98,7 @@ # Per §Deployment knobs vs spec self-containment, this is a # deployment-level capacity knob — operators may tune; specs MUST # NOT depend on a specific value. -_DEFAULT_IDLE_TIMEOUT_S = 30 * 60 # 30 minutes +_DEFAULT_IDLE_TIMEOUT_S = 60 * 60 # 1 hour # How often the idle reaper wakes up to check for stale entries. # Picking 1/30th of the timeout keeps reaping reasonably prompt @@ -115,7 +115,7 @@ def _resolve_harness_idle_timeout_s() -> float: """Resolve the harness idle-reap window in seconds. Honors :envvar:`OMNIGENT_HARNESS_IDLE_TIMEOUT_S` (``0`` disables reaping); - otherwise the 30-minute default. An unparseable or negative value logs a + otherwise the 1-hour default. An unparseable or negative value logs a warning and falls back to the default rather than failing the runner at boot — an env typo shouldn't take the runner down. """ @@ -526,7 +526,7 @@ class HarnessProcessManager: :param idle_timeout_s: Seconds of inactivity after which a subprocess gets reaped. Deployment-level capacity knob; - defaults to 30 minutes. Specs MUST NOT depend on a + defaults to 1 hour. Specs MUST NOT depend on a specific value. :param reaper_interval_s: Seconds between idle-reaper passes. Defaults to 60. @@ -546,7 +546,7 @@ def __init__( tmp_parent: Path | None = None, ) -> None: # ``None`` (the default at both construction sites) resolves from the - # OMNIGENT_HARNESS_IDLE_TIMEOUT_S env var, else the 30-minute default. + # OMNIGENT_HARNESS_IDLE_TIMEOUT_S env var, else the 1-hour default. self._idle_timeout_s = ( idle_timeout_s if idle_timeout_s is not None else _resolve_harness_idle_timeout_s() ) diff --git a/omnigent/terminals/pane_reaper.py b/omnigent/terminals/pane_reaper.py index 482bbf2cd8e..896c1d19556 100644 --- a/omnigent/terminals/pane_reaper.py +++ b/omnigent/terminals/pane_reaper.py @@ -65,8 +65,8 @@ ) # Default idle window before an unused native pane is reaped. Mirrors -# ``HarnessProcessManager``'s 30-minute SDK-proxy default for consistency. -_DEFAULT_IDLE_TIMEOUT_S = 30 * 60 +# ``HarnessProcessManager``'s 1-hour SDK-proxy default for consistency. +_DEFAULT_IDLE_TIMEOUT_S = 60 * 60 _DEFAULT_REAPER_INTERVAL_S = 60.0 _IDLE_TIMEOUT_ENV = "OMNIGENT_NATIVE_PANE_IDLE_TIMEOUT_S" @@ -91,7 +91,7 @@ def resolve_native_pane_idle_timeout_s() -> float: """Resolve the native-pane idle window in seconds. Honors :envvar:`OMNIGENT_NATIVE_PANE_IDLE_TIMEOUT_S` (``0`` disables pane - reaping); otherwise the 30-minute default. An unparseable or negative value + reaping); otherwise the 1-hour default. An unparseable or negative value logs a warning and falls back to the default rather than failing the runner at boot — an env typo shouldn't take the runner down or (worse) make the reaper act on a bogus window. From a077eb6835a7ff1e08018f5331dd7c0d28a167a2 Mon Sep 17 00:00:00 2001 From: Tomu Hirata Date: Tue, 21 Jul 2026 20:08:09 +0900 Subject: [PATCH 526/546] feat(session-ui): HTTP headers support for MCP servers in session UI (#2989) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(telemetry): track sdk harness name in SessionCreatedEvent SDK sessions (claude-sdk, openai-agents, codex, etc.) previously emitted `harness: null` on the SessionCreatedEvent because only native agents have a `native_agent.harness` attribute. Fall back to `_resolve_harness(conv)`, which already handles harness_override and spec lookup, so every harness kind is now represented in telemetry. Signed-off-by: Tomu Hirata * feat(session-ui): support HTTP headers on MCP servers in session UI Adds the ability to set, view, and edit HTTP headers (e.g. Authorization) on HTTP-transport MCP servers through the session agent info panel. Backend: - MCPServerSummary now includes a headers field; values are always [REDACTED] in API responses (only key names are exposed). - UpsertMCPServerRequest accepts headers: dict[str, str] | None. None preserves existing headers; {} clears them. - New _apply_headers() helper replaces the old _preserve_keys() call for headers so edits via the UI actually take effect rather than always restoring the bundle's headers. - Fixed sessions.py and builtin_agents.py MCPServerSummary construction to populate headers (previously always returned {}), which caused headers to disappear when reopening the edit dialog. Frontend: - McpFormState/UpsertMcpServerInput/McpServerSummary all carry headers. - McpServerManagerDialog shows a key-value editor for HTTP headers (add row with +, remove with x, values show as [REDACTED] for existing headers). - Fixed AgentInfoButton popover closing when the MCP manager Dialog opens: uses onInteractOutside/onFocusOutside on PopoverContent to suppress Radix's outside-click dismiss while a nested dialog is open. Signed-off-by: Tomu Hirata * fix(create-agent): accept KEY: VALUE format in headers textarea parseKVLines only split on '=' so users typing the natural HTTP header format (Authorization: Bearer ...) got silently dropped. Now accepts both '=' and ':' as separators, taking whichever comes first. Updated the placeholder to show the colon form. Signed-off-by: Tomu Hirata * fix(mcp-headers): preserve real secrets when [REDACTED] sent on edit When a user opens the MCP server edit dialog, header values come back as [REDACTED] from the API. If they save without changing those values the client sends { Authorization: '[REDACTED]' }, which was being written literally into the bundle YAML — overwriting the real token. _apply_headers now treats a value equal to the '[REDACTED]' sentinel for an existing key as 'preserve the stored value', restoring it from the existing bundle entry instead of writing the placeholder. Also reverts unrelated package-lock.json churn and adds a round-trip integration test covering the edit-with-existing-headers scenario. Signed-off-by: Tomu Hirata * chore: regenerate openapi.json for MCP headers fields Signed-off-by: Tomu Hirata * fix(mcp-headers): send {} to clear headers when all rows removed When editing a server and removing all header rows, the frontend was sending null (preserve) instead of {} (clear), so stale auth tokens were silently kept in the bundle. null now only means 'preserve' for new servers (no originalName). Editing an existing server with zero rows sends {} to explicitly clear. Adds integration test covering the clear-all path. Signed-off-by: Tomu Hirata --------- Signed-off-by: Tomu Hirata --- omnigent/server/routes/builtin_agents.py | 1 + omnigent/server/routes/session_mcp_servers.py | 39 ++++- omnigent/server/routes/sessions.py | 1 + omnigent/server/schemas.py | 17 ++- openapi.json | 26 +++- .../integration/test_session_mcp_servers.py | 77 ++++++++++ web/src/components/AgentInfo.tsx | 134 ++++++++++++++++-- web/src/hooks/useAgents.ts | 6 + web/src/shell/CreateAgentDialog.tsx | 14 +- 9 files changed, 287 insertions(+), 28 deletions(-) diff --git a/omnigent/server/routes/builtin_agents.py b/omnigent/server/routes/builtin_agents.py index 05a60472e1e..1063cc97e03 100644 --- a/omnigent/server/routes/builtin_agents.py +++ b/omnigent/server/routes/builtin_agents.py @@ -82,6 +82,7 @@ def _to_agent_object(agent: Agent, agent_cache: AgentCache) -> AgentObject: transport=srv.transport, description=srv.description, url=srv.url, + headers=dict.fromkeys(srv.headers, "[REDACTED]") if srv.headers else {}, command=srv.command, args=srv.args, ) diff --git a/omnigent/server/routes/session_mcp_servers.py b/omnigent/server/routes/session_mcp_servers.py index f259ff2998a..85391eb73a5 100644 --- a/omnigent/server/routes/session_mcp_servers.py +++ b/omnigent/server/routes/session_mcp_servers.py @@ -313,6 +313,7 @@ def _summary_from_config(server: MCPServerConfig) -> MCPServerSummary: transport=server.transport, description=server.description, url=server.url, + headers=dict.fromkeys(server.headers, "[REDACTED]") if server.headers else {}, command=server.command, args=server.args, ) @@ -431,7 +432,8 @@ def _body_to_file_yaml( _copy_description(result, body) if body.transport == "http": result["url"] = body.url - _preserve_keys(result, existing, ("headers", "auth", "timeout", "retry")) + _apply_headers(result, body, existing) + _preserve_keys(result, existing, ("auth", "timeout", "retry")) else: result["command"] = body.command if body.args: @@ -449,7 +451,8 @@ def _body_to_inline_yaml( _copy_description(result, body) if body.transport == "http": result["url"] = body.url - _preserve_keys(result, existing, ("headers", "auth", "timeout", "retry")) + _apply_headers(result, body, existing) + _preserve_keys(result, existing, ("auth", "timeout", "retry")) else: result["command"] = body.command if body.args: @@ -458,6 +461,38 @@ def _body_to_inline_yaml( return result +_REDACTED_SENTINEL = "[REDACTED]" + + +def _apply_headers( + result: dict[str, Any], + body: UpsertMCPServerRequest, + existing: dict[str, Any], +) -> None: + """Write headers into the YAML result. + + Uses body.headers when provided; falls back to preserving the existing + bundle's headers so a URL-only edit doesn't wipe configured auth tokens. + Omits the key entirely when neither is present. + + Values equal to ``"[REDACTED]"`` are treated as the UI's sentinel for + "this header exists but I didn't change it" — those values are restored + from the existing bundle rather than written as the literal string. + """ + if body.headers is not None: + if not body.headers: + # Explicitly cleared — omit the key entirely. + return + existing_headers: dict[str, Any] = existing.get("headers") or {} + merged = { + k: (existing_headers.get(k, v) if v == _REDACTED_SENTINEL else v) + for k, v in body.headers.items() + } + result["headers"] = merged + elif "headers" in existing: + result["headers"] = existing["headers"] + + def _copy_description(result: dict[str, Any], body: UpsertMCPServerRequest) -> None: """Copy a non-empty description into a YAML mapping.""" if body.description: diff --git a/omnigent/server/routes/sessions.py b/omnigent/server/routes/sessions.py index 51203d34d5b..5ab1b77cda2 100644 --- a/omnigent/server/routes/sessions.py +++ b/omnigent/server/routes/sessions.py @@ -21681,6 +21681,7 @@ def _to_agent_object(agent: Agent, cache: AgentCache | None) -> AgentObject: transport=srv.transport, description=srv.description, url=srv.url, + headers=dict.fromkeys(srv.headers, "[REDACTED]") if srv.headers else {}, command=srv.command, args=srv.args, ) diff --git a/omnigent/server/schemas.py b/omnigent/server/schemas.py index f63b9c93c5d..88186581840 100644 --- a/omnigent/server/schemas.py +++ b/omnigent/server/schemas.py @@ -54,10 +54,9 @@ class MCPServerSummary(BaseModel): """ Safe subset of an MCP server's configuration for API exposure. - Secret-bearing fields (``headers``, ``env``) are intentionally - excluded. This model is the wire shape returned inside - :class:`AgentObject` so clients can display which MCP servers - an agent is connected to without leaking credentials. + Header values are redacted (``"[REDACTED]"``) so callers can see + which headers are configured without leaking the actual secrets. + ``env`` is still fully excluded. :param name: Server name as declared in the agent spec, e.g. ``"github"``. @@ -67,6 +66,9 @@ class MCPServerSummary(BaseModel): :param url: HTTP(S) endpoint URL for ``transport="http"`` servers, e.g. ``"https://mcp.example.com/sse"``. ``None`` for stdio servers. + :param headers: HTTP headers for ``transport="http"`` servers. + Values are always ``"[REDACTED]"``; only the key names are + exposed. :param command: Executable path for ``transport="stdio"`` servers, e.g. ``"uvx"``. ``None`` for http servers. :param args: Command-line arguments for ``transport="stdio"`` @@ -78,6 +80,7 @@ class MCPServerSummary(BaseModel): transport: str description: str | None = None url: str | None = None + headers: dict[str, str] = Field(default_factory=dict) command: str | None = None args: list[str] = Field(default_factory=list) @@ -89,15 +92,15 @@ class UpsertMCPServerRequest(BaseModel): """ Request body for creating or updating a session agent MCP server. - Secret-bearing fields (``headers`` and ``env``) are intentionally - not accepted by the UI route. Existing secrets are preserved when a - server is edited without changing transport. + ``env`` is still excluded. ``headers`` is accepted for HTTP servers; + when omitted, existing headers in the bundle are preserved unchanged. """ name: str = Field(min_length=1, max_length=128, pattern=_MCP_SERVER_NAME_RE) transport: Literal["http", "stdio"] description: str | None = Field(default=None, max_length=512) url: str | None = None + headers: dict[str, str] | None = None command: str | None = None args: list[str] = Field(default_factory=list, max_length=64) diff --git a/openapi.json b/openapi.json index 28a1c13a9ce..414226c6b93 100644 --- a/openapi.json +++ b/openapi.json @@ -1924,7 +1924,7 @@ "type": "object" }, "MCPServerSummary": { - "description": "Safe subset of an MCP server's configuration for API exposure.\n\nSecret-bearing fields (`headers`, `env`) are intentionally\nexcluded. This model is the wire shape returned inside\n`AgentObject` so clients can display which MCP servers\nan agent is connected to without leaking credentials.", + "description": "Safe subset of an MCP server's configuration for API exposure.\n\nHeader values are redacted (`\"[REDACTED]\"`) so callers can see\nwhich headers are configured without leaking the actual secrets.\n`env` is still fully excluded.", "properties": { "args": { "description": "Command-line arguments for `transport=\"stdio\"` servers, e.g. `[\"mcp-server-github\"]`. Empty list when unset.", @@ -1958,6 +1958,14 @@ "description": "Optional free-text description from the spec, e.g. `\"GitHub MCP server\"`. `None` when unset.", "title": "Description" }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "description": "HTTP headers for `transport=\"http\"` servers. Values are always `\"[REDACTED]\"`; only the key names are exposed.", + "title": "Headers", + "type": "object" + }, "name": { "description": "Server name as declared in the agent spec, e.g. `\"github\"`.", "title": "Name", @@ -6188,7 +6196,7 @@ "type": "object" }, "UpsertMCPServerRequest": { - "description": "Request body for creating or updating a session agent MCP server.\n\nSecret-bearing fields (`headers` and `env`) are intentionally\nnot accepted by the UI route. Existing secrets are preserved when a\nserver is edited without changing transport.", + "description": "Request body for creating or updating a session agent MCP server.\n\n`env` is still excluded. `headers` is accepted for HTTP servers;\nwhen omitted, existing headers in the bundle are preserved unchanged.", "properties": { "args": { "items": { @@ -6221,6 +6229,20 @@ ], "title": "Description" }, + "headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Headers" + }, "name": { "maxLength": 128, "minLength": 1, diff --git a/tests/server/integration/test_session_mcp_servers.py b/tests/server/integration/test_session_mcp_servers.py index b2339553e10..7a6ec00cec0 100644 --- a/tests/server/integration/test_session_mcp_servers.py +++ b/tests/server/integration/test_session_mcp_servers.py @@ -37,6 +37,7 @@ async def test_create_mcp_server_updates_agent_bundle(client: httpx.AsyncClient) "transport": "http", "description": "GitHub tools", "url": "https://example.com/sse", + "headers": {}, "command": None, "args": [], } @@ -135,6 +136,7 @@ async def test_update_mcp_server_can_rename_and_change_transport( "transport": "stdio", "description": None, "url": None, + "headers": {}, "command": "npx", "args": ["-y", "@modelcontextprotocol/server-search"], } @@ -208,6 +210,81 @@ async def test_create_mcp_server_supports_single_yaml_bundle(client: httpx.Async assert [server["name"] for server in agent_resp.json()["mcp_servers"]] == ["browser-search"] +async def test_update_mcp_server_preserves_headers_on_redacted_roundtrip( + client: httpx.AsyncClient, +) -> None: + """Editing a server while sending [REDACTED] header values must not overwrite secrets.""" + session = await create_test_session(client, name="mcp-headers-agent") + session_id = session["id"] + + # Create server with a real Authorization header. + create = await client.post( + f"/v1/sessions/{session_id}/agent/mcp-servers", + json={ + "name": "secure", + "transport": "http", + "url": "https://example.com/sse", + "headers": {"Authorization": "Bearer real-token"}, + }, + ) + assert create.status_code == 200, create.text + + # Simulate the UI round-trip: the GET returns [REDACTED] values; the client + # sends them back verbatim when editing only the URL. + update = await client.put( + f"/v1/sessions/{session_id}/agent/mcp-servers/secure", + json={ + "name": "secure", + "transport": "http", + "url": "https://example.com/sse-v2", + "headers": {"Authorization": "[REDACTED]"}, + }, + ) + assert update.status_code == 200, update.text + + # The bundle must still contain the real token, not the sentinel. + bundle = await _agent_bundle(client, session_id) + mcp_file = _mcp_file_from_bundle(bundle, "secure.yaml") + assert mcp_file["url"] == "https://example.com/sse-v2" + assert mcp_file.get("headers") == {"Authorization": "Bearer real-token"} + + +async def test_update_mcp_server_clears_headers_when_empty_dict_sent( + client: httpx.AsyncClient, +) -> None: + """Sending headers={} on update must remove all headers from the bundle.""" + session = await create_test_session(client, name="mcp-clear-headers-agent") + session_id = session["id"] + + create = await client.post( + f"/v1/sessions/{session_id}/agent/mcp-servers", + json={ + "name": "secure", + "transport": "http", + "url": "https://example.com/sse", + "headers": {"Authorization": "Bearer real-token"}, + }, + ) + assert create.status_code == 200, create.text + + # User removes all header rows — client sends {}. + update = await client.put( + f"/v1/sessions/{session_id}/agent/mcp-servers/secure", + json={ + "name": "secure", + "transport": "http", + "url": "https://example.com/sse", + "headers": {}, + }, + ) + assert update.status_code == 200, update.text + assert update.json()["headers"] == {} + + bundle = await _agent_bundle(client, session_id) + mcp_file = _mcp_file_from_bundle(bundle, "secure.yaml") + assert "headers" not in mcp_file + + async def _agent_bundle(client: httpx.AsyncClient, session_id: str) -> bytes: """Download the session agent bundle.""" resp = await client.get(f"/v1/sessions/{session_id}/agent/contents") diff --git a/web/src/components/AgentInfo.tsx b/web/src/components/AgentInfo.tsx index 17c5ab012e8..a698cd25648 100644 --- a/web/src/components/AgentInfo.tsx +++ b/web/src/components/AgentInfo.tsx @@ -660,6 +660,8 @@ interface McpFormState { transport: "http" | "stdio"; description: string; url: string; + /** Key-value pairs for HTTP headers. Values from existing servers are "[REDACTED]". */ + headers: { key: string; value: string }[]; command: string; argsText: string; } @@ -670,6 +672,7 @@ const EMPTY_MCP_FORM: McpFormState = { transport: "http", description: "", url: "", + headers: [], command: "", argsText: "", }; @@ -681,6 +684,7 @@ function mcpFormFromServer(server: McpServerSummary): McpFormState { transport: server.transport === "stdio" ? "stdio" : "http", description: server.description ?? "", url: server.url ?? "", + headers: Object.entries(server.headers ?? {}).map(([key, value]) => ({ key, value })), command: server.command ?? "", argsText: (server.args ?? []).join("\n"), }; @@ -693,10 +697,21 @@ function payloadFromMcpForm(form: McpFormState): UpsertMcpServerInput { description: form.description.trim() || null, }; if (form.transport === "http") { + // null → "preserve existing" (used when creating a new server with no headers). + // {} → "clear all headers" (user explicitly removed every row on an existing server). + // {…} → replace with these headers. + const filledHeaders = + form.headers.length > 0 + ? Object.fromEntries( + form.headers.filter((h) => h.key.trim()).map((h) => [h.key.trim(), h.value]), + ) + : null; + const headers = filledHeaders ?? (form.originalName !== null ? {} : null); return { ...base, transport: "http", url: form.url.trim(), + headers, command: null, args: [], }; @@ -900,14 +915,76 @@ function McpServerManagerDialog({ {form.transport === "http" ? ( - + <> + +
    +
    + Headers + +
    + {form.headers.map((header, i) => ( +
    + + setForm((prev) => { + const headers = [...prev.headers]; + headers[i] = { ...headers[i], key: e.target.value }; + return { ...prev, headers }; + }) + } + className="font-mono text-xs" + placeholder="Header-Name" + /> + + setForm((prev) => { + const headers = [...prev.headers]; + headers[i] = { ...headers[i], value: e.target.value }; + return { ...prev, headers }; + }) + } + className="font-mono text-xs" + placeholder="value" + /> + +
    + ))} +
    + ) : ( <>
    setMessage((prev) => (prev ? `${prev} ${text}` : text))} + onTranscript={dictation.appendFinal} + onInterim={dictation.replaceInterim} />
    diff --git a/web/src/shell/Sidebar.rowActions.test.tsx b/web/src/shell/Sidebar.rowActions.test.tsx index f7d525318be..0f91b62bcfa 100644 --- a/web/src/shell/Sidebar.rowActions.test.tsx +++ b/web/src/shell/Sidebar.rowActions.test.tsx @@ -136,6 +136,7 @@ function serverInfo(overrides: Partial = {}): ServerInfo { public_sharing_enabled: true, server_version: null, smart_routing_enabled: false, + dictation_available: false, ...overrides, }; } From 886f9d43ddb6de331cf08992b07a4ad8cebd980e Mon Sep 17 00:00:00 2001 From: Sabhya Chhabria Date: Tue, 21 Jul 2026 14:57:15 -0700 Subject: [PATCH 537/546] fix(sessions): avoid GeneratorExit on SSE stream disconnect cleanup (#2584) * fix(sessions): avoid GeneratorExit on SSE stream disconnect cleanup Yielding [DONE] from _stream_live_events finally raised RuntimeError on client aclose; keep finally cleanup-only and aclose the subscribe slot. Signed-off-by: SabhyaC26 * chore(openapi): regenerate session stream description Signed-off-by: SabhyaC26 --------- Signed-off-by: SabhyaC26 --- omnigent/server/routes/sessions.py | 81 +++++++------ openapi.json | 2 +- .../server/routes/test_stream_live_events.py | 114 ++++++++++++++++++ 3 files changed, 161 insertions(+), 36 deletions(-) create mode 100644 tests/server/routes/test_stream_live_events.py diff --git a/omnigent/server/routes/sessions.py b/omnigent/server/routes/sessions.py index 812cdb6c920..44585af243c 100644 --- a/omnigent/server/routes/sessions.py +++ b/omnigent/server/routes/sessions.py @@ -12196,13 +12196,19 @@ async def _stream_live_events( reconcile pre-subscribe state via the snapshot endpoint (``GET /v1/sessions/{id}``) and dedupe by item id. - On client disconnect the subscribe loop breaks; the ``finally`` block - emits a ``[DONE]`` sentinel so well-behaved SSE consumers see a clean - stream termination. A subscriber-queue overflow instead ends without - ``[DONE]`` so clients treat it as a dropped transport, reconnect, and - reconcile from the persisted snapshot. The pub-sub layer auto-cleans - this generator's subscriber slot in its own ``finally`` when iteration - exits. + On normal completion (subscribe ends or the disconnect check + breaks the loop) this generator emits a ``[DONE]`` sentinel so + well-behaved SSE consumers see a clean stream termination. A + subscriber-queue overflow instead ends without ``[DONE]`` so clients + treat it as a dropped transport, reconnect, and reconcile from the + persisted snapshot. + + ``finally`` is cleanup-only (presence deregistration): yielding + from ``finally`` during client ``aclose`` / ``GeneratorExit`` + raises ``RuntimeError: async generator ignored GeneratorExit``. + The subscribe iterator is wrapped in ``contextlib.aclosing`` so + outer ``aclose`` tears down the pub-sub subscriber slot + immediately (a bare ``async for`` would defer that to GC). Each emitted dict is validated against :data:`ServerStreamEvent` at the wire boundary so a runtime @@ -12262,35 +12268,42 @@ async def _stream_live_events( presence_token = presence.connect( presence_root_id, session_id, viewer_user_id, viewer_idle ) - subscriber_overflowed = False try: - async for event in session_stream.subscribe( - session_id, - heartbeat_interval_s=_SESSION_STREAM_HEARTBEAT_INTERVAL_S, - ready_event={"type": "session.heartbeat"}, - # In-flight text replay must be captured synchronously at slot - # registration (before ``ready_event`` suspends), not in the - # async ``on_subscribed`` hook, or window deltas double-render. - # Resource state stays in ``on_subscribed`` — it needs - # awaits and is not dedup-sensitive. - pre_ready_snapshot=lambda: inflight_text.snapshot_for(session_id), - on_subscribed=on_subscribed, - ): - if await request.is_disconnected(): - break - event_type = event.get("type") - if not isinstance(event_type, str): - raise ValueError( - f"session stream event missing string ``type`` field: {event!r}", - ) - validated = _SERVER_STREAM_EVENT_ADAPTER.validate_python(event) - yield _format_sse(event_type, validated.model_dump()) + # ``aclosing`` propagates outer ``aclose`` into ``subscribe``; + # a bare ``async for`` would leave the subscriber slot until GC. + async with contextlib.aclosing( + session_stream.subscribe( + session_id, + heartbeat_interval_s=_SESSION_STREAM_HEARTBEAT_INTERVAL_S, + ready_event={"type": "session.heartbeat"}, + # In-flight text replay must be captured synchronously at slot + # registration (before ``ready_event`` suspends), not in the + # async ``on_subscribed`` hook, or window deltas double-render. + # Resource state stays in ``on_subscribed`` — it needs + # awaits and is not dedup-sensitive. + pre_ready_snapshot=lambda: inflight_text.snapshot_for(session_id), + on_subscribed=on_subscribed, + ) + ) as live_events: + async for event in live_events: + if await request.is_disconnected(): + break + event_type = event.get("type") + if not isinstance(event_type, str): + raise ValueError( + f"session stream event missing string ``type`` field: {event!r}", + ) + validated = _SERVER_STREAM_EVENT_ADAPTER.validate_python(event) + yield _format_sse(event_type, validated.model_dump()) except session_stream.SubscriberOverflowError: - subscriber_overflowed = True _logger.warning( "session stream subscriber overflowed for %s; closing for snapshot reconnect", session_id, ) + else: + # Normal completion only — never yield from ``finally`` (aclose / + # GeneratorExit would raise ``async generator ignored GeneratorExit``). + yield "data: [DONE]\n\n" finally: # The non-None checks besides presence_token's are type # narrowing only: a minted token implies both were set above. @@ -12300,8 +12313,6 @@ async def _stream_live_events( and presence_root_id is not None ): presence.disconnect(presence_root_id, viewer_user_id, presence_token) - if not subscriber_overflowed: - yield "data: [DONE]\n\n" # Bounds for per-session native-terminal pass-through args @@ -21072,9 +21083,9 @@ async def stream_session( Subscribe to the session's live SSE event stream. Does NOT replay history; clients reconcile via the snapshot - endpoint. The generator handles disconnects via a - ``try/finally`` that emits the ``[DONE]`` sentinel in all - exit paths — see :func:`_stream_live_events`. + endpoint. The generator emits ``[DONE]`` on normal completion + and uses ``finally`` only for presence cleanup — see + :func:`_stream_live_events`. Holding this stream open registers the caller as a session *viewer* (presence): co-viewers' streams receive diff --git a/openapi.json b/openapi.json index 004af36f31b..aa7c33b2306 100644 --- a/openapi.json +++ b/openapi.json @@ -11072,7 +11072,7 @@ }, "/v1/sessions/{session_id}/stream": { "get": { - "description": "Subscribe to the session's live SSE event stream.\n\nDoes NOT replay history; clients reconcile via the snapshot\nendpoint. The generator handles disconnects via a\n`try/finally` that emits the `[DONE]` sentinel in all\nexit paths \u2014 see `_stream_live_events`.\n\nHolding this stream open registers the caller as a session\n*viewer* (presence): co-viewers' streams receive\n`session.presence` events on join/leave/idle edges, and\nthis stream's snapshot-on-connect includes the current\nviewer list. Presence is scoped to the session tree's root\nconversation, so viewers of different agents/sub-agents in\none session see each other. See\n`omnigent/server/presence.py`.\n\n**Returns:** An SSE `StreamingResponse`.\n\n**Raises**\n\n- `OmnigentError` \u2014 404 if no session exists.", + "description": "Subscribe to the session's live SSE event stream.\n\nDoes NOT replay history; clients reconcile via the snapshot\nendpoint. The generator emits `[DONE]` on normal completion\nand uses `finally` only for presence cleanup \u2014 see\n`_stream_live_events`.\n\nHolding this stream open registers the caller as a session\n*viewer* (presence): co-viewers' streams receive\n`session.presence` events on join/leave/idle edges, and\nthis stream's snapshot-on-connect includes the current\nviewer list. Presence is scoped to the session tree's root\nconversation, so viewers of different agents/sub-agents in\none session see each other. See\n`omnigent/server/presence.py`.\n\n**Returns:** An SSE `StreamingResponse`.\n\n**Raises**\n\n- `OmnigentError` \u2014 404 if no session exists.", "operationId": "stream_session_v1_sessions__session_id__stream_get", "parameters": [ { diff --git a/tests/server/routes/test_stream_live_events.py b/tests/server/routes/test_stream_live_events.py new file mode 100644 index 00000000000..d18990b8fc2 --- /dev/null +++ b/tests/server/routes/test_stream_live_events.py @@ -0,0 +1,114 @@ +"""Unit tests for :func:`_stream_live_events` disconnect / completion cleanup. + +Pins the contract that ``finally`` is cleanup-only (presence + nested +subscriber teardown) and that ``data: [DONE]`` is emitted only on +normal stream completion — never during ``aclose`` / ``GeneratorExit``. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from omnigent.runtime import session_stream +from omnigent.server import presence +from omnigent.server.routes.sessions import _stream_live_events + +pytestmark = pytest.mark.asyncio + +SESSION_ID = "conv_stream_live_aclose" +USER_ID = "alice@example.com" + + +class _ConnectedRequest: + """Minimal request stand-in: client stays connected.""" + + async def is_disconnected(self) -> bool: + return False + + +@pytest.fixture(autouse=True) +def _reset_presence_and_subscribers() -> Any: + """Isolate module-global presence + session_stream state per test.""" + presence.reset_for_tests() + session_stream._subscribers.clear() + yield + presence.reset_for_tests() + session_stream._subscribers.clear() + + +async def test_aclose_cleans_presence_and_subscribers_without_runtime_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Client ``aclose`` must not raise, and must tear down presence + slots. + + Regression: yielding ``[DONE]`` from the generator ``finally`` during + ``aclose`` raised ``RuntimeError: async generator ignored GeneratorExit``, + which could skip or obscure cleanup. + """ + monkeypatch.setattr(presence, "_LEAVE_GRACE_S", 0.05) + + gen = _stream_live_events( + _ConnectedRequest(), # type: ignore[arg-type] + SESSION_ID, + viewer_user_id=USER_ID, + viewer_idle=False, + presence_root_id=SESSION_ID, + ) + # Ready heartbeat proves the subscribe slot is registered before aclose. + first = await asyncio.wait_for(gen.__anext__(), timeout=2.0) + assert "session.heartbeat" in first + assert SESSION_ID in session_stream._subscribers + assert [v["user_id"] for v in presence.snapshot(SESSION_ID, SESSION_ID)["viewers"]] == [ + USER_ID + ] + + # Direct close — the path StreamingResponse takes on client disconnect. + await gen.aclose() + + assert SESSION_ID not in session_stream._subscribers, ( + "subscribe finally must drop the subscriber slot on aclose" + ) + # Disconnect schedules leave after grace; wait past the shrunken window. + for _ in range(50): + if presence.snapshot(SESSION_ID, SESSION_ID)["viewers"] == []: + break + await asyncio.sleep(0.02) + assert presence.snapshot(SESSION_ID, SESSION_ID)["viewers"] == [], ( + "presence.disconnect in finally must clear the viewer after grace" + ) + + +async def test_normal_completion_emits_done_and_cleans_up( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Subscribe end-of-stream still yields ``[DONE]`` then cleans up.""" + monkeypatch.setattr(presence, "_LEAVE_GRACE_S", 0.05) + + gen = _stream_live_events( + _ConnectedRequest(), # type: ignore[arg-type] + SESSION_ID, + viewer_user_id=USER_ID, + viewer_idle=False, + presence_root_id=SESSION_ID, + ) + first = await asyncio.wait_for(gen.__anext__(), timeout=2.0) + assert "session.heartbeat" in first + assert SESSION_ID in session_stream._subscribers + + session_stream.close(SESSION_ID) + chunks: list[str] = [] + async for chunk in gen: + chunks.append(chunk) + + assert chunks[-1] == "data: [DONE]\n\n", ( + f"normal completion must emit [DONE]; got trailing {chunks[-1]!r}" + ) + assert SESSION_ID not in session_stream._subscribers + for _ in range(50): + if presence.snapshot(SESSION_ID, SESSION_ID)["viewers"] == []: + break + await asyncio.sleep(0.02) + assert presence.snapshot(SESSION_ID, SESSION_ID)["viewers"] == [] From de7cc8df1610d8eba975c922665fede7356f4243 Mon Sep 17 00:00:00 2001 From: Rahul Ravindranathan <70488221+rahulrav1@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:34:53 -0700 Subject: [PATCH 538/546] feat(scheduled tasks): run-completion tracking + run-history endpoint (#3014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(scheduled tasks): track run completion + expose run history The fire path records a scheduled_task_runs row as `running` and never revisits it, so runs stayed `running` with finished_at=NULL forever even after the agent turn completed (the FU-1 gap confirmed in prior E2E). list_runs also existed in the store but was exposed by no REST route. Add a periodic reconciliation backstop + run-history endpoint: - Store `update_run` (conditional WHERE status=running, idempotent — an already-terminal run is never clobbered and concurrent sweeps can't double-transition) and `list_runs_by_status_all_workspaces` (the sweep source). ScheduledTaskRun entity now carries workspace_id so the sweep can re-enter each run's workspace_scope. - `run_reconciler.py`: a 60s asyncio loop (own module, off the ScheduledTaskScheduler) that reads each running run's conversation and transitions it — completed transcript -> succeeded; a failure label / missing conversation -> failed(code); live_status running/waiting is a cheap pre-filter. A run past a 6h max-age with no terminal state is force-failed (error_code=incomplete) so every run eventually terminates. Wired into the server lifespan next to the scheduler. - `GET /v1/scheduled-tasks/{id}/runs`: owner-scoped run history (404 if not owned), API-stable field naming. No schema/migration change — status codec already had succeeded/failed and the columns (finished_at/error/error_code) already exist. FU-3 + #2978 semantics intact (owner via user_id; API-stable owner_user_id JSON key). Tests: update_run transitions + idempotency; reconciler classification matrix (completed->succeeded, errored/cancelled->failed, in-flight and young runs left alone, stale->failed(incomplete)); GET runs 200/empty/404. Full targeted suite green (155). E2E on a live server + connected host: a real timer fire's run flipped running->succeeded with finished_at set (the exact thing that stayed running before), readable via the runs endpoint; honest-fail still records failed(no_online_host) and the sweep leaves terminal runs untouched. Co-authored-by: Isaac Signed-off-by: Rahul Ravindranathan * feat(scheduled tasks): make run completion event-driven (replaces poll) Replaces the 60s all-workspaces reconciliation poll from the previous commit with an event-driven completion hook + a poll-free orphan backstop, matching how the sibling scheduled-task systems reconcile (at a lifecycle boundary, not on a timer). Primary mechanism: a completion hook (``session_live_state.persist_scheduled_run_completion``) fired from ``_publish_status`` the instant a fired conversation's turn reaches a terminal edge (idle -> succeeded, failed -> failed+error_code). It rides the same long-lived SSE relay that already persists ``live_status`` for a browserless scheduled fire, routed through the same ordered/contextvar-copying executor so the run's ``workspace_scope`` reaches the write thread. A reverse lookup (``get_running_run_by_conversation``, backed by a new ``(workspace_id, conversation_id)`` index) finds the run; the idempotent conditional ``update_run`` (WHERE status=running) transitions it and never clobbers an already-terminal row. For the common (non-scheduled) conversation the lookup returns None and the hook is a cheap no-op. Orphan backstop (no periodic poll): the ``ScheduledRunReconciler`` becomes a ONE-SHOT startup sweep (reconciles runs left ``running`` by a restart mid-fire), and a lazy-on-read pass at ``GET /v1/scheduled-tasks/{id}/runs`` force-fails a task's runs past the 6h max age (``incomplete``). Together they keep the invariant "every run eventually reaches a terminal state" without a recurring background sweep. One migration: the ``conversation_id`` index. FU-3 / #2978 owner semantics, the ``GET /runs`` response shape, and the fire-time ``_record_run`` writes are unchanged. Co-authored-by: Isaac Signed-off-by: Rahul Ravindranathan * refactor(scheduled tasks): drop startup sweep, lazy-on-read is sole backstop Simplifies the orphan backstop per review. The event hook already transitions every normal run the instant its turn ends; the boot-time startup sweep is removed entirely (fewer moving parts). A run orphaned by a mid-fire restart that nobody ever opens staying `running` in the DB is harmless until read, and reading it fixes it. Changes: - Remove `run_startup_sweep`, the `ScheduledRunReconciler` class, and its lifespan wiring in app.py. `run_reconciler.py` reduces to the stale-run policy: the constants + a shared `force_fail_stale_runs` helper (pure age-based, no conversation I/O). - Run the lazy force-fail-stale reconcile on BOTH read endpoints: - `GET /v1/scheduled-tasks/{id}/runs` (detail, already there). - `GET /v1/scheduled-tasks` (list, ADDED) — force-fail the owner's tasks' runs still `running` past 6h so a Tasks-list badge never shows a stale orphan as `running`. Owner-scoped indexed query (`list_running_runs_for_tasks`), conditional `update_run`, no per-run conversation read. - Drop the now-unused `list_runs_by_status_all_workspaces` store method. Net mechanism: (a) event hook = primary, instant terminal transition; (b) lazy-on-read force-fail-stale on list + detail = the only orphan backstop. No startup sweep, no periodic poll of any kind. Keeps the 6h STALE_RUN_MAX_AGE_SECONDS invariant "every run eventually terminal". Co-authored-by: Isaac Signed-off-by: Rahul Ravindranathan * refactor(scheduled tasks): drop dead ScheduledTaskRun.workspace_id field The ``ScheduledTaskRun`` entity carried a ``workspace_id`` field solely so the cross-workspace reconciler sweep could re-enter each run's ``workspace_scope`` before acting on it. That sweep is gone — completion is event-driven and the lazy-on-read backstop both run inside a single ambient ``workspace_scope`` — so the field has no reader. Its only consumer was the deleted ``_reconcile_run``. Remove the field from the entity dataclass and drop the ``workspace_id=`` line in ``_run_to_entity``. The DB column ``scheduled_task_runs.workspace_id`` (the real tenant partition key) and its index are unchanged; the store still filters every query on ``current_workspace_id()``. Co-authored-by: Isaac Signed-off-by: Rahul Ravindranathan * refactor(scheduled tasks): PR polish — comment fix, fired_at age basis, hook wiring test Addresses three review findings on the FU-1 run-completion PR: - Fix a stale finally-block comment in app.py: it still said the run reconciler is "a one-shot startup sweep (no periodic task to cancel)", but the startup sweep was removed — completion is event-driven + lazy-on-read, so there is no reconciler task at all. Comment now says only the per-job scheduler needs stopping. The scheduled_task_scheduler.stop() logic is unchanged. - Measure the lazy-on-read stale window from fired_at (falling back to scheduled_at when a run never recorded a fire time), not scheduled_at. A run that fired late no longer gets a shortened effective window — the 6h clock starts when dispatch actually began. Locked by two unit tests: a run fired >6h ago is force-failed; a run scheduled >6h ago but fired recently is left alone. - Add integration coverage for the primary completion mechanism at the _publish_status seam: drive the real _publish_status(conversation_id, "idle") / "failed" edge (the way the SSE relay does) and assert the scheduled_task_run transitions running -> succeeded / failed(+error_code) with finished_at set, through the hook + shared session_live_state executor (workspace_scope contract exercised, not bypassed). This locks the wiring so a future _publish_status refactor can't silently break scheduled-run completion. Co-authored-by: Isaac Signed-off-by: Rahul Ravindranathan --------- Signed-off-by: Rahul Ravindranathan --- omnigent/db/db_models.py | 8 + ..._ix_scheduled_task_runs_conversation_id.py | 44 ++ omnigent/server/app.py | 17 +- omnigent/server/routes/scheduled_tasks.py | 62 ++- omnigent/server/routes/sessions.py | 19 + omnigent/server/scheduled/run_reconciler.py | 112 +++++ omnigent/server/session_live_state.py | 79 +++- .../stores/scheduled_task_store/__init__.py | 73 +++ .../scheduled_task_store/sqlalchemy_store.py | 72 +++ .../test_scheduled_tasks_routes.py | 427 ++++++++++++++++++ tests/server/scheduled/test_run_reconciler.py | 194 ++++++++ tests/server/test_session_live_state.py | 117 +++++ tests/stores/test_scheduled_task_store.py | 234 ++++++++++ 13 files changed, 1451 insertions(+), 7 deletions(-) create mode 100644 omnigent/db/migrations/versions/d4f2a1b6c8e9_add_ix_scheduled_task_runs_conversation_id.py create mode 100644 omnigent/server/scheduled/run_reconciler.py create mode 100644 tests/server/scheduled/test_run_reconciler.py diff --git a/omnigent/db/db_models.py b/omnigent/db/db_models.py index 6f0d8e5ef10..473b5cba532 100644 --- a/omnigent/db/db_models.py +++ b/omnigent/db/db_models.py @@ -1478,4 +1478,12 @@ class SqlScheduledTaskRun(OmnigentBase): "scheduled_at", "id", ), + # Reverse lookup conversation_id -> run for the event-driven completion + # hook (get_running_run_by_conversation), which fires on every turn's + # terminal edge; without this the lookup is a full-table scan. + Index( + "ix_scheduled_task_runs_conversation_id", + "workspace_id", + "conversation_id", + ), ) diff --git a/omnigent/db/migrations/versions/d4f2a1b6c8e9_add_ix_scheduled_task_runs_conversation_id.py b/omnigent/db/migrations/versions/d4f2a1b6c8e9_add_ix_scheduled_task_runs_conversation_id.py new file mode 100644 index 00000000000..43722be5638 --- /dev/null +++ b/omnigent/db/migrations/versions/d4f2a1b6c8e9_add_ix_scheduled_task_runs_conversation_id.py @@ -0,0 +1,44 @@ +"""Add the ``ix_scheduled_task_runs_conversation_id`` index. + +Revision ID: d4f2a1b6c8e9 +Revises: 72e6dceae14f +Create Date: 2026-07-21 00:00:00.000000 + +The event-driven run-completion hook transitions a scheduled-task run the +instant its conversation's turn reaches a terminal state. To find the run it +reverse-looks-up by ``conversation_id`` (``get_running_run_by_conversation``) +on every turn-terminal edge — for interactive sessions too, not just scheduled +ones. Without an index that is a full scan of ``scheduled_task_runs``. Index +``(workspace_id, conversation_id)`` to make the lookup a selective point read. + +Creating an index is a simple operation on SQLite, PostgreSQL, and MySQL +alike, so no table rebuild / batch mode is needed. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "d4f2a1b6c8e9" +down_revision: str | None = "72e6dceae14f" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add the ``ix_scheduled_task_runs_conversation_id`` index.""" + op.create_index( + "ix_scheduled_task_runs_conversation_id", + "scheduled_task_runs", + ["workspace_id", "conversation_id"], + ) + + +def downgrade() -> None: + """Drop the ``ix_scheduled_task_runs_conversation_id`` index.""" + op.drop_index( + "ix_scheduled_task_runs_conversation_id", + table_name="scheduled_task_runs", + ) diff --git a/omnigent/server/app.py b/omnigent/server/app.py index bc13b0efb5d..023dac51170 100644 --- a/omnigent/server/app.py +++ b/omnigent/server/app.py @@ -1424,9 +1424,19 @@ async def _lifespan( exc, ) + # Run completion is event-driven (persist_scheduled_run_completion + # fires from _publish_status the instant a fired conversation's turn + # ends — no poll). The only orphan backstop is a lazy-on-read + # force-fail of stale ``running`` runs on the scheduled-task read + # endpoints (see routes/scheduled_tasks.py); there is no startup + # sweep and no periodic reconcile. + try: yield finally: + # Run completion is event-driven (the _publish_status hook) plus a + # lazy-on-read stale backstop — there is no run-reconciler task to + # cancel. Only the per-job scheduler holds timers that need stopping. if scheduled_task_scheduler is not None: scheduled_task_scheduler.stop() metrics_publish_task.cancel() @@ -1556,8 +1566,11 @@ def _resolve_public_sharing() -> bool: set_server_runner_router(runner_router) # Mirror per-session live state (turn status, pending-approval count, # runner liveness) onto the conversations row so replicas that don't - # hold a session's runner tunnel serve the same sidebar fields. - session_live_state.configure(conversation_store) + # hold a session's runner tunnel serve the same sidebar fields. The + # scheduled-task store additionally enables the event-driven + # run-completion hook (persist_scheduled_run_completion) fired from + # _publish_status when a fired conversation's turn reaches terminal. + session_live_state.configure(conversation_store, scheduled_task_store) pending_elicitations.set_count_persist_hook(session_live_state.persist_pending_count) @app.middleware("http") diff --git a/omnigent/server/routes/scheduled_tasks.py b/omnigent/server/routes/scheduled_tasks.py index 677821c40bb..8e6e7749407 100644 --- a/omnigent/server/routes/scheduled_tasks.py +++ b/omnigent/server/routes/scheduled_tasks.py @@ -22,7 +22,7 @@ from fastapi import APIRouter, Request from pydantic import BaseModel, ConfigDict, Field, model_validator -from omnigent.entities import ScheduledTask +from omnigent.entities import ScheduledTask, ScheduledTaskRun from omnigent.errors import ErrorCode, OmnigentError from omnigent.server.auth import RESERVED_USER_LOCAL, AuthProvider from omnigent.server.routes._auth_helpers import require_user @@ -33,6 +33,7 @@ validate_session_model_metadata, ) from omnigent.server.scheduled.rrule import RRuleValidationError, validate_rrule +from omnigent.server.scheduled.run_reconciler import force_fail_stale_runs from omnigent.stores import AgentStore, ConversationStore, PermissionStore from omnigent.stores.scheduled_task_store import ScheduledTaskStore @@ -113,6 +114,24 @@ def _to_response(task: ScheduledTask) -> dict[str, Any]: } +def _run_to_response(run: ScheduledTaskRun) -> dict[str, Any]: + """Serialize a :class:`ScheduledTaskRun` to a JSON-safe dict. + + Excludes the free-text ``error`` blob (never SQL-queried, potentially + large); ``error_code`` carries the queryable failure classification. + """ + return { + "id": run.id, + "scheduled_task_id": run.scheduled_task_id, + "status": run.status, + "scheduled_at": run.scheduled_at, + "conversation_id": run.conversation_id, + "fired_at": run.fired_at, + "finished_at": run.finished_at, + "error_code": run.error_code, + } + + def _validate_rrule_or_400(rrule: str) -> None: """Raise a 400 ``OmnigentError`` if the RRULE is invalid.""" try: @@ -284,10 +303,22 @@ async def create_scheduled_task( @router.get("/scheduled-tasks") async def list_scheduled_tasks(request: Request) -> dict[str, list[dict[str, Any]]]: - """List the caller's scheduled tasks.""" + """List the caller's scheduled tasks. + + Lazy-on-read stale backstop: before returning, force-fail any of this + owner's runs still ``running`` past the 6h max age (``incomplete``), so + a future Tasks-list "last-run status" badge never shows a stale orphan + as ``running``. Pure age check — one indexed, owner-scoped query for the + owner's running runs, then a conditional ``update_run``; NO per-run + conversation I/O. Young in-flight runs are untouched, and completion of + a normal run is handled event-driven (the ``_publish_status`` hook), not + here. + """ owner = _owner(request) owner_id = None if owner == RESERVED_USER_LOCAL else owner tasks = [t for t in store.list() if t.user_id == owner_id] + running = store.list_running_runs_for_tasks([t.id for t in tasks]) + force_fail_stale_runs(store, running) return {"scheduled_tasks": [_to_response(t) for t in tasks]} @router.get("/scheduled-tasks/{scheduled_task_id}") @@ -301,6 +332,33 @@ async def get_scheduled_task( task = _require_owned(scheduled_task_id, owner_id) return _to_response(task) + @router.get("/scheduled-tasks/{scheduled_task_id}/runs") + async def list_scheduled_task_runs( + request: Request, + scheduled_task_id: str, + ) -> dict[str, list[dict[str, Any]]]: + """List the run history for one of the caller's scheduled tasks. + + Owner-scoped: a task owned by someone else (or absent) 404s via + ``_require_owned``, so runs aren't enumerable across users. Runs come + back most-recent-first (``scheduled_at DESC``); an empty history is an + empty list. + + Lazy-on-read backstop: before listing, force-fail any of this task's + runs still ``running`` past the 6h max age (``incomplete``). Completion + itself is event-driven (the ``_publish_status`` hook); this only + catches a genuine orphan — a run whose terminal event never fired (host + died mid-turn) — so the "every run eventually terminal" invariant holds + without a background poll or startup sweep. Pure age check (no + conversation I/O); a young in-flight run is untouched, and the + conditional ``update_run`` never clobbers an already-terminal row. + """ + owner = _owner(request) + owner_id = None if owner == RESERVED_USER_LOCAL else owner + _require_owned(scheduled_task_id, owner_id) + runs = force_fail_stale_runs(store, store.list_runs(scheduled_task_id)) + return {"runs": [_run_to_response(r) for r in runs]} + @router.patch("/scheduled-tasks/{scheduled_task_id}") async def update_scheduled_task( request: Request, diff --git a/omnigent/server/routes/sessions.py b/omnigent/server/routes/sessions.py index 44585af243c..35200cce682 100644 --- a/omnigent/server/routes/sessions.py +++ b/omnigent/server/routes/sessions.py @@ -5828,6 +5828,25 @@ def _publish_status( # deduplicated, off-loop) so replicas that don't hold this session's # runner tunnel serve the same sidebar status. session_live_state.persist_live_status(session_id, status) + # Event-driven scheduled-run completion. A terminal edge (idle = the turn + # completed; failed = it errored/disconnected) flips the conversation's + # still-``running`` scheduled_task_run to succeeded/failed. This is the + # primary FU-1 mechanism: the run transitions the instant the turn ends, + # driven by the same terminal event that persists live_status — no poll. + # The event's own ``error`` carries the failure classification, so no label + # re-read is needed (and none of the race that would imply). A no-op for + # the common case: interactive (non-scheduled) conversations have no + # running run, and the reverse lookup cheaply returns None. running/waiting + # edges are skipped entirely so the hot path pays nothing mid-turn. + if status == "idle": + session_live_state.persist_scheduled_run_completion(session_id, "succeeded") + elif status == "failed": + session_live_state.persist_scheduled_run_completion( + session_id, + "failed", + error_code=error.code if error is not None else None, + error=error.message if error is not None else None, + ) # Track the in-flight response id for snapshot-based reconnect (see # _session_active_response_cache). A running/waiting edge that names a # turn opens it; any idle/failed edge closes it. diff --git a/omnigent/server/scheduled/run_reconciler.py b/omnigent/server/scheduled/run_reconciler.py new file mode 100644 index 00000000000..486b73a7928 --- /dev/null +++ b/omnigent/server/scheduled/run_reconciler.py @@ -0,0 +1,112 @@ +"""Scheduled-task run-completion stale backstop (lazy-on-read only). + +The fire path (:mod:`omnigent.server.scheduled.fire`) records a +``scheduled_task_runs`` row as ``running`` and returns immediately, WITHOUT +waiting for the agent turn to finish. + +**The PRIMARY completion mechanism is event-driven** and lives elsewhere: +:func:`omnigent.server.session_live_state.persist_scheduled_run_completion`, +fired from ``_publish_status`` the instant a fired conversation's turn reaches +a terminal edge, flips the run ``running`` → ``succeeded``/``failed``. It rides +the same long-lived SSE relay that already persists the conversation's +``live_status`` for a browserless scheduled fire, so it needs no live client +and no periodic poll. + +This module is the **sole orphan backstop**: a pure age-based force-fail run +on the READ path. If a run is left ``running`` because its terminal event never +fired (host died mid-turn, or a server restart while a fire was in flight), it +stays ``running`` in the DB — harmless until someone looks — and the next read +that surfaces it force-fails it. :func:`force_fail_stale_runs` is called from +both scheduled-task read endpoints (list + detail), so a stale orphan is +reconciled the moment it would otherwise be shown: + +- ``GET /v1/scheduled-tasks/{id}/runs`` — force-fails that task's runs still + ``running`` past :data:`STALE_RUN_MAX_AGE_SECONDS`. +- ``GET /v1/scheduled-tasks`` — force-fails the owner's tasks' stale ``running`` + runs, so a future Tasks-list "last-run status" badge never shows a stale + orphan as ``running``. + +This is a pure age check — NO conversation I/O on the read path. The idempotent, +conditional :meth:`update_run` (``WHERE status = running``) means a run already +terminal (via the event hook, a fire-time ``skipped``/``failed``, or a prior +read) is never clobbered. There is deliberately NO startup sweep and NO periodic +poll of any cadence: the event hook handles every normal run, and lazy-on-read +reconciles anything a user actually views. +""" + +from __future__ import annotations + +import logging +import time +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from omnigent.entities import ScheduledTaskRun + from omnigent.stores.scheduled_task_store import ScheduledTaskStore + +_logger = logging.getLogger(__name__) + +# A run still ``running`` longer than this is force-failed with +# ``error_code = "incomplete"`` (host died mid-turn, runner never reported +# completion). Deliberately generous (6h) so a legitimately long agent turn is +# never killed; a stuck-``running`` row is a far milder bug than a +# falsely-``failed`` one. +STALE_RUN_MAX_AGE_SECONDS: int = 6 * 60 * 60 + +# error_code recorded on the stale-run force-fail path. +STALE_RUN_ERROR_CODE: str = "incomplete" + + +def force_fail_stale_runs( + store: ScheduledTaskStore, + runs: list[ScheduledTaskRun], + *, + now: int | None = None, +) -> list[ScheduledTaskRun]: + """Force-fail ``running`` runs older than the max age; return the list. + + The lazy-on-read orphan backstop, shared by the scheduled-task list and + detail read endpoints. Pure age check — NO conversation I/O. The age is + measured from ``fired_at`` (when dispatch actually began), falling back to + ``scheduled_at`` when a run has no ``fired_at`` (never dispatched). Measuring + from ``fired_at`` means a run that fired late doesn't get a shortened + effective window — the 6h clock starts when the turn actually started, not + when it was scheduled. Only rows past :data:`STALE_RUN_MAX_AGE_SECONDS` are + touched; the store's conditional :meth:`update_run` (``WHERE status = + running``) makes it idempotent and safe against a run that just transitioned + via the event hook. Must be called inside the runs' ``workspace_scope`` (the + store filters every query on ``current_workspace_id()``); the read endpoints + already run there. + + The returned list reflects any transition (a force-failed run carries its + new terminal state) so a caller rendering the runs stays consistent with the + write; a caller that only needs the side effect can ignore the return. + + :param store: The scheduled-task store to transition runs through. + :param runs: Candidate runs (typically a task's history, or an owner's + running runs). + :param now: Unix epoch seconds to age against; defaults to ``time.time()``. + :returns: ``runs`` with any stale ``running`` row replaced by its terminal + form. + """ + ts = int(time.time()) if now is None else now + result: list[ScheduledTaskRun] = [] + for run in runs: + # Age from when dispatch began (fired_at); fall back to scheduled_at for + # a run that somehow never recorded a fire time. + age_from = run.fired_at if run.fired_at is not None else run.scheduled_at + if run.status == "running" and (ts - age_from) >= STALE_RUN_MAX_AGE_SECONDS: + updated = store.update_run( + run.id, + status="failed", + finished_at=ts, + error=( + "scheduled run did not reach a terminal state within " + f"{STALE_RUN_MAX_AGE_SECONDS}s" + ), + error_code=STALE_RUN_ERROR_CODE, + ) + result.append(updated if updated is not None else run) + else: + result.append(run) + return result diff --git a/omnigent/server/session_live_state.py b/omnigent/server/session_live_state.py index 60956d9c84b..4ab81fdc38e 100644 --- a/omnigent/server/session_live_state.py +++ b/omnigent/server/session_live_state.py @@ -47,6 +47,7 @@ if TYPE_CHECKING: from omnigent.stores import ConversationStore + from omnigent.stores.scheduled_task_store import ScheduledTaskStore _logger = logging.getLogger(__name__) @@ -58,6 +59,10 @@ _KNOWN_LIVE_STATUSES: frozenset[str] = frozenset(SESSION_LIVE_STATUS) _store: ConversationStore | None = None +# Scheduled-task store for the event-driven run-completion hook. Wired +# alongside ``_store`` by :func:`configure`; ``None`` disables the hook (the +# runner process and unit tests that never configure it are unaffected). +_scheduled_task_store: ScheduledTaskStore | None = None # Single worker => writes apply in submission order (see module docstring). _executor: ThreadPoolExecutor | None = None # Last status seen per session, for dedupe — the value whose write was @@ -69,15 +74,22 @@ _last_pending: dict[str, int] = {} -def configure(store: ConversationStore | None) -> None: +def configure( + store: ConversationStore | None, + scheduled_task_store: ScheduledTaskStore | None = None, +) -> None: """ - Wire (or clear) the conversation store live-state writes go to. + Wire (or clear) the stores live-state writes go to. :param store: The server's conversation store, or ``None`` to disable persistence (tests / non-server processes). + :param scheduled_task_store: The server's scheduled-task store, enabling + the event-driven run-completion hook + (:func:`persist_scheduled_run_completion`); ``None`` disables it. """ - global _store + global _store, _scheduled_task_store _store = store + _scheduled_task_store = scheduled_task_store _last_status.clear() _last_pending.clear() @@ -164,6 +176,67 @@ def _evict() -> None: _submit("live_status", _store.set_session_live_status, session_id, status, on_failure=_evict) +def persist_scheduled_run_completion( + conversation_id: str, + run_status: str, + *, + error_code: str | None = None, + error: str | None = None, +) -> None: + """Transition a scheduled-task run to terminal when its turn ends. + + The event-driven completion mechanism: called from ``_publish_status`` + wherever a session reaches a durable terminal edge (``idle`` = the turn + completed, ``failed`` = it errored/disconnected). Most conversations are + not scheduled-task fires, so the reverse lookup returns ``None`` and this + is a cheap no-op; only a fired conversation with a still-``running`` run + gets transitioned. + + Runs on the SAME ordered single-worker executor as + :func:`persist_live_status`, inside a copy of the caller's ``contextvars`` + (see :func:`_submit`). This is load-bearing: the store filters every query + on ``current_workspace_id()``, and the reverse lookup + ``update_run`` must + resolve to the fired run's workspace — the relay call site's + ``workspace_scope`` reaches the worker thread exactly as it does for the + ``live_status`` mirror. A bare executor would run at workspace 0 and match + no rows on a multi-tenant replica. + + Idempotent by construction: ``update_run`` is conditional on + ``WHERE status = running``, so a run already terminal (a fire-time + ``skipped``/``failed``, or the startup/lazy backstop) is never clobbered + and a terminal edge seen twice transitions at most once. Best-effort like + the other writes here — a failure logs and is dropped; the backstop + (startup sweep / lazy-on-read) is the durability guarantee for the rare + dropped-write or restart-in-flight case. + + :param conversation_id: The fired conversation whose turn just ended. + :param run_status: Terminal run status to set — ``"succeeded"`` (turn + completed) or ``"failed"`` (turn errored/cancelled/disconnected). + :param error_code: Short failure classification when ``run_status`` is + ``"failed"`` (e.g. the conversation's ``last_task_error_code``). + :param error: Optional human-readable failure detail for ``"failed"``. + """ + store = _scheduled_task_store + if store is None: + return + + def _transition() -> None: + run = store.get_running_run_by_conversation(conversation_id) + if run is None: + # Not a scheduled fire, or its run is already terminal — nothing to + # do. This is the common case (interactive sessions). + return + store.update_run( + run.id, + status=run_status, + finished_at=int(time.time()), + error=error, + error_code=error_code, + ) + + _submit("scheduled_run_completion", _transition) + + def persist_pending_count(conversation_id: str, count: int) -> None: """ Persist an outstanding-elicitation count change. diff --git a/omnigent/stores/scheduled_task_store/__init__.py b/omnigent/stores/scheduled_task_store/__init__.py index 1cf2f08af1a..6f946e92860 100644 --- a/omnigent/stores/scheduled_task_store/__init__.py +++ b/omnigent/stores/scheduled_task_store/__init__.py @@ -208,3 +208,76 @@ def list_runs(self, scheduled_task_id: str) -> list[ScheduledTaskRun]: :returns: List of :class:`ScheduledTaskRun` instances. """ ... + + @abstractmethod + def update_run( + self, + run_id: str, + *, + status: str, + finished_at: int, + error: str | None = None, + error_code: str | None = None, + ) -> ScheduledTaskRun | None: + """ + Transition a still-``running`` run to a terminal status. + + Idempotent and conditional: the update only applies to a run whose + current status is ``running`` (guarded by ``WHERE status = running``), + so a run already advanced to a terminal state (a fire-time + ``skipped``/``failed``, or a prior reconciliation) is never clobbered + and two concurrent sweeps cannot double-transition it. + + :param run_id: The run to transition. + :param status: The terminal status to set — ``succeeded`` or + ``failed``. + :param finished_at: Unix epoch seconds the run reached the terminal + state. + :param error: Optional failure detail (only for ``failed``). + :param error_code: Optional short failure classification (only for + ``failed``), e.g. ``"incomplete"``. + :returns: The updated :class:`ScheduledTaskRun` if a ``running`` run + was transitioned; ``None`` if no matching ``running`` run existed + (not found, or already terminal). + """ + ... + + @abstractmethod + def get_running_run_by_conversation(self, conversation_id: str) -> ScheduledTaskRun | None: + """ + Return the ``running`` run for a conversation, or ``None``. + + The event-driven completion hook (fired when a conversation's turn + reaches a terminal state) uses this reverse lookup to find the + scheduled-task run to transition. Workspace-scoped like every other + store read (filters on ``current_workspace_id()``), so the caller must + run it inside the run's ``workspace_scope``. Backed by the + ``ix_scheduled_task_runs_conversation_id`` index on + ``(workspace_id, conversation_id)``. + + A conversation maps to at most one ``running`` run (a fire creates one + run per conversation), so this returns a single row rather than a list. + + :param conversation_id: The fired conversation to look up. + :returns: The matching ``running`` :class:`ScheduledTaskRun`, or + ``None`` if the conversation has no run, or its run is already + terminal. + """ + ... + + @abstractmethod + def list_running_runs_for_tasks(self, scheduled_task_ids: list[str]) -> list[ScheduledTaskRun]: + """ + List ``running`` runs for the given tasks in the current workspace. + + Powers the lazy-on-read stale backstop on the scheduled-task LIST + endpoint: the route resolves the owner's tasks, then this returns their + still-``running`` runs so the route can force-fail the ones past the max + age. Workspace-scoped (filters on ``current_workspace_id()``) like every + other read; an empty id list returns an empty list. + + :param scheduled_task_ids: Task ids (already owner-scoped by the caller). + :returns: ``running`` :class:`ScheduledTaskRun` instances for those + tasks, ordered ``scheduled_at DESC, id DESC``. + """ + ... diff --git a/omnigent/stores/scheduled_task_store/sqlalchemy_store.py b/omnigent/stores/scheduled_task_store/sqlalchemy_store.py index c4c0a97d158..febae6e7f56 100644 --- a/omnigent/stores/scheduled_task_store/sqlalchemy_store.py +++ b/omnigent/stores/scheduled_task_store/sqlalchemy_store.py @@ -325,3 +325,75 @@ def list_runs(self, scheduled_task_id: str) -> list[ScheduledTaskRun]: ) rows = session.execute(stmt).scalars().all() return [_run_to_entity(r) for r in rows] + + def update_run( + self, + run_id: str, + *, + status: str, + finished_at: int, + error: str | None = None, + error_code: str | None = None, + ) -> ScheduledTaskRun | None: + """Transition a still-``running`` run to a terminal status. + + Conditional on the current status being ``running`` so an + already-terminal run is never clobbered and concurrent sweeps cannot + double-transition (see the interface docstring). + """ + running_code = encode_scheduled_task_run_status("running") + with self._session() as session: + row = session.get(SqlScheduledTaskRun, (current_workspace_id(), run_id)) + if row is None or row.status != running_code: + return None + row.status = encode_scheduled_task_run_status(status) + row.finished_at = finished_at + row.error = error + row.error_code = error_code + session.flush() + return _run_to_entity(row) + + def get_running_run_by_conversation(self, conversation_id: str) -> ScheduledTaskRun | None: + """Return the ``running`` run for a conversation, or ``None``. + + Workspace-scoped reverse lookup for the event-driven completion hook; + backed by ``ix_scheduled_task_runs_conversation_id``. A conversation has + at most one ``running`` run, so ``.first()`` is exact rather than lossy. + """ + running_code = encode_scheduled_task_run_status("running") + with self._session() as session: + stmt = ( + select(SqlScheduledTaskRun) + .where(SqlScheduledTaskRun.workspace_id == current_workspace_id()) + .where(SqlScheduledTaskRun.conversation_id == conversation_id) + .where(SqlScheduledTaskRun.status == running_code) + ) + row = session.execute(stmt).scalars().first() + return _run_to_entity(row) if row is not None else None + + def list_running_runs_for_tasks(self, scheduled_task_ids: list[str]) -> list[ScheduledTaskRun]: + """List ``running`` runs for the given tasks in the current workspace. + + Powers the lazy-on-read stale backstop on the scheduled-task LIST + endpoint: the route resolves the owner's tasks, then this returns their + still-``running`` runs (one indexed, workspace-scoped query over the + ``scheduled_task_id`` index) so the route can force-fail the stale ones. + An empty ``scheduled_task_ids`` returns an empty list without a query. + + :param scheduled_task_ids: Task ids (already owner-scoped by the caller). + :returns: ``running`` runs for those tasks, ordered + ``scheduled_at DESC, id DESC``. + """ + if not scheduled_task_ids: + return [] + running_code = encode_scheduled_task_run_status("running") + with self._session() as session: + stmt = ( + select(SqlScheduledTaskRun) + .where(SqlScheduledTaskRun.workspace_id == current_workspace_id()) + .where(SqlScheduledTaskRun.scheduled_task_id.in_(scheduled_task_ids)) + .where(SqlScheduledTaskRun.status == running_code) + .order_by(desc(SqlScheduledTaskRun.scheduled_at), desc(SqlScheduledTaskRun.id)) + ) + rows = session.execute(stmt).scalars().all() + return [_run_to_entity(r) for r in rows] diff --git a/tests/server/integration/test_scheduled_tasks_routes.py b/tests/server/integration/test_scheduled_tasks_routes.py index 52e9a981799..1b543a98d55 100644 --- a/tests/server/integration/test_scheduled_tasks_routes.py +++ b/tests/server/integration/test_scheduled_tasks_routes.py @@ -502,3 +502,430 @@ async def test_scheduler_synced_on_create_and_delete( await auth_client.delete(f"/v1/scheduled-tasks/{created['id']}", headers=_headers()) assert scheduler.job_count == before + + +# ── GET /v1/scheduled-tasks/{id}/runs (run history) ────────────────────────── + + +def _seed_run(db_uri: str, task_id: str, run_id: str, **overrides: object) -> None: + """Seed a run row directly (the sweep/fire path writes these in prod). + + Tests run at the default workspace (no tenant middleware), matching the + route's read scope. + """ + from omnigent.stores.scheduled_task_store.sqlalchemy_store import ( + SqlAlchemyScheduledTaskStore, + ) + + store = SqlAlchemyScheduledTaskStore(db_uri) + kwargs: dict[str, object] = { + "run_id": run_id, + "scheduled_task_id": task_id, + "status": "succeeded", + "scheduled_at": 1000, + "conversation_id": "conv_seed", + "fired_at": 1001, + "finished_at": 1002, + } + kwargs.update(overrides) + store.create_run(**kwargs) # type: ignore[arg-type] + + +async def test_list_runs_returns_history_for_owned_task( + auth_client: httpx.AsyncClient, db_uri: str +) -> None: + """An owned task's run history comes back most-recent-first with run fields.""" + _make_user(db_uri) + created = ( + await auth_client.post("/v1/scheduled-tasks", json=_create_body(), headers=_headers()) + ).json() + task_id = created["id"] + + import uuid + + older_id = uuid.uuid4().hex + newer_id = uuid.uuid4().hex + conv_id = uuid.uuid4().hex + _seed_run( + db_uri, task_id, older_id, scheduled_at=1000, status="succeeded", conversation_id=conv_id + ) + _seed_run( + db_uri, + task_id, + newer_id, + scheduled_at=2000, + status="failed", + error_code="incomplete", + finished_at=2002, + conversation_id=conv_id, + ) + + resp = await auth_client.get(f"/v1/scheduled-tasks/{task_id}/runs", headers=_headers()) + assert resp.status_code == 200, resp.text + runs = resp.json()["runs"] + assert [r["id"] for r in runs] == [newer_id, older_id] # scheduled_at DESC + newest = runs[0] + assert newest["status"] == "failed" + assert newest["error_code"] == "incomplete" + assert newest["finished_at"] == 2002 + assert newest["conversation_id"] == conv_id + assert newest["scheduled_task_id"] == task_id + # The free-text error blob is not exposed on the run list. + assert "error" not in newest + + +async def test_list_runs_empty_for_task_with_no_runs( + auth_client: httpx.AsyncClient, db_uri: str +) -> None: + """A task that has never fired returns an empty run list (not a 404).""" + _make_user(db_uri) + created = ( + await auth_client.post("/v1/scheduled-tasks", json=_create_body(), headers=_headers()) + ).json() + resp = await auth_client.get(f"/v1/scheduled-tasks/{created['id']}/runs", headers=_headers()) + assert resp.status_code == 200, resp.text + assert resp.json()["runs"] == [] + + +async def test_list_runs_404_for_nonexistent_task( + auth_client: httpx.AsyncClient, db_uri: str +) -> None: + """Runs for an unknown task id 404 (owner-scoped, not enumerable).""" + _make_user(db_uri) + resp = await auth_client.get( + "/v1/scheduled-tasks/ffffffffffffffffffffffffffffffff/runs", headers=_headers() + ) + assert resp.status_code == 404, resp.text + + +async def test_list_runs_404_for_nonowned_task( + auth_client: httpx.AsyncClient, db_uri: str +) -> None: + """A task owned by another user 404s its runs (no cross-user enumeration).""" + _make_user(db_uri, "alice@example.com") + _make_user(db_uri, "bob@example.com") + created = ( + await auth_client.post( + "/v1/scheduled-tasks", json=_create_body(), headers=_headers("alice@example.com") + ) + ).json() + # Bob asks for Alice's task runs → 404. + resp = await auth_client.get( + f"/v1/scheduled-tasks/{created['id']}/runs", headers=_headers("bob@example.com") + ) + assert resp.status_code == 404, resp.text + + +# ── lazy-on-read orphan backstop ───────────────────────────────────────────── + + +async def test_list_runs_force_fails_stale_running_run( + auth_client: httpx.AsyncClient, db_uri: str +) -> None: + """Reading history force-fails a run left ``running`` past the 6h max age. + + The lazy-on-read backstop for a genuine orphan (terminal event never + fired). ``scheduled_at`` is set well beyond the max age, so the read + transitions it to ``failed(incomplete)`` with ``finished_at`` stamped. + """ + import time + import uuid + + from omnigent.server.scheduled.run_reconciler import STALE_RUN_MAX_AGE_SECONDS + + _make_user(db_uri) + created = ( + await auth_client.post("/v1/scheduled-tasks", json=_create_body(), headers=_headers()) + ).json() + task_id = created["id"] + run_id = uuid.uuid4().hex + stale_scheduled_at = int(time.time()) - STALE_RUN_MAX_AGE_SECONDS - 60 + _seed_run( + db_uri, + task_id, + run_id, + status="running", + scheduled_at=stale_scheduled_at, + fired_at=stale_scheduled_at + 1, + finished_at=None, + conversation_id=uuid.uuid4().hex, + ) + + resp = await auth_client.get(f"/v1/scheduled-tasks/{task_id}/runs", headers=_headers()) + assert resp.status_code == 200, resp.text + run = resp.json()["runs"][0] + assert run["status"] == "failed" + assert run["error_code"] == "incomplete" + assert run["finished_at"] is not None + + +async def test_list_runs_leaves_young_running_run_untouched( + auth_client: httpx.AsyncClient, db_uri: str +) -> None: + """A recently-fired ``running`` run is NOT force-failed on read. + + Only runs past the max age are reaped; a young in-flight run is left for + the event hook to complete normally. + """ + import time + import uuid + + _make_user(db_uri) + created = ( + await auth_client.post("/v1/scheduled-tasks", json=_create_body(), headers=_headers()) + ).json() + task_id = created["id"] + run_id = uuid.uuid4().hex + recent = int(time.time()) - 30 # 30s ago, well within the max age + _seed_run( + db_uri, + task_id, + run_id, + status="running", + scheduled_at=recent, + fired_at=recent + 1, + finished_at=None, + conversation_id=uuid.uuid4().hex, + ) + + resp = await auth_client.get(f"/v1/scheduled-tasks/{task_id}/runs", headers=_headers()) + assert resp.status_code == 200, resp.text + run = resp.json()["runs"][0] + assert run["status"] == "running" + assert run["finished_at"] is None + + +async def test_list_tasks_force_fails_stale_running_run( + auth_client: httpx.AsyncClient, db_uri: str +) -> None: + """The LIST endpoint also force-fails a stale ``running`` run (no conv read). + + Reading GET /v1/scheduled-tasks reaps the owner's stale orphans so a + Tasks-list badge never shows one as ``running``. Verified via the detail + endpoint afterward (the list response itself carries no run rows yet). + """ + import time + import uuid + + from omnigent.server.scheduled.run_reconciler import STALE_RUN_MAX_AGE_SECONDS + + _make_user(db_uri) + created = ( + await auth_client.post("/v1/scheduled-tasks", json=_create_body(), headers=_headers()) + ).json() + task_id = created["id"] + run_id = uuid.uuid4().hex + stale_at = int(time.time()) - STALE_RUN_MAX_AGE_SECONDS - 60 + _seed_run( + db_uri, + task_id, + run_id, + status="running", + scheduled_at=stale_at, + fired_at=stale_at + 1, + finished_at=None, + conversation_id=uuid.uuid4().hex, + ) + + # Hitting the LIST endpoint triggers the lazy stale backstop. + list_resp = await auth_client.get("/v1/scheduled-tasks", headers=_headers()) + assert list_resp.status_code == 200, list_resp.text + + # The run was force-failed as a side effect of the list read. + detail = await auth_client.get(f"/v1/scheduled-tasks/{task_id}/runs", headers=_headers()) + run = detail.json()["runs"][0] + assert run["status"] == "failed" + assert run["error_code"] == "incomplete" + assert run["finished_at"] is not None + + +async def test_list_tasks_leaves_young_running_run_untouched( + auth_client: httpx.AsyncClient, db_uri: str +) -> None: + """The LIST endpoint does NOT reap a young in-flight run.""" + import time + import uuid + + _make_user(db_uri) + created = ( + await auth_client.post("/v1/scheduled-tasks", json=_create_body(), headers=_headers()) + ).json() + task_id = created["id"] + run_id = uuid.uuid4().hex + recent = int(time.time()) - 30 + _seed_run( + db_uri, + task_id, + run_id, + status="running", + scheduled_at=recent, + fired_at=recent + 1, + finished_at=None, + conversation_id=uuid.uuid4().hex, + ) + + list_resp = await auth_client.get("/v1/scheduled-tasks", headers=_headers()) + assert list_resp.status_code == 200, list_resp.text + + detail = await auth_client.get(f"/v1/scheduled-tasks/{task_id}/runs", headers=_headers()) + run = detail.json()["runs"][0] + assert run["status"] == "running" + assert run["finished_at"] is None + + +# ── event-hook wiring: _publish_status -> persist_scheduled_run_completion ──── +# +# These lock the PRIMARY completion mechanism at the _publish_status seam (not +# by re-calling the hook directly): a real terminal edge published the way the +# SSE relay publishes it must reach the hook and transition the run, resolving +# under the run's workspace_scope via the shared session_live_state executor. +# Layer exercised: the sync _publish_status(...) call (its no-subscriber +# session_stream.publish is a no-op) → session_live_state.persist_scheduled_run_ +# completion → the ThreadPoolExecutor(max_workers=1) worker → store.update_run. +# A full runner/relay round-trip is covered by the live E2E; this covers the +# server-side wiring so a future _publish_status refactor can't silently break +# scheduled-run completion. + + +def _seed_running_run_for_conv(db_uri: str, conversation_id: str) -> tuple[str, str]: + """Create a task + a ``running`` run bound to ``conversation_id``. + + :returns: ``(task_id, run_id)``. + """ + import uuid + + from omnigent.stores.scheduled_task_store.sqlalchemy_store import ( + SqlAlchemyScheduledTaskStore, + ) + + store = SqlAlchemyScheduledTaskStore(db_uri) + task_id = uuid.uuid4().hex + store.create( + scheduled_task_id=task_id, + name="hook-wiring", + prompt="p", + rrule="FREQ=HOURLY;BYMINUTE=0", + user_id=None, + agent_id=uuid.uuid4().hex, + timezone="UTC", + ) + run_id = uuid.uuid4().hex + store.create_run( + run_id=run_id, + scheduled_task_id=task_id, + status="running", + scheduled_at=1000, + conversation_id=conversation_id, + fired_at=1001, + ) + return task_id, run_id + + +def _wait_for_run_status( + db_uri: str, task_id: str, run_id: str, want: str, timeout_s: float = 10.0 +): # type: ignore[no-untyped-def] + """Poll the store until ``run_id`` reaches ``want`` (or timeout). + + The hook write lands on session_live_state's background single-worker + executor, so the assertion must wait for that thread rather than read + synchronously. + """ + import time + + from omnigent.stores.scheduled_task_store.sqlalchemy_store import ( + SqlAlchemyScheduledTaskStore, + ) + + store = SqlAlchemyScheduledTaskStore(db_uri) + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + for r in store.list_runs(task_id): + if r.id == run_id and r.status == want: + return r + time.sleep(0.02) + # Return the current row (whatever status) so the caller's assert reports it. + for r in store.list_runs(task_id): + if r.id == run_id: + return r + return None + + +async def test_publish_status_idle_edge_transitions_scheduled_run_to_succeeded( + db_uri: str, +) -> None: + """A completed-turn edge through _publish_status flips the run to succeeded. + + Drives the real _publish_status(conversation_id, "idle") the relay emits and + asserts the run transitions running -> succeeded with finished_at set, via + the hook + executor path (workspace_scope contract exercised, not bypassed). + + Async to satisfy the module's ``pytestmark = pytest.mark.asyncio``; the body + is synchronous (the hook write lands on session_live_state's background + executor, polled below) — no awaits needed. + """ + import uuid + + from omnigent.server import session_live_state + from omnigent.server.routes.sessions import _publish_status, _session_status_cache + from omnigent.stores.conversation_store.sqlalchemy_store import SqlAlchemyConversationStore + from omnigent.stores.scheduled_task_store.sqlalchemy_store import ( + SqlAlchemyScheduledTaskStore, + ) + + conv_id = uuid.uuid4().hex + task_id, run_id = _seed_running_run_for_conv(db_uri, conv_id) + + session_live_state.configure( + SqlAlchemyConversationStore(db_uri), SqlAlchemyScheduledTaskStore(db_uri) + ) + try: + # The relay publishes "running" as the turn starts, then "idle" at the + # terminal (completed) edge. Drive the terminal edge. + _publish_status(conv_id, "idle") + row = _wait_for_run_status(db_uri, task_id, run_id, "succeeded") + finally: + session_live_state.configure(None) + _session_status_cache.pop(conv_id, None) + + assert row is not None + assert row.status == "succeeded" + assert row.finished_at is not None + assert row.error_code is None + + +async def test_publish_status_failed_edge_transitions_scheduled_run_to_failed( + db_uri: str, +) -> None: + """A failed-turn edge through _publish_status flips the run to failed+code. + + Async for the module ``pytestmark`` (see the idle-edge test); body is sync. + """ + import uuid + + from omnigent.server import session_live_state + from omnigent.server.routes.sessions import _publish_status, _session_status_cache + from omnigent.server.schemas import ErrorDetail + from omnigent.stores.conversation_store.sqlalchemy_store import SqlAlchemyConversationStore + from omnigent.stores.scheduled_task_store.sqlalchemy_store import ( + SqlAlchemyScheduledTaskStore, + ) + + conv_id = uuid.uuid4().hex + task_id, run_id = _seed_running_run_for_conv(db_uri, conv_id) + + session_live_state.configure( + SqlAlchemyConversationStore(db_uri), SqlAlchemyScheduledTaskStore(db_uri) + ) + try: + _publish_status( + conv_id, "failed", ErrorDetail(code="runner_disconnected", message="dropped") + ) + row = _wait_for_run_status(db_uri, task_id, run_id, "failed") + finally: + session_live_state.configure(None) + _session_status_cache.pop(conv_id, None) + + assert row is not None + assert row.status == "failed" + assert row.finished_at is not None + assert row.error_code == "runner_disconnected" diff --git a/tests/server/scheduled/test_run_reconciler.py b/tests/server/scheduled/test_run_reconciler.py new file mode 100644 index 00000000000..af3901f224a --- /dev/null +++ b/tests/server/scheduled/test_run_reconciler.py @@ -0,0 +1,194 @@ +"""Tests for the scheduled-task stale-run backstop (lazy-on-read). + +Exercises ``force_fail_stale_runs`` — the pure age-based orphan backstop the +scheduled-task read endpoints call. Completion of a normal run is event-driven +(``session_live_state.persist_scheduled_run_completion``, covered in +``tests/server/test_session_live_state.py``); there is no startup sweep and no +periodic reconcile, so this module only covers the stale force-fail policy. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from omnigent.server.scheduled.run_reconciler import ( + STALE_RUN_ERROR_CODE, + STALE_RUN_MAX_AGE_SECONDS, + force_fail_stale_runs, +) + + +@dataclass +class _RunRow: + """Mutable run row for the fake scheduled-task store.""" + + id: str + scheduled_task_id: str + status: str + scheduled_at: int + conversation_id: str | None = None + fired_at: int | None = None + finished_at: int | None = None + error: str | None = None + error_code: str | None = None + + +class _FakeScheduledTaskStore: + """Fake store exposing only ``update_run`` (what the backstop calls).""" + + def __init__(self, runs: list[_RunRow]) -> None: + self._runs = {r.id: r for r in runs} + self.update_calls: list[str] = [] + + def update_run( + self, + run_id: str, + *, + status: str, + finished_at: int, + error: str | None = None, + error_code: str | None = None, + ) -> _RunRow | None: + self.update_calls.append(run_id) + row = self._runs.get(run_id) + if row is None or row.status != "running": + return None # conditional WHERE status = running + row.status = status + row.finished_at = finished_at + row.error = error + row.error_code = error_code + return row + + +def _run( + seed: str, + *, + status: str = "running", + scheduled_at: int = 100, + fired_at: int | None = None, +) -> _RunRow: + return _RunRow( + id=f"run_{seed}", + scheduled_task_id=f"task_{seed}", + status=status, + scheduled_at=scheduled_at, + conversation_id=f"conv_{seed}", + fired_at=fired_at if fired_at is not None else scheduled_at + 1, + ) + + +def test_force_fails_stale_running_run() -> None: + """A run past the max age flips to failed(incomplete) with finished_at.""" + now = 10_000_000 + run = _run("stale", scheduled_at=now - STALE_RUN_MAX_AGE_SECONDS - 1) + store = _FakeScheduledTaskStore([run]) + out = force_fail_stale_runs(store, [run], now=now) + assert run.status == "failed" + assert run.error_code == STALE_RUN_ERROR_CODE + assert run.finished_at == now + # The returned list reflects the transition. + assert out[0].status == "failed" + + +def test_leaves_young_running_run_untouched() -> None: + """A recently-fired running run is not touched (no update attempted).""" + now = 10_000_000 + run = _run("young", scheduled_at=now - 30) # 30s old + store = _FakeScheduledTaskStore([run]) + out = force_fail_stale_runs(store, [run], now=now) + assert run.status == "running" + assert run.finished_at is None + assert store.update_calls == [] # never even attempted an update + assert out[0].status == "running" + + +def test_age_measured_from_fired_at_force_fails() -> None: + """Age is measured from fired_at: a run fired >6h ago is force-failed.""" + now = 10_000_000 + # Scheduled recently but fired_at is >6h ago (e.g. a clock/late-record edge): + # fired_at is what counts, so this IS stale. + run = _run("firedstale", scheduled_at=now - 60, fired_at=now - STALE_RUN_MAX_AGE_SECONDS - 1) + store = _FakeScheduledTaskStore([run]) + force_fail_stale_runs(store, [run], now=now) + assert run.status == "failed" + assert run.error_code == STALE_RUN_ERROR_CODE + assert run.finished_at == now + + +def test_scheduled_long_ago_but_fired_recently_left_alone() -> None: + """A run scheduled >6h ago but fired recently is NOT force-failed. + + The behavior change: the 6h window measures from fired_at (when dispatch + began), so a run that fired late still gets its full window and is not + prematurely killed. + """ + now = 10_000_000 + run = _run( + "firedlate", + scheduled_at=now - STALE_RUN_MAX_AGE_SECONDS - 3600, # scheduled 7h+ ago + fired_at=now - 60, # but only fired 60s ago + ) + store = _FakeScheduledTaskStore([run]) + force_fail_stale_runs(store, [run], now=now) + assert run.status == "running" + assert run.finished_at is None + assert store.update_calls == [] + + +def test_age_falls_back_to_scheduled_at_when_no_fired_at() -> None: + """A run that never recorded fired_at ages from scheduled_at (fallback).""" + now = 10_000_000 + run = _run("nofire", scheduled_at=now - STALE_RUN_MAX_AGE_SECONDS - 1, fired_at=None) + # _run's default would set fired_at; force it None to exercise the fallback. + run.fired_at = None + store = _FakeScheduledTaskStore([run]) + force_fail_stale_runs(store, [run], now=now) + assert run.status == "failed" + assert run.error_code == STALE_RUN_ERROR_CODE + + +def test_leaves_already_terminal_run_untouched() -> None: + """A terminal run (even if old) is not a candidate — status gate only.""" + now = 10_000_000 + run = _run("done", status="succeeded", scheduled_at=now - STALE_RUN_MAX_AGE_SECONDS - 100) + store = _FakeScheduledTaskStore([run]) + force_fail_stale_runs(store, [run], now=now) + assert run.status == "succeeded" + assert store.update_calls == [] + + +def test_mixed_batch_transitions_only_stale_running() -> None: + """Across a batch, only stale running rows transition; others pass through.""" + now = 10_000_000 + stale = _run("stale", scheduled_at=now - STALE_RUN_MAX_AGE_SECONDS - 1) + young = _run("young", scheduled_at=now - 10) + done = _run("done", status="succeeded", scheduled_at=now - STALE_RUN_MAX_AGE_SECONDS - 5) + store = _FakeScheduledTaskStore([stale, young, done]) + force_fail_stale_runs(store, [stale, young, done], now=now) + assert stale.status == "failed" and stale.error_code == STALE_RUN_ERROR_CODE + assert young.status == "running" + assert done.status == "succeeded" + assert store.update_calls == ["run_stale"] # only the stale running row + + +def test_idempotent_when_run_already_transitioned() -> None: + """A racing event-hook transition (update returns None) is not double-counted. + + Models the event hook winning between the read that listed the run and the + backstop's update: the conditional ``WHERE status=running`` update returns + None, and the original (stale) row is passed through unchanged in the list. + """ + now = 10_000_000 + run = _run("race", scheduled_at=now - STALE_RUN_MAX_AGE_SECONDS - 1) + + class _RacingStore(_FakeScheduledTaskStore): + def update_run(self, run_id: str, **kw: Any) -> _RunRow | None: + self._runs[run_id].status = "succeeded" # event hook won first + return super().update_run(run_id, **kw) + + store = _RacingStore([run]) + out = force_fail_stale_runs(store, [run], now=now) + # update_run returned None (not running anymore); the row we return is the + # original object, whose status the racing store flipped to succeeded. + assert out[0].status == "succeeded" diff --git a/tests/server/test_session_live_state.py b/tests/server/test_session_live_state.py index 112a78f7889..ef5e0c92217 100644 --- a/tests/server/test_session_live_state.py +++ b/tests/server/test_session_live_state.py @@ -249,6 +249,123 @@ def test_unencodable_status_is_dropped_before_enqueue( assert recording_store.status_writes == [("conv_1", "running")] +class _FakeScheduledTaskStore: + """Scheduled-task-store stand-in recording the hook's lookup + update.""" + + def __init__(self, running_by_conv: dict[str, str] | None = None) -> None: + # conversation_id -> run_id for conversations that have a running run. + self._running_by_conv = running_by_conv or {} + self.lookup_calls: list[str] = [] + self.update_calls: list[tuple[str, str, str | None, str | None]] = [] + self.lookup_workspaces: list[int] = [] + + def get_running_run_by_conversation(self, conversation_id: str): # type: ignore[no-untyped-def] + from omnigent.db.db_models import current_workspace_id + + self.lookup_calls.append(conversation_id) + self.lookup_workspaces.append(current_workspace_id()) + run_id = self._running_by_conv.get(conversation_id) + if run_id is None: + return None + # Minimal object carrying only the ``id`` the hook reads. + return type("_Run", (), {"id": run_id})() + + def update_run( + self, + run_id: str, + *, + status: str, + finished_at: int, + error: str | None = None, + error_code: str | None = None, + ): # type: ignore[no-untyped-def] + self.update_calls.append((run_id, status, error, error_code)) + return type("_Run", (), {"id": run_id, "status": status})() + + +def test_scheduled_run_completion_idle_transitions_to_succeeded() -> None: + """A terminal ``idle`` edge flips the conversation's running run to succeeded.""" + sched = _FakeScheduledTaskStore({"conv_1": "run_1"}) + session_live_state.configure(_RecordingStore(), sched) # type: ignore[arg-type] + try: + session_live_state.persist_scheduled_run_completion("conv_1", "succeeded") + _wait_until(lambda: bool(sched.update_calls)) + finally: + session_live_state.configure(None) + assert sched.lookup_calls == ["conv_1"] + assert len(sched.update_calls) == 1 + run_id, status, error, error_code = sched.update_calls[0] + assert (run_id, status) == ("run_1", "succeeded") + assert error is None and error_code is None + + +def test_scheduled_run_completion_failed_carries_error_code() -> None: + """A terminal ``failed`` edge flips the run to failed with the error detail.""" + sched = _FakeScheduledTaskStore({"conv_1": "run_1"}) + session_live_state.configure(_RecordingStore(), sched) # type: ignore[arg-type] + try: + session_live_state.persist_scheduled_run_completion( + "conv_1", "failed", error_code="runner_disconnected", error="dropped" + ) + _wait_until(lambda: bool(sched.update_calls)) + finally: + session_live_state.configure(None) + assert sched.update_calls == [("run_1", "failed", "dropped", "runner_disconnected")] + + +def test_scheduled_run_completion_noop_for_non_scheduled_conversation() -> None: + """An interactive conversation has no running run → lookup only, no update. + + This is the common case: the hook fires on every terminal edge, and the + cheap reverse lookup returning ``None`` keeps it a no-op for the vast + majority of (non-scheduled) conversations. + """ + sched = _FakeScheduledTaskStore({}) # no running runs + session_live_state.configure(_RecordingStore(), sched) # type: ignore[arg-type] + try: + session_live_state.persist_scheduled_run_completion("conv_x", "succeeded") + _wait_until(lambda: bool(sched.lookup_calls)) + finally: + session_live_state.configure(None) + assert sched.lookup_calls == ["conv_x"] + assert sched.update_calls == [] + + +def test_scheduled_run_completion_noop_without_scheduled_store() -> None: + """With only a conversation store wired the hook is a pure no-op. + + ``configure(store)`` (no scheduled-task store) must not enqueue any work — + the runner process and most tests never wire one. + """ + session_live_state.configure(_RecordingStore()) # type: ignore[arg-type] + try: + # No scheduled store => returns before touching the executor. + session_live_state.persist_scheduled_run_completion("conv_1", "succeeded") + finally: + session_live_state.configure(None) + + +def test_scheduled_run_completion_runs_in_callers_workspace_scope() -> None: + """The lookup + update inherit the caller's ``workspace_scope``. + + Same contract as the live_status mirror: the store filters on + ``current_workspace_id()``, so the hook's reverse lookup must resolve to + the fired run's workspace. Bind a non-default workspace, leave the scope + before the worker runs, and assert the write thread still observed it. + """ + from omnigent.db.db_models import workspace_scope + + sched = _FakeScheduledTaskStore({"conv_1": "run_1"}) + session_live_state.configure(_RecordingStore(), sched) # type: ignore[arg-type] + try: + with workspace_scope(4242): + session_live_state.persist_scheduled_run_completion("conv_1", "succeeded") + _wait_until(lambda: bool(sched.lookup_workspaces)) + finally: + session_live_state.configure(None) + assert sched.lookup_workspaces == [4242] + + @pytest.mark.asyncio async def test_liveness_pass_zeroes_pending_count_for_offline_runner() -> None: """A stale persisted pending count can't light a phantom inbox badge. diff --git a/tests/stores/test_scheduled_task_store.py b/tests/stores/test_scheduled_task_store.py index 2942aa2d30a..be39cb82590 100644 --- a/tests/stores/test_scheduled_task_store.py +++ b/tests/stores/test_scheduled_task_store.py @@ -646,3 +646,237 @@ def test_delete_does_not_remove_other_tasks_runs(store: SqlAlchemyScheduledTaskS remaining = store.list_runs(_uid("st_b_scope")) assert len(remaining) == 1 assert remaining[0].id == _uid("sr_st_b_scope") + + +# ── update_run (terminal transition + idempotency) ─────────────────────────── + + +def _seed_running_run(store: SqlAlchemyScheduledTaskStore, seed: str) -> str: + """Create a task + a ``running`` run for it; return the run id.""" + store.create( + scheduled_task_id=_uid(f"task_{seed}"), + name=seed, + prompt="p", + rrule="FREQ=MINUTELY", + user_id="u", + agent_id=_uid("ag"), + timezone="UTC", + ) + run_id = _uid(f"run_{seed}") + store.create_run( + run_id=run_id, + scheduled_task_id=_uid(f"task_{seed}"), + status="running", + scheduled_at=100, + conversation_id=_uid(f"conv_{seed}"), + fired_at=101, + ) + return run_id + + +def test_update_run_transitions_running_to_succeeded( + store: SqlAlchemyScheduledTaskStore, +) -> None: + """``update_run`` flips a ``running`` run to ``succeeded`` with finished_at.""" + run_id = _seed_running_run(store, "ok") + updated = store.update_run(run_id, status="succeeded", finished_at=202) + assert updated is not None + assert updated.status == "succeeded" + assert updated.finished_at == 202 + assert updated.error is None and updated.error_code is None + + +def test_update_run_transitions_running_to_failed_with_code( + store: SqlAlchemyScheduledTaskStore, +) -> None: + """``update_run`` flips a ``running`` run to ``failed`` carrying error detail.""" + run_id = _seed_running_run(store, "bad") + updated = store.update_run( + run_id, status="failed", finished_at=303, error="boom", error_code="incomplete" + ) + assert updated is not None + assert updated.status == "failed" + assert updated.finished_at == 303 + assert updated.error == "boom" + assert updated.error_code == "incomplete" + + +def test_update_run_is_idempotent_on_already_terminal( + store: SqlAlchemyScheduledTaskStore, +) -> None: + """A second ``update_run`` on an already-terminal run is a no-op (returns None). + + The conditional ``WHERE status = running`` guard means a run advanced to a + terminal state — by a prior sweep or a fire-time write — is never + clobbered, and two concurrent sweeps cannot double-transition it. + """ + run_id = _seed_running_run(store, "once") + first = store.update_run(run_id, status="succeeded", finished_at=202) + assert first is not None and first.status == "succeeded" + # Second attempt (e.g. a racing sweep) must not overwrite it. + second = store.update_run(run_id, status="failed", finished_at=999, error_code="incomplete") + assert second is None + # State is unchanged from the first transition. + run = store.list_runs(_uid("task_once"))[0] + assert run.status == "succeeded" + assert run.finished_at == 202 + assert run.error_code is None + + +def test_update_run_unknown_run_returns_none( + store: SqlAlchemyScheduledTaskStore, +) -> None: + """``update_run`` on a missing run id returns ``None``.""" + assert store.update_run(_uid("nope"), status="succeeded", finished_at=1) is None + + +# ── list_running_runs_for_tasks (lazy-on-read LIST backstop source) ────────── + + +def test_list_running_runs_for_tasks_filters_status_and_tasks( + store: SqlAlchemyScheduledTaskStore, +) -> None: + """Returns only ``running`` runs, and only for the requested tasks. + + Powers the LIST endpoint's lazy stale backstop: the route passes the + owner's task ids and gets back their still-``running`` runs to age-check. + """ + for seed in ("a", "b"): + store.create( + scheduled_task_id=_uid(f"task_{seed}"), + name=seed, + prompt="p", + rrule="FREQ=MINUTELY", + user_id="u", + agent_id=_uid("ag"), + timezone="UTC", + ) + # task_a: one running + one terminal run. + store.create_run( + run_id=_uid("run_a_running"), + scheduled_task_id=_uid("task_a"), + status="running", + scheduled_at=100, + ) + store.create_run( + run_id=_uid("run_a_done"), + scheduled_task_id=_uid("task_a"), + status="succeeded", + scheduled_at=90, + finished_at=95, + ) + # task_b: one running run. + store.create_run( + run_id=_uid("run_b_running"), + scheduled_task_id=_uid("task_b"), + status="running", + scheduled_at=200, + ) + + got = store.list_running_runs_for_tasks([_uid("task_a"), _uid("task_b")]) + ids = {r.id for r in got} + assert ids == {_uid("run_a_running"), _uid("run_b_running")} # terminal excluded + # Ordered scheduled_at DESC (run_b scheduled_at=200 > run_a=100). + assert got[0].id == _uid("run_b_running") + + +def test_list_running_runs_for_tasks_empty_ids_returns_empty( + store: SqlAlchemyScheduledTaskStore, +) -> None: + """An empty task-id list short-circuits to an empty result (no query).""" + assert store.list_running_runs_for_tasks([]) == [] + + +def test_list_running_runs_for_tasks_is_workspace_scoped( + store: SqlAlchemyScheduledTaskStore, +) -> None: + """A task's running run is invisible from another workspace.""" + with workspace_scope(11): + store.create( + scheduled_task_id=_uid("task_w11"), + name="w11", + prompt="p", + rrule="FREQ=MINUTELY", + user_id="a", + agent_id=_uid("ag"), + timezone="UTC", + ) + store.create_run( + run_id=_uid("run_w11"), + scheduled_task_id=_uid("task_w11"), + status="running", + scheduled_at=100, + ) + # Default workspace cannot see the workspace-11 run. + assert store.list_running_runs_for_tasks([_uid("task_w11")]) == [] + with workspace_scope(11): + got = store.list_running_runs_for_tasks([_uid("task_w11")]) + assert [r.id for r in got] == [_uid("run_w11")] + + +# ── get_running_run_by_conversation (event-hook reverse lookup) ─────────────── + + +def test_get_running_run_by_conversation_returns_running_run( + store: SqlAlchemyScheduledTaskStore, +) -> None: + """The reverse lookup finds the ``running`` run for a conversation.""" + run_id = _seed_running_run(store, "hook") + found = store.get_running_run_by_conversation(_uid("conv_hook")) + assert found is not None + assert found.id == run_id + assert found.status == "running" + + +def test_get_running_run_by_conversation_none_when_terminal( + store: SqlAlchemyScheduledTaskStore, +) -> None: + """Once the run is terminal the reverse lookup returns ``None`` (hook no-op). + + This is what makes the event hook idempotent: a second terminal edge finds + no ``running`` run to transition. + """ + run_id = _seed_running_run(store, "term") + store.update_run(run_id, status="succeeded", finished_at=202) + assert store.get_running_run_by_conversation(_uid("conv_term")) is None + + +def test_get_running_run_by_conversation_none_for_unknown_conversation( + store: SqlAlchemyScheduledTaskStore, +) -> None: + """An interactive (non-scheduled) conversation has no run → ``None``.""" + assert store.get_running_run_by_conversation(_uid("conv_absent")) is None + + +def test_get_running_run_by_conversation_is_workspace_scoped( + store: SqlAlchemyScheduledTaskStore, +) -> None: + """The lookup filters on the current workspace, like every other store read. + + A run seeded in workspace 11 is invisible from the default workspace and + visible only inside its own ``workspace_scope`` — the property the event + hook relies on to write to the fired run's workspace. + """ + with workspace_scope(11): + store.create( + scheduled_task_id=_uid("task_ws"), + name="ws", + prompt="p", + rrule="FREQ=MINUTELY", + user_id="a", + agent_id=_uid("ag"), + timezone="UTC", + ) + store.create_run( + run_id=_uid("run_ws"), + scheduled_task_id=_uid("task_ws"), + status="running", + scheduled_at=100, + conversation_id=_uid("conv_ws"), + ) + # Default workspace cannot see the workspace-11 run. + assert store.get_running_run_by_conversation(_uid("conv_ws")) is None + # Inside its own scope it resolves. + with workspace_scope(11): + found = store.get_running_run_by_conversation(_uid("conv_ws")) + assert found is not None and found.id == _uid("run_ws") From a944270cc933919f69c2f7564147a1afb47801c2 Mon Sep 17 00:00:00 2001 From: Kerry Chang Date: Tue, 21 Jul 2026 17:54:58 -0700 Subject: [PATCH 539/546] feat(dictation): remote worker engine for offloading speech-to-text (#3025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reintroduce the remote path split out of the initial dictation PR, now as a registered engine rather than a special-cased branch. - Register a `remote` engine (OMNIGENT_DICTATION_ENGINE=remote) that relays each take to a dictation worker over the same wire protocol the browser speaks. Selected purely by env var — OMNIGENT_DICTATION_REMOTE_URL points at the worker; no CLI integration, keeping the surface small for a niche deployment (weak main server + a beefier LAN box). - Ship the standalone worker (python -m omnigent.server.dictation_worker): create_dictation_router served on its own, unauthenticated, LAN-only. - Per-take fallback to the local sherpa engine (lazy) when the worker is unreachable and models are installed. - Widen the web client's ready/stop timeouts to outlast the worker's cold-load budget. websockets is already a core dependency, so no new package. The engine slots into the registry with no changes to the route, protocol, or selection logic. Co-authored-by: Isaac Signed-off-by: kerry.chang Co-authored-by: Ubuntu --- designs/server-dictation.md | 48 ++++++- omnigent/server/dictation.py | 198 ++++++++++++++++++++++++++ omnigent/server/dictation_worker.py | 70 +++++++++ tests/server/test_dictation_remote.py | 139 ++++++++++++++++++ web/src/lib/dictation.ts | 14 +- 5 files changed, 458 insertions(+), 11 deletions(-) create mode 100644 omnigent/server/dictation_worker.py create mode 100644 tests/server/test_dictation_remote.py diff --git a/designs/server-dictation.md b/designs/server-dictation.md index ecae25a579e..878f0a2008e 100644 --- a/designs/server-dictation.md +++ b/designs/server-dictation.md @@ -97,7 +97,8 @@ connections (default 2, `OMNIGENT_DICTATION_MAX_STREAMS`). | `OMNIGENT_DICTATION_MODEL_DIR` | `~/.omnigent/models/dictation/asr` | dir containing `encoder*.onnx`, `decoder*.onnx`, `joiner*.onnx`, `tokens.txt` | | `OMNIGENT_DICTATION_PUNCT_DIR` | `~/.omnigent/models/dictation/punct` | optional online-punctuation model dir (`model*.onnx` + `bpe.vocab`) | | `OMNIGENT_DICTATION_MAX_STREAMS` | `2` | concurrent dictation WebSockets | -| `OMNIGENT_DICTATION_ENGINE` | unset (`sherpa`) | engine to use by registered name; `fake` for tests | +| `OMNIGENT_DICTATION_ENGINE` | unset (`sherpa`) | engine to use by registered name (`sherpa`, `remote`, `fake`) | +| `OMNIGENT_DICTATION_REMOTE_URL` | unset | worker stream URL for the `remote` engine, e.g. `ws://venus:8100/v1/dictation/stream` | `scripts/fetch-dictation-models.sh` downloads a known-good pair (streaming Nemotron 0.6 B int8 + English online punctuation, both Apache-2.0 upstream) @@ -132,11 +133,46 @@ recognizer output is emitted as-is), and the mic button's `lang` prop only affects the Web Speech path — the server path's language is decided by the operator's model choice. -Where a mini-PC server can't run the model an operator wants at realtime, a -follow-up adds a **remote worker**: a `RemoteDictationEngine` (registered as -`OMNIGENT_DICTATION_ENGINE=remote`) that relays takes over this same wire -protocol to a beefier LAN box. It slots into the registry without changing -the route or the protocol, so it ships separately from this core PR. +### Remote worker + +Where a mini-PC server can't run the model an operator wants at realtime, the +`remote` engine relays each take to a **dictation worker** on a beefier LAN +box. The worker is just `create_dictation_router` served on its own — it +speaks the exact same wire protocol the browser does (PCM frames up, +transcript events down), so no new protocol or code path was needed. The +browser never talks to the worker; the main server authenticates the user on +its own route, then relays over a `websockets` client. + +Run the worker wherever the models live (it is **unauthenticated** — bind it +to a trusted LAN/VPN only): + +``` +pip install omnigent[dictation] && scripts/fetch-dictation-models.sh +python -m omnigent.server.dictation_worker --host 0.0.0.0 --port 8100 +``` + +Then select the `remote` engine on the main server via env vars — no CLI +integration is required: + +``` +OMNIGENT_DICTATION_ENGINE=remote \ +OMNIGENT_DICTATION_REMOTE_URL=ws://:8100/v1/dictation/stream \ +omnigent server ... +``` + +`RemoteDictationEngine` registers by name like every other engine (no changes +to the route, protocol, or selection logic). `_RemoteStream` bridges the +worker's async push events into the synchronous handle interface via a daemon +reader thread, and `close()` releases the worker's capacity slot promptly. +Fallback is per take: if the worker is unreachable and local models are +installed, a lazily-built local sherpa engine serves the take instead (its +weights cost no RAM until the worker actually goes down); each new take +retries the worker first. + +Client timeouts (`web/src/lib/dictation.ts`) are widened to exceed the +worker's cold-load budget (`_REMOTE_READY_TIMEOUT_S` / `_REMOTE_STOP_TIMEOUT_S` +in `dictation.py`) so a relayed take doesn't time out on the browser side just +as the worker finishes loading its model. ### Routes — `omnigent/server/routes/dictation.py` diff --git a/omnigent/server/dictation.py b/omnigent/server/dictation.py index bc5cd146ce7..370aad4b453 100644 --- a/omnigent/server/dictation.py +++ b/omnigent/server/dictation.py @@ -17,6 +17,11 @@ model on disk; both are checked lazily so the base install carries no new dependencies. - ``sherpa`` — the same engine, named explicitly. +- ``remote`` — relays takes to a dictation worker on another machine + (``OMNIGENT_DICTATION_REMOTE_URL``), so a small main server can borrow + a beefier LAN box's CPU. Falls back to the local sherpa engine (when + models are installed) if the worker is unreachable. See + :class:`RemoteDictationEngine` and ``dictation_worker.py``. - ``fake`` — a deterministic scripted engine used by tests and the Playwright e2e suite; no native dependency, no models, no microphone. @@ -65,11 +70,14 @@ from __future__ import annotations +import contextlib import importlib.util +import json import logging import os import re import threading +import time from collections.abc import Callable from dataclasses import dataclass from pathlib import Path @@ -81,13 +89,21 @@ MODEL_DIR_ENV = "OMNIGENT_DICTATION_MODEL_DIR" PUNCT_DIR_ENV = "OMNIGENT_DICTATION_PUNCT_DIR" MAX_STREAMS_ENV = "OMNIGENT_DICTATION_MAX_STREAMS" +#: Worker stream URL for the ``remote`` engine, e.g. +#: ``ws://venus:8100/v1/dictation/stream``. +REMOTE_URL_ENV = "OMNIGENT_DICTATION_REMOTE_URL" #: Built-in engine names. The default (empty ``OMNIGENT_DICTATION_ENGINE``) #: resolves to the sherpa engine. ENGINE_SHERPA = "sherpa" ENGINE_FAKE = "fake" +ENGINE_REMOTE = "remote" _DEFAULT_ENGINE = ENGINE_SHERPA +#: Worker handshake budget: covers a cold model load on the worker side. +_REMOTE_READY_TIMEOUT_S = 30.0 +_REMOTE_STOP_TIMEOUT_S = 10.0 + #: The one PCM format the stream route accepts: 16 kHz mono s16le. SAMPLE_RATE = 16000 _BYTES_PER_SECOND = SAMPLE_RATE * 2 @@ -96,6 +112,7 @@ REASON_EXTRA_NOT_INSTALLED = "extra_not_installed" REASON_MODELS_MISSING = "models_missing" REASON_UNKNOWN_ENGINE = "unknown_engine" +REASON_REMOTE_URL_MISSING = "remote_url_missing" DEFAULT_MAX_STREAMS = 2 @@ -455,6 +472,186 @@ def close(self) -> None: """No-op: the recognizer stream frees with the handle.""" +class RemoteDictationEngine: + """Relays dictation takes to a remote worker over WebSocket. + + The worker is anything speaking the ``/v1/dictation/stream`` wire + protocol — another omnigent server or the standalone + ``python -m omnigent.server.dictation_worker``. Lets a small main + server (a mini-PC) borrow a beefier LAN box for recognition. + + Fallback happens per take, at stream creation: if the worker is + unreachable, the lazily-built local engine (when models are + installed) serves the take instead. A worker dying mid-take fails + that take; the next one retries the worker. + """ + + def __init__( + self, + url: str, + *, + fallback_factory: Callable[[], DictationEngine] | None = None, + ) -> None: + """ + :param url: Worker stream URL, e.g. + ``ws://venus:8100/v1/dictation/stream``. + :param fallback_factory: Builds the local fallback engine on + first use (lazy — its model weights cost ~real RAM), or + ``None`` when no local model is installed. + """ + self._url = url + self._fallback_factory = fallback_factory + self._fallback: DictationEngine | None = None + self._fallback_lock = threading.Lock() + + def create_stream(self) -> DictationStreamHandle: + """Connect a take to the worker, or to the local fallback.""" + try: + return _RemoteStream(self._url) + except Exception: + if self._fallback_factory is None: + raise + _logger.warning( + "dictation worker unreachable at %s; using local fallback engine", + self._url, + exc_info=True, + ) + with self._fallback_lock: + if self._fallback is None: + self._fallback = self._fallback_factory() + return self._fallback.create_stream() + + +class _RemoteStream: + """One relayed take: raw PCM up, transcript events down. + + A daemon reader thread folds the worker's ``partial``/``final`` + events into state that :meth:`feed_pcm16` returns on each call, so + the relay presents the same synchronous handle interface the local + engines do. The worker returns display-ready text already, so the + relay just forwards it. + """ + + def __init__(self, url: str) -> None: + from websockets.sync.client import connect + + self._ws = connect(url, open_timeout=5) + try: + deadline = time.monotonic() + _REMOTE_READY_TIMEOUT_S + while True: + message = self._ws.recv(timeout=max(0.1, deadline - time.monotonic())) + if not isinstance(message, str): + continue + event = json.loads(message) + if event.get("type") == "ready": + break + if event.get("type") == "error": + raise RuntimeError(f"dictation worker error: {event.get('message')}") + except BaseException: + self._ws.close() + raise + self._lock = threading.Lock() + self._partial = "" + self._finals: list[str] = [] + self._tail = "" + self._dead = False + self._stopped = threading.Event() + threading.Thread(target=self._read_loop, daemon=True).start() + + def _read_loop(self) -> None: + try: + while True: + message = self._ws.recv() + if not isinstance(message, str): + continue + try: + event = json.loads(message) + except ValueError: + continue + kind = event.get("type") + with self._lock: + if kind == "partial": + self._partial = str(event.get("text", "")) + elif kind == "final": + self._finals.append(str(event.get("text", ""))) + self._partial = "" + elif kind == "stopped": + self._tail = str(event.get("text", "")) + break + elif kind == "error": + self._dead = True + break + except Exception: # noqa: BLE001 - any transport failure kills the take + with self._lock: + self._dead = True + self._stopped.set() + + def feed_pcm16(self, data: bytes) -> DictationUpdate: + """Ship a chunk to the worker; return its latest transcript state.""" + with self._lock: + if self._dead: + raise RuntimeError("dictation worker connection lost") + self._ws.send(data) + with self._lock: + finalized = " ".join(t for t in self._finals if t).strip() or None + self._finals.clear() + return DictationUpdate(partial=self._partial, finalized=finalized) + + def finish(self) -> str: + """Ask the worker to flush; return its tail utterance.""" + with contextlib.suppress(Exception): + self._ws.send(json.dumps({"type": "stop"})) + self._stopped.wait(timeout=_REMOTE_STOP_TIMEOUT_S) + self.close() + with self._lock: + return self._tail + + def close(self) -> None: + """Close the worker socket, releasing its capacity slot. + + Also unblocks the reader thread's ``recv``. Idempotent — the + sync websockets client tolerates repeated ``close`` calls. + """ + with contextlib.suppress(Exception): + self._ws.close() + + +def _remote_url() -> str: + """The configured worker stream URL (may be empty).""" + return os.environ.get(REMOTE_URL_ENV, "").strip() + + +def _remote_available() -> tuple[bool, str | None]: + """Availability probe for the remote engine. + + A configured worker counts as available without probing it — the + worker may be briefly down or still booting, and the stream route + degrades cleanly (local fallback, or an error frame) when a take + actually starts. + """ + if not _remote_url(): + return False, REASON_REMOTE_URL_MISSING + return True, None + + +def _build_remote_engine() -> RemoteDictationEngine: + """Factory for the remote engine, with a lazy local fallback. + + Local models, when installed, back the worker up. The fallback + factory is lazy so its ~650 MB of weights cost no RAM unless the + worker actually goes down. + """ + url = _remote_url() + if not url: + raise RuntimeError(f"dictation unavailable: {REASON_REMOTE_URL_MISSING}") + fallback = ( + (lambda: SherpaDictationEngine(_asr_dir(), _punct_dir())) + if _sherpa_available()[0] + else None + ) + return RemoteDictationEngine(url, fallback_factory=fallback) + + #: Scripted transcript the fake engine reveals; asserted verbatim by the #: server route tests and the Playwright e2e test. FAKE_SCRIPT = "server dictation smoke test transcript" @@ -522,4 +719,5 @@ def close(self) -> None: lambda: SherpaDictationEngine(_asr_dir(), _punct_dir()), available=_sherpa_available, ) +register_engine(ENGINE_REMOTE, _build_remote_engine, available=_remote_available) register_engine(ENGINE_FAKE, FakeDictationEngine) diff --git a/omnigent/server/dictation_worker.py b/omnigent/server/dictation_worker.py new file mode 100644 index 00000000000..744f1854db8 --- /dev/null +++ b/omnigent/server/dictation_worker.py @@ -0,0 +1,70 @@ +"""Standalone dictation worker: serves only ``WS /v1/dictation/stream``. + +Lets a machine with spare CPU do speech-to-text for an omnigent server +that can't keep up with the model it wants (designs/server-dictation.md, +"Hardware sizing"). The main server selects the ``remote`` engine and +points ``OMNIGENT_DICTATION_REMOTE_URL`` at this worker; it relays takes +over the same wire protocol the browser speaks, so the worker needs no +new code — it is ``create_dictation_router`` served on its own. The +browser never talks to the worker directly. + +Run it wherever the models live:: + + pip install omnigent[dictation] + scripts/fetch-dictation-models.sh + python -m omnigent.server.dictation_worker --host 0.0.0.0 --port 8100 + +Then start the main server pointed at it:: + + OMNIGENT_DICTATION_ENGINE=remote \\ + OMNIGENT_DICTATION_REMOTE_URL=ws://:8100/v1/dictation/stream \\ + omnigent server ... + +The same ``OMNIGENT_DICTATION_*`` env vars configure the worker itself +(model dirs, stream cap, fake engine for tests). + +Security: the worker has NO authentication — it accepts raw audio from +anyone who can reach the port and returns transcripts. Bind it to a +trusted network (LAN/VPN) only; the main server enforces user auth on +its own dictation route before relaying. +""" + +from __future__ import annotations + +import argparse +import logging +from collections.abc import Sequence + +from fastapi import FastAPI + +from omnigent.server.routes.dictation import create_dictation_router + + +def create_worker_app() -> FastAPI: + """Build the single-route worker app.""" + app = FastAPI(title="omnigent dictation worker") + app.include_router(create_dictation_router(), prefix="/v1") + return app + + +def main(argv: Sequence[str] | None = None) -> int: + """CLI entry point: parse args and serve until interrupted.""" + parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + parser.add_argument( + "--host", + default="127.0.0.1", + help="bind address; use a LAN/VPN address for a remote main server " + "(the worker is unauthenticated — never expose it publicly)", + ) + parser.add_argument("--port", type=int, default=8100) + args = parser.parse_args(argv) + + import uvicorn + + logging.basicConfig(level=logging.INFO) + uvicorn.run(create_worker_app(), host=args.host, port=args.port, log_level="info") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/server/test_dictation_remote.py b/tests/server/test_dictation_remote.py new file mode 100644 index 00000000000..2ad5b38e786 --- /dev/null +++ b/tests/server/test_dictation_remote.py @@ -0,0 +1,139 @@ +"""Tests for the remote dictation engine and the standalone worker. + +Spins a real ``dictation_worker`` app (uvicorn on an ephemeral loopback +port, fake engine injected via env) and drives :class:`RemoteDictationEngine` +against it over actual TCP — the same relay path a beelink-class server +uses to borrow a beefier box's CPU. No sherpa dependency: the worker +runs the fake engine. +""" + +from __future__ import annotations + +import threading +import time +from collections.abc import Iterator + +import pytest +import uvicorn + +from omnigent.server import dictation + + +@pytest.fixture(autouse=True) +def _fake_engine_env(monkeypatch: pytest.MonkeyPatch) -> None: + """The spawned worker (same process) must resolve the fake engine.""" + monkeypatch.setenv(dictation.ENGINE_ENV, dictation.ENGINE_FAKE) + monkeypatch.delenv(dictation.REMOTE_URL_ENV, raising=False) + monkeypatch.setattr(dictation, "_engine", None) + + +@pytest.fixture +def worker_url() -> Iterator[str]: + """Run the real worker app on an ephemeral port; yield its stream URL.""" + from omnigent.server.dictation_worker import create_worker_app + + config = uvicorn.Config(create_worker_app(), host="127.0.0.1", port=0, log_level="warning") + server = uvicorn.Server(config) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + deadline = time.monotonic() + 15 + while not server.started: + if time.monotonic() > deadline: + raise RuntimeError("worker did not start") + time.sleep(0.05) + port = server.servers[0].sockets[0].getsockname()[1] + yield f"ws://127.0.0.1:{port}/v1/dictation/stream" + server.should_exit = True + thread.join(timeout=10) + + +_WORD = b"\x00" * (dictation.SAMPLE_RATE * 2 // 10) # 100 ms per fake word +_WORDS = dictation.FAKE_SCRIPT.split() + + +def _drain_partial(handle: dictation.DictationStreamHandle, expected: str) -> None: + """Poll feeds until the relayed partial catches up (reader is async).""" + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + update = handle.feed_pcm16(b"") + if update.partial == expected: + return + time.sleep(0.05) + raise AssertionError(f"partial never reached {expected!r}") + + +def test_remote_engine_relays_partials_and_finish(worker_url: str) -> None: + """PCM up, partial state down, stop-flush returns the tail.""" + engine = dictation.RemoteDictationEngine(worker_url) + handle = engine.create_stream() + handle.feed_pcm16(_WORD * 3) + # The worker throttles partial emission (~150 ms); poll until the + # 3-word partial arrives. + _drain_partial(handle, " ".join(_WORDS[:3])) + assert handle.finish() == " ".join(_WORDS[:3]) + + +def test_remote_engine_relays_finalized_utterances(worker_url: str) -> None: + """A worker 'final' event surfaces as DictationUpdate.finalized.""" + engine = dictation.RemoteDictationEngine(worker_url) + handle = engine.create_stream() + handle.feed_pcm16(_WORD * len(_WORDS)) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + update = handle.feed_pcm16(b"") + if update.finalized: + assert update.finalized == dictation.FAKE_SCRIPT + break + time.sleep(0.05) + else: + raise AssertionError("finalized never arrived") + assert handle.finish() == "" + + +def test_remote_stream_close_releases_worker_slot(worker_url: str) -> None: + """close() frees the worker's capacity slot for later takes. + + Three sequential takes against a worker capped at two concurrent + streams: without the close, the third handshake would be rejected + with the 1013 at-capacity close. + """ + engine = dictation.RemoteDictationEngine(worker_url) + for _ in range(3): + handle = engine.create_stream() + handle.feed_pcm16(_WORD) + handle.close() + + +def test_remote_engine_falls_back_when_worker_down() -> None: + """Unreachable worker + local fallback → the take still serves.""" + engine = dictation.RemoteDictationEngine( + "ws://127.0.0.1:9/v1/dictation/stream", # port 9: nothing listens + fallback_factory=dictation.FakeDictationEngine, + ) + handle = engine.create_stream() + update = handle.feed_pcm16(_WORD * 2) + assert update.partial == " ".join(_WORDS[:2]) + + +def test_remote_engine_raises_without_fallback() -> None: + """Unreachable worker and no local models → the take fails loudly.""" + engine = dictation.RemoteDictationEngine("ws://127.0.0.1:9/v1/dictation/stream") + with pytest.raises(OSError): + engine.create_stream() + + +def test_remote_unavailable_without_url(monkeypatch: pytest.MonkeyPatch) -> None: + """Selecting remote without a worker URL is unavailable, not a crash.""" + monkeypatch.setenv(dictation.ENGINE_ENV, dictation.ENGINE_REMOTE) + monkeypatch.delenv(dictation.REMOTE_URL_ENV, raising=False) + assert dictation.engine_availability() == (False, dictation.REASON_REMOTE_URL_MISSING) + + +def test_remote_engine_selected_by_name(monkeypatch: pytest.MonkeyPatch) -> None: + """OMNIGENT_DICTATION_ENGINE=remote + a URL selects the relay engine.""" + monkeypatch.setenv(dictation.ENGINE_ENV, dictation.ENGINE_REMOTE) + monkeypatch.setenv(dictation.REMOTE_URL_ENV, "ws://example:8100/v1/dictation/stream") + monkeypatch.setattr(dictation, "_engine", None) + assert dictation.engine_availability() == (True, None) + engine = dictation.get_engine() + assert isinstance(engine, dictation.RemoteDictationEngine) diff --git a/web/src/lib/dictation.ts b/web/src/lib/dictation.ts index fcb526dafb6..2b7d41686db 100644 --- a/web/src/lib/dictation.ts +++ b/web/src/lib/dictation.ts @@ -70,11 +70,15 @@ export function parseDictationEvent(raw: string): DictationEvent | null { } // Client budgets must exceed the server's own worst cases or takes fail -// spuriously right when they'd have succeeded. The dominant cost is the -// first take's engine construction, which loads the model weights (seconds -// on a cold server); the stop budget just needs to outlast the tail flush. -const READY_TIMEOUT_MS = 20_000; -const STOP_TIMEOUT_MS = 5_000; +// spuriously right when they'd have succeeded: +// - ready: engine construction loads model weights on the first take, and +// the remote-relay path allows the worker 30 s for its own cold load +// (_REMOTE_READY_TIMEOUT_S in omnigent/server/dictation.py). +// - stop: the relay waits up to 10 s (_REMOTE_STOP_TIMEOUT_S) for the +// worker to flush the tail; resolving earlier would drop the user's +// last words even though they were transcribed moments later. +const READY_TIMEOUT_MS = 40_000; +const STOP_TIMEOUT_MS = 15_000; // How long stop() waits for the worklet to post its final partial chunk // before tearing the audio graph down. Message-port turnaround is From a1c6608b7c606924acc1bc3f8a97695c8ff50a0f Mon Sep 17 00:00:00 2001 From: Sabhya Chhabria Date: Tue, 21 Jul 2026 18:18:08 -0700 Subject: [PATCH 540/546] fix(runner): fail closed when tool policies fail to resolve (#2589) Skipping unresolvable function policies left an empty gate that allowed every tool call. Install a deny sentinel instead so a misconfigured policy cannot disappear silently. Signed-off-by: SabhyaC26 --- omnigent/runner/policy.py | 47 +++- tests/runner/test_runner_tool_policy_gate.py | 258 +++++++++++++++++++ 2 files changed, 298 insertions(+), 7 deletions(-) create mode 100644 tests/runner/test_runner_tool_policy_gate.py diff --git a/omnigent/runner/policy.py b/omnigent/runner/policy.py index acc7da27dfa..3fd637ba030 100644 --- a/omnigent/runner/policy.py +++ b/omnigent/runner/policy.py @@ -106,6 +106,35 @@ class PolicyVerdict: _ALLOW: PolicyVerdict = PolicyVerdict(action="allow") +def _resolve_failure_diagnostic(ps: FunctionPolicySpec, exc: BaseException) -> str: + """ + Build an actionable load-failure reason without embedding exception text. + + Factory kwargs (API keys, tokens) can appear in ``str(exc)``; keep only + the exception type and the configured function path so operators can fix + the spec without secrets landing in tool output. + """ + path = ps.function.path if ps.function is not None else "" + return ( + f"policy failed to resolve ({type(exc).__name__}); " + f"function path {path!r} could not be loaded; " + f"tool calls are denied until this policy is fixed" + ) + + +def _unresolved_policy_sentinel( + ps: FunctionPolicySpec, + exc: BaseException, +) -> FunctionPolicy: + """Fail-closed stand-in for a configured policy that failed to resolve.""" + reason = _resolve_failure_diagnostic(ps, exc) + + def _always_deny(_event: Any) -> dict[str, str]: + return {"result": "DENY", "reason": reason} + + return FunctionPolicy(ps, _always_deny) + + class RunnerToolPolicyGate: """Per-spec runner-side enforcement of function-type policies. @@ -120,7 +149,12 @@ def __init__(self, policies: list[_GatedPolicy]) -> None: @classmethod def from_spec(cls, spec: AgentSpec) -> RunnerToolPolicyGate: - """Pick out function-type tool_call/tool_result policies and resolve them.""" + """Pick out function-type tool_call/tool_result policies and resolve them. + + A configured tool-phase policy that fails to resolve is replaced with + a fail-closed sentinel that always DENYs. Skipping would leave an + empty gate that ALLOWs every tool call (fail-open). + """ guard = getattr(spec, "guardrails", None) if guard is None or not guard.policies: return cls([]) @@ -137,12 +171,11 @@ def from_spec(cls, spec: AgentSpec) -> RunnerToolPolicyGate: continue try: policy = resolve_function_policy(ps) - except Exception: - _logger.exception( - "runner failed to resolve function policy %r; skipping", - ps.name, - ) - continue + except Exception as exc: # noqa: BLE001 - all resolution failures deny + diagnostic = _resolve_failure_diagnostic(ps, exc) + _logger.error("runner %s", diagnostic) + policy = _unresolved_policy_sentinel(ps, exc) + phases = frozenset([Phase.TOOL_CALL, Phase.TOOL_RESULT]) out.append(_GatedPolicy(name=ps.name, policy=policy, phases=phases)) return cls(out) diff --git a/tests/runner/test_runner_tool_policy_gate.py b/tests/runner/test_runner_tool_policy_gate.py new file mode 100644 index 00000000000..f9be2fa4775 --- /dev/null +++ b/tests/runner/test_runner_tool_policy_gate.py @@ -0,0 +1,258 @@ +"""Tests for :class:`RunnerToolPolicyGate` resolve-time fail-closed behavior. + +A configured tool-phase policy that fails to resolve must not disappear +silently: ``from_spec`` installs a sentinel that DENYs on TOOL_CALL and +TOOL_RESULT. Successfully resolved policies keep their existing verdicts. +""" + +from __future__ import annotations + +import json + +import pytest + +from omnigent.runner.policy import RunnerToolPolicyGate +from omnigent.spec.types import ( + AgentSpec, + FunctionPolicySpec, + FunctionRef, + GuardrailsSpec, + Phase, + PhaseSelector, +) + +_BROKEN_PATH = "omnigent.nonexistent_module.broken_policy" +_FIXED_ALLOW = FunctionRef( + path="omnigent.policies.function.make_fixed_action_callable", + arguments={"action": "allow"}, +) +_FIXED_DENY = FunctionRef( + path="omnigent.policies.function.make_fixed_action_callable", + arguments={"action": "deny", "reason": "blocked by valid policy"}, +) +_RAISING_PATH = "omnigent.policies.function.make_fixed_action_callable" + + +def _agent_with_policies(*policies: FunctionPolicySpec) -> AgentSpec: + """Minimal agent whose guardrails carry the given function policies.""" + return AgentSpec( + spec_version=1, + name="runner-policy-gate-test", + guardrails=GuardrailsSpec(policies=list(policies)), + ) + + +def _tool_policy( + name: str, + function: FunctionRef, + *, + phases: list[Phase] | None = None, +) -> FunctionPolicySpec: + """Build a function policy that fires on the given tool phases.""" + if phases is None: + on = None + else: + on = [PhaseSelector(phase=p) for p in phases] + return FunctionPolicySpec(name=name, on=on, function=function) + + +def _broken_policy(name: str = "broken") -> FunctionPolicySpec: + """Configured tool policy whose function path cannot be imported.""" + return _tool_policy(name, FunctionRef(path=_BROKEN_PATH)) + + +@pytest.mark.asyncio +async def test_single_unresolved_policy_denies_tool_call() -> None: + """One broken configured policy must DENY TOOL_CALL, not leave an empty ALLOW gate.""" + gate = RunnerToolPolicyGate.from_spec(_agent_with_policies(_broken_policy())) + assert not gate.is_empty + + verdict = await gate.evaluate_tool_call("web_search", {"q": "x"}) + assert verdict.action == "deny" + assert verdict.policy_name == "broken" + assert verdict.deny_text is not None + assert "failed to resolve" in verdict.deny_text + assert "ModuleNotFoundError" in verdict.deny_text + assert _BROKEN_PATH in verdict.deny_text + + +@pytest.mark.asyncio +async def test_single_unresolved_policy_denies_tool_result() -> None: + """Unresolved policies must also fail closed on TOOL_RESULT.""" + gate = RunnerToolPolicyGate.from_spec(_agent_with_policies(_broken_policy())) + output = await gate.evaluate_tool_result("web_search", "raw tool output") + assert "Denied by policy: broken" in output + assert "failed to resolve" in output + assert "raw tool output" not in output + + +@pytest.mark.asyncio +async def test_unresolved_alongside_valid_still_denies() -> None: + """A broken policy next to a valid ALLOW policy must still DENY.""" + gate = RunnerToolPolicyGate.from_spec( + _agent_with_policies( + _tool_policy("allow_all", _FIXED_ALLOW), + _broken_policy("broken"), + ), + ) + verdict = await gate.evaluate_tool_call("web_search", {}) + assert verdict.action == "deny" + assert verdict.policy_name == "broken" + + output = await gate.evaluate_tool_result("web_search", "ok") + assert "Denied by policy: broken" in output + + +@pytest.mark.asyncio +@pytest.mark.parametrize("configured_phase", [Phase.TOOL_CALL, Phase.TOOL_RESULT]) +async def test_unresolved_policy_denies_both_tool_phases( + configured_phase: Phase, +) -> None: + """Resolution failure denies both tool phases regardless of its selector.""" + broken = _tool_policy( + "phase_broken", + FunctionRef(path=_BROKEN_PATH), + phases=[configured_phase], + ) + gate = RunnerToolPolicyGate.from_spec(_agent_with_policies(broken)) + + verdict = await gate.evaluate_tool_call("web_search", {}) + assert verdict.action == "deny" + assert verdict.policy_name == "phase_broken" + + output = await gate.evaluate_tool_result("web_search", "raw tool output") + assert "Denied by policy: phase_broken" in output + assert "raw tool output" not in output + + +@pytest.mark.asyncio +async def test_valid_allow_policy_unchanged() -> None: + """Successfully resolved ALLOW policies still allow tool call and result.""" + gate = RunnerToolPolicyGate.from_spec( + _agent_with_policies(_tool_policy("allow_all", _FIXED_ALLOW)), + ) + verdict = await gate.evaluate_tool_call("web_search", {"q": "ok"}) + assert verdict.action == "allow" + assert verdict.deny_text is None + + output = await gate.evaluate_tool_result("web_search", "tool output") + assert output == "tool output" + + +@pytest.mark.asyncio +async def test_valid_deny_policy_unchanged() -> None: + """Successfully resolved DENY policies keep their reason and denial shape.""" + gate = RunnerToolPolicyGate.from_spec( + _agent_with_policies(_tool_policy("block", _FIXED_DENY)), + ) + verdict = await gate.evaluate_tool_call("web_search", {}) + assert verdict.action == "deny" + assert verdict.policy_name == "block" + assert verdict.reason == "blocked by valid policy" + assert verdict.deny_text is not None + body = json.loads(verdict.deny_text.split("] ", 1)[1]) + assert body["denied_by_policy"] == "block" + assert body["reason"] == "blocked by valid policy" + + +@pytest.mark.asyncio +async def test_resolve_diagnostic_omits_exception_message_secrets() -> None: + """Deny text must not echo exception strings that may contain secrets.""" + + class _SecretError(RuntimeError): + """Stand-in whose message looks like a leaked credential.""" + + secret = "api_key=SUPER_SECRET_TOKEN_XYZ" + + def _boom(_ps: FunctionPolicySpec) -> None: + raise _SecretError(secret) + + import omnigent.runner.policy as policy_mod + + original = policy_mod.resolve_function_policy + policy_mod.resolve_function_policy = _boom # type: ignore[assignment] + try: + gate = RunnerToolPolicyGate.from_spec(_agent_with_policies(_broken_policy("leaky"))) + finally: + policy_mod.resolve_function_policy = original + + verdict = await gate.evaluate_tool_call("web_search", {}) + assert verdict.action == "deny" + assert verdict.deny_text is not None + assert secret not in verdict.deny_text + assert "SUPER_SECRET" not in verdict.deny_text + assert "_SecretError" in verdict.deny_text + assert "failed to resolve" in verdict.deny_text + + +@pytest.mark.asyncio +async def test_resolve_log_omits_exception_message_secrets( + caplog: pytest.LogCaptureFixture, +) -> None: + """Resolution logs retain safe context without exception text or traceback.""" + import omnigent.runner.policy as policy_mod + + secret = "api_key=SUPER_SECRET_LOG_TOKEN_XYZ" + + class _SecretLogError(RuntimeError): + """Stand-in whose message looks like a leaked log credential.""" + + def _boom(_ps: FunctionPolicySpec) -> None: + raise _SecretLogError(secret) + + original = policy_mod.resolve_function_policy + policy_mod.resolve_function_policy = _boom # type: ignore[assignment] + try: + with caplog.at_level("ERROR", logger=policy_mod.__name__): + RunnerToolPolicyGate.from_spec(_agent_with_policies(_broken_policy("leaky_log"))) + finally: + policy_mod.resolve_function_policy = original + + assert "_SecretLogError" in caplog.text + assert _BROKEN_PATH in caplog.text + assert secret not in caplog.text + assert "SUPER_SECRET" not in caplog.text + + +@pytest.mark.asyncio +async def test_evaluation_time_exception_still_fails_closed() -> None: + """A resolved policy that raises at evaluate time remains fail-closed DENY.""" + # arguments force the factory to return a raising evaluator. + raising = FunctionRef( + path=_RAISING_PATH, + arguments={"action": "allow"}, + ) + gate = RunnerToolPolicyGate.from_spec( + _agent_with_policies(_tool_policy("raises_at_eval", raising)), + ) + # Swap the underlying callable to raise while keeping the gate non-empty. + gated = gate._policies[0] + original = gated.policy._callable + + def _raise(_event: object) -> None: + raise RuntimeError("eval boom") + + gated.policy._callable = _raise # type: ignore[method-assign] + try: + verdict = await gate.evaluate_tool_call("web_search", {}) + finally: + gated.policy._callable = original + + assert verdict.action == "deny" + assert verdict.policy_name == "raises_at_eval" + assert verdict.deny_text is not None + assert "policy raised" in verdict.deny_text + assert "RuntimeError" in verdict.deny_text + + +@pytest.mark.asyncio +async def test_no_policies_still_allows() -> None: + """An agent with no guardrails policies keeps the empty-gate ALLOW path.""" + gate = RunnerToolPolicyGate.from_spec( + AgentSpec(spec_version=1, name="no-policies"), + ) + assert gate.is_empty + verdict = await gate.evaluate_tool_call("web_search", {}) + assert verdict.action == "allow" + output = await gate.evaluate_tool_result("web_search", "ok") + assert output == "ok" From 8adf530912d69866bb3f405ade9eadcb25344278 Mon Sep 17 00:00:00 2001 From: Tomu Hirata Date: Wed, 22 Jul 2026 11:52:33 +0900 Subject: [PATCH 541/546] perf(store): batch FTS inserts in append and fork_conversation (#2998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(store): batch FTS inserts in append and fork_conversation Each call to insert_fts issued a separate raw SQL INSERT into the conversation_items_fts table, causing N+1 queries when appending or forking conversations with many items. Add insert_fts_bulk(session, rows) in omnigent/db/utils.py that issues a single multi-row INSERT for any number of rows. Replace the per-item insert_fts calls in append and fork_conversation with a single insert_fts_bulk call after the loop. Keep insert_fts intact for single-item callers. Signed-off-by: Tomu Hirata * fix(db): chunk insert_fts_bulk to avoid SQLite variable limit Split rows into chunks of 300 (3 params × 300 = 900 binds) so a single INSERT never exceeds SQLite's SQLITE_MAX_VARIABLE_NUMBER (999 on pre-3.32 builds). Without chunking, fork_conversation on a large conversation raises OperationalError: too many SQL variables. Also add the list[tuple[str, str, str]] annotation to fts_rows in fork_conversation to match the append call site. Signed-off-by: Tomu Hirata --------- Signed-off-by: Tomu Hirata --- omnigent/db/utils.py | 39 +++++++++++++++++++ .../conversation_store/sqlalchemy_store.py | 15 ++++--- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/omnigent/db/utils.py b/omnigent/db/utils.py index e2eb1010a1a..4187d95229f 100644 --- a/omnigent/db/utils.py +++ b/omnigent/db/utils.py @@ -775,6 +775,45 @@ def insert_fts( ) +def insert_fts_bulk( + session: Session, + rows: list[tuple[str, str, str]], +) -> None: + """ + Dual-write multiple rows into the FTS5 table in a single INSERT. + + On dialects without FTS5 this is a no-op. An empty ``rows`` list is also + a no-op. + + :param session: An active SQLAlchemy session. + :param rows: Each tuple is ``(item_id, conversation_id, search_text)``. + """ + if not rows: + return + if not (session.bind and _supports_fts5(session.bind.dialect.name)): + return + # 3 params per row; keep total < 999 (SQLite's safe SQLITE_MAX_VARIABLE_NUMBER + # on pre-3.32 builds). Newer SQLite raised the limit to 32766, but chunking at + # 300 is safe on all versions. + _CHUNK_SIZE = 300 + for chunk_start in range(0, len(rows), _CHUNK_SIZE): + chunk = rows[chunk_start : chunk_start + _CHUNK_SIZE] + placeholders = ", ".join(f"(:item_id_{i}, :cid_{i}, :st_{i})" for i in range(len(chunk))) + params: dict[str, str] = {} + for i, (item_id, conversation_id, search_text) in enumerate(chunk): + params[f"item_id_{i}"] = item_id + params[f"cid_{i}"] = conversation_id + params[f"st_{i}"] = search_text + session.execute( + text( + f"INSERT INTO {_FTS_TABLE}" + f"(item_id, conversation_id, search_text) " + f"VALUES {placeholders}" + ), + params, + ) + + def delete_fts_by_conversation(session: Session, conversation_id: str) -> None: """ Remove all FTS rows for a conversation (SQLite-family dialects only). diff --git a/omnigent/stores/conversation_store/sqlalchemy_store.py b/omnigent/stores/conversation_store/sqlalchemy_store.py index c6f5c094a71..c642363749c 100644 --- a/omnigent/stores/conversation_store/sqlalchemy_store.py +++ b/omnigent/stores/conversation_store/sqlalchemy_store.py @@ -59,7 +59,7 @@ generate_item_id, get_or_create_conversation_engine, get_or_create_engine, - insert_fts, + insert_fts_bulk, make_managed_session_maker, now_epoch, strip_nul_bytes, @@ -1856,6 +1856,7 @@ def append( + 1 ) + fts_rows: list[tuple[str, str, str]] = [] for item in items: position = next_pos next_pos += 1 @@ -1880,7 +1881,7 @@ def append( created_by=item.created_by, ) session.add(row) - insert_fts(session, item_id, conversation_id, search) + fts_rows.append((item_id, conversation_id, search)) persisted.append( ConversationItem( id=row.id, @@ -1895,6 +1896,7 @@ def append( created_by=item.created_by, ) ) + insert_fts_bulk(session, fts_rows) # Persist the advanced counter so the next append reads it instead # of scanning; this also lazily backfills a pre-counter conversation. @@ -3221,6 +3223,7 @@ def fork_conversation( items_query = items_query.where(SqlConversationItem.position <= cutoff_position) source_items = session.execute(items_query).scalars().all() + fts_rows: list[tuple[str, str, str]] = [] for pos, src_item in enumerate(source_items): # src_item.type/status are int codes copied verbatim to the new # row; only generate_item_id needs the decoded string type. @@ -3238,12 +3241,8 @@ def fork_conversation( created_by=src_item.created_by, ) session.add(new_item) - insert_fts( - session, - new_item_id, - new_conv.id, - src_item.search_text or "", - ) + fts_rows.append((new_item_id, new_conv.id, src_item.search_text or "")) + insert_fts_bulk(session, fts_rows) # The clone copied len(source_items) items at dense positions # 0..N-1, so its position allocator starts at N. Seed it from the From 8ee18e95354e71f863d3276494508833c82be6e7 Mon Sep 17 00:00:00 2001 From: Tomu Hirata Date: Wed, 22 Jul 2026 11:53:05 +0900 Subject: [PATCH 542/546] perf(permission-store): eliminate N+1 queries in reassign_user_grants (#2994) Replace M individual session.get() PK lookups + M individual UPDATEs with a single IN-clause query to fetch existing to_user grants, then one bulk DELETE for duplicates and one bulk UPDATE for reassigns. For M grants this reduces the query count from 1 + M + up to M = 1+2M down to 3 queries regardless of M. Signed-off-by: Tomu Hirata --- .../permission_store/sqlalchemy_store.py | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/omnigent/stores/permission_store/sqlalchemy_store.py b/omnigent/stores/permission_store/sqlalchemy_store.py index e722bb9ab44..c68894fa570 100644 --- a/omnigent/stores/permission_store/sqlalchemy_store.py +++ b/omnigent/stores/permission_store/sqlalchemy_store.py @@ -173,30 +173,44 @@ def reassign_user_grants(self, from_user_id: str, to_user_id: str) -> int: .scalars() .all() ) - for row in rows: - conversation_id = row.conversation_id - if ( - session.get( - SqlSessionPermission, - (current_workspace_id(), to_user_id, conversation_id), + if not rows: + return 0 + conversation_ids = [r.conversation_id for r in rows] + # Single query: which conversation_ids does to_user already hold? + existing_to = set( + session.execute( + select(SqlSessionPermission.conversation_id).where( + SqlSessionPermission.workspace_id == current_workspace_id(), + SqlSessionPermission.user_id == to_user_id, + SqlSessionPermission.conversation_id.in_(conversation_ids), + ) + ).scalars() + ) + # Partition into duplicates (to_user already has access) vs. reassigns. + duplicate_ids = [cid for cid in conversation_ids if cid in existing_to] + reassign_ids = [cid for cid in conversation_ids if cid not in existing_to] + # Bulk delete duplicates (to_user already has the grant). + if duplicate_ids: + session.execute( + delete(SqlSessionPermission).where( + SqlSessionPermission.workspace_id == current_workspace_id(), + SqlSessionPermission.user_id == from_user_id, + SqlSessionPermission.conversation_id.in_(duplicate_ids), ) - is not None - ): - # Destination already has access — drop the duplicate. - session.delete(row) - continue - # user_id is part of the PK, so repoint with a targeted Core - # UPDATE rather than mutating the ORM object's primary key. + ) + # Bulk UPDATE reassigns in one statement. + if reassign_ids: + # user_id is part of the PK, so use a Core UPDATE. session.execute( update(SqlSessionPermission) .where( SqlSessionPermission.workspace_id == current_workspace_id(), SqlSessionPermission.user_id == from_user_id, - SqlSessionPermission.conversation_id == conversation_id, + SqlSessionPermission.conversation_id.in_(reassign_ids), ) .values(user_id=to_user_id) ) - moved += 1 + moved = len(reassign_ids) return moved def list_for_session(self, conversation_id: str) -> list[SessionPermission]: From b221bb9f2eaeab44ba2eaf3d60f9d796f673872c Mon Sep 17 00:00:00 2001 From: Serena Ruan <82044803+serena-ruan@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:07:52 +0800 Subject: [PATCH 543/546] fix(web): don't queue messages while only background work is running (#2974) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): don't queue messages while only background work is running A session with a running background job (background shell / still-running sub-agent) settles into the `waiting` status: the turn already ended and the server's turn gate is free to accept a new turn, but the frontend treated `waiting` as busy and queued every new message client-side until full idle. Two independent gates forced this: - `shouldQueueSend` / `maybeFlushQueuedHead` treated `sessionStatus === "waiting"` as busy, so sends queued and the queue wouldn't drain. - The `session_status` handler grouped a `waiting` edge carrying a `response_id` (which the claude/cursor-native Stop hook always posts) with `running`, forcing local `status = "streaming"`, which never cleared while background work ran. The composer's "(queued)" placeholder and the send gate both key off local `status`, so this alone kept messages queued on native sessions. Treat `waiting` as a turn-end edge everywhere it gates sends: drop it from the busy checks and finalize the local send lifecycle like `idle`, while keeping `sessionStatus = "waiting"` and `backgroundTaskCount` so the "Working…" spinner and sidebar dot still reflect the background activity. A new message now starts a fresh turn immediately, matching what the server already accepts. This only affects sessions with background work running — a turn that ends with no background work still settles on `idle` and behaves exactly as before. Co-authored-by: Isaac Signed-off-by: Serena Ruan * fix(web): treat waiting as turn-end on reconnect; add e2e coverage Address the Polly review notes on the message-queueing fix and add the e2e_ui coverage the required gate asks for. - `reconnectStatusPatch`: a `waiting` snapshot is a turn-end edge, so it now finalizes the local send lifecycle like `idle` instead of reopening a streaming response. The server keeps `active_response_id` populated across `waiting` (it only pops on idle/failed), so grouping `waiting` with `running` re-opened "streaming" on a reload/reconnect and re-queued sends — the exact behavior the fix removes. Now covered for the reloaded-tab path, not just live SSE. - The live-SSE mismatched-id `waiting` branch now finalizes a still-streaming bubble to `completed`, matching the matching-id path, so a stale bubble doesn't linger spinning with no edge left to close it. - Add tests/e2e_ui/chat/test_send_while_background_task.py: publishes the native Stop-hook `waiting`+response_id edge live, then asserts the composer sends directly (idle placeholder, user bubble renders, no queued strip) instead of queueing behind the background task. Co-authored-by: Isaac Signed-off-by: Serena Ruan --------- Signed-off-by: Serena Ruan --- .../chat/test_send_while_background_task.py | 124 ++++++++++++++++++ web/src/pages/ChatPage.composer.test.tsx | 10 +- web/src/pages/ChatPage.tsx | 10 +- web/src/store/chatStore.test.ts | 79 ++++++++++- web/src/store/chatStore.ts | 54 ++++++-- 5 files changed, 259 insertions(+), 18 deletions(-) create mode 100644 tests/e2e_ui/chat/test_send_while_background_task.py diff --git a/tests/e2e_ui/chat/test_send_while_background_task.py b/tests/e2e_ui/chat/test_send_while_background_task.py new file mode 100644 index 00000000000..376d66c4666 --- /dev/null +++ b/tests/e2e_ui/chat/test_send_while_background_task.py @@ -0,0 +1,124 @@ +"""Sending a message while only background work is running. + +A claude-native turn can settle into the user-visible ``waiting`` state: +the turn already ended and the server's turn gate is free, but background +shells (or a still-running sub-agent) outlive it. The claude/cursor-native +Stop hook reports this as an ``external_session_status`` edge carrying +``status: "waiting"``, the ended turn's ``response_id``, and a positive +``background_task_count``. + +The web composer must treat that as free-to-send — a new message starts a +fresh turn immediately — NOT queue it behind the background work. The +regression this guards: the ``waiting``+``response_id`` edge used to force +the local send lifecycle into ``streaming``, so the composer showed +"Send a follow-up (queued)" and held every message in the client-side +queue strip until the background work finished. + +The ``waiting`` edge is published LIVE (after navigation) so it arrives via +the SSE ``session.status`` path — the one the fix targets. Publishing it +before navigation is not equivalent: this suite's ``openai-agents`` runner +collapses a posted ``waiting`` to ``running`` in the snapshot projection, so +a pre-navigation post would not reproduce the ``waiting`` state at all. + +Like ``test_working_indicator_background_tasks``, this drives the real +status edge through the Sessions events route (the same path the +claude-native forwarder posts to), so it is deterministic — no live LLM +turn whose timing would make the assertions flaky. +""" + +from __future__ import annotations + +import httpx +from playwright.sync_api import Page, expect + +_QUEUED_STRIP = '[data-testid="composer-queued-strip"]' +_WORKING = '[data-testid="working-indicator"]' +_COMPOSER_PLACEHOLDER_IDLE = "Ask the agent anything…" + +_SEND_MSG = "sentinel-bg-send-2a9c sent while a background task runs" + + +def _publish_status( + base_url: str, + session_id: str, + status: str, + *, + response_id: str | None = None, + background_task_count: int | None = None, +) -> None: + """Publish a session status through the native-harness events route. + + :param base_url: Base URL of the local e2e server. + :param session_id: Session/conversation id. + :param status: Session status to publish, e.g. ``"waiting"``. + :param response_id: Ended turn's response id, as the native Stop hook + attaches it. ``None`` omits the field. + :param background_task_count: Background shells still running as of this + edge. ``None`` omits the field (leaves the sticky tally untouched). + :returns: None. + """ + data: dict[str, object] = {"status": status} + if response_id is not None: + data["response_id"] = response_id + if background_task_count is not None: + data["background_task_count"] = background_task_count + resp = httpx.post( + f"{base_url}/v1/sessions/{session_id}/events", + json={"type": "external_session_status", "data": data}, + timeout=10.0, + ) + resp.raise_for_status() + + +def _user_bubble(page: Page, text: str): + """Locator for the user-message bubble carrying ``text``.""" + return page.locator('[data-testid="message-bubble"][data-role="user"]').filter(has_text=text) + + +def test_message_sends_directly_while_background_task_runs( + page: Page, + seeded_session: tuple[str, str], +) -> None: + """A message sends immediately while the session is only ``waiting``. + + Drives the native Stop-hook edge live (``waiting`` + ``response_id`` + + a positive ``background_task_count``), then asserts the composer is + free to send: the placeholder reads the idle prompt (not the queued + follow-up), sending renders the user bubble immediately, and the + message never lands in the client-side queued strip. + + :param page: Playwright page fixture. + :param seeded_session: ``(base_url, session_id)`` from the local server + fixture. + :returns: None. + """ + base_url, session_id = seeded_session + composer = page.get_by_label("Message the agent") + page.goto(f"{base_url}/c/{session_id}") + expect(composer).to_be_visible() + + # The turn ended but a background shell outlives it: the Stop hook posts + # `waiting` with the ended turn's response_id and a positive count. The + # working indicator stays lit ("1 background task still running"). + _publish_status( + base_url, + session_id, + "waiting", + response_id="resp_bg_1", + background_task_count=1, + ) + expect(page.locator(_WORKING)).to_contain_text( + "1 background task still running", timeout=15_000 + ) + + # The composer must be free to send — NOT stuck on the queued follow-up + # placeholder. This is the exact regression: `waiting`+response_id used + # to leave the local send lifecycle "streaming", showing the queued hint. + expect(composer).to_have_attribute("placeholder", _COMPOSER_PLACEHOLDER_IDLE, timeout=15_000) + + # Sending must dispatch directly (a fresh turn), not enqueue: the user + # bubble renders immediately and nothing appears in the queued strip. + composer.fill(_SEND_MSG) + page.get_by_role("button", name="Send", exact=True).click() + expect(_user_bubble(page, _SEND_MSG)).to_be_visible(timeout=10_000) + expect(page.locator(_QUEUED_STRIP)).to_have_count(0) diff --git a/web/src/pages/ChatPage.composer.test.tsx b/web/src/pages/ChatPage.composer.test.tsx index fc4d6454bc3..4dfa909aee2 100644 --- a/web/src/pages/ChatPage.composer.test.tsx +++ b/web/src/pages/ChatPage.composer.test.tsx @@ -1274,16 +1274,22 @@ describe("shouldQueueSend", () => { expect(shouldQueueSend(null, "streaming", "running", [])).toBe(false); }); - it("queues while the session is busy (streaming or running/waiting)", () => { + it("queues while the session is busy (streaming or running)", () => { expect(shouldQueueSend("conv_a", "streaming", "idle", [])).toBe(true); expect(shouldQueueSend("conv_a", "idle", "running", [])).toBe(true); - expect(shouldQueueSend("conv_a", "idle", "waiting", [])).toBe(true); }); it("sends directly when idle and nothing is queued for this conversation", () => { expect(shouldQueueSend("conv_a", "idle", "idle", [])).toBe(false); }); + it("sends directly on `waiting` (turn ended, only background work remains)", () => { + // A background shell / still-running sub-agent keeps the session in + // `waiting`, but the server's turn gate is already free — a new message + // must start a fresh turn rather than stalling in the client queue. + expect(shouldQueueSend("conv_a", "idle", "waiting", [])).toBe(false); + }); + it("queues when idle but this conversation already has a queued message", () => { // The ordering fix: an idle flicker must not let a later send overtake the // still-queued earlier one. diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index ec086a7086f..cee7cea171c 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -475,6 +475,13 @@ export function shouldShowAuthorBadge( * if it reads idle: the direct-send and queue-drain paths aren't ordered, so a * later direct send could overtake a still-queued earlier one when status * flickers idle mid-queue (cursor-native). A new chat always sends. + * + * ``waiting`` is NOT busy for queueing: it means the turn already ended and the + * agent loop is only parked on background work (background shells / sub-agents) + * — the server's turn gate is already free, so a new message starts a fresh + * turn immediately instead of stalling behind that background work. (The + * "Working…" spinner and sidebar dot still treat ``waiting`` as active — those + * reflect background activity, which is a separate concern from send gating.) */ export function shouldQueueSend( conversationId: string | null, @@ -483,8 +490,7 @@ export function shouldQueueSend( queuedMessages: QueuedMessage[], ): boolean { if (conversationId === null) return false; - const isBusy = - status === "streaming" || sessionStatus === "running" || sessionStatus === "waiting"; + const isBusy = status === "streaming" || sessionStatus === "running"; const hasQueued = queuedMessages.some((m) => m.conversationId === conversationId); return isBusy || hasQueued; } diff --git a/web/src/store/chatStore.test.ts b/web/src/store/chatStore.test.ts index a686005f14e..a4c0e5821e6 100644 --- a/web/src/store/chatStore.test.ts +++ b/web/src/store/chatStore.test.ts @@ -2594,6 +2594,57 @@ describe("chatStore — background-shell tally (claude-native)", () => { expect(state.backgroundTaskCount).toBe(0); }); + it("frees the local send lifecycle on a Stop-derived waiting edge (with responseId)", () => { + // Regression: the claude/cursor-native Stop hook posts the turn-end + // `waiting` edge WITH the ended turn's `response_id`. It must finalize the + // local `status` to idle (the turn is done) so the composer sends the next + // message instead of queuing it — while `sessionStatus` stays `waiting` and + // the shell count sticks, keeping the "Working…" spinner lit. + useChatStore.setState({ + conversationId: "conv_abc", + status: "streaming", + sessionStatus: "running", + backgroundTaskCount: 0, + activeResponse: { responseId: "resp_1", state: "streaming", error: null }, + }); + handleSessionEvent({ + type: "session_status", + conversationId: "conv_abc", + status: "waiting", + responseId: "resp_1", + backgroundTaskCount: 1, + }); + const state = useChatStore.getState(); + expect(state.status).toBe("idle"); + expect(state.activeResponse?.state).toBe("completed"); + expect(state.sessionStatus).toBe("waiting"); + expect(state.backgroundTaskCount).toBe(1); + }); + + it("frees the local send lifecycle on a waiting edge whose id doesn't match", () => { + // A `waiting` edge that carries no id (or a stale one) with no tracked + // response must still free the send lifecycle so a message isn't stranded. + useChatStore.setState({ + conversationId: "conv_abc", + status: "streaming", + sessionStatus: "running", + backgroundTaskCount: 0, + activeResponse: { responseId: "resp_1", state: "streaming", error: null }, + }); + handleSessionEvent({ + type: "session_status", + conversationId: "conv_abc", + status: "waiting", + backgroundTaskCount: 1, + }); + const state = useChatStore.getState(); + expect(state.status).toBe("idle"); + expect(state.sessionStatus).toBe("waiting"); + // The stale streaming bubble is finalized so it doesn't linger spinning — + // no future edge names this id to close it. + expect(state.activeResponse?.state).toBe("completed"); + }); + it("clears the shell count when a new turn starts (running edge)", () => { // A `running` edge with no count means a fresh turn began; the prior // turn's tally is stale and must clear, mirroring the server's @@ -8351,6 +8402,29 @@ describe("chatStore — client-side message queue", () => { expect(sendSpy.mock.calls[1]!.slice(0, 2)).toEqual(["second", "agent_xyz"]); }); + // Regression: a background shell / still-running sub-agent keeps the session + // in `waiting` after the turn ends, but the server's turn gate is already + // free. The flush must NOT treat `waiting` as busy — otherwise a queued + // message stays stuck until full idle even though a new turn could start now. + it("flushes the head on `waiting` (background work outlives the turn)", async () => { + const sendSpy = vi.fn().mockResolvedValue(undefined); + useChatStore.setState({ + conversationId: "conv_abc", + boundAgentId: "agent_xyz", + status: "idle", + sessionStatus: "waiting", + backgroundTaskCount: 1, + send: sendSpy, + queuedMessages: [{ queueId: "q_1", text: "first", conversationId: "conv_abc" }], + }); + + useChatStore.getState().maybeFlushQueuedHead(); + await tick(); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy.mock.calls[0]!.slice(0, 2)).toEqual(["first", "agent_xyz"]); + expect(useChatStore.getState().queuedMessages).toEqual([]); + }); + it("does not flush a queue owned by a different conversation", () => { const sendSpy = vi.fn().mockResolvedValue(undefined); useChatStore.setState({ @@ -8436,7 +8510,7 @@ describe("chatStore — client-side message queue", () => { expect(useChatStore.getState().queuedMessages.map((m) => m.text)).toEqual(["b1"]); }); - it("does not flush while busy (streaming or running/waiting)", () => { + it("does not flush while busy (streaming or running)", () => { const sendSpy = vi.fn().mockResolvedValue(undefined); const base = { conversationId: "conv_abc", @@ -8451,9 +8525,6 @@ describe("chatStore — client-side message queue", () => { // Server-side turn still running. useChatStore.setState({ ...base, status: "idle", sessionStatus: "running" }); useChatStore.getState().maybeFlushQueuedHead(); - // Draining background work. - useChatStore.setState({ ...base, status: "idle", sessionStatus: "waiting" }); - useChatStore.getState().maybeFlushQueuedHead(); expect(sendSpy).not.toHaveBeenCalled(); expect(useChatStore.getState().queuedMessages.map((m) => m.text)).toEqual(["wait"]); diff --git a/web/src/store/chatStore.ts b/web/src/store/chatStore.ts index 1dbe420d02e..1707d6d9c5e 100644 --- a/web/src/store/chatStore.ts +++ b/web/src/store/chatStore.ts @@ -1017,14 +1017,16 @@ export const useChatStore = create((set, get) => ({ maybeFlushQueuedHead: () => { const s = get(); - // Only when fully idle: both the local send lifecycle AND the server-side - // session status. No agent → nothing to send to. + // Flush once the agent loop is free to take a turn. `waiting` is NOT busy: + // the turn already ended and only background work (background shells / + // sub-agents) outlives it, so the server accepts a new turn immediately — + // mirror `shouldQueueSend`. Only the local send lifecycle (`streaming`) and + // an actively `running` turn gate the flush. No agent → nothing to send to. if ( s.conversationId === null || s.boundAgentId === null || s.status === "streaming" || - s.sessionStatus === "running" || - s.sessionStatus === "waiting" + s.sessionStatus === "running" ) { return; } @@ -2687,6 +2689,14 @@ function reconnectStatusPatch(session: Session, s: ChatState): Partial Date: Wed, 22 Jul 2026 10:34:13 +0700 Subject: [PATCH 544/546] feat(server): install a missing harness onto a connected host from the UI (backend, flag-gated) (#2912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(host): add install-harness tunnel frame pair + registry plumbing Adds the HostInstallHarnessFrame / HostInstallHarnessResultFrame pair to the host tunnel protocol, mirroring the existing HostCreateDirFrame request/result pattern, plus the pending_installs future map on HostConnection. This is the vocabulary the server and a connected host use to negotiate a UI-driven harness install (later PRs add the host handler, the route, and the frontend button). Additive only: no frame is sent or received yet, so behavior is unchanged. The result frame carries a freshly-recomputed readiness map (configured_harnesses, reusing _optional_str_availability_map) so the UI can flip the harness badge without waiting for a reconnect. Co-authored-by: Isaac Signed-off-by: xq-yin * refactor(onboarding): surface install failure reason from install_harness_cli Extracts install_harness_cli_with_reason(key) -> tuple[bool, str | None] alongside the existing install_harness_cli(key) -> bool, which becomes a thin wrapper that discards the reason. Single implementation, no caller churn: the four setup-wizard call sites keep their boolean contract unchanged. The reason is derived from the existing failure branches (manual-only spec, missing installer, timeout, OS error, non-zero exit, post-install binary-not-found) without capturing installer output — so omni setup's live npm output UX is preserved. A later PR's UI-driven install returns this reason to the user instead of a bare failure. Co-authored-by: Isaac Signed-off-by: xq-yin * feat(host): install harness on request + resolve the install result Adds the host daemon side of UI-driven install: - _handle_install_harness in host/connect.py runs install_harness_cli_with_reason off the event loop, recomputes configured_harness_map(), and returns a HostInstallHarnessResultFrame carrying either the fresh readiness map or a failure reason. - host_tunnel.py's receive loop resolves the pending_installs future. - A shared allowlist/resolver (ui_installable_harnesses / ui_install_key) in onboarding/harness_install.py is the single source of truth for which harnesses are UI-installable (claude, codex, pi, opencode, qwen) and their install-spec keys. Defence in depth: the handler re-checks ui_install_key, so a stray or spoofed frame can never drive the installer for a non-allowlisted harness (e.g. hermes, whose installer is a curl | bash). Inert until PR4 wires a sender: nothing emits HostInstallHarnessFrame yet. Co-authored-by: Isaac Signed-off-by: xq-yin * feat(server): add UI harness-install route behind a default-off flag Adds POST /v1/hosts/{host_id}/harnesses/{harness}/install: the server endpoint the web UI's Install action calls. It validates in order — feature flag (404 when off) -> allowlist (400) -> auth/require_user -> owner (403) -> liveness (409) — then forwards a HostInstallHarnessFrame over the tunnel via _proxy_install_harness and returns the host's refreshed configured_harnesses map. - Reuses the _proxy_create_dir request/future/wait_for template; the install timeout (330s) sits above install_harness_cli's 300s subprocess ceiling so the result is received before the server gives up. - Concurrent installs of the same (host, harness) coalesce onto one in-flight task (conn.inflight_installs) so a double-click can't fire two non-race-safe global npm installs. - Gated by OMNIGENT_HARNESS_INSTALL_ENABLED, surfaced to the SPA via GET /v1/info (harness_install_enabled), mirroring smart_routing_enabled. Allowlist ordering (400 before 403) avoids leaking host ownership through error codes. Ships dark: with the flag off the route is 404, so merging this changes nothing in production. Co-authored-by: Isaac Signed-off-by: xq-yin * fix(host): make UI install idempotent + widen the server wait End-to-end testing against a real host surfaced two issues the stubbed unit tests masked: - The host ran `npm install -g` even when the harness CLI was already on PATH; npm re-resolves over the network and took >60s for an already-present binary, so a repeat Install click hung. _handle_install_harness now short-circuits on harness_cli_installed(key) and just returns fresh readiness (reusing the existing check) — sub-second on the happy path. - The server's per-call wait (330s) sat only 30s above install_harness_cli's own 300s subprocess cap, so a genuine cold npm install could finish right as the server gave up — a "504 but actually installed" outcome. Widened to 420s (300s + 2min headroom for readiness recompute + tunnel latency). Verified end-to-end: happy path 200 in 0.8s (already-installed fast-path), a real cold opencode install completes route->tunnel->daemon->npm->readiness, hermes rejected 400, codex reports needs-auth post-install. Co-authored-by: Isaac Signed-off-by: xq-yin * chore(openapi): regenerate spec for the harness-install route CI's openapi-drift guard flagged openapi.json as out of sync after the new POST /v1/hosts/{host_id}/harnesses/{harness}/install route. Regenerated via scripts/dump_openapi.py so the committed spec matches the app. Co-authored-by: Isaac Signed-off-by: xq-yin * refactor(server): share the harness-install flag env-var name Extract OMNIGENT_HARNESS_INSTALL_ENABLED into a single HARNESS_INSTALL_ENABLED_ENV constant in hosts.py, read by both the install route and the /v1/info flag in app.py, so the flag the UI sees and the flag the route enforces can never drift on a typo. Also switch the install-task scheduling from asyncio.ensure_future to the more idiomatic asyncio.create_task. Co-authored-by: Isaac Signed-off-by: xq-yin * feat(server): describe per-harness setup steps for the UI setup flow Extends the harness-install backend so the web UI can render a "set up this agent" checklist that mirrors omnigent setup, instead of a single Install button. - /v1/harnesses now carries an ordered setup_steps list per harness (install, then auth), derived from the existing HarnessInstallSpec so it can't drift from the real install/login commands. Claude/Codex/Pi/OpenCode/Qwen get a first-class two-step flow; other harnesses get a generic "run omnigent setup" step. - The host readiness map now reports a two-step signal (binary-missing / needs-auth) for Claude and OpenCode too, matching Codex, so the UI can show install-done vs sign-in-done. Pi/Qwen stay binary-only (their credential isn't locally determinable). - The launch gate (harness_is_configured) is unchanged and stays binary-only, so a not-signed-in harness is never blocked from launching. - /v1/info advertises installable_harnesses (bare + native spellings) so the UI offers setup only where the install route will accept it. Co-authored-by: Isaac Signed-off-by: xq-yin * feat(server): key harness setup steps by every spelling for the UI The setup dialog looks up steps by the harness a session declares — often a native wrapper (codex-native) or an installable id that isn't a picker row (opencode/qwen), none of which appear in the harness catalog. Add harness_setup_steps_by_spelling() and return it from GET /v1/harnesses as a top-level setup_steps map so the dialog can resolve steps for whatever id it holds, without adding non-pickable rows to the catalog. Co-authored-by: Isaac Signed-off-by: xq-yin * fix(server): use host.user_id in the install route's owner check The install route still compared host.owner, but the Host model's owner field was renamed to user_id (identity-columns unification on main). An authenticated install therefore 500'd with AttributeError. Switch to host.user_id (matching every other host route) and add an owner-mismatch test that exercises the ownership branch with a real user_id — the existing tests run unauthenticated, so the comparison was never hit. Co-authored-by: Isaac Signed-off-by: xq-yin * docs(server): correct the setup-step "can't drift" comment The auth-step commands (codex login, etc.) are display-only literals, not derived from HarnessInstallSpec.login_args — only the install step's label is derived. Reword the comment/docstring so they don't overstate the guarantee. Co-authored-by: Isaac Signed-off-by: xq-yin * Address review: family-keyed install coalescing + clearer naming - Coalesce concurrent UI installs on the resolved install *family* key (ui_install_key) rather than the raw spelling, so codex + codex-native (both the openai npm package) share one in-flight install. Cleanup is tied to task completion via add_done_callback and every caller awaits under asyncio.shield, so a cancelled request can't clear the map out from under a follow-up and start a second concurrent `npm install -g`. - Add an integration test that fires two overlapping same-family installs and asserts exactly one frame reaches the host. - Rename install_harness_cli_with_reason -> try_install_harness_cli and return a HarnessInstallResult NamedTuple instead of a bare tuple. - Trim the over-long install-handler docstring and UI-installable map comment to the essentials. Co-authored-by: Isaac Signed-off-by: xq-yin --------- Signed-off-by: xq-yin --- omnigent/harness_install_spec.py | 46 ++ omnigent/harness_plugins.py | 43 +- omnigent/host/connect.py | 61 +- omnigent/host/frames.py | 99 +++ omnigent/onboarding/harness_install.py | 240 +++++++- omnigent/onboarding/harness_readiness.py | 37 ++ omnigent/server/app.py | 25 +- omnigent/server/host_registry.py | 17 + omnigent/server/routes/harnesses.py | 15 +- omnigent/server/routes/host_tunnel.py | 13 + omnigent/server/routes/hosts.py | 174 ++++++ openapi.json | 70 ++- tests/host/test_connect.py | 118 ++++ tests/host/test_frames.py | 66 ++ tests/onboarding/test_harness_install.py | 131 ++++ tests/onboarding/test_harness_readiness.py | 66 +- .../integration/test_hosts_install_harness.py | 571 ++++++++++++++++++ .../integration/test_utility_endpoints.py | 28 + tests/test_harness_capabilities.py | 34 ++ tests/test_harness_plugins.py | 6 +- 20 files changed, 1824 insertions(+), 36 deletions(-) create mode 100644 tests/server/integration/test_hosts_install_harness.py diff --git a/omnigent/harness_install_spec.py b/omnigent/harness_install_spec.py index 2b2eb75709f..add26c0d18f 100644 --- a/omnigent/harness_install_spec.py +++ b/omnigent/harness_install_spec.py @@ -24,3 +24,49 @@ class HarnessInstallSpec: login_status_key: str | None = None auth_hint: str | None = None install_command: tuple[str, ...] | None = None + + +@dataclass(frozen=True) +class SetupStep: + """One requirement in getting a harness ready to run on a host. + + Serialized into the ``GET /v1/harnesses`` catalog (``setup_steps``) so the + web UI can render a "set up this agent" checklist that mirrors what + ``omnigent setup`` walks a user through — one row per requirement, in order. + + :param kind: Machine id for the requirement, ``"install"`` or ``"auth"``. + :param title: Human row label, agent-framed (e.g. ``"Install Codex"``, + ``"Sign in to Codex"``). + :param detail: Optional one-line explanation of what the step means for + this harness (e.g. "Uses your ChatGPT subscription"). + :param action: How the user resolves it — ``"install"`` (a one-click + install the server performs), ``"command"`` (a command the user runs on + the host, in :attr:`command`), or ``"setup"`` (run ``omnigent setup`` — + the M1 fallback for auth methods the UI can't yet drive, e.g. entering + an API key or gateway). + :param command: The command for ``action="command"``/``"setup"`` steps + (e.g. ``"codex login"``); ``None`` for one-click installs. + :param status_key: Which readiness sub-state marks this step done, or + ``None`` when the host can't determine it (the step renders as an + informational instruction, not a tracked ✓/○). ``"installed"`` → + done once the binary is present; ``"authed"`` → done once the harness + reports it's authenticated. + """ + + kind: str + title: str + detail: str + action: str + command: str | None = None + status_key: str | None = None + + def as_dict(self) -> dict[str, str | None]: + """JSON-serializable row for the ``/v1/harnesses`` catalog.""" + return { + "kind": self.kind, + "title": self.title, + "detail": self.detail, + "action": self.action, + "command": self.command, + "status_key": self.status_key, + } diff --git a/omnigent/harness_plugins.py b/omnigent/harness_plugins.py index 455d53376f0..9190fd2645b 100644 --- a/omnigent/harness_plugins.py +++ b/omnigent/harness_plugins.py @@ -901,11 +901,20 @@ def harness_catalog() -> list[dict[str, Any]]: Each row carries ``id`` and ``label``; rows for harnesses with declared capabilities also carry a ``capabilities`` object (see - :meth:`HarnessCapabilities.as_dict`), so the ``/v1/harnesses`` catalog can - surface the feature matrix to clients. + :meth:`HarnessCapabilities.as_dict`). ``setup_steps`` lists the ordered + requirements to get the harness ready on a host (install + auth), so the + web UI can render a "set up this agent" checklist that mirrors + ``omnigent setup``; the host reports each step's status in its readiness map. """ labels = harness_labels() capabilities = harness_capabilities() + # Lazy import for the same reason as the acp rows below: keep this registry + # importable without pulling in the onboarding/config stack at module load. + try: + from omnigent.onboarding.harness_install import ui_setup_steps + except Exception: # noqa: BLE001 — a broken onboarding import must not break the catalog + _logger.debug("setup-step metadata unavailable", exc_info=True) + ui_setup_steps = None # type: ignore[assignment] rows: list[dict[str, Any]] = [] for harness in sorted(labels, key=lambda key: labels[key].lower()): if harness not in valid_harnesses(): @@ -914,6 +923,8 @@ def harness_catalog() -> list[dict[str, Any]]: capability = capabilities.get(harness) if capability is not None: row["capabilities"] = capability.as_dict() + if ui_setup_steps is not None: + row["setup_steps"] = [step.as_dict() for step in ui_setup_steps(harness)] rows.append(row) # Dynamic rows: one per user-configured generic-ACP agent, id ``acp:``. @@ -935,6 +946,34 @@ def harness_catalog() -> list[dict[str, Any]]: return rows +def harness_setup_steps_by_spelling() -> dict[str, list[dict[str, Any]]]: + """Map every harness spelling to its ordered UI setup steps. + + The web setup dialog looks steps up by the harness a *session* declares — + which is often a native wrapper (``codex-native``) or an installable id + that is not a picker row (``opencode``/``qwen``), neither of which appears + in :func:`harness_catalog`. Keying by spelling here lets the dialog resolve + steps for whatever id it holds. Values mirror ``harness_catalog``'s + ``setup_steps`` (same :func:`ui_setup_steps` source), so the two can't + drift. + + :returns: ``{spelling: [step.as_dict(), ...]}`` for every accepted spelling; + empty when the onboarding stack can't be imported (fail-open). + """ + try: + from omnigent.onboarding.harness_install import ui_installable_harnesses, ui_setup_steps + except Exception: # noqa: BLE001 — a broken onboarding import must not break the catalog + _logger.debug("setup-step metadata unavailable", exc_info=True) + return {} + # Cover the picker ids (catalog rows) plus every installable spelling + # (bare + native), so a session's declared harness always resolves. + spellings: set[str] = set(valid_harnesses()) + spellings.update(ui_installable_harnesses()) + return { + spelling: [step.as_dict() for step in ui_setup_steps(spelling)] for spelling in spellings + } + + def load_object(import_path: str) -> Any: """Load ``module:attribute`` or ``module.attribute``.""" if ":" in import_path: diff --git a/omnigent/host/connect.py b/omnigent/host/connect.py index 39cdc9e47a3..71058ea7384 100644 --- a/omnigent/host/connect.py +++ b/omnigent/host/connect.py @@ -35,6 +35,8 @@ HostFsResultFrame, HostHarnessReadinessFrame, HostHelloFrame, + HostInstallHarnessFrame, + HostInstallHarnessResultFrame, HostLaunchRunnerFrame, HostLaunchRunnerResultFrame, HostListDirEntry, @@ -61,7 +63,12 @@ remove_worktree, ) from omnigent.host.identity import HostIdentity, load_or_create_host_identity -from omnigent.onboarding.harness_install import harness_setup_hint +from omnigent.onboarding.harness_install import ( + harness_cli_installed, + harness_setup_hint, + try_install_harness_cli, + ui_install_key, +) from omnigent.onboarding.harness_readiness import ( configured_harness_map, harness_is_configured, @@ -1558,6 +1565,53 @@ def _handle_create_dir(self, frame: HostCreateDirFrame) -> HostCreateDirResultFr path=created, ) + def _handle_install_harness( + self, frame: HostInstallHarnessFrame + ) -> HostInstallHarnessResultFrame: + """Handle a ``host.install_harness`` request from the server. + + Runs the same installer :func:`try_install_harness_cli` (hence + ``omnigent setup``) uses, then recomputes readiness so the result frame + carries a fresh ``configured_harnesses`` map. The ``ui_install_key`` + guard re-checks the allowlist as defence in depth against a spoofed + frame. Idempotent: an already-installed CLI skips the install. Runs off + the event loop (it shells out / probes ``PATH``). + + :param frame: The install request frame. ``frame.harness`` is a UI + harness identifier, e.g. ``"claude"``. + :returns: Result frame with ``status`` ``"ok"``/``"failed"``, the + refreshed readiness map on success, and a reason on failure. + """ + key = ui_install_key(frame.harness) + if key is None: + return HostInstallHarnessResultFrame( + request_id=frame.request_id, + status="failed", + error=f"harness {frame.harness!r} is not installable from the UI", + ) + if harness_cli_installed(key): + # Already installed — skip the slow npm re-resolve and just report + # current readiness (which may still be "needs-auth", e.g. codex). + _logger.info("Harness %s already installed; skipping install", frame.harness) + return HostInstallHarnessResultFrame( + request_id=frame.request_id, + status="ok", + configured_harnesses=configured_harness_map(), + ) + installed, reason = try_install_harness_cli(key) + if not installed: + return HostInstallHarnessResultFrame( + request_id=frame.request_id, + status="failed", + error=reason or "install failed", + ) + _logger.info("Installed harness %s via UI request", frame.harness) + return HostInstallHarnessResultFrame( + request_id=frame.request_id, + status="ok", + configured_harnesses=configured_harness_map(), + ) + def _handle_fs_request(self, frame: HostFsRequestFrame) -> HostFsResultFrame: """Serve a read-only workspace filesystem request from the host. @@ -2199,6 +2253,11 @@ async def _dispatch_host_frame( await ws.send(encode_host_frame(self._handle_list_dir(frame))) elif isinstance(frame, HostCreateDirFrame): await ws.send(encode_host_frame(self._handle_create_dir(frame))) + elif isinstance(frame, HostInstallHarnessFrame): + # The installer shells out (npm) and can run for minutes, so run + # it off the event loop and reply when it completes. + result = await asyncio.to_thread(self._handle_install_harness, frame) + await ws.send(encode_host_frame(result)) elif isinstance(frame, HostCreateWorktreeFrame): await ws.send(encode_host_frame(await self._handle_create_worktree(frame))) elif isinstance(frame, HostRemoveWorktreeFrame): diff --git a/omnigent/host/frames.py b/omnigent/host/frames.py index 8fdbd9a67bd..d8c500689d8 100644 --- a/omnigent/host/frames.py +++ b/omnigent/host/frames.py @@ -58,6 +58,8 @@ class HostFrameKind(str, Enum): LIST_WORKTREES_RESULT = "host.list_worktrees_result" CREATE_DIR = "host.create_dir" CREATE_DIR_RESULT = "host.create_dir_result" + INSTALL_HARNESS = "host.install_harness" + INSTALL_HARNESS_RESULT = "host.install_harness_result" FS_REQUEST = "host.fs_request" FS_RESULT = "host.fs_result" @@ -579,6 +581,55 @@ class HostCreateDirResultFrame: error: str | None = None +@dataclass +class HostInstallHarnessFrame: + """Server → host: install a harness CLI on the host. + + Backs ``POST /v1/hosts/{id}/harnesses/{harness}/install``, used by + the Web UI's New Chat dialog so a user can install a missing, + npm-installable harness onto a connected host without dropping to a + terminal. The host runs the same :func:`install_harness_cli` the + ``omnigent setup`` wizard uses. Only allowlisted, npm-installable + harnesses reach this frame — the server rejects curl/brew and + interactive-auth harnesses before sending it. + + :param request_id: Correlates the result, e.g. ``"req_install_1"``. + :param harness: Harness identifier to install, e.g. ``"claude"`` or + ``"codex"``. The host maps it to its install-spec key. + """ + + request_id: str + harness: str + + +@dataclass +class HostInstallHarnessResultFrame: + """Host → server: outcome of an install request. + + Carries the freshly-recomputed readiness map so the server can + update its view and the UI can flip the harness badge without + waiting for a reconnect (the ``host.hello`` handshake is the only + other readiness carrier, sent once per connect). + + :param request_id: Correlates to the + :class:`HostInstallHarnessFrame`, e.g. ``"req_install_1"``. + :param status: ``"ok"`` when the installer ran and the binary landed + on ``PATH``, ``"failed"`` otherwise. A ``"failed"`` status pairs + with a human-readable ``error`` (e.g. ``"npm not found"``). + :param configured_harnesses: The host's readiness map recomputed + after the install attempt, e.g. ``{"claude-native": True, + "codex-native": "needs-auth"}``. ``None`` when the install could + not run (the server keeps its prior readiness view). + :param error: Why the install failed, e.g. ``"npm not found"`` or + ``"install timed out"``. ``None`` on success. + """ + + request_id: str + status: str + configured_harnesses: dict[str, HarnessAvailability] | None = None + error: str | None = None + + @dataclass class HostFsRequestFrame: """Server → host: read-only workspace filesystem request. @@ -903,6 +954,24 @@ def encode_host_frame(frame: HostFrame) -> str: "error": frame.error, } ) + if isinstance(frame, HostInstallHarnessFrame): + return _encode_payload( + { + "kind": HostFrameKind.INSTALL_HARNESS.value, + "request_id": frame.request_id, + "harness": frame.harness, + } + ) + if isinstance(frame, HostInstallHarnessResultFrame): + return _encode_payload( + { + "kind": HostFrameKind.INSTALL_HARNESS_RESULT.value, + "request_id": frame.request_id, + "status": frame.status, + "configured_harnesses": frame.configured_harnesses, + "error": frame.error, + } + ) if isinstance(frame, HostFsRequestFrame): return _encode_payload( { @@ -1028,6 +1097,10 @@ def _decode_known_host_frame( return _decode_create_dir(msg) case HostFrameKind.CREATE_DIR_RESULT: return _decode_create_dir_result(msg) + case HostFrameKind.INSTALL_HARNESS: + return _decode_install_harness(msg) + case HostFrameKind.INSTALL_HARNESS_RESULT: + return _decode_install_harness_result(msg) case HostFrameKind.FS_REQUEST: return _decode_fs_request(msg) case HostFrameKind.FS_RESULT: @@ -1381,6 +1454,32 @@ def _decode_create_dir_result(msg: dict[str, Any]) -> HostCreateDirResultFrame: ) +def _decode_install_harness(msg: dict[str, Any]) -> HostInstallHarnessFrame: + """Decode a host.install_harness request frame. + + :param msg: Decoded frame object. + :returns: Typed host.install_harness frame. + """ + return HostInstallHarnessFrame( + request_id=_required_str(msg, "request_id"), + harness=_required_str(msg, "harness"), + ) + + +def _decode_install_harness_result(msg: dict[str, Any]) -> HostInstallHarnessResultFrame: + """Decode a host.install_harness_result frame. + + :param msg: Decoded frame object. + :returns: Typed host.install_harness_result frame. + """ + return HostInstallHarnessResultFrame( + request_id=_required_str(msg, "request_id"), + status=_required_str(msg, "status"), + configured_harnesses=_optional_str_availability_map(msg, "configured_harnesses"), + error=_optional_nullable_str(msg, "error"), + ) + + def _decode_fs_request(msg: dict[str, Any]) -> HostFsRequestFrame: """Decode a host.fs_request request frame. diff --git a/omnigent/onboarding/harness_install.py b/omnigent/onboarding/harness_install.py index 5d8f8b145d7..1e0daad1dc9 100644 --- a/omnigent/onboarding/harness_install.py +++ b/omnigent/onboarding/harness_install.py @@ -41,9 +41,10 @@ import subprocess import sys from pathlib import Path +from typing import NamedTuple from omnigent._platform import resolve_cli_binary -from omnigent.harness_install_spec import HarnessInstallSpec +from omnigent.harness_install_spec import HarnessInstallSpec, SetupStep from omnigent.onboarding.provider_config import ANTHROPIC_FAMILY, GEMINI_FAMILY, OPENAI_FAMILY # Pi is not a configure-menu family (the menu is Claude + Codex), but the @@ -268,6 +269,176 @@ } +# UI-installable harnesses: the identifiers the web UI's New Chat dialog may +# request an install for, mapped to their :data:`_HARNESS_INSTALL` key. Single +# source of truth for both the host install handler (which runs the installer) +# and the server route (which allowlists the request). Scope is deliberately +# narrow — npm-installable, key/env-auth harnesses only; curl/brew/shell +# installers (cursor, kimi, hermes, …) are absent, so an install request for +# them is rejected before any installer runs. +_UI_INSTALLABLE_HARNESS_TO_KEY: dict[str, str] = { + "claude": ANTHROPIC_FAMILY, + "codex": OPENAI_FAMILY, + PI_KEY: PI_KEY, + OPENCODE_KEY: OPENCODE_KEY, + QWEN_KEY: QWEN_KEY, +} + + +# Family keys the UI may install, derived once from the allowlist so the +# executor-spelling fallback in ``ui_install_key`` can't admit a non-installable +# family (e.g. cursor) that happens to share the name map. +_UI_INSTALLABLE_KEYS: frozenset[str] = frozenset(_UI_INSTALLABLE_HARNESS_TO_KEY.values()) + + +def ui_install_key(harness: str) -> str | None: + """Resolve a harness identifier to its UI-installable install-spec key. + + Accepts both the bare install ids (``"claude"``, ``"codex"``, ``"pi"``, + ``"opencode"``, ``"qwen"``) and the executor spellings a session actually + carries — the native TUI wrappers (``"codex-native"``, ``"qwen-native"``, + …) resolve through the shared :data:`_HARNESS_NAME_TO_KEY` map to the same + family key. Any harness that doesn't map onto the UI-installable family set + (SDK harnesses like ``"claude-sdk"``, or curl/OAuth harnesses like + ``"cursor"``/``"hermes"``) returns ``None`` so the caller rejects it. + + :param harness: A harness identifier from the web UI, e.g. ``"claude"`` or + ``"codex-native"``. + :returns: The :data:`_HARNESS_INSTALL` key (e.g. ``"anthropic"``) when the + harness is UI-installable; ``None`` otherwise (caller rejects it). + """ + direct = _UI_INSTALLABLE_HARNESS_TO_KEY.get(harness) + if direct is not None: + return direct + # Fall back to the executor-spelling map, but only accept keys that are + # themselves UI-installable — this keeps curl/OAuth harnesses (cursor, + # hermes, …) out even though they appear in _HARNESS_NAME_TO_KEY. + key = _all_harness_name_to_key().get(harness) + if key is not None and key in _UI_INSTALLABLE_KEYS: + return key + return None + + +def ui_installable_harnesses() -> frozenset[str]: + """Return every harness identifier the web UI may install. + + Includes the bare install ids and all executor spellings that resolve to a + UI-installable family (e.g. ``"codex-native"``, ``"qwen-native"``), so the + New Chat dialog can offer setup for the harness a session actually declares + — not just the bare ids. + + :returns: The full set of accepted harness identifiers, e.g. + ``{"claude", "claude-native", "codex", "codex-native", "pi", ...}``. + """ + resolvable = set(_UI_INSTALLABLE_HARNESS_TO_KEY) + for name, mapped in _all_harness_name_to_key().items(): + if mapped in _UI_INSTALLABLE_KEYS: + resolvable.add(name) + return frozenset(resolvable) + + +# The auth step per UI-installable family, for the setup checklist. These are +# display-only checklist rows (the command is shown for the user to run on the +# host, never executed server-side), so the commands are literal here rather +# than derived from ``HarnessInstallSpec.login_args`` — keep them in sync with +# that spec by hand if a harness's login command changes. +# ``command`` steps run on the host and are status-tracked; ``setup`` steps +# (pi/qwen: API key or gateway) can't be driven from the UI yet, so M1 points at +# ``omnigent setup`` and does not track their status. +# claude/codex: subscription login via the CLI's own login command. +# opencode: its own `opencode auth login`. +# pi/qwen: a provider credential (API key or gateway) — configured by setup. +_UI_AUTH_STEP_BY_KEY: dict[str, SetupStep] = { + ANTHROPIC_FAMILY: SetupStep( + kind="auth", + title="Sign in to Claude", + detail="Uses your Claude subscription — sign in on the host.", + action="command", + command="claude auth login --claudeai", + status_key="authed", + ), + OPENAI_FAMILY: SetupStep( + kind="auth", + title="Sign in to Codex", + detail="Uses your ChatGPT subscription — sign in on the host.", + action="command", + command="codex login", + status_key="authed", + ), + OPENCODE_KEY: SetupStep( + kind="auth", + title="Sign in to OpenCode", + detail="OpenCode manages its own credentials — sign in on the host.", + action="command", + command="opencode auth login", + status_key="authed", + ), + PI_KEY: SetupStep( + kind="auth", + title="Add a Pi credential", + detail="Pi needs an API key or gateway. Set it up on the host for now.", + action="setup", + command="omnigent setup", + status_key=None, + ), + QWEN_KEY: SetupStep( + kind="auth", + title="Add a Qwen credential", + detail="Qwen needs an API key or gateway. Set it up on the host for now.", + action="setup", + command="omnigent setup", + status_key=None, + ), +} + + +def ui_setup_steps(harness: str) -> list[SetupStep]: + """Return the ordered setup checklist for a UI harness identifier. + + Mirrors what ``omnigent setup`` walks a user through for the harness: an + install step, then (for the five first-class families) an auth step. The + install step's label uses the harness's :class:`HarnessInstallSpec` display + name; the auth step's command is a display-only literal from + :data:`_UI_AUTH_STEP_BY_KEY` (shown for the user to run, not executed). + Harnesses outside the UI-installable set get a single generic + "run ``omnigent setup``" step (M1 scope). + + :param harness: A harness identifier the UI holds, e.g. ``"codex"`` or the + native spelling ``"codex-native"`` (both resolve to the same steps). + :returns: Ordered :class:`SetupStep` list; never empty. + """ + key = ui_install_key(harness) + if key is None: + # Not UI-installable (curl/OAuth/SDK harness): one generic step. + return [ + SetupStep( + kind="install", + title="Set up on the host", + detail="Run omnigent setup on the host to configure this agent.", + action="setup", + command="omnigent setup", + status_key=None, + ) + ] + + spec = _all_harness_install().get(key) + display = spec.display if spec is not None else harness + steps = [ + SetupStep( + kind="install", + title=f"Install {display}", + detail=f"We'll install {display} on the host for you.", + action="install", + command=None, + status_key="installed", + ) + ] + auth = _UI_AUTH_STEP_BY_KEY.get(key) + if auth is not None: + steps.append(auth) + return steps + + def _all_harness_install() -> dict[str, HarnessInstallSpec]: from omnigent.harness_plugins import install_specs @@ -408,31 +579,48 @@ def harness_install_command(key: str) -> list[str]: return ["npm", "install", "-g", package] -def install_harness_cli(key: str) -> bool: - """Install the harness CLI; return whether it landed on ``PATH``. +class HarnessInstallResult(NamedTuple): + """Outcome of :func:`try_install_harness_cli`. - Shells out to :func:`harness_install_command` and re-checks - :func:`harness_cli_installed`. Surfaces the installer's own output (no - capture) so a failing install is visible. Harnesses without an npm package - or explicit installer command remain manual-only. + :param installed: Whether the CLI is on ``PATH`` after the attempt. + :param reason: Human-readable failure reason when ``installed`` is False; + ``None`` on success. + """ + + installed: bool + reason: str | None + + +def try_install_harness_cli(key: str) -> HarnessInstallResult: + """Install the harness CLI, returning whether it landed and why not. + + Same behavior and side effects as :func:`install_harness_cli` (the + installer's output streams to this process, uncaptured, so failures stay + visible in the setup terminal / host log), but returns a human-readable + reason so a UI-driven install can surface "npm is not available on the + host" instead of a silent boolean failure. :param key: A harness family or :data:`PI_KEY`. - :returns: ``True`` when the CLI is on ``PATH`` after the install attempt - (including the no-op case where npm reports success but the binary is - present), ``False`` if npm is missing or the install failed. + :returns: A :class:`HarnessInstallResult` — ``(True, None)`` once the CLI + is on ``PATH`` (including the no-op where it was already present), + otherwise ``(False, reason)`` naming the failure (manual-only spec, + missing installer, timeout, OS error, non-zero exit, or a post-install + binary-not-found). :raises KeyError: If *key* has no install spec. """ spec = harness_install_spec(key) if spec is not None and spec.package is None and spec.install_command is None: # Manual-only CLI (e.g. cursor-agent): caller shows install_hint. - return False + return HarnessInstallResult(False, f"{spec.binary!r} is not installable automatically") cmd = harness_install_command(key) if shutil.which(cmd[0]) is None: - return False + return HarnessInstallResult(False, f"{cmd[0]!r} is not available on the host") try: - subprocess.run(cmd, check=False, timeout=300) - except (OSError, subprocess.TimeoutExpired): - return False + result = subprocess.run(cmd, check=False, timeout=300) + except subprocess.TimeoutExpired: + return HarnessInstallResult(False, "install timed out after 300s") + except OSError as exc: + return HarnessInstallResult(False, f"install command failed to run: {exc}") # harness_install_command would have raised for a spec-less key, so spec is # non-None past this point. assert spec is not None @@ -442,7 +630,7 @@ def install_harness_cli(key: str) -> bool: # subsequent harness_login/harness_cli_logged_in shell out with the bare # binary name and rely on the inherited ``PATH``. if shutil.which(spec.binary) is not None: - return True + return HarnessInstallResult(True, None) # uv-based vendor installers commonly place entry points here and update # shell startup files, which cannot change this already-running process. @@ -453,7 +641,25 @@ def install_harness_cli(key: str) -> bool: path_entries = current_path.split(os.pathsep) if current_path else [] if str(user_bin) not in path_entries: os.environ["PATH"] = os.pathsep.join([str(user_bin), *path_entries]) - return shutil.which(spec.binary) is not None + if shutil.which(spec.binary) is not None: + return HarnessInstallResult(True, None) + if result.returncode != 0: + return HarnessInstallResult(False, f"installer exited with code {result.returncode}") + return HarnessInstallResult(False, f"installer completed but {spec.binary!r} is not on PATH") + + +def install_harness_cli(key: str) -> bool: + """Install the harness CLI; return whether it landed on ``PATH``. + + Thin wrapper over :func:`try_install_harness_cli` that discards the failure + reason, preserving the boolean contract the setup wizard relies on. + + :param key: A harness family or :data:`PI_KEY`. + :returns: ``True`` when the CLI is on ``PATH`` after the install attempt, + ``False`` if the installer is missing or the install failed. + :raises KeyError: If *key* has no install spec. + """ + return try_install_harness_cli(key).installed def harness_cli_logged_in(key: str) -> bool: diff --git a/omnigent/onboarding/harness_readiness.py b/omnigent/onboarding/harness_readiness.py index 6e9517b3f4a..0486cdb241f 100644 --- a/omnigent/onboarding/harness_readiness.py +++ b/omnigent/onboarding/harness_readiness.py @@ -289,12 +289,49 @@ def harness_is_configured(harness: str) -> bool: return True +# Native CLI harnesses that authenticate via their own login command and can +# report auth state locally, so the picker map can distinguish "installed but +# not signed in" (``needs-auth``) from "not installed" (``binary-missing``) — +# the same two-step signal Codex already provides. This is picker-facing ONLY; +# the launch gate (:func:`harness_is_configured`) stays binary-only, so a +# not-signed-in harness is never blocked from launching (its login surfaces at +# run time). Pi / Qwen are absent on purpose: they auth via a provider +# credential the daemon can't probe, so they report binary presence only. +_AUTH_AWARE_NATIVE_HARNESSES: dict[str, str] = { + "claude-native": "anthropic", + "native-claude": "anthropic", + "opencode-native": OPENCODE_KEY, +} + + +def _cli_family_availability(canonical: str, install_key: str) -> HarnessAvailability: + """Two-step availability for a login-command CLI harness. + + :returns: ``"binary-missing"`` when the CLI isn't installed, + ``"needs-auth"`` when installed but not signed in, else ``True``. + """ + if not harness_cli_installed(install_key): + return "binary-missing" + if install_key == OPENCODE_KEY: + from omnigent.onboarding.opencode_auth import opencode_auth_summary + + return True if opencode_auth_summary().has_provider else "needs-auth" + # claude: `claude auth status` (subprocess) — same probe the setup wizard + # uses; runs off the event loop on the throttled readiness refresh path. + from omnigent.onboarding.harness_install import harness_cli_logged_in + + return True if harness_cli_logged_in(install_key) else "needs-auth" + + def _harness_availability(canonical: str) -> HarnessAvailability: """Return picker-facing availability for one canonical harness spelling.""" if _is_codex_family_harness(canonical): from omnigent.codex_native import _codex_auth_unavailable_reason return _codex_auth_unavailable_reason() or True + install_key = _AUTH_AWARE_NATIVE_HARNESSES.get(canonical) + if install_key is not None: + return _cli_family_availability(canonical, install_key) return harness_is_configured(canonical) diff --git a/omnigent/server/app.py b/omnigent/server/app.py index 023dac51170..3bde905d524 100644 --- a/omnigent/server/app.py +++ b/omnigent/server/app.py @@ -1994,7 +1994,7 @@ async def version() -> dict[str, str]: return {"version": _server_version()} @app.get("/v1/info") - async def info() -> dict[str, bool | str | None]: + async def info() -> dict[str, bool | str | list[str] | None]: """Runtime capabilities probe for the SPA + CLI. Returned at app boot by the frontend (and by ``omnigent @@ -2092,6 +2092,27 @@ async def info() -> dict[str, bool | str | None]: ) except ImportError: smart_routing_enabled = False + # harness_install_enabled gates the web UI's "Install" action for a + # missing, npm-installable harness on a connected host. Off by default + # (OMNIGENT_HARNESS_INSTALL_ENABLED=1 opts in) while the feature rolls + # out; when false the SPA keeps the prior "run omnigent setup" hint. + # Read live so flipping the env var takes effect without a rebuild. + # The env-var name is shared with the install route so the flag the UI + # sees and the flag the route enforces can never drift apart. + from omnigent.process_logging import env_truthy + from omnigent.server.routes.hosts import HARNESS_INSTALL_ENABLED_ENV + + harness_install_enabled = env_truthy(os.environ.get(HARNESS_INSTALL_ENABLED_ENV)) + # installable_harnesses: the exact harness ids the install route accepts + # (bare ids + native spellings resolving to an npm-installable family), + # so the SPA offers setup only where it will succeed and never has to + # duplicate the server's allowlist. Empty when the feature is off, so a + # disabled flag also blanks the set the UI keys off of. + from omnigent.onboarding.harness_install import ui_installable_harnesses + + installable_harnesses = ( + sorted(ui_installable_harnesses()) if harness_install_enabled else [] + ) # dictation_available gates the composer mic button's server # speech-to-text fallback (designs/server-dictation.md). Checks # config presence only (extra installed + models on disk) — no @@ -2111,6 +2132,8 @@ async def info() -> dict[str, bool | str | None]: "public_sharing_enabled": public_sharing_enabled, "server_version": _server_version(), "smart_routing_enabled": smart_routing_enabled, + "harness_install_enabled": harness_install_enabled, + "installable_harnesses": installable_harnesses, "dictation_available": dictation_available, } diff --git a/omnigent/server/host_registry.py b/omnigent/server/host_registry.py index f14c4882025..0451479de81 100644 --- a/omnigent/server/host_registry.py +++ b/omnigent/server/host_registry.py @@ -221,6 +221,17 @@ class HostConnection: host sends ``host.create_dir_result``. Values carry the result fields (``status``, ``path``, ``error``). Same ``Any`` typing rationale as ``pending_stats``. + :param pending_installs: Per-``request_id`` futures for in-flight + ``host.install_harness`` requests. Resolved when the host sends + ``host.install_harness_result``. Values carry the result fields + (``status``, ``configured_harnesses``, ``error``). Same ``Any`` + typing rationale as ``pending_stats``. + :param inflight_installs: Install tasks used to coalesce concurrent + install requests for the same harness family (a double-click, or + two spellings of one npm package) onto one in-flight install, so + npm's non-race-safe global writes never run twice at once. Keyed by + the resolved install key (not ``request_id``) and cleared when the + install completes. :param pending_fs_requests: Per-``request_id`` futures for in-flight ``host.fs_request`` reads (the workspace file panel served from the host while the runner is offline). @@ -264,6 +275,12 @@ class HostConnection: pending_create_dirs: dict[str, asyncio.Future[dict[str, Any]]] = field( default_factory=dict, ) + pending_installs: dict[str, asyncio.Future[dict[str, Any]]] = field( + default_factory=dict, + ) + inflight_installs: dict[str, asyncio.Task[dict[str, Any]]] = field( + default_factory=dict, + ) pending_fs_requests: dict[str, asyncio.Future[dict[str, Any]]] = field( default_factory=dict, ) diff --git a/omnigent/server/routes/harnesses.py b/omnigent/server/routes/harnesses.py index 419211e87c9..ce234c88cc7 100644 --- a/omnigent/server/routes/harnesses.py +++ b/omnigent/server/routes/harnesses.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Request -from omnigent.harness_plugins import harness_catalog +from omnigent.harness_plugins import harness_catalog, harness_setup_steps_by_spelling from omnigent.server.auth import AuthProvider from omnigent.server.routes._auth_helpers import require_user @@ -16,8 +16,17 @@ def create_harnesses_router(*, auth_provider: AuthProvider | None = None) -> API router = APIRouter() @router.get("/harnesses") - async def list_harnesses(request: Request) -> dict[str, list[dict[str, Any]]]: + async def list_harnesses(request: Request) -> dict[str, Any]: require_user(request, auth_provider) - return {"data": harness_catalog()} + # ``data`` is the picker catalog (keyed by picker id). ``setup_steps`` + # is a separate map keyed by EVERY harness spelling a session may + # declare — native wrappers (``codex-native``) and installable ids that + # aren't picker rows (``opencode``/``qwen``) — so the setup dialog can + # resolve steps by the harness it actually holds without the picker + # list gaining non-pickable rows. + return { + "data": harness_catalog(), + "setup_steps": harness_setup_steps_by_spelling(), + } return router diff --git a/omnigent/server/routes/host_tunnel.py b/omnigent/server/routes/host_tunnel.py index 2a2f460dfe8..679b8a77f87 100644 --- a/omnigent/server/routes/host_tunnel.py +++ b/omnigent/server/routes/host_tunnel.py @@ -32,6 +32,7 @@ HostFsResultFrame, HostHarnessReadinessFrame, HostHelloFrame, + HostInstallHarnessResultFrame, HostLaunchRunnerResultFrame, HostListDirResultFrame, HostListWorktreesResultFrame, @@ -607,6 +608,18 @@ async def _receive_loop( ) continue + if isinstance(frame, HostInstallHarnessResultFrame): + install_future = conn.pending_installs.pop(frame.request_id, None) + if install_future is not None and not install_future.done(): + install_future.set_result( + { + "status": frame.status, + "configured_harnesses": frame.configured_harnesses, + "error": frame.error, + } + ) + continue + if isinstance(frame, HostFsResultFrame): fs_future = conn.pending_fs_requests.pop(frame.request_id, None) if fs_future is not None and not fs_future.done(): diff --git a/omnigent/server/routes/hosts.py b/omnigent/server/routes/hosts.py index 0fd6f5f6e18..7072defbd6d 100644 --- a/omnigent/server/routes/hosts.py +++ b/omnigent/server/routes/hosts.py @@ -18,6 +18,7 @@ import asyncio import logging +import os import secrets from typing import Any @@ -31,10 +32,13 @@ from omnigent.host.frames import ( HARNESS_NOT_CONFIGURED_ERROR_CODE, HostCreateDirFrame, + HostInstallHarnessFrame, HostLaunchRunnerFrame, HostListDirFrame, encode_host_frame, ) +from omnigent.onboarding.harness_install import ui_install_key, ui_installable_harnesses +from omnigent.process_logging import env_truthy from omnigent.runner.identity import token_bound_runner_id from omnigent.runtime.agent_cache import AgentCache from omnigent.server.auth import AuthProvider @@ -59,6 +63,19 @@ # fast syscall on the host side; 5s matches list_dir and is generous # for transient network slowness without making the picker feel hung. _CREATE_DIR_TIMEOUT_S = 5.0 +# Per-call timeout for host.install_harness round-trips. The host runs +# `npm install -g ` — install_harness_cli caps that subprocess at 300s — +# then recomputes readiness and sends the result back over the tunnel. The +# server must wait comfortably longer than the 300s subprocess ceiling, not +# just a hair over it: a cold npm install can run near the full cap, and the +# readiness recompute + tunnel round-trip add more on top. 420s (300s + 2min +# headroom) keeps a genuine slow install from timing out at the server while +# the host is still succeeding — a "504 but actually installed" outcome. +_INSTALL_HARNESS_TIMEOUT_S = 420.0 +# Env var that opts a deployment into the UI harness-install feature (default +# off). Named once here and shared by the route (this file) and the /v1/info +# flag in app.py so the two reads can never diverge on a typo. +HARNESS_INSTALL_ENABLED_ENV = "OMNIGENT_HARNESS_INSTALL_ENABLED" async def _proxy_list_dir( @@ -193,6 +210,64 @@ async def _proxy_create_dir( host_conn.pending_create_dirs.pop(request_id, None) +async def _proxy_install_harness( + *, + host_registry: HostRegistry, + host_conn: HostConnection, + harness: str, +) -> dict[str, Any]: + """ + Send a ``host.install_harness`` frame and await the result. + + Mirrors :func:`_proxy_create_dir`: register a future on the host + connection's ``pending_installs`` map, enqueue the frame, await with a + timeout, and clean up in a finally block. ``host_tunnel.py``'s receive + loop resolves the future when the result frame arrives. + + :param host_registry: Server-side registry; used to enqueue the outbound + frame on the host's send queue. + :param host_conn: Live host connection. + :param harness: The UI harness identifier to install, e.g. ``"claude"``. + :returns: Dict with the result fields: ``status`` (``"ok"`` / + ``"failed"``), ``configured_harnesses`` (the refreshed readiness map or + ``None``), ``error`` (string or ``None``). + :raises HTTPException: 504 on timeout, 502 on connection drop. + """ + request_id = secrets.token_hex(8) + loop = asyncio.get_running_loop() + future: asyncio.Future[dict[str, Any]] = loop.create_future() + host_conn.pending_installs[request_id] = future + + frame = encode_host_frame( + HostInstallHarnessFrame( + request_id=request_id, + harness=harness, + ) + ) + try: + try: + host_registry.send_text(host_conn, frame) + except ConnectionError as exc: + raise HTTPException( + status_code=502, + detail=f"host '{host_conn.host_id}' connection lost", + ) from exc + try: + return await asyncio.wait_for(future, timeout=_INSTALL_HARNESS_TIMEOUT_S) + except asyncio.TimeoutError as exc: + raise HTTPException( + status_code=504, + detail=( + f"host '{host_conn.host_id}' did not respond to install_harness " + f"within {_INSTALL_HARNESS_TIMEOUT_S:.0f}s" + ), + ) from exc + finally: + # Cleanup runs on every path so a cancelled caller doesn't + # leave an orphan in the pending dict. + host_conn.pending_installs.pop(request_id, None) + + class CreateDirectoryRequest(BaseModel): """Request body for ``POST /v1/hosts/{host_id}/directories``. @@ -934,6 +1009,105 @@ async def create_host_directory( "path": result.get("path"), } + @router.post("/hosts/{host_id}/harnesses/{harness}/install") + async def install_host_harness( + request: Request, + host_id: str, + harness: str, + ) -> dict[str, Any]: + """ + Install a missing, npm-installable harness CLI onto a host. + + Backs the Web UI's New Chat dialog "Install" action so a user can + install a harness the connected host is missing without dropping to a + terminal. Owner-scoped like the other host actions: only the host owner + may install onto it. Scoped to the UI-installable allowlist (claude, + codex, pi, opencode, qwen) — curl/brew and interactive-auth harnesses + are refused. The whole route is gated behind + ``OMNIGENT_HARNESS_INSTALL_ENABLED`` (default off): when disabled it + returns 404 so the feature is invisible until opted in. + + Concurrent requests for the same (host, harness) coalesce onto one + in-flight install so a double-click can't fire two global npm installs. + + :param request: FastAPI request (for auth). + :param host_id: Host identifier, e.g. ``"host_a1b2c3d4..."``. + :param harness: Harness identifier to install, e.g. ``"claude"``. + :returns: ``{"object": "harness_install", "harness": ..., + "configured_harnesses": {...}}`` — the host's refreshed readiness + map so the UI can flip the badge without a reconnect. + :raises HTTPException: 404 when the feature is disabled or the host is + unknown, 400 when the harness is not UI-installable, 403 when the + caller is not the host owner, 409 when the host is offline, 502 on + a host-side install failure, 504 on host timeout. + """ + # Feature flag (default off): a disabled route is indistinguishable + # from a non-existent one, so the feature is fully dark until opted in. + if not env_truthy(os.environ.get(HARNESS_INSTALL_ENABLED_ENV)): + raise HTTPException(status_code=404, detail="not found") + + # Allowlist (400) is checked before the ownership check (403) so error + # codes can't be used to enumerate host ownership. Never trust the + # client: the server is the source of truth for what is installable. + if harness not in ui_installable_harnesses(): + raise HTTPException( + status_code=400, + detail=f"harness {harness!r} is not installable from the UI", + ) + + # require_user: unauthenticated callers 401 instead of slipping past + # the owner check below as None. + user_id = require_user(request, auth_provider) + + host = await asyncio.to_thread(host_store.get_host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="host not found") + if user_id is not None and host.user_id != user_id: + raise HTTPException(status_code=403, detail="not your host") + + conn = host_registry.get(host.host_id) + if conn is None: + raise HTTPException(status_code=409, detail="host is offline") + + # Coalesce concurrent installs of the same harness FAMILY onto one + # in-flight request so a double-click (or `codex` + `codex-native`, which + # resolve to the same npm package) can't launch two global npm installs + # (npm's global writes aren't race-safe). Keyed on the resolved install + # key, not the raw spelling. The map lives on the connection, so it's + # discarded when the host disconnects. + # + # Cleanup is tied to the task's completion (add_done_callback), not the + # awaiter, and every caller awaits under a shield: if this request is + # cancelled (client disconnect) mid-install, the shared task keeps + # running to completion and stays in the map, so a follow-up request + # coalesces onto it instead of starting a second npm install. + install_key = ui_install_key(harness) or harness + existing = conn.inflight_installs.get(install_key) + if existing is None: + task = asyncio.create_task( + _proxy_install_harness( + host_registry=host_registry, + host_conn=conn, + harness=harness, + ) + ) + conn.inflight_installs[install_key] = task + task.add_done_callback(lambda _t: conn.inflight_installs.pop(install_key, None)) + existing = task + result = await asyncio.shield(existing) + + if result.get("status") == "failed": + raise HTTPException( + status_code=502, + detail=f"host install failed: {result.get('error') or 'unknown error'}", + ) + + return { + "object": "harness_install", + "harness": harness, + "configured_harnesses": result.get("configured_harnesses") or {}, + } + @router.get("/hosts/{host_id}/worktrees") async def list_host_worktrees( request: Request, diff --git a/openapi.json b/openapi.json index aa7c33b2306..515c94520d5 100644 --- a/openapi.json +++ b/openapi.json @@ -6630,13 +6630,7 @@ "content": { "application/json": { "schema": { - "additionalProperties": { - "items": { - "additionalProperties": true, - "type": "object" - }, - "type": "array" - }, + "additionalProperties": true, "title": "Response List Harnesses V1 Harnesses Get", "type": "object" } @@ -6979,6 +6973,62 @@ ] } }, + "/v1/hosts/{host_id}/harnesses/{harness}/install": { + "post": { + "description": "Install a missing, npm-installable harness CLI onto a host.\n\nBacks the Web UI's New Chat dialog \"Install\" action so a user can\ninstall a harness the connected host is missing without dropping to a\nterminal. Owner-scoped like the other host actions: only the host owner\nmay install onto it. Scoped to the UI-installable allowlist (claude,\ncodex, pi, opencode, qwen) \u2014 curl/brew and interactive-auth harnesses\nare refused. The whole route is gated behind\n`OMNIGENT_HARNESS_INSTALL_ENABLED` (default off): when disabled it\nreturns 404 so the feature is invisible until opted in.\n\nConcurrent requests for the same (host, harness) coalesce onto one\nin-flight install so a double-click can't fire two global npm installs.\n\n**Returns:** `{\"object\": \"harness_install\", \"harness\": ..., \"configured_harnesses\": {...}}` \u2014 the host's refreshed readiness map so the UI can flip the badge without a reconnect.\n\n**Raises**\n\n- `HTTPException` \u2014 404 when the feature is disabled or the host is unknown, 400 when the harness is not UI-installable, 403 when the caller is not the host owner, 409 when the host is offline, 502 on a host-side install failure, 504 on host timeout.", + "operationId": "install_host_harness_v1_hosts__host_id__harnesses__harness__install_post", + "parameters": [ + { + "description": "Host identifier, e.g. `\"host_a1b2c3d4...\"`.", + "in": "path", + "name": "host_id", + "required": true, + "schema": { + "title": "Host Id", + "type": "string" + } + }, + { + "description": "Harness identifier to install, e.g. `\"claude\"`.", + "in": "path", + "name": "harness", + "required": true, + "schema": { + "title": "Harness", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Install Host Harness V1 Hosts Host Id Harnesses Harness Install Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Install Host Harness", + "tags": [ + "hosts" + ] + } + }, "/v1/hosts/{host_id}/runners": { "post": { "description": "Launch a runner on a host for a session.\n\nGenerates a binding token, writes the expected runner_id\nto the session row, sends the launch command to the host,\nand waits for the host's acknowledgement.\n\n**Parameters**\n\n- `body` \u2014 Launch request with `session_id` and `workspace`.\n\n**Returns:** `{\"runner_id\": ..., \"status\": \"launching\"}`.\n\n**Raises**\n\n- `HTTPException` \u2014 404 if host not found, 409 if host offline, 403 if caller doesn't own the host, 400 if session already has a runner.", @@ -7150,6 +7200,12 @@ { "type": "string" }, + { + "items": { + "type": "string" + }, + "type": "array" + }, { "type": "null" } diff --git a/tests/host/test_connect.py b/tests/host/test_connect.py index f6263d87bfb..63531eddc56 100644 --- a/tests/host/test_connect.py +++ b/tests/host/test_connect.py @@ -27,6 +27,8 @@ HostCreateDirResultFrame, HostHarnessReadinessFrame, HostHelloFrame, + HostInstallHarnessFrame, + HostInstallHarnessResultFrame, HostLaunchRunnerFrame, HostLaunchRunnerResultFrame, HostListDirFrame, @@ -2043,6 +2045,122 @@ def test_handle_create_dir_expands_tilde(tmp_path: Path, monkeypatch) -> None: assert result.path == str(tmp_path / "scratch") +# ── host.install_harness handler ──────────────────────── + + +def test_handle_install_harness_success_returns_refreshed_readiness( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + A successful install returns ``ok`` and the recomputed readiness map. + + The server flips the UI badge off this map, so the handler must run the + installer and then re-probe readiness, returning the fresh result. + """ + import omnigent.host.connect as connect + + # Not yet installed, so the handler runs the installer. + monkeypatch.setattr(connect, "harness_cli_installed", lambda key: False) + monkeypatch.setattr(connect, "try_install_harness_cli", lambda key: (True, None)) + monkeypatch.setattr( + connect, + "configured_harness_map", + lambda: {"claude-native": True, "codex-native": "needs-auth"}, + ) + + host = _make_host_process() + result = host._handle_install_harness( + HostInstallHarnessFrame(request_id="i1", harness="claude") + ) + + assert isinstance(result, HostInstallHarnessResultFrame) + assert result.status == "ok" + assert result.error is None + assert result.configured_harnesses == {"claude-native": True, "codex-native": "needs-auth"} + + +def test_handle_install_harness_already_installed_skips_installer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + An already-installed harness returns fresh readiness without running npm. + + ``npm install -g`` re-resolves over the network and can take minutes even + when the binary is already present, so a re-request must short-circuit — + otherwise a user clicking Install on an installed harness waits pointlessly + (and can hit the request timeout). + """ + import omnigent.host.connect as connect + + monkeypatch.setattr(connect, "harness_cli_installed", lambda key: True) + + def _must_not_install(key: str) -> tuple[bool, str | None]: + raise AssertionError("installer ran despite the harness already being installed") + + monkeypatch.setattr(connect, "try_install_harness_cli", _must_not_install) + monkeypatch.setattr(connect, "configured_harness_map", lambda: {"opencode-native": True}) + + host = _make_host_process() + result = host._handle_install_harness( + HostInstallHarnessFrame(request_id="i0", harness="opencode") + ) + + assert result.status == "ok" + assert result.configured_harnesses == {"opencode-native": True} + + +def test_handle_install_harness_failure_surfaces_reason( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + A failed install returns ``failed`` with the installer's reason and no + readiness map (the server keeps its prior view). + """ + import omnigent.host.connect as connect + + monkeypatch.setattr(connect, "harness_cli_installed", lambda key: False) + monkeypatch.setattr( + connect, + "try_install_harness_cli", + lambda key: (False, "npm is not available on the host"), + ) + + host = _make_host_process() + result = host._handle_install_harness( + HostInstallHarnessFrame(request_id="i2", harness="codex") + ) + + assert result.status == "failed" + assert result.error == "npm is not available on the host" + assert result.configured_harnesses is None + + +def test_handle_install_harness_rejects_non_allowlisted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + A non-UI-installable harness is refused without invoking the installer. + + Defence in depth: even if a stray frame reaches the daemon, a harness + whose installer is a ``curl | bash`` (e.g. hermes) must never run. + """ + import omnigent.host.connect as connect + + def _must_not_install(key: str) -> tuple[bool, str | None]: + raise AssertionError("installer reached for a non-allowlisted harness") + + monkeypatch.setattr(connect, "try_install_harness_cli", _must_not_install) + + host = _make_host_process() + result = host._handle_install_harness( + HostInstallHarnessFrame(request_id="i3", harness="hermes") + ) + + assert result.status == "failed" + assert result.error is not None and "hermes" in result.error + assert result.configured_harnesses is None + + # --- Fail-loud on permanent tunnel failures ---------------------------- # # Before the fix, HostProcess.run() caught every connection exception and diff --git a/tests/host/test_frames.py b/tests/host/test_frames.py index 3851998e9ac..9627617db5f 100644 --- a/tests/host/test_frames.py +++ b/tests/host/test_frames.py @@ -16,6 +16,8 @@ HostFsResultFrame, HostHarnessReadinessFrame, HostHelloFrame, + HostInstallHarnessFrame, + HostInstallHarnessResultFrame, HostLaunchRunnerFrame, HostLaunchRunnerResultFrame, HostListDirEntry, @@ -1083,6 +1085,70 @@ def test_create_dir_result_error_round_trip() -> None: assert decoded.error == "directory already exists" +def test_install_harness_frame_round_trip() -> None: + """ + Verify HostInstallHarnessFrame survives encode → decode. + + The host maps ``harness`` to an install-spec key; a garbled value + would install (or reject) the wrong harness. + """ + original = HostInstallHarnessFrame( + request_id="req_install_1", + harness="claude", + ) + decoded = decode_host_frame(encode_host_frame(original)) + assert isinstance(decoded, HostInstallHarnessFrame) + assert decoded.request_id == "req_install_1" + assert decoded.harness == "claude" + + +def test_install_harness_result_success_round_trip() -> None: + """ + Verify a successful install result round-trips with the refreshed + readiness map intact. + + The server flips the UI badge off this map, so a dropped or garbled + ``configured_harnesses`` would leave a stale "binary missing" badge + after a successful install. The map mixes bool and string values + (``"needs-auth"`` for an installed-but-unauthed harness), so both + must survive the wire. + """ + original = HostInstallHarnessResultFrame( + request_id="req_install_2", + status="ok", + configured_harnesses={"claude-native": True, "codex-native": "needs-auth"}, + ) + decoded = decode_host_frame(encode_host_frame(original)) + assert isinstance(decoded, HostInstallHarnessResultFrame) + assert decoded.status == "ok" + assert decoded.configured_harnesses == { + "claude-native": True, + "codex-native": "needs-auth", + } + assert decoded.error is None + + +def test_install_harness_result_failure_round_trip() -> None: + """ + Verify a failed install round-trips the reason and leaves the + readiness map ``None``. + + The dialog surfaces ``error`` inline so the user sees why the + install failed; when the installer never ran there is no fresh + readiness to report, so the server keeps its prior view. + """ + original = HostInstallHarnessResultFrame( + request_id="req_install_3", + status="failed", + error="npm not found", + ) + decoded = decode_host_frame(encode_host_frame(original)) + assert isinstance(decoded, HostInstallHarnessResultFrame) + assert decoded.status == "failed" + assert decoded.configured_harnesses is None + assert decoded.error == "npm not found" + + def test_fs_request_round_trip() -> None: """ Verify an fs request round-trips with op, workspace, session, and params. diff --git a/tests/onboarding/test_harness_install.py b/tests/onboarding/test_harness_install.py index b2feeadd3c9..07a9611953f 100644 --- a/tests/onboarding/test_harness_install.py +++ b/tests/onboarding/test_harness_install.py @@ -366,6 +366,74 @@ def _explode(*a: object, **k: object) -> None: assert hi.install_harness_cli(ANTHROPIC_FAMILY) is False +def test_try_install_harness_cli_missing_npm(monkeypatch: pytest.MonkeyPatch) -> None: + """No npm on PATH → ``(False, reason)`` naming the missing installer. + + The UI-driven install shows this reason instead of a bare failure, so the + user knows the host lacks npm rather than guessing. + """ + monkeypatch.setattr(hi.shutil, "which", lambda name: None) + monkeypatch.setattr( + hi.subprocess, + "run", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not shell out")), + ) + installed, reason = hi.try_install_harness_cli(ANTHROPIC_FAMILY) + assert installed is False + assert reason is not None and "npm" in reason + + +def test_try_install_harness_cli_manual_only() -> None: + """A manual-only CLI (no npm package, no install_command) → ``(False, reason)``. + + Cursor installs out-of-band; the reason tells the caller it can't be + auto-installed so the UI can fall back to showing the install hint. + """ + installed, reason = hi.try_install_harness_cli(hi.CURSOR_KEY) + assert installed is False + assert reason is not None and "automatically" in reason + + +def test_try_install_harness_cli_nonzero_exit(monkeypatch: pytest.MonkeyPatch) -> None: + """A non-zero installer exit with the binary still absent → ``(False, reason)``. + + Surfaces the installer's exit code so a failed npm install is actionable. + """ + + def _which(name: str) -> str | None: + return "/usr/bin/npm" if name == "npm" else None + + monkeypatch.setattr(hi.shutil, "which", _which) + monkeypatch.setattr( + hi.subprocess, + "run", + lambda argv, **k: subprocess.CompletedProcess(args=argv, returncode=1), + ) + installed, reason = hi.try_install_harness_cli(OPENAI_FAMILY) + assert installed is False + assert reason is not None and "code 1" in reason + + +def test_try_install_harness_cli_success(monkeypatch: pytest.MonkeyPatch) -> None: + """A successful install → ``(True, None)``; the bool wrapper agrees.""" + state = {"installed": False} + + def _which(name: str) -> str | None: + if name == "npm": + return "/usr/bin/npm" + if name == "codex": + return "/usr/bin/codex" if state["installed"] else None + return None + + def _run(argv: list[str], **k: object): + state["installed"] = True + return subprocess.CompletedProcess(args=argv, returncode=0) + + monkeypatch.setattr(hi.shutil, "which", _which) + monkeypatch.setattr(hi.subprocess, "run", _run) + assert hi.try_install_harness_cli(OPENAI_FAMILY) == (True, None) + + def test_install_harness_cli_runs_npm_then_rechecks(monkeypatch: pytest.MonkeyPatch) -> None: """Installs via ``npm install -g `` and reports the post-install PATH state (True once the binary appears).""" @@ -798,3 +866,66 @@ def _explode(*a: object, **k: object) -> None: monkeypatch.setattr(hi.subprocess, "run", _explode) assert hi.harness_cli_logged_in(hi.PI_KEY) is False + + +# ── UI setup-step descriptor ───────────────────────────── + + +def test_ui_install_key_resolves_bare_and_native_spellings() -> None: + """The UI may pass either the bare id or the native executor spelling.""" + assert hi.ui_install_key("codex") == OPENAI_FAMILY + assert hi.ui_install_key("codex-native") == OPENAI_FAMILY + assert hi.ui_install_key("qwen-native") == hi.QWEN_KEY + assert hi.ui_install_key("claude-native") == ANTHROPIC_FAMILY + # Non-installable (curl/OAuth/SDK) harnesses resolve to None. + assert hi.ui_install_key("cursor") is None + assert hi.ui_install_key("cursor-native") is None + assert hi.ui_install_key("claude-sdk") is None + + +def test_ui_installable_harnesses_includes_native_spellings() -> None: + installable = hi.ui_installable_harnesses() + assert {"claude", "codex", "pi", "opencode", "qwen"} <= installable + assert {"codex-native", "qwen-native", "opencode-native"} <= installable + assert "cursor" not in installable + assert "claude-sdk" not in installable + + +def test_ui_setup_steps_install_then_command_auth_for_codex() -> None: + """Codex: one-click install, then a status-tracked login command.""" + steps = hi.ui_setup_steps("codex") + assert [s.kind for s in steps] == ["install", "auth"] + install, auth = steps + assert install.action == "install" + assert install.status_key == "installed" + assert install.command is None + assert auth.action == "command" + assert auth.command == "codex login" + assert auth.status_key == "authed" + + +def test_ui_setup_steps_native_spelling_matches_bare() -> None: + """The native spelling yields the same steps as the bare id.""" + assert [s.as_dict() for s in hi.ui_setup_steps("codex-native")] == [ + s.as_dict() for s in hi.ui_setup_steps("codex") + ] + + +def test_ui_setup_steps_pi_auth_is_untracked_setup_fallback() -> None: + """Pi's credential (API key / gateway) can't be driven from the UI yet, so + its auth step points at ``omnigent setup`` and is not status-tracked.""" + steps = hi.ui_setup_steps("pi") + assert [s.kind for s in steps] == ["install", "auth"] + assert steps[1].action == "setup" + assert steps[1].command == "omnigent setup" + assert steps[1].status_key is None + + +def test_ui_setup_steps_generic_for_non_installable() -> None: + """A non-installable harness (cursor) gets a single generic setup step.""" + for harness in ("cursor", "claude-sdk"): + steps = hi.ui_setup_steps(harness) + assert len(steps) == 1 + assert steps[0].action == "setup" + assert steps[0].command == "omnigent setup" + assert steps[0].status_key is None diff --git a/tests/onboarding/test_harness_readiness.py b/tests/onboarding/test_harness_readiness.py index c00f48e7f77..e3fdc64e896 100644 --- a/tests/onboarding/test_harness_readiness.py +++ b/tests/onboarding/test_harness_readiness.py @@ -125,6 +125,56 @@ def test_cli_harness_configured_only_when_binary_installed( assert harness_is_configured(harness) is False +def test_auth_aware_native_harness_reports_binary_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """claude-native / opencode-native report ``binary-missing`` when absent. + + These now carry a two-step signal in the picker map (install, then auth), + mirroring Codex — so a missing binary is ``"binary-missing"``, not a bare + ``False``. + """ + _no_clis_installed(monkeypatch) + result = configured_harness_map() + assert result["claude-native"] == "binary-missing" + assert result["opencode-native"] == "binary-missing" + + +def test_auth_aware_native_harness_needs_auth_when_installed_not_signed_in( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Installed but not signed in → ``needs-auth`` (the second step).""" + _all_clis_installed(monkeypatch) + # claude: `claude auth status` reports not-logged-in. + monkeypatch.setattr(hi, "harness_cli_logged_in", lambda key: False) + # opencode: no stored/env provider. + import omnigent.onboarding.opencode_auth as oc + + monkeypatch.setattr( + oc, + "opencode_auth_summary", + lambda: oc.OpenCodeAuthSummary(installed=True, stored_providers=(), env_providers=()), + ) + result = configured_harness_map() + assert result["claude-native"] == "needs-auth" + assert result["opencode-native"] == "needs-auth" + + +def test_auth_aware_native_harness_launch_gate_stays_binary_only( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The LAUNCH gate must not gain the auth check — only the picker map does. + + ``harness_is_configured`` drives whether a runner may spawn; gating it on + login state would wrongly block a launch whose auth resolves at run time. + So with the binary present it stays ``True`` even when not signed in. + """ + _all_clis_installed(monkeypatch) + monkeypatch.setattr(hi, "harness_cli_logged_in", lambda key: False) + assert harness_is_configured("claude-native") is True + assert harness_is_configured("opencode-native") is True + + def test_configured_harness_map_covers_all_spellings( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -234,8 +284,6 @@ def test_configured_harness_map_gates_only_cli_harnesses( # antigravity-native is also gated (it wraps the ``agy`` CLI); with no # binary it reads False before its credential check is even reached. for cli in ( - "claude-native", - "native-claude", "pi", "kimi", "cursor-native", @@ -250,8 +298,18 @@ def test_configured_harness_map_gates_only_cli_harnesses( "hermes", ): assert result[cli] is False, f"{cli} should be gated on its CLI binary" - for codex in ("codex", "codex-native", "native-codex"): - assert result[codex] == "binary-missing", f"{codex} should name the missing Codex binary" + # Auth-aware native harnesses (codex, claude, opencode) carry a two-step + # signal in the picker map, so a missing binary is the structured + # ``"binary-missing"`` (step 1 to-do), not a bare ``False``. + for missing in ( + "codex", + "codex-native", + "native-codex", + "claude-native", + "native-claude", + "opencode-native", + ): + assert result[missing] == "binary-missing", f"{missing} should name the missing CLI binary" def test_configured_harness_map_all_true_with_clis( diff --git a/tests/server/integration/test_hosts_install_harness.py b/tests/server/integration/test_hosts_install_harness.py new file mode 100644 index 00000000000..8472dd4406f --- /dev/null +++ b/tests/server/integration/test_hosts_install_harness.py @@ -0,0 +1,571 @@ +""" +Integration tests for ``POST /v1/hosts/{id}/harnesses/{harness}/install``. + +Wires up a real host tunnel + REST router pair, drives a fake host that +auto-replies to ``host.install_harness`` frames, and exercises the +endpoint's contract end-to-end. Mirrors ``test_hosts_create_directory.py`` +(the create-folder action) — installing a harness shares the same +owner-scoped, host-forwarded design. + +These are the executable acceptance criteria for Milestone 1 of the +"Setup From the UI" project: turning the dead-end "binary missing" +warning into a working Install action. The route is gated behind +``OMNIGENT_HARNESS_INSTALL_ENABLED``; the fixture enables it so the +happy-path and validation cases can run, and one test asserts the route +is 404 (invisible) when the flag is off. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from typing import Any + +import pytest +from asgiref.testing import ApplicationCommunicator +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from omnigent.host.frames import ( + HostHelloFrame, + HostInstallHarnessFrame, + HostInstallHarnessResultFrame, + decode_host_frame, + encode_host_frame, +) +from omnigent.server.host_registry import HostRegistry +from omnigent.server.routes.host_tunnel import create_host_tunnel_router +from omnigent.server.routes.hosts import create_hosts_router +from omnigent.stores.conversation_store.sqlalchemy_store import ( + SqlAlchemyConversationStore, +) +from omnigent.stores.host_store import HostStore + +# Same liveness-race flake guard as test_hosts_create_directory.py: the +# mock WS host can be starved + deregistered under parallel CI load. +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.flaky(reruns=2, reruns_delay=1), +] + +_HOST_ID = "a1b2c3d4e5f60718293a4b5c6d7e8f90" +_HOST_NAME = "install-test-laptop" + + +@pytest.fixture(autouse=True) +def _enable_install_flag(monkeypatch: pytest.MonkeyPatch) -> None: + """Enable the feature flag for every test except the flag-off case. + + The route is invisible (404) unless ``OMNIGENT_HARNESS_INSTALL_ENABLED`` + is truthy; the happy-path and validation tests need it on. + """ + monkeypatch.setenv("OMNIGENT_HARNESS_INSTALL_ENABLED", "1") + + +def _websocket_scope(path: str) -> dict[str, object]: + """Build a minimal ASGI WebSocket scope. + + :param path: WebSocket path, e.g. ``"/v1/hosts/X/tunnel"``. + :returns: ASGI scope dict. + """ + return { + "type": "websocket", + "asgi": {"version": "3.0"}, + "scheme": "ws", + "path": path, + "raw_path": path.encode("ascii"), + "query_string": b"", + "headers": [], + "client": ("127.0.0.1", 50000), + "server": ("testserver", 80), + "subprotocols": [], + } + + +def _hello_text(name: str = _HOST_NAME) -> str: + """Encode a hello frame for tests. + + :param name: Host name reported in the hello frame. + :returns: JSON-encoded hello frame. + """ + return encode_host_frame( + HostHelloFrame( + version="0.1.0-test", + frame_protocol_version=1, + name=name, + ) + ) + + +async def _connect_mock_host(app: FastAPI, registry: HostRegistry) -> ApplicationCommunicator: + """Open a tunnel, complete the hello handshake, and wait for registration. + + :param app: The wired FastAPI app (tunnel + REST routers). + :param registry: The registry the tunnel registers the connection into. + :returns: The connected ``ApplicationCommunicator`` (caller drains it). + """ + comm = ApplicationCommunicator(app, _websocket_scope(f"/v1/hosts/{_HOST_ID}/tunnel")) + await comm.send_input({"type": "websocket.connect"}) + accepted = await comm.receive_output(timeout=1.0) + assert accepted["type"] == "websocket.accept" + await comm.send_input({"type": "websocket.receive", "text": _hello_text()}) + while registry.get(_HOST_ID) is None: + await asyncio.sleep(0.01) + return comm + + +@pytest.fixture() +def install_app( + db_uri: str, +) -> tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore]: + """ + App with host tunnel + REST routes for install-harness tests. + + :param db_uri: SQLite URI fixture. + :returns: (app, registry, host_store, conv_store). + """ + registry = HostRegistry() + host_store = HostStore(db_uri) + conv_store = SqlAlchemyConversationStore(db_uri) + app = FastAPI() + app.include_router( + create_host_tunnel_router(registry, host_store), + prefix="/v1", + ) + app.include_router( + create_hosts_router(registry, host_store, conv_store), + prefix="/v1", + ) + return app, registry, host_store, conv_store + + +@pytest.fixture() +async def install_setup( + install_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore], +) -> AsyncIterator[ + tuple[ + FastAPI, + HostRegistry, + ApplicationCommunicator, + dict[str, dict[str, Any]], + asyncio.Task[None], + ] +]: + """ + Connect a mock host and start an auto-replier for install_harness frames. + + Tests register fake replies in ``replies`` (harness → reply dict) + before calling the REST endpoint. The auto-replier consumes the + ``host.install_harness`` frames the route pushes through the + registry, decodes them, and feeds the configured result back — + mirroring what ``host_tunnel.py`` does in production. An unregistered + harness defaults to a successful install that flips the harness to + ready in the returned readiness map. + + :param install_app: The fixture above. + :returns: Async iterator yielding the wired-up state. + """ + app, registry, _hs, _cs = install_app + comm = await _connect_mock_host(app, registry) + + conn = registry.get(_HOST_ID) + assert conn is not None + replies: dict[str, dict[str, Any]] = {} + stop_drain = asyncio.Event() + + async def _drain() -> None: + """Drain outbound WS frames and reply to install_harness frames. + + :returns: None when ``stop_drain`` is set or no events arrive + within the per-iteration timeout. + """ + while not stop_drain.is_set(): + try: + output = await comm.receive_output(timeout=0.5) + except asyncio.TimeoutError: + continue + if output.get("type") != "websocket.send": + continue + text = output.get("text") + if not isinstance(text, str): + continue + frame = decode_host_frame(text) + if not isinstance(frame, HostInstallHarnessFrame): + continue + reply = replies.get(frame.harness) + if reply is None: + # Default: success, harness flips to ready in the + # recomputed readiness map the host returns. + reply_frame = HostInstallHarnessResultFrame( + request_id=frame.request_id, + status="ok", + configured_harnesses={frame.harness: True}, + ) + else: + reply_frame = HostInstallHarnessResultFrame( + request_id=frame.request_id, + status=reply.get("status", "ok"), + configured_harnesses=reply.get("configured_harnesses"), + error=reply.get("error"), + ) + await comm.send_input( + { + "type": "websocket.receive", + "text": encode_host_frame(reply_frame), + } + ) + + drain_task = asyncio.create_task(_drain()) + try: + yield app, registry, comm, replies, drain_task + finally: + stop_drain.set() + try: + await asyncio.wait_for(drain_task, timeout=1.0) + except asyncio.TimeoutError: + drain_task.cancel() + + +# ── Happy path ────────────────────────────────────────── + + +async def test_install_harness_returns_refreshed_readiness( + install_setup: tuple[ + FastAPI, + HostRegistry, + ApplicationCommunicator, + dict[str, dict[str, Any]], + asyncio.Task[None], + ], +) -> None: + """ + A successful install returns the harness flipped to ready. + + The New Chat dialog reads this refreshed readiness to swap the + Install button back to a ready badge without waiting for a + reconnect, so the flipped ``configured_harnesses`` entry must + round-trip through the endpoint. + """ + app, _reg, _comm, replies, _drain = install_setup + replies["claude"] = { + "status": "ok", + "configured_harnesses": {"claude": True, "claude-native": True}, + } + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.post(f"/v1/hosts/{_HOST_ID}/harnesses/claude/install") + + assert resp.status_code == 200 + body = resp.json() + assert body["object"] == "harness_install" + assert body["harness"] == "claude" + assert body["configured_harnesses"]["claude"] is True + + +async def test_install_harness_codex_reports_needs_auth_not_ready( + install_setup: tuple[ + FastAPI, + HostRegistry, + ApplicationCommunicator, + dict[str, dict[str, Any]], + asyncio.Task[None], + ], +) -> None: + """ + Installing codex succeeds but readiness stays ``"needs-auth"``. + + codex-native is auth-gated: the binary installs, but readiness only + flips to ready once a credential is configured (Milestone 2). M1 must + faithfully surface the intermediate ``"needs-auth"`` state rather than + pretending the harness is ready. + """ + app, _reg, _comm, replies, _drain = install_setup + replies["codex"] = { + "status": "ok", + "configured_harnesses": {"codex": "needs-auth", "codex-native": "needs-auth"}, + } + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.post(f"/v1/hosts/{_HOST_ID}/harnesses/codex/install") + + assert resp.status_code == 200 + body = resp.json() + assert body["configured_harnesses"]["codex"] == "needs-auth" + + +# ── Coalescing concurrent installs ────────────────────── + + +async def test_install_coalesces_concurrent_same_family( + install_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore], +) -> None: + """ + Two overlapping installs of one family reach the host as a single frame. + + ``codex`` and ``codex-native`` both resolve to the ``openai`` install key, + so a user who fires both (a double-click, or two spellings) must not drive + two concurrent global ``npm install -g`` runs — npm's global writes aren't + race-safe. The route coalesces them onto one in-flight task keyed on the + resolved family, so exactly one ``host.install_harness`` frame is sent and + both HTTP callers get the same result. + """ + app, registry, _hs, _cs = install_app + comm = await _connect_mock_host(app, registry) + conn = registry.get(_HOST_ID) + assert conn is not None + + install_frames: list[str] = [] + release = asyncio.Event() + stop_drain = asyncio.Event() + + async def _drain_holding_reply() -> None: + """Record each install frame, then reply once ``release`` is set. + + Holding the reply keeps the shared task in flight so a second + request lands while the first is still pending — exactly the + window coalescing must cover. + """ + while not stop_drain.is_set(): + try: + output = await comm.receive_output(timeout=0.5) + except asyncio.TimeoutError: + continue + if output.get("type") != "websocket.send": + continue + text = output.get("text") + if not isinstance(text, str): + continue + frame = decode_host_frame(text) + if not isinstance(frame, HostInstallHarnessFrame): + continue + install_frames.append(frame.harness) + await release.wait() + await comm.send_input( + { + "type": "websocket.receive", + "text": encode_host_frame( + HostInstallHarnessResultFrame( + request_id=frame.request_id, + status="ok", + configured_harnesses={frame.harness: "needs-auth"}, + ) + ), + } + ) + + drain_task = asyncio.create_task(_drain_holding_reply()) + try: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + # Fire the first request and wait until its task is registered + # in-flight before firing the second, so the second provably hits + # the coalescing branch instead of racing task creation. + first = asyncio.create_task( + client.post(f"/v1/hosts/{_HOST_ID}/harnesses/codex/install") + ) + while "openai" not in conn.inflight_installs: + await asyncio.sleep(0.01) + second = asyncio.create_task( + client.post(f"/v1/hosts/{_HOST_ID}/harnesses/codex-native/install") + ) + # Let the second request reach the coalescing branch (it only has to + # clear an in-memory host lookup) before releasing the held reply. + await asyncio.sleep(0.1) + release.set() + resp_first, resp_second = await asyncio.gather(first, second) + finally: + stop_drain.set() + release.set() + try: + await asyncio.wait_for(drain_task, timeout=1.0) + except asyncio.TimeoutError: + drain_task.cancel() + + # Exactly one frame reached the host despite two concurrent requests. + assert install_frames == ["codex"] + assert resp_first.status_code == 200 + assert resp_second.status_code == 200 + # Both callers echo their own requested harness but share the one coalesced + # readiness map (keyed on the harness that actually reached the host). + assert resp_first.json()["harness"] == "codex" + assert resp_second.json()["harness"] == "codex-native" + assert resp_first.json()["configured_harnesses"]["codex"] == "needs-auth" + assert resp_second.json()["configured_harnesses"]["codex"] == "needs-auth" + + +# ── Feature flag ──────────────────────────────────────── + + +async def test_install_harness_route_hidden_when_flag_off( + install_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + With the flag off the route is 404 — the feature is invisible. + + Ships dark by default; only opt-in deployments expose it. + """ + monkeypatch.setenv("OMNIGENT_HARNESS_INSTALL_ENABLED", "0") + app, _reg, _hs, _cs = install_app + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.post(f"/v1/hosts/{_HOST_ID}/harnesses/claude/install") + + assert resp.status_code == 404 + + +# ── Allowlist enforcement ─────────────────────────────── + + +@pytest.mark.parametrize("harness", ["cursor", "goose", "gemini", "kimi", "hermes"]) +async def test_install_harness_rejects_non_allowlisted( + install_setup: tuple[ + FastAPI, + HostRegistry, + ApplicationCommunicator, + dict[str, dict[str, Any]], + asyncio.Task[None], + ], + harness: str, +) -> None: + """ + A non-allowlisted harness is rejected with 400 before any frame. + + M1 only supports npm-installable, key/env-auth harnesses; OAuth and + curl/brew-hint harnesses (notably hermes, whose installer is a + ``curl | bash``) must be refused server-side so the UI cannot trigger + an unsupported — or unsafe — install. + """ + app, _reg, _comm, _replies, _drain = install_setup + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.post(f"/v1/hosts/{_HOST_ID}/harnesses/{harness}/install") + + assert resp.status_code == 400 + + +@pytest.mark.parametrize("harness", ["claude", "codex", "pi", "opencode", "qwen"]) +async def test_install_harness_allows_npm_key_auth_harnesses( + install_setup: tuple[ + FastAPI, + HostRegistry, + ApplicationCommunicator, + dict[str, dict[str, Any]], + asyncio.Task[None], + ], + harness: str, +) -> None: + """ + Every M1 allowlisted harness is accepted and installs. + + Pins the exact allowlist (claude, codex, pi, opencode, qwen) so a + future edit that drops one is caught. + """ + app, _reg, _comm, _replies, _drain = install_setup + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.post(f"/v1/hosts/{_HOST_ID}/harnesses/{harness}/install") + + assert resp.status_code == 200 + assert resp.json()["configured_harnesses"][harness] is True + + +# ── Failure surfaces ──────────────────────────────────── + + +async def test_install_harness_failed_status_returns_502( + install_setup: tuple[ + FastAPI, + HostRegistry, + ApplicationCommunicator, + dict[str, dict[str, Any]], + asyncio.Task[None], + ], +) -> None: + """ + A host-side install failure maps to 502 with the host's message. + + The dialog surfaces this inline so the user sees why the install + failed rather than a silent no-op. + """ + app, _reg, _comm, replies, _drain = install_setup + replies["codex"] = {"status": "failed", "error": "npm registry unreachable"} + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.post(f"/v1/hosts/{_HOST_ID}/harnesses/codex/install") + + assert resp.status_code == 502 + assert "npm registry unreachable" in resp.json()["detail"] + + +async def test_install_harness_unknown_host_returns_404( + install_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore], +) -> None: + """ + Installing on an unknown host returns 404 (don't leak existence). + """ + app, _reg, _hs, _cs = install_app + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.post( + "/v1/hosts/7139b7e896ef9478abca6480107d1677/harnesses/claude/install" + ) + + assert resp.status_code == 404 + + +async def test_install_harness_offline_host_returns_409( + install_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore], +) -> None: + """ + Installing on a registered-but-offline host returns 409. + + A host row can exist in the store while no live tunnel connection is + present; the install needs a live connection to forward the frame. + """ + app, _reg, host_store, _cs = install_app + # Persist a host row without a live registry connection. + host_store.upsert_on_connect( + host_id=_HOST_ID, + name=_HOST_NAME, + user_id="local", + ) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.post(f"/v1/hosts/{_HOST_ID}/harnesses/claude/install") + + assert resp.status_code == 409 + + +async def test_install_harness_non_owner_returns_403( + install_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore], +) -> None: + """A host owned by another user returns 403 — not installable by non-owners. + + Exercises the ownership branch with a real authenticated ``user_id`` (the + default fixtures run unauthenticated, so ``user_id`` is ``None`` and the + comparison is skipped): a host owned by alice, hit with bob's identity, must + 403. Guards the ``host.user_id`` owner check against a field rename. + """ + from omnigent.server.auth import AuthProvider + + _app, _reg, host_store, conv_store = install_app + + class _Stub(AuthProvider): + def get_user_id(self, request: Any) -> str | None: + return request.headers.get("X-Test-User") + + auth = _Stub() + auth_app = FastAPI() + registry = HostRegistry() + auth_app.include_router( + create_host_tunnel_router(registry, host_store, auth_provider=auth), prefix="/v1" + ) + auth_app.include_router( + create_hosts_router(registry, host_store, conv_store, auth_provider=auth), prefix="/v1" + ) + host_store.upsert_on_connect(host_id=_HOST_ID, name=_HOST_NAME, user_id="alice@example.com") + + async with AsyncClient( + transport=ASGITransport(app=auth_app), base_url="http://test" + ) as client: + resp = await client.post( + f"/v1/hosts/{_HOST_ID}/harnesses/claude/install", + headers={"X-Test-User": "bob@example.com"}, + ) + + assert resp.status_code == 403 diff --git a/tests/server/integration/test_utility_endpoints.py b/tests/server/integration/test_utility_endpoints.py index f35ff6054eb..ae946dd0640 100644 --- a/tests/server/integration/test_utility_endpoints.py +++ b/tests/server/integration/test_utility_endpoints.py @@ -80,6 +80,13 @@ async def test_info_returns_expected_fields(client: httpx.AsyncClient) -> None: assert data["needs_setup"] is False assert isinstance(data["databricks_features"], bool) assert isinstance(data["managed_sandboxes_enabled"], bool) + # harness_install_enabled gates the UI Install action; default off unless + # OMNIGENT_HARNESS_INSTALL_ENABLED is set, so it's false in the test app. + assert data["harness_install_enabled"] is False + # installable_harnesses is the allowlist the SPA offers setup for; blank + # while the feature is off so the UI never offers an install the disabled + # route would reject. + assert data["installable_harnesses"] == [] # single_user reflects OMNIGENT_LOCAL_SINGLE_USER, which the suite's # conftest sets to "1" (the default local-dev posture), so it's true here. # The multi-user (marker-off) case is covered below. @@ -107,6 +114,27 @@ async def test_info_single_user_false_without_marker( assert data["login_url"] is None +async def test_info_advertises_installable_harnesses_when_enabled( + client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """With the feature on, ``/v1/info`` publishes the install allowlist. + + The SPA gates its setup offer on membership in this set, so it must carry + the ids the route accepts — including the native spellings a session + declares (``codex-native``), not just the bare ids. + """ + from omnigent.onboarding.harness_install import ui_installable_harnesses + from omnigent.server.routes.hosts import HARNESS_INSTALL_ENABLED_ENV + + monkeypatch.setenv(HARNESS_INSTALL_ENABLED_ENV, "1") + resp = await client.get("/v1/info") + assert resp.status_code == 200 + data = resp.json() + assert data["harness_install_enabled"] is True + assert set(data["installable_harnesses"]) == set(ui_installable_harnesses()) + assert "codex-native" in data["installable_harnesses"] + + # ── GET /v1/me ─────────────────────────────────────────── diff --git a/tests/test_harness_capabilities.py b/tests/test_harness_capabilities.py index 5c10daf98e8..4fc973dad35 100644 --- a/tests/test_harness_capabilities.py +++ b/tests/test_harness_capabilities.py @@ -24,6 +24,7 @@ HarnessContribution, harness_capabilities, harness_catalog, + harness_setup_steps_by_spelling, native_agents, valid_harnesses, ) @@ -165,3 +166,36 @@ def test_catalog_rows_include_capabilities() -> None: # JSON-serializable: values are primitives, not enums. for value in row["capabilities"].values(): assert value is None or isinstance(value, (str, bool)) + + +def test_catalog_rows_carry_setup_steps() -> None: + """Every row exposes an ordered, JSON-serializable setup checklist.""" + rows = {row["id"]: row for row in harness_catalog()} + for row in rows.values(): + assert "setup_steps" in row, row["id"] + assert len(row["setup_steps"]) >= 1 + for step in row["setup_steps"]: + assert step["kind"] in ("install", "auth") + assert step["action"] in ("install", "command", "setup") + # JSON-serializable primitives only. + for value in step.values(): + assert value is None or isinstance(value, str) + # Codex is a first-class harness: install (one-click) then a login command. + codex = rows["codex"]["setup_steps"] + assert [s["action"] for s in codex] == ["install", "command"] + assert codex[1]["command"] == "codex login" + + +def test_setup_steps_by_spelling_covers_native_and_installable_ids() -> None: + """The by-spelling map resolves the ids a session declares, not just picker + rows — native wrappers and installable non-picker ids included.""" + by_spelling = harness_setup_steps_by_spelling() + # Native wrappers (what a session actually declares) resolve to steps... + for native in ("codex-native", "claude-native", "opencode-native", "qwen-native"): + assert native in by_spelling, native + assert len(by_spelling[native]) >= 1 + # ...and match their bare-id counterpart's steps (same ui_setup_steps source). + assert by_spelling["codex-native"] == by_spelling["codex"] + # Installable ids that are NOT picker rows still resolve. + assert "opencode" in by_spelling + assert "qwen" in by_spelling diff --git a/tests/test_harness_plugins.py b/tests/test_harness_plugins.py index 60e8a480d04..c1964459bd8 100644 --- a/tests/test_harness_plugins.py +++ b/tests/test_harness_plugins.py @@ -59,7 +59,11 @@ def _contribution() -> hp.HarnessContribution: assert ( hp.spawn_env_builders()["foo"] == "omnigent.community.harness.foo.plugin:build_spawn_env" ) - assert {"id": "foo", "label": "Foo"} in hp.harness_catalog() + foo_row = next((row for row in hp.harness_catalog() if row["id"] == "foo"), None) + assert foo_row is not None + assert foo_row["label"] == "Foo" + # Every catalog row now also carries a setup_steps checklist. + assert "setup_steps" in foo_row def test_community_harness_rejects_non_community_import_path( From 24831901e76fd37376494ee2cb0a283892807c75 Mon Sep 17 00:00:00 2001 From: Sabhya Chhabria Date: Tue, 21 Jul 2026 20:41:13 -0700 Subject: [PATCH 545/546] feat: import Qwen, Kiro, Pi, and Kimi chats (#3032) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: import Qwen Kiro Pi and Kimi chats Signed-off-by: sabhya-db * 🐛 fix(import): Harden JSONL adapter contracts - Expose stable Kiro and Kimi parser APIs for import reuse - Hash overlong source IDs and bound Qwen locators safely Signed-off-by: sabhya-db --------- Signed-off-by: sabhya-db Co-authored-by: sabhya-db --- omnigent/cli.py | 10 +- omnigent/kimi_native_forwarder.py | 46 +- omnigent/kiro_native_session_forwarder.py | 33 +- omnigent/session_import/local.py | 680 ++++++++++++++++- omnigent/session_import/models.py | 2 +- openapi.json | 6 +- tests/cli/test_import.py | 42 +- tests/e2e/test_chat_import_e2e.py | 206 +++++- tests/test_kimi_native_forwarder.py | 8 +- tests/test_kiro_native_session_forwarder.py | 12 +- tests/test_session_import.py | 768 ++++++++++++++++++++ 11 files changed, 1748 insertions(+), 65 deletions(-) diff --git a/omnigent/cli.py b/omnigent/cli.py index fe0e5e6fe3c..2993301a2c5 100644 --- a/omnigent/cli.py +++ b/omnigent/cli.py @@ -6271,7 +6271,7 @@ def resume( @cli.command("import") @click.option( "--harness", - type=click.Choice(["claude", "codex"], case_sensitive=False), + type=click.Choice(["claude", "codex", "kimi", "kiro", "pi", "qwen"], case_sensitive=False), required=True, help="Local coding harness that owns the source session.", ) @@ -6304,16 +6304,18 @@ def import_session_command( recent_session_count: int | None, server: str | None, ) -> None: - """Import local Claude Code or Codex chats. + """Import chats from supported local coding harnesses. The source transcript is converted to ordinary Omnigent items and stored - as a normal session. Use --session for one chat or --last for a bounded - batch. A source session can only be imported once. + as a normal session. Qwen, Kiro, and Kimi currently preserve visible + messages but not native tool activity. Use --session for one chat or --last + for a bounded batch. A source session can only be imported once. \b Examples: omnigent import --harness claude --session omnigent import --harness codex --session + omnigent import --harness qwen --session omnigent import --harness claude --last 10 """ import httpx diff --git a/omnigent/kimi_native_forwarder.py b/omnigent/kimi_native_forwarder.py index 78ce763ed19..7128e93fe57 100644 --- a/omnigent/kimi_native_forwarder.py +++ b/omnigent/kimi_native_forwarder.py @@ -61,8 +61,8 @@ class _ForwardState: @dataclass -class _MirrorItem: - """One conversation item to POST, plus the line index it came from.""" +class KimiWireItem: + """Stable parsed-wire contract shared by forwarding and offline import.""" line_no: int role: str @@ -73,6 +73,9 @@ class _MirrorItem: kind: str = "message" +_MirrorItem = KimiWireItem + + def clear_kimi_bridge_state(bridge_dir: Path) -> None: """Drop any stale forwarder state so a new terminal starts a fresh tail. @@ -110,7 +113,7 @@ def _write_state(bridge_dir: Path, state: _ForwardState) -> None: tmp.replace(bridge_dir / _STATE_FILE) -def _workdirs_for_sessions(kimi_home: Path) -> dict[str, str]: +def workdirs_for_kimi_sessions(kimi_home: Path) -> dict[str, str]: """Map each session dir → its ``workDir`` from ``session_index.jsonl``. Returns ``{}`` when the index is absent/unreadable (a brand-new home before @@ -138,6 +141,9 @@ def _workdirs_for_sessions(kimi_home: Path) -> dict[str, str]: return mapping +_workdirs_for_sessions = workdirs_for_kimi_sessions + + def _discover_wire(kimi_home: Path, workspace: str, launch_epoch_ms: int) -> Path | None: """Locate the wire log for *workspace*'s newest session created at/after launch. @@ -150,7 +156,7 @@ def _discover_wire(kimi_home: Path, workspace: str, launch_epoch_ms: int) -> Pat sessions_root = kimi_home / "sessions" if not sessions_root.exists(): return None - workdirs = _workdirs_for_sessions(kimi_home) + workdirs = workdirs_for_kimi_sessions(kimi_home) floor_s = (launch_epoch_ms - _DISCOVER_SKEW_MS) / 1000.0 best: tuple[float, Path] | None = None for wire in sessions_root.glob("*/session_*/agents/main/wire.jsonl"): @@ -185,7 +191,7 @@ def _input_text(blocks: object) -> str: return "".join(parts) -def _row_to_item(line_no: int, row: dict[str, object]) -> _MirrorItem | None: +def _row_to_item(line_no: int, row: dict[str, object]) -> KimiWireItem | None: """Map one wire-log row to a conversation item, or ``None`` to skip it.""" row_type = row.get("type") if row_type == "turn.prompt": @@ -195,7 +201,7 @@ def _row_to_item(line_no: int, row: dict[str, object]) -> _MirrorItem | None: text = _input_text(row.get("input")) if not text: return None - return _MirrorItem( + return KimiWireItem( line_no=line_no, role="user", text=text, @@ -212,11 +218,14 @@ def _row_to_item(line_no: int, row: dict[str, object]) -> _MirrorItem | None: response_id = f"kimi:{uuid}" if isinstance(uuid, str) and uuid else f"kimi:line:{line_no}" part_type = part.get("type") if part_type == "text": - text = part.get("text") - if not isinstance(text, str) or not text: + part_text = part.get("text") + if not isinstance(part_text, str) or not part_text: return None - return _MirrorItem( - line_no=line_no, role="assistant", text=text, response_id=response_id + return KimiWireItem( + line_no=line_no, + role="assistant", + text=part_text, + response_id=response_id, ) if part_type == "think": # Reasoning lives in ``part["think"]`` (not ``part["text"]``). Mirror it @@ -225,7 +234,7 @@ def _row_to_item(line_no: int, row: dict[str, object]) -> _MirrorItem | None: think = part.get("think") if not isinstance(think, str) or not think: return None - return _MirrorItem( + return KimiWireItem( line_no=line_no, role="assistant", text=think, @@ -236,8 +245,8 @@ def _row_to_item(line_no: int, row: dict[str, object]) -> _MirrorItem | None: return None -def _read_new_items(wire_path: Path, last_line: int) -> list[_MirrorItem]: - """Parse wire-log lines beyond *last_line* into conversation items. +def read_kimi_wire_items(wire_path: Path, last_line: int) -> list[KimiWireItem]: + """Parse wire-log lines beyond *last_line* into the stable shared contract. The wire log is append-only JSONL, so a line count is a stable high-water mark. Non-JSON / unrecognized lines advance the cursor without emitting. @@ -246,7 +255,7 @@ def _read_new_items(wire_path: Path, last_line: int) -> list[_MirrorItem]: lines = wire_path.read_text(encoding="utf-8").splitlines() except OSError: return [] - items: list[_MirrorItem] = [] + items: list[KimiWireItem] = [] for idx in range(last_line, len(lines)): line = lines[idx].strip() if not line or not line.startswith("{"): @@ -263,13 +272,16 @@ def _read_new_items(wire_path: Path, last_line: int) -> list[_MirrorItem]: return items +_read_new_items = read_kimi_wire_items + + async def _post_conversation_item( client: httpx.AsyncClient, *, base_url: str, headers: dict[str, str], session_id: str, - item: _MirrorItem, + item: KimiWireItem, agent_name: str, ) -> None: """POST one mirrored turn as an external conversation item.""" @@ -299,7 +311,7 @@ async def _post_reasoning_item( base_url: str, headers: dict[str, str], session_id: str, - item: _MirrorItem, + item: KimiWireItem, ) -> None: """POST one mirrored think block as a transient reasoning event. @@ -347,7 +359,7 @@ async def forward_kimi_wire_to_session( last_line = 0 _write_state(bridge_dir, _ForwardState(str(wire_path), last_line)) if wire_path is not None and wire_path.exists(): - items = await asyncio.to_thread(_read_new_items, wire_path, last_line) + items = await asyncio.to_thread(read_kimi_wire_items, wire_path, last_line) for item in items: try: if item.kind == "reasoning": diff --git a/omnigent/kiro_native_session_forwarder.py b/omnigent/kiro_native_session_forwarder.py index e419ba1a61a..a679823c072 100644 --- a/omnigent/kiro_native_session_forwarder.py +++ b/omnigent/kiro_native_session_forwarder.py @@ -43,19 +43,25 @@ class _ForwardState: @dataclass(frozen=True) -class _KiroConversationMessage: - """One conversation message parsed from Kiro's JSONL store.""" +class KiroConversationMessage: + """Stable parsed-message contract shared by forwarding and offline import.""" message_id: str role: str text: str -def _kiro_cli_sessions_dir(home: Path | None = None) -> Path: +_KiroConversationMessage = KiroConversationMessage + + +def kiro_cli_sessions_dir(home: Path | None = None) -> Path: """Return Kiro CLI's session directory for this user.""" return (home or Path.home()) / ".kiro" / "sessions" / "cli" +_kiro_cli_sessions_dir = kiro_cli_sessions_dir + + def _read_state(bridge_dir: Path) -> _ForwardState: """Load the persisted forward cursor, or a cold default.""" try: @@ -161,7 +167,7 @@ def _discover_kiro_session_jsonl( The resume/fork path doesn't reach here: when the Kiro id is already known the caller binds it directly via :func:`_kiro_session_jsonl_for_id`. """ - root = sessions_dir or _kiro_cli_sessions_dir() + root = sessions_dir or kiro_cli_sessions_dir() if not root.is_dir(): return None floor_ms = max(0, launch_epoch_ms - _DISCOVERY_SKEW_MS) @@ -198,7 +204,7 @@ def _kiro_session_jsonl_for_id( sessions_dir: Path | None = None, ) -> Path | None: """Return the JSONL path for a known Kiro session id, if it is usable.""" - root = sessions_dir or _kiro_cli_sessions_dir() + root = sessions_dir or kiro_cli_sessions_dir() metadata_path = root / f"{session_id}.json" jsonl_path = root / f"{session_id}.jsonl" if not jsonl_path.is_file(): @@ -215,9 +221,9 @@ def _kiro_session_jsonl_for_id( def _read_new_kiro_messages( jsonl_path: Path, byte_offset: int, -) -> tuple[list[_KiroConversationMessage], int]: +) -> tuple[list[KiroConversationMessage], int]: """Read conversation messages after *byte_offset* from Kiro's JSONL file.""" - messages: list[_KiroConversationMessage] = [] + messages: list[KiroConversationMessage] = [] try: with jsonl_path.open("rb") as handle: handle.seek(byte_offset) @@ -235,7 +241,7 @@ def _read_new_kiro_messages( line = raw_line.decode("utf-8") except UnicodeDecodeError: continue - message = _parse_kiro_jsonl_line(line) + message = parse_kiro_jsonl_line(line) if message is not None: messages.append(message) return messages, offset @@ -243,8 +249,8 @@ def _read_new_kiro_messages( return [], byte_offset -def _parse_kiro_jsonl_line(line: str) -> _KiroConversationMessage | None: - """Parse one Kiro JSONL line into a mirrorable conversation message.""" +def parse_kiro_jsonl_line(line: str) -> KiroConversationMessage | None: + """Parse one Kiro JSONL line into the stable shared message contract.""" try: record = json.loads(line) except ValueError: @@ -267,7 +273,10 @@ def _parse_kiro_jsonl_line(line: str) -> _KiroConversationMessage | None: text = _kiro_content_text(data.get("content")).strip() if not text: return None - return _KiroConversationMessage(message_id=message_id, role=role, text=text) + return KiroConversationMessage(message_id=message_id, role=role, text=text) + + +_parse_kiro_jsonl_line = parse_kiro_jsonl_line def _kiro_content_text(content: object) -> str: @@ -292,7 +301,7 @@ async def _post_conversation_message( *, session_id: str, agent_name: str, - message: _KiroConversationMessage, + message: KiroConversationMessage, ) -> None: """POST one Kiro message as an external conversation item.""" if message.role == "assistant": diff --git a/omnigent/session_import/local.py b/omnigent/session_import/local.py index 56e83ed3222..a99bebe7348 100644 --- a/omnigent/session_import/local.py +++ b/omnigent/session_import/local.py @@ -4,17 +4,42 @@ import json import os +import re +from hashlib import sha256 from pathlib import Path from omnigent.claude_native_bridge import read_transcript_items_from_offset from omnigent.codex_native import _CODEX_THREAD_ID_RE, _find_codex_rollout from omnigent.entities import NewConversationItem, parse_item_data +from omnigent.kimi_native_credentials import resolve_user_kimi_home +from omnigent.kimi_native_forwarder import ( + read_kimi_wire_items, + workdirs_for_kimi_sessions, +) +from omnigent.kiro_native_session_forwarder import ( + kiro_cli_sessions_dir, + parse_kiro_jsonl_line, +) from omnigent.session_import.models import ( ImportSource, LocalSessionImport, SessionImportNotFoundError, ) +_PI_IMPORT_SESSION_ID_RE = re.compile(r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?") +_MAX_EXTERNAL_SESSION_ID_LENGTH = 128 +_MAX_RESPONSE_ID_LENGTH = 64 + + +def _bounded_response_id(response_id: str) -> str: + """Keep short native ids readable and hash long ids without collisions.""" + if len(response_id) <= _MAX_RESPONSE_ID_LENGTH: + return response_id + harness, separator, _ = response_id.partition(":") + prefix = f"{harness}:sha256:" if separator else "sha256:" + digest_length = _MAX_RESPONSE_ID_LENGTH - len(prefix) + return prefix + sha256(response_id.encode()).hexdigest()[:digest_length] + def _find_transcript(root: Path, session_id: str) -> Path | None: """Return the newest parent JSONL transcript whose stem matches the id.""" @@ -47,6 +72,41 @@ def _recent_unique_session_ids( return tuple(ordered[:limit]) +def _pi_session_id_from_path(path: Path) -> str | None: + """Read a safe native session id from a Pi transcript header.""" + try: + with path.open(encoding="utf-8") as handle: + header = json.loads(handle.readline()) + except (OSError, ValueError): + return None + session_id = header.get("id") if isinstance(header, dict) else None + if not isinstance(session_id, str) or not _is_safe_pi_import_session_id(session_id): + return None + return session_id + + +def _is_safe_pi_import_session_id(session_id: str) -> bool: + """Match Pi's safe syntax within the import API's identity limit.""" + return ( + len(session_id) <= _MAX_EXTERNAL_SESSION_ID_LENGTH + and _PI_IMPORT_SESSION_ID_RE.fullmatch(session_id) is not None + ) + + +def _qwen_session_locator(path: Path) -> str: + """Qualify a Qwen id by project while staying within API limits.""" + project = path.parent.parent.name + session_id = path.stem + locator = f"{project}:{session_id}" + if len(locator) <= _MAX_EXTERNAL_SESSION_ID_LENGTH: + return locator + project_digest = sha256(project.encode()).hexdigest()[:16] + locator = f"{project_digest}:{session_id}" + if len(locator) <= _MAX_EXTERNAL_SESSION_ID_LENGTH: + return locator + return f"{project_digest}:{sha256(session_id.encode()).hexdigest()}" + + def list_recent_local_session_ids( source: ImportSource, *, @@ -64,23 +124,66 @@ def list_recent_local_session_ids( ] return _recent_unique_session_ids(candidates, limit=limit) - configured_home = os.environ.get("CODEX_HOME") - home = Path(configured_home).expanduser() if configured_home else Path.home() / ".codex" - rollouts: list[Path] = [] - sessions = home / "sessions" - archived_sessions = home / "archived_sessions" - if sessions.is_dir(): - rollouts.extend(path for path in sessions.glob("**/rollout-*.jsonl") if path.is_file()) - if archived_sessions.is_dir(): - rollouts.extend( - path for path in archived_sessions.glob("rollout-*.jsonl") if path.is_file() + if source == "qwen": + configured_home = os.environ.get("QWEN_HOME") + home = Path(configured_home).expanduser() if configured_home else Path.home() / ".qwen" + paths = [path for path in (home / "projects").glob("*/chats/*.jsonl") if path.is_file()] + candidates = [(path, _qwen_session_locator(path)) for path in paths] + return _recent_unique_session_ids(candidates, limit=limit) + + if source == "kiro": + root = kiro_cli_sessions_dir() + candidates = [ + (path, path.stem) + for path in root.glob("*.jsonl") + if path.is_file() and path.with_suffix(".json").is_file() + ] + return _recent_unique_session_ids(candidates, limit=limit) + + if source == "pi": + configured_home = os.environ.get("PI_CODING_AGENT_DIR") + home = ( + Path(configured_home).expanduser() + if configured_home + else Path.home() / ".pi" / "agent" ) - candidates = [] - for path in rollouts: - session_id = path.stem[-36:] - if _CODEX_THREAD_ID_RE.fullmatch(session_id): - candidates.append((path, session_id)) - return _recent_unique_session_ids(candidates, limit=limit) + # Pi stores ids in the header, so discovery intentionally reads one line per file. + candidates = [ + (path, session_id) + for path in (home / "sessions").rglob("*.jsonl") + if path.is_file() and (session_id := _pi_session_id_from_path(path)) is not None + ] + return _recent_unique_session_ids(candidates, limit=limit) + + if source == "kimi": + home = resolve_user_kimi_home() + candidates = [ + (path, path.parent.parent.parent.name) + for path in (home / "sessions").glob("*/session_*/agents/main/wire.jsonl") + if path.is_file() + ] + return _recent_unique_session_ids(candidates, limit=limit) + + if source == "codex": + configured_home = os.environ.get("CODEX_HOME") + home = Path(configured_home).expanduser() if configured_home else Path.home() / ".codex" + rollouts: list[Path] = [] + sessions = home / "sessions" + archived_sessions = home / "archived_sessions" + if sessions.is_dir(): + rollouts.extend(path for path in sessions.glob("**/rollout-*.jsonl") if path.is_file()) + if archived_sessions.is_dir(): + rollouts.extend( + path for path in archived_sessions.glob("rollout-*.jsonl") if path.is_file() + ) + candidates = [] + for path in rollouts: + session_id = path.stem[-36:] + if _CODEX_THREAD_ID_RE.fullmatch(session_id): + candidates.append((path, session_id)) + return _recent_unique_session_ids(candidates, limit=limit) + + raise ValueError(f"Unsupported import source: {source}") def _claude_workspace(transcript_path: Path) -> str | None: @@ -308,16 +411,559 @@ def load_codex_session( ) +def _qwen_message_data(record: dict[str, object]) -> dict[str, object] | None: + """Convert one visible Qwen recording row to Omnigent message data.""" + # Qwen records assistant events as type="assistant" while message.role is "model". + record_type = record.get("type") + if record_type == "user": + role = "user" + content_type = "input_text" + elif record_type == "assistant": + role = "assistant" + content_type = "output_text" + else: + return None + message = record.get("message") + if not isinstance(message, dict) or not isinstance(message.get("parts"), list): + return None + content = [ + {"type": content_type, "text": part["text"]} + for part in message["parts"] + if isinstance(part, dict) and isinstance(part.get("text"), str) and part["text"] + ] + if not content: + return None + data: dict[str, object] = {"role": role, "content": content} + if role == "assistant": + data["agent"] = "qwen-native-ui" + return data + + +def _qwen_active_branch(records: list[dict[str, object]]) -> list[dict[str, object]]: + """Return Qwen records on the current leaf's root-to-leaf path.""" + linked = [record for record in records if isinstance(record.get("uuid"), str)] + if not linked or all("parentUuid" not in record for record in linked): + return records + by_id = {record["uuid"]: record for record in linked} + if len(by_id) != len(linked): + return [] + + branch: list[dict[str, object]] = [] + current = linked[-1] + seen: set[str] = set() + while True: + record_id = current["uuid"] + if not isinstance(record_id, str) or record_id in seen: + return [] + seen.add(record_id) + branch.append(current) + parent_id = current.get("parentUuid") + if parent_id is None: + branch.reverse() + return branch + if not isinstance(parent_id, str) or parent_id not in by_id: + return [] + current = by_id[parent_id] + + +def load_qwen_session( + session_id: str, + *, + qwen_home: Path | None = None, +) -> LocalSessionImport: + """Load one Qwen Code session from its project chat recording.""" + configured_home = os.environ.get("QWEN_HOME") + home = qwen_home or (Path(configured_home).expanduser() if configured_home else None) + root = (home or Path.home() / ".qwen") / "projects" + qualified = ":" in session_id + matches = [ + path + for path in root.glob("*/chats/*.jsonl") + if path.is_file() + and (_qwen_session_locator(path) == session_id if qualified else path.stem == session_id) + ] + if not matches: + raise SessionImportNotFoundError(f"Qwen Code session {session_id!r} was not found") + if len(matches) > 1: + choices = ", ".join(sorted(_qwen_session_locator(path) for path in matches)) + raise SessionImportNotFoundError( + f"Qwen Code session {session_id!r} is ambiguous; use one of: {choices}" + ) + transcript_path = matches[0] + + records: list[dict[str, object]] = [] + with transcript_path.open(encoding="utf-8") as handle: + for line in handle: + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(record, dict): + continue + records.append(record) + + workspace: str | None = None + items: list[NewConversationItem] = [] + for record_number, record in enumerate(_qwen_active_branch(records), start=1): + if workspace is None: + cwd = record.get("cwd") + if isinstance(cwd, str) and cwd.strip(): + workspace = cwd.strip() + data = _qwen_message_data(record) + if data is None: + continue + record_id = record.get("uuid") + response_id = ( + f"qwen:{record_id}" + if isinstance(record_id, str) and record_id + else f"qwen:{record_number}" + ) + items.append( + NewConversationItem( + type="message", + response_id=_bounded_response_id(response_id), + data=parse_item_data("message", data), + ) + ) + if not items: + raise SessionImportNotFoundError( + f"Qwen Code session {session_id!r} has no importable history" + ) + return LocalSessionImport( + source="qwen", + external_session_id=_qwen_session_locator(transcript_path), + workspace=workspace, + items=tuple(items), + ) + + +def load_kiro_session( + session_id: str, + *, + kiro_home: Path | None = None, +) -> LocalSessionImport: + """Load one Kiro CLI session from its metadata and JSONL transcript.""" + root = kiro_cli_sessions_dir(kiro_home) + transcript_path = next( + (path for path in root.glob("*.jsonl") if path.is_file() and path.stem == session_id), + None, + ) + if transcript_path is None: + raise SessionImportNotFoundError(f"Kiro session {session_id!r} was not found") + metadata_path = transcript_path.with_suffix(".json") + if not metadata_path.is_file(): + raise SessionImportNotFoundError(f"Kiro session {session_id!r} was not found") + try: + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise SessionImportNotFoundError( + f"Kiro session {session_id!r} has unreadable metadata" + ) from exc + workspace_value = metadata.get("cwd") if isinstance(metadata, dict) else None + workspace = workspace_value.strip() if isinstance(workspace_value, str) else None + try: + messages = [ + message + for line in transcript_path.read_text(encoding="utf-8").splitlines() + if (message := parse_kiro_jsonl_line(line)) is not None + ] + except OSError as exc: + raise SessionImportNotFoundError( + f"Kiro session {session_id!r} has an unreadable transcript" + ) from exc + items = tuple( + NewConversationItem( + type="message", + response_id=_bounded_response_id(f"kiro:{message.message_id}"), + data=parse_item_data( + "message", + { + "role": message.role, + **({"agent": "kiro-native-ui"} if message.role == "assistant" else {}), + "content": [ + { + "type": "output_text" if message.role == "assistant" else "input_text", + "text": message.text, + } + ], + }, + ), + ) + for message in messages + ) + if not items: + raise SessionImportNotFoundError(f"Kiro session {session_id!r} has no importable history") + return LocalSessionImport( + source="kiro", + external_session_id=session_id, + workspace=workspace or None, + items=items, + ) + + +def _pi_text(content: object) -> str: + """Flatten Pi string or typed-text content.""" + if isinstance(content, str): + return content + if not isinstance(content, list): + return "" + return "".join( + block["text"] + for block in content + if isinstance(block, dict) + and block.get("type") == "text" + and isinstance(block.get("text"), str) + ) + + +def _pi_message_content(content: object, *, role: str) -> list[dict[str, object]]: + """Map Pi text and user-image blocks without changing their order.""" + content_type = "input_text" if role == "user" else "output_text" + if isinstance(content, str): + return [{"type": content_type, "text": content}] if content else [] + if not isinstance(content, list): + return [] + normalized: list[dict[str, object]] = [] + for block in content: + if not isinstance(block, dict): + continue + text = block.get("text") + if block.get("type") == "text" and isinstance(text, str) and text: + normalized.append({"type": content_type, "text": text}) + continue + data = block.get("data") + mime_type = block.get("mimeType") + if ( + role == "user" + and block.get("type") == "image" + and isinstance(data, str) + and data + and isinstance(mime_type, str) + and mime_type.startswith("image/") + ): + normalized.append( + {"type": "input_image", "image_url": f"data:{mime_type};base64,{data}"} + ) + return normalized + + +def _pi_active_branch(records: list[dict[str, object]]) -> list[dict[str, object]]: + """Return Pi entries on the current leaf's root-to-leaf path.""" + header = next((record for record in records if record.get("type") == "session"), {}) + version = header.get("version") + if not isinstance(version, int) or version < 2: + legacy_parent_id: str | None = None + migrated: list[dict[str, object]] = [] + for index, record in enumerate(records): + if record.get("type") == "session": + migrated.append(record) + continue + entry = dict(record) + legacy_entry_id = f"legacy-{index}" + entry["id"] = legacy_entry_id + entry["parentId"] = legacy_parent_id + migrated.append(entry) + legacy_parent_id = legacy_entry_id + records = migrated + entries = [ + record + for record in records + if record.get("type") != "session" and isinstance(record.get("id"), str) + ] + if not entries: + return [] + if all("parentId" not in entry for entry in entries): + return entries + by_id = {entry["id"]: entry for entry in entries} + if len(by_id) != len(entries): + return [] + branch: list[dict[str, object]] = [] + current = entries[-1] + seen: set[str] = set() + while True: + entry_id = current["id"] + if not isinstance(entry_id, str) or entry_id in seen: + return [] + seen.add(entry_id) + branch.append(current) + parent_id = current.get("parentId") + if parent_id is None: + branch.reverse() + return branch + if not isinstance(parent_id, str) or parent_id not in by_id: + return [] + current = by_id[parent_id] + + +def _pi_message_items(record: dict[str, object]) -> tuple[NewConversationItem, ...]: + """Convert one Pi message entry to visible Omnigent items.""" + if record.get("type") == "branch_summary": + summary = record.get("summary") + if not isinstance(summary, str) or not summary: + return () + entry_id = record.get("id") + response_id = f"pi:{entry_id}" if isinstance(entry_id, str) else "pi:history" + return ( + NewConversationItem( + type="message", + response_id=_bounded_response_id(response_id), + data=parse_item_data( + "message", + { + "role": "user", + "is_meta": True, + "content": [ + { + "type": "input_text", + "text": ( + "The following is a summary of a branch that this " + "conversation came back from:\n\n\n" + f"{summary}\n" + ), + } + ], + }, + ), + ), + ) + message = record.get("message") + if record.get("type") != "message" or not isinstance(message, dict): + return () + entry_id = record.get("id") + response_id = f"pi:{entry_id}" if isinstance(entry_id, str) else "pi:history" + role = message.get("role") + if role == "toolResult": + call_id = message.get("toolCallId") + if not isinstance(call_id, str) or not call_id: + return () + return ( + NewConversationItem( + type="function_call_output", + response_id=_bounded_response_id(response_id), + data=parse_item_data( + "function_call_output", + {"call_id": call_id, "output": _pi_text(message.get("content"))}, + ), + ), + ) + if role not in {"user", "assistant"}: + return () + + items: list[NewConversationItem] = [] + content = message.get("content") + if role == "user": + normalized = _pi_message_content(content, role=role) + if not normalized: + return () + items.append( + NewConversationItem( + type="message", + response_id=_bounded_response_id(response_id), + data=parse_item_data( + "message", + {"role": "user", "content": normalized}, + ), + ) + ) + return tuple(items) + + interrupted = message.get("stopReason") == "aborted" + + def append_assistant_text(blocks: list[dict[str, object]]) -> None: + if not blocks: + return + data: dict[str, object] = { + "role": "assistant", + "agent": "pi-native-ui", + "content": blocks, + } + if interrupted: + data["interrupted"] = True + items.append( + NewConversationItem( + type="message", + response_id=_bounded_response_id(response_id), + data=parse_item_data("message", data), + ) + ) + + if isinstance(content, str): + append_assistant_text(_pi_message_content(content, role="assistant")) + elif isinstance(content, list): + pending_text: list[dict[str, object]] = [] + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "text": + pending_text.extend(_pi_message_content([block], role="assistant")) + continue + if block.get("type") != "toolCall": + continue + append_assistant_text(pending_text) + pending_text = [] + call_id = block.get("id") + name = block.get("name") + if ( + not isinstance(call_id, str) + or not call_id + or not isinstance(name, str) + or not name + ): + continue + arguments = block.get("arguments") + serialized_arguments = ( + arguments + if isinstance(arguments, str) + else json.dumps(arguments if arguments is not None else {}, separators=(",", ":")) + ) + # Only message items support interrupted state; retain aborted-turn tool calls. + items.append( + NewConversationItem( + type="function_call", + response_id=_bounded_response_id(response_id), + data=parse_item_data( + "function_call", + { + "agent": "pi-native-ui", + "name": name, + "arguments": serialized_arguments, + "call_id": call_id, + }, + ), + ) + ) + append_assistant_text(pending_text) + return tuple(items) + + +def load_pi_session( + session_id: str, + *, + pi_home: Path | None = None, +) -> LocalSessionImport: + """Load the active branch of one Pi coding-agent JSONL session.""" + configured_home = os.environ.get("PI_CODING_AGENT_DIR") + home = pi_home or (Path(configured_home).expanduser() if configured_home else None) + root = (home or Path.home() / ".pi" / "agent") / "sessions" + if not _is_safe_pi_import_session_id(session_id): + raise SessionImportNotFoundError(f"Pi session {session_id!r} was not found") + matches = [ + path + for path in root.rglob("*.jsonl") + if path.is_file() and _pi_session_id_from_path(path) == session_id + ] + if not matches: + raise SessionImportNotFoundError(f"Pi session {session_id!r} was not found") + if len(matches) > 1: + raise SessionImportNotFoundError(f"Pi session {session_id!r} is ambiguous across projects") + transcript_path = matches[0] + records: list[dict[str, object]] = [] + with transcript_path.open(encoding="utf-8") as handle: + for line in handle: + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(record, dict): + records.append(record) + header = next((record for record in records if record.get("type") == "session"), {}) + if header.get("id") != session_id: + raise SessionImportNotFoundError( + f"Pi session {session_id!r} has mismatched transcript metadata" + ) + workspace_value = header.get("cwd") + workspace = workspace_value.strip() if isinstance(workspace_value, str) else None + items = tuple( + item for record in _pi_active_branch(records) for item in _pi_message_items(record) + ) + if not items: + raise SessionImportNotFoundError(f"Pi session {session_id!r} has no importable history") + return LocalSessionImport( + source="pi", + external_session_id=session_id, + workspace=workspace or None, + items=items, + ) + + +def load_kimi_session( + session_id: str, + *, + kimi_home: Path | None = None, +) -> LocalSessionImport: + """Load one Kimi Code session from its append-only wire log.""" + home = kimi_home or resolve_user_kimi_home() + matches = [ + path + for path in (home / "sessions").glob("*/session_*/agents/main/wire.jsonl") + if path.is_file() and path.parent.parent.parent.name == session_id + ] + if not matches: + raise SessionImportNotFoundError(f"Kimi session {session_id!r} was not found") + if len(matches) > 1: + raise SessionImportNotFoundError( + f"Kimi session {session_id!r} is ambiguous across workspaces" + ) + wire_path = matches[0] + session_dir = wire_path.parent.parent.parent + workspace_value = workdirs_for_kimi_sessions(home).get(str(session_dir)) + workspace = workspace_value.strip() if isinstance(workspace_value, str) else None + mirrored = read_kimi_wire_items(wire_path, 0) + items = tuple( + NewConversationItem( + type="message", + response_id=_bounded_response_id(item.response_id), + data=parse_item_data( + "message", + { + "role": item.role, + **({"agent": "kimi-native-ui"} if item.role == "assistant" else {}), + "content": [ + { + "type": "output_text" if item.role == "assistant" else "input_text", + "text": item.text, + } + ], + }, + ), + ) + for item in mirrored + if item.kind == "message" + ) + if not items: + raise SessionImportNotFoundError(f"Kimi session {session_id!r} has no importable history") + return LocalSessionImport( + source="kimi", + external_session_id=session_id, + workspace=workspace or None, + items=items, + ) + + def load_local_session(source: ImportSource, session_id: str) -> LocalSessionImport: """Load one local session from the selected first-party harness.""" if source == "claude": return load_claude_session(session_id) - return load_codex_session(session_id) + if source == "codex": + return load_codex_session(session_id) + if source == "qwen": + return load_qwen_session(session_id) + if source == "kiro": + return load_kiro_session(session_id) + if source == "pi": + return load_pi_session(session_id) + if source == "kimi": + return load_kimi_session(session_id) + raise ValueError(f"Unsupported import source: {source}") __all__ = [ "list_recent_local_session_ids", "load_claude_session", "load_codex_session", + "load_kimi_session", + "load_kiro_session", "load_local_session", + "load_pi_session", + "load_qwen_session", ] diff --git a/omnigent/session_import/models.py b/omnigent/session_import/models.py index 9d8e9c17944..2708164d7e4 100644 --- a/omnigent/session_import/models.py +++ b/omnigent/session_import/models.py @@ -9,7 +9,7 @@ from omnigent.entities import MessageData, NewConversationItem from omnigent.entities.conversation import synthesize_conversation_title -ImportSource = Literal["claude", "codex"] +ImportSource = Literal["claude", "codex", "kimi", "kiro", "pi", "qwen"] IMPORT_SOURCE_LABEL_KEY = "omnigent.import.source" IMPORT_EXTERNAL_SESSION_ID_LABEL_KEY = "omnigent.import.external_session_id" diff --git a/openapi.json b/openapi.json index 515c94520d5..c09cc8fb715 100644 --- a/openapi.json +++ b/openapi.json @@ -1759,7 +1759,11 @@ "source": { "enum": [ "claude", - "codex" + "codex", + "kimi", + "kiro", + "pi", + "qwen" ], "title": "Source", "type": "string" diff --git a/tests/cli/test_import.py b/tests/cli/test_import.py index 69274bb78ac..5743a6f93fd 100644 --- a/tests/cli/test_import.py +++ b/tests/cli/test_import.py @@ -86,7 +86,7 @@ def test_import_command_loads_local_session_and_posts_normalized_items(tmp_path: def test_import_command_rejects_cursor() -> None: - """The v0 import command accepts only Claude Code and Codex.""" + """The import command rejects sources without a supported adapter.""" result = CliRunner().invoke( cli, ["import", "--harness", "cursor", "--session", "cursor-session"], @@ -96,6 +96,46 @@ def test_import_command_rejects_cursor() -> None: assert "Invalid value for '--harness'" in result.output +@respx.mock +def test_import_command_accepts_qwen_session(tmp_path: Path) -> None: + """The public CLI accepts a newly supported JSONL harness.""" + session_id = "019f8648-2797-7170-bf73-837f2655c47e" + transcript = tmp_path / ".qwen" / "projects" / "-repo" / "chats" / f"{session_id}.jsonl" + transcript.parent.mkdir(parents=True) + transcript.write_text( + json.dumps( + { + "uuid": "user-1", + "sessionId": session_id, + "type": "user", + "cwd": "/repo", + "message": {"role": "user", "parts": [{"text": "hello"}]}, + } + ) + + "\n", + encoding="utf-8", + ) + route = respx.post(f"{_BASE}/v1/imports").mock( + return_value=httpx.Response( + 201, + json={"session_id": "conv_qwen", "status": "imported", "item_count": 1}, + ) + ) + + with patch("omnigent.cli._resolve_attach_server", return_value=_BASE): + result = CliRunner().invoke( + cli, + ["import", "--harness", "qwen", "--session", session_id], + env={"HOME": str(tmp_path), "QWEN_HOME": str(tmp_path / ".qwen")}, + ) + + assert result.exit_code == 0, result.output + payload = json.loads(route.calls.last.request.content) + assert payload["source"] == "qwen" + assert payload["external_session_id"] == f"-repo:{session_id}" + assert "conv_qwen" in result.output + + @respx.mock def test_import_command_imports_last_sessions_oldest_first_and_skips_duplicates( tmp_path: Path, diff --git a/tests/e2e/test_chat_import_e2e.py b/tests/e2e/test_chat_import_e2e.py index 18ddb955483..4c22e89dc15 100644 --- a/tests/e2e/test_chat_import_e2e.py +++ b/tests/e2e/test_chat_import_e2e.py @@ -10,6 +10,135 @@ from pathlib import Path import httpx +import pytest + + +def _write_jsonl_import_fixture(home: Path, harness: str) -> str: + """Write one two-message native transcript and return its source id.""" + if harness == "qwen": + session_id = "019f8648-2797-7170-bf73-837f2655c471" + transcript = home / ".qwen" / "projects" / "-repo" / "chats" / f"{session_id}.jsonl" + records = [ + { + "uuid": "user-1", + "parentUuid": None, + "sessionId": session_id, + "timestamp": "2026-07-21T12:00:00Z", + "type": "user", + "cwd": "/repo", + "message": {"role": "user", "parts": [{"text": "inspect TODO.md"}]}, + }, + { + "uuid": "assistant-1", + "parentUuid": "user-1", + "sessionId": session_id, + "timestamp": "2026-07-21T12:00:01Z", + "type": "assistant", + "cwd": "/repo", + "message": {"role": "model", "parts": [{"text": "Done."}]}, + }, + ] + elif harness == "kiro": + session_id = "kiro-import-e2e" + root = home / ".kiro" / "sessions" / "cli" + transcript = root / f"{session_id}.jsonl" + root.mkdir(parents=True) + (root / f"{session_id}.json").write_text( + json.dumps({"cwd": "/repo", "created_at": "2026-07-21T12:00:00Z"}), + encoding="utf-8", + ) + records = [ + { + "kind": "Prompt", + "data": { + "message_id": "user-1", + "content": [{"kind": "text", "data": "inspect TODO.md"}], + }, + }, + { + "kind": "AssistantMessage", + "data": { + "message_id": "assistant-1", + "content": [{"kind": "text", "data": "Done."}], + }, + }, + ] + elif harness == "pi": + session_id = "019f8648-2797-7170-bf73-837f2655c472" + transcript = home / ".pi" / "agent" / "sessions" / "--repo--" / f"stamp_{session_id}.jsonl" + records = [ + {"type": "session", "version": 3, "id": session_id, "cwd": "/repo"}, + { + "type": "message", + "id": "11111111", + "parentId": None, + "timestamp": "2026-07-21T12:00:00Z", + "message": { + "role": "user", + "content": [{"type": "text", "text": "inspect TODO.md"}], + "timestamp": 0, + }, + }, + { + "type": "message", + "id": "22222222", + "parentId": "11111111", + "timestamp": "2026-07-21T12:00:01Z", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "Done."}], + "api": "anthropic-messages", + "provider": "anthropic", + "model": "claude-sonnet", + "usage": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 0, + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0, + }, + }, + "stopReason": "stop", + "timestamp": 0, + }, + }, + ] + else: + session_id = "session_import_e2e" + session_dir = home / ".kimi-code" / "sessions" / "wd_repo" / session_id + transcript = session_dir / "agents" / "main" / "wire.jsonl" + index = home / ".kimi-code" / "session_index.jsonl" + index.parent.mkdir(parents=True) + index.write_text( + json.dumps({"sessionDir": str(session_dir), "workDir": "/repo"}) + "\n", + encoding="utf-8", + ) + records = [ + { + "type": "turn.prompt", + "origin": {"kind": "user"}, + "input": [{"type": "text", "text": "inspect TODO.md"}], + }, + { + "type": "context.append_loop_event", + "event": { + "type": "content.part", + "uuid": "assistant-1", + "part": {"type": "text", "text": "Done."}, + }, + }, + ] + transcript.parent.mkdir(parents=True, exist_ok=True) + transcript.write_text( + "".join(f"{json.dumps(record)}\n" for record in records), encoding="utf-8" + ) + return f"-repo:{session_id}" if harness == "qwen" else session_id def test_cli_imports_claude_chat_into_live_server(live_server: str, tmp_path: Path) -> None: @@ -82,8 +211,10 @@ def test_cli_imports_claude_chat_into_live_server(live_server: str, tmp_path: Pa timeout=10, ) session.raise_for_status() - assert session.json()["external_session_id"] == source_session_id - assert session.json()["workspace"] == "/repo" + session_data = session.json() + assert session_data["external_session_id"] == source_session_id + assert session_data["workspace"] == "/repo" + assert session_data["title"] == "inspect TODO.md" items = httpx.get(f"{live_server}/v1/sessions/{session_id}/items", timeout=10) items.raise_for_status() assert [item["type"] for item in items.json()["data"]] == ["message", "message"] @@ -245,3 +376,74 @@ def test_cli_imports_recent_codex_chats_as_batch(live_server: str, tmp_path: Pat ) session.raise_for_status() assert session.json()["external_session_id"] == source_id + + +@pytest.mark.parametrize("harness", ["qwen", "kiro", "pi", "kimi"]) +def test_cli_imports_jsonl_harness_chat_end_to_end( + live_server: str, + tmp_path: Path, + harness: str, +) -> None: + """The real CLI discovers, uploads, and serves each supported JSONL format.""" + source_session_id = _write_jsonl_import_fixture(tmp_path, harness) + env = os.environ.copy() + env.update( + { + "HOME": str(tmp_path), + "QWEN_HOME": str(tmp_path / ".qwen"), + "PI_CODING_AGENT_DIR": str(tmp_path / ".pi" / "agent"), + "KIMI_CODE_HOME": str(tmp_path / ".kimi-code"), + "OMNIGENT_CONFIG_HOME": str(tmp_path / "config"), + "OMNIGENT_DATA_DIR": str(tmp_path / "omnigent-data"), + } + ) + + result = subprocess.run( + [ + sys.executable, + "-m", + "omnigent", + "import", + "--harness", + harness, + "--last", + "1", + "--server", + live_server, + ], + check=True, + capture_output=True, + text=True, + timeout=30, + env=env, + ) + + match = re.search( + rf"Imported 2 item\(s\) from {re.escape(source_session_id)} into (\S+)\.", + result.stdout, + ) + assert match is not None, result.stdout + imported_session_id = match.group(1) + session = httpx.get( + f"{live_server}/v1/sessions/{imported_session_id}", + params={"include_items": "false", "include_liveness": "false"}, + timeout=10, + ) + session.raise_for_status() + session_data = session.json() + assert session_data["external_session_id"] == source_session_id + assert session_data["workspace"] == "/repo" + assert session_data["title"] == "inspect TODO.md" + items = httpx.get( + f"{live_server}/v1/sessions/{imported_session_id}/items", + timeout=10, + ) + items.raise_for_status() + item_data = items.json()["data"] + assert [item["type"] for item in item_data] == ["message", "message"] + assert [item["role"] for item in item_data] == ["user", "assistant"] + assert [item["content"][0]["text"] for item in item_data] == [ + "inspect TODO.md", + "Done.", + ] + assert item_data[1]["model"] == f"{harness}-native-ui" diff --git a/tests/test_kimi_native_forwarder.py b/tests/test_kimi_native_forwarder.py index 602dc62f20f..3926502b4e1 100644 --- a/tests/test_kimi_native_forwarder.py +++ b/tests/test_kimi_native_forwarder.py @@ -14,11 +14,11 @@ from omnigent.kimi_native_forwarder import ( _discover_wire, _ForwardState, - _read_new_items, _read_state, _row_to_item, _write_state, clear_kimi_bridge_state, + read_kimi_wire_items, ) @@ -113,18 +113,18 @@ def _part(uuid: str, part_type: str, text: str) -> dict[str, object]: return p def test_parses_user_and_assistant_only(self, tmp_path: Path) -> None: - items = _read_new_items(self._wire(tmp_path), 0) + items = read_kimi_wire_items(self._wire(tmp_path), 0) assert [(i.role, i.text) for i in items] == [("user", "hi"), ("assistant", "hello!")] def test_offset_skips_already_seen(self, tmp_path: Path) -> None: wire = self._wire(tmp_path) # last_line past the user prompt (line 1) → only the assistant text (line 3). - items = _read_new_items(wire, 2) + items = read_kimi_wire_items(wire, 2) assert [(i.role, i.text) for i in items] == [("assistant", "hello!")] assert items[0].line_no == 3 def test_missing_file_is_empty(self, tmp_path: Path) -> None: - assert _read_new_items(tmp_path / "nope.jsonl", 0) == [] + assert read_kimi_wire_items(tmp_path / "nope.jsonl", 0) == [] class TestState: diff --git a/tests/test_kiro_native_session_forwarder.py b/tests/test_kiro_native_session_forwarder.py index ddafbaeec1f..b81cfa916aa 100644 --- a/tests/test_kiro_native_session_forwarder.py +++ b/tests/test_kiro_native_session_forwarder.py @@ -294,7 +294,7 @@ async def test_forward_kiro_session_posts_conversation_messages( }, ], ) - monkeypatch.setattr(forwarder, "_kiro_cli_sessions_dir", lambda: sessions_dir) + monkeypatch.setattr(forwarder, "kiro_cli_sessions_dir", lambda: sessions_dir) posted: list[tuple[str, str, forwarder._KiroConversationMessage]] = [] external_ids: list[tuple[str, str]] = [] @@ -425,7 +425,7 @@ async def test_forward_kiro_session_posts_cumulative_cost_once( ], model_id="auto", ) - monkeypatch.setattr(forwarder, "_kiro_cli_sessions_dir", lambda: sessions_dir) + monkeypatch.setattr(forwarder, "kiro_cli_sessions_dir", lambda: sessions_dir) costs: list[tuple[str, float, str | None]] = [] async def _fake_post_cost( @@ -543,7 +543,7 @@ async def test_forward_kiro_session_prefers_expected_resume_session( bridge_dir, forwarder._ForwardState(session_id="stale-session", byte_offset=0), ) - monkeypatch.setattr(forwarder, "_kiro_cli_sessions_dir", lambda: sessions_dir) + monkeypatch.setattr(forwarder, "kiro_cli_sessions_dir", lambda: sessions_dir) posted: list[forwarder._KiroConversationMessage] = [] external_ids: list[str] = [] @@ -628,7 +628,7 @@ async def test_forward_kiro_session_waits_for_expected_resume_session( } ], ) - monkeypatch.setattr(forwarder, "_kiro_cli_sessions_dir", lambda: sessions_dir) + monkeypatch.setattr(forwarder, "kiro_cli_sessions_dir", lambda: sessions_dir) posted: list[forwarder._KiroConversationMessage] = [] external_ids: list[str] = [] @@ -711,7 +711,7 @@ async def test_forward_kiro_session_does_not_post_session_status( }, ], ) - monkeypatch.setattr(forwarder, "_kiro_cli_sessions_dir", lambda: sessions_dir) + monkeypatch.setattr(forwarder, "kiro_cli_sessions_dir", lambda: sessions_dir) posted_event_types: list[str] = [] @@ -785,7 +785,7 @@ async def test_forward_kiro_session_mirrors_current_model_once( # No metering: proves the model mirror fires at launch, before a turn's cost. model_id="claude-haiku-4.5", ) - monkeypatch.setattr(forwarder, "_kiro_cli_sessions_dir", lambda: sessions_dir) + monkeypatch.setattr(forwarder, "kiro_cli_sessions_dir", lambda: sessions_dir) models: list[tuple[str, str]] = [] async def _fake_post_model(client: httpx.AsyncClient, *, session_id: str, model: str) -> None: diff --git a/tests/test_session_import.py b/tests/test_session_import.py index 7cf96752b9c..083defcc262 100644 --- a/tests/test_session_import.py +++ b/tests/test_session_import.py @@ -8,14 +8,166 @@ import pytest +from omnigent.kimi_native_forwarder import KimiWireItem, read_kimi_wire_items +from omnigent.kiro_native_session_forwarder import ( + KiroConversationMessage, + parse_kiro_jsonl_line, +) from omnigent.session_import.local import ( list_recent_local_session_ids, load_claude_session, load_codex_session, + load_kimi_session, + load_kiro_session, + load_pi_session, + load_qwen_session, ) from omnigent.session_import.models import SessionImportNotFoundError +def test_import_adapters_use_stable_forwarder_parser_contracts(tmp_path: Path) -> None: + """Shared Kiro and Kimi parsers expose the fields offline import consumes.""" + kiro = parse_kiro_jsonl_line( + json.dumps( + { + "kind": "Prompt", + "data": { + "message_id": "kiro-1", + "content": [{"kind": "text", "data": "hello"}], + }, + } + ) + ) + assert kiro == KiroConversationMessage(message_id="kiro-1", role="user", text="hello") + + wire = tmp_path / "wire.jsonl" + wire.write_text( + json.dumps( + { + "type": "turn.prompt", + "origin": {"kind": "user"}, + "input": [{"type": "text", "text": "hello"}], + } + ) + + "\n", + encoding="utf-8", + ) + items = read_kimi_wire_items(wire, 0) + assert items == [ + KimiWireItem( + line_no=0, + kind="message", + role="user", + text="hello", + response_id="kimi:turn:0", + ) + ] + + +@pytest.mark.parametrize("source", ["qwen", "kiro", "pi", "kimi"]) +def test_long_source_ids_get_distinct_bounded_response_ids( + tmp_path: Path, + source: str, +) -> None: + """Long native entry ids remain distinct after normalization.""" + native_ids = ("x" * 100 + "a", "x" * 100 + "b") + if source == "qwen": + home = tmp_path / "qwen" + session_id = "qwen-session" + transcript = home / "projects" / "-repo" / "chats" / f"{session_id}.jsonl" + records = [ + { + "uuid": native_ids[0], + "parentUuid": None, + "type": "user", + "message": {"parts": [{"text": "first"}]}, + }, + { + "uuid": native_ids[1], + "parentUuid": native_ids[0], + "type": "assistant", + "message": {"parts": [{"text": "second"}]}, + }, + ] + loader = load_qwen_session + loader_kwargs = {"qwen_home": home} + elif source == "kiro": + home = tmp_path / "kiro" + session_id = "kiro-session" + root = home / ".kiro" / "sessions" / "cli" + transcript = root / f"{session_id}.jsonl" + root.mkdir(parents=True) + (root / f"{session_id}.json").write_text("{}\n", encoding="utf-8") + records = [ + { + "kind": kind, + "data": { + "message_id": native_id, + "content": [{"kind": "text", "data": text}], + }, + } + for kind, native_id, text in zip( + ("Prompt", "AssistantMessage"), + native_ids, + ("first", "second"), + strict=True, + ) + ] + loader = load_kiro_session + loader_kwargs = {"kiro_home": home} + elif source == "pi": + home = tmp_path / "pi" + session_id = "pi-session" + transcript = home / "sessions" / "--repo--" / f"stamp_{session_id}.jsonl" + records = [ + {"type": "session", "version": 3, "id": session_id}, + { + "type": "message", + "id": native_ids[0], + "parentId": None, + "message": {"role": "user", "content": "first"}, + }, + { + "type": "message", + "id": native_ids[1], + "parentId": native_ids[0], + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "second"}], + }, + }, + ] + loader = load_pi_session + loader_kwargs = {"pi_home": home} + else: + home = tmp_path / "kimi" + session_id = "session_long_ids" + transcript = home / "sessions" / "wd_repo" / session_id / "agents" / "main" / "wire.jsonl" + records = [ + { + "type": "context.append_loop_event", + "event": { + "type": "content.part", + "uuid": native_id, + "part": {"type": "text", "text": text}, + }, + } + for native_id, text in zip(native_ids, ("first", "second"), strict=True) + ] + loader = load_kimi_session + loader_kwargs = {"kimi_home": home} + + transcript.parent.mkdir(parents=True, exist_ok=True) + transcript.write_text( + "".join(f"{json.dumps(record)}\n" for record in records), encoding="utf-8" + ) + + response_ids = [item.response_id for item in loader(session_id, **loader_kwargs).items] + assert len(response_ids) == 2 + assert response_ids[0] != response_ids[1] + assert all(len(response_id) <= 64 for response_id in response_ids) + + def test_load_claude_session_normalizes_parent_transcript(tmp_path: Path) -> None: """Claude parent messages and tools become ordinary Omnigent items.""" session_id = "a1b2c3d4-1234-5678-9abc-def012345678" @@ -338,3 +490,619 @@ def test_list_recent_codex_sessions_includes_archived_and_deduplicates( recent = list_recent_local_session_ids("codex", limit=10) assert recent == (first_id, second_id) + + +def test_load_qwen_session_normalizes_recorded_messages(tmp_path: Path) -> None: + """A Qwen recording imports its visible user and assistant messages.""" + session_id = "019f8648-2797-7170-bf73-837f2655c47e" + transcript = tmp_path / "projects" / "-repo" / "chats" / f"{session_id}.jsonl" + transcript.parent.mkdir(parents=True) + records = [ + { + "uuid": "user-1", + "sessionId": session_id, + "type": "user", + "cwd": "/repo", + "message": {"role": "user", "parts": [{"text": "inspect TODO.md"}]}, + }, + { + "uuid": "assistant-1", + "sessionId": session_id, + "type": "assistant", + "cwd": "/repo", + "message": {"role": "model", "parts": [{"text": "Done."}]}, + }, + ] + transcript.write_text( + "".join(f"{json.dumps(record)}\n" for record in records), + encoding="utf-8", + ) + + imported = load_qwen_session(session_id, qwen_home=tmp_path) + + assert imported.source == "qwen" + assert imported.external_session_id == f"-repo:{session_id}" + assert imported.workspace == "/repo" + assert imported.title == "inspect TODO.md" + assert [item.data.model_dump()["role"] for item in imported.items] == [ + "user", + "assistant", + ] + assert imported.items[1].data.model_dump()["agent"] == "qwen-native-ui" + + +def test_load_qwen_session_follows_the_current_branch(tmp_path: Path) -> None: + """Qwen import excludes stale siblings from its linked recording.""" + session_id = "019f8648-2797-7170-bf73-837f2655c47e" + transcript = tmp_path / "projects" / "-repo" / "chats" / f"{session_id}.jsonl" + transcript.parent.mkdir(parents=True) + records = [ + { + "uuid": "root-user", + "parentUuid": None, + "type": "user", + "cwd": "/repo", + "message": {"parts": [{"text": "start"}]}, + }, + { + "uuid": "stale-assistant", + "parentUuid": "root-user", + "type": "assistant", + "message": {"parts": [{"text": "stale answer"}]}, + }, + { + "uuid": "active-user", + "parentUuid": "root-user", + "type": "user", + "message": {"parts": [{"text": "try again"}]}, + }, + { + "uuid": "active-assistant", + "parentUuid": "active-user", + "type": "assistant", + "message": {"parts": [{"text": "active answer"}]}, + }, + ] + transcript.write_text( + "".join(f"{json.dumps(record)}\n" for record in records), encoding="utf-8" + ) + + imported = load_qwen_session(session_id, qwen_home=tmp_path) + + assert [item.data.model_dump()["content"][0]["text"] for item in imported.items] == [ + "start", + "try again", + "active answer", + ] + + +@pytest.mark.parametrize( + "records", + [ + [ + { + "uuid": "duplicate", + "parentUuid": None, + "type": "user", + "message": {"parts": [{"text": "first"}]}, + }, + { + "uuid": "duplicate", + "parentUuid": None, + "type": "assistant", + "message": {"parts": [{"text": "second"}]}, + }, + ], + [ + { + "uuid": "orphan", + "parentUuid": "missing", + "type": "user", + "message": {"parts": [{"text": "partial"}]}, + } + ], + ], +) +def test_load_qwen_session_rejects_malformed_links( + tmp_path: Path, + records: list[dict[str, object]], +) -> None: + """Malformed Qwen links cannot create a permanently partial import.""" + session_id = "019f8648-2797-7170-bf73-837f2655c47e" + transcript = tmp_path / "projects" / "-repo" / "chats" / f"{session_id}.jsonl" + transcript.parent.mkdir(parents=True) + transcript.write_text( + "".join(f"{json.dumps(record)}\n" for record in records), encoding="utf-8" + ) + + with pytest.raises(SessionImportNotFoundError, match="no importable history"): + load_qwen_session(session_id, qwen_home=tmp_path) + + +def test_load_qwen_session_qualifies_ambiguous_project_id( + tmp_path: Path, + monkeypatch, +) -> None: + """Project-qualified Qwen locators keep duplicate native ids importable.""" + session_id = "019f8648-2797-7170-bf73-837f2655c47e" + monkeypatch.setenv("QWEN_HOME", str(tmp_path)) + for project in ("-repo-a", "-repo-b"): + transcript = tmp_path / "projects" / project / "chats" / f"{session_id}.jsonl" + transcript.parent.mkdir(parents=True) + transcript.write_text( + json.dumps( + { + "uuid": f"user-{project}", + "type": "user", + "message": {"parts": [{"text": project}]}, + } + ) + + "\n", + encoding="utf-8", + ) + + with pytest.raises(SessionImportNotFoundError, match="ambiguous; use one of"): + load_qwen_session(session_id, qwen_home=tmp_path) + locators = list_recent_local_session_ids("qwen", limit=10) + assert set(locators) == {f"-repo-a:{session_id}", f"-repo-b:{session_id}"} + imported = load_qwen_session(f"-repo-a:{session_id}", qwen_home=tmp_path) + assert imported.external_session_id == f"-repo-a:{session_id}" + assert imported.title == "-repo-a" + + +def test_list_recent_qwen_sessions_scans_projects(tmp_path: Path, monkeypatch) -> None: + """Qwen batch discovery returns the newest recordings across projects.""" + monkeypatch.setenv("QWEN_HOME", str(tmp_path)) + recordings = [ + (tmp_path / "projects" / "-old" / "chats" / "old.jsonl", 1), + (tmp_path / "projects" / "-new" / "chats" / "new.jsonl", 3), + (tmp_path / "projects" / "-middle" / "chats" / "middle.jsonl", 2), + ] + for path, modified_at in recordings: + path.parent.mkdir(parents=True) + path.touch() + os.utime(path, (modified_at, modified_at)) + + recent = list_recent_local_session_ids("qwen", limit=2) + + assert recent == ("-new:new", "-middle:middle") + + +def test_qwen_locator_bounds_an_overlong_session_stem(tmp_path: Path, monkeypatch) -> None: + """Canonical Qwen identity always fits the import API's 128-char limit.""" + monkeypatch.setenv("QWEN_HOME", str(tmp_path)) + session_id = "s" * 180 + transcript = tmp_path / "projects" / "-repo" / "chats" / f"{session_id}.jsonl" + transcript.parent.mkdir(parents=True) + transcript.write_text( + json.dumps( + { + "uuid": "user-1", + "type": "user", + "message": {"parts": [{"text": "hello"}]}, + } + ) + + "\n", + encoding="utf-8", + ) + + (locator,) = list_recent_local_session_ids("qwen", limit=1) + imported = load_qwen_session(locator, qwen_home=tmp_path) + + assert len(locator) <= 128 + assert imported.external_session_id == locator + + +def test_load_kiro_session_uses_metadata_and_visible_messages(tmp_path: Path) -> None: + """A Kiro session imports JSONL messages with workspace metadata.""" + session_id = "kiro-session-1" + sessions = tmp_path / ".kiro" / "sessions" / "cli" + sessions.mkdir(parents=True) + (sessions / f"{session_id}.json").write_text( + json.dumps({"cwd": "/repo", "created_at": "2026-07-21T12:00:00Z"}), + encoding="utf-8", + ) + records = [ + { + "kind": "Prompt", + "data": { + "message_id": "user-1", + "content": [{"kind": "text", "data": "inspect TODO.md"}], + }, + }, + { + "kind": "AssistantMessage", + "data": { + "message_id": "assistant-1", + "content": [{"kind": "text", "data": "Done."}], + }, + }, + ] + (sessions / f"{session_id}.jsonl").write_text( + "\n".join(json.dumps(record) for record in records), + encoding="utf-8", + ) + + imported = load_kiro_session(session_id, kiro_home=tmp_path) + + assert imported.source == "kiro" + assert imported.workspace == "/repo" + assert imported.title == "inspect TODO.md" + assert [item.data.model_dump()["role"] for item in imported.items] == [ + "user", + "assistant", + ] + assert imported.items[1].data.model_dump()["agent"] == "kiro-native-ui" + + +def test_list_recent_kiro_sessions_requires_metadata(tmp_path: Path, monkeypatch) -> None: + """Kiro batch discovery orders complete metadata/transcript pairs.""" + monkeypatch.setenv("HOME", str(tmp_path)) + sessions = tmp_path / ".kiro" / "sessions" / "cli" + sessions.mkdir(parents=True) + for session_id, modified_at in (("old", 1), ("new", 3)): + (sessions / f"{session_id}.json").write_text( + json.dumps({"cwd": "/repo"}), encoding="utf-8" + ) + transcript = sessions / f"{session_id}.jsonl" + transcript.touch() + os.utime(transcript, (modified_at, modified_at)) + incomplete = sessions / "incomplete.jsonl" + incomplete.touch() + os.utime(incomplete, (4, 4)) + + recent = list_recent_local_session_ids("kiro", limit=10) + + assert recent == ("new", "old") + + +def test_load_pi_session_follows_the_current_branch(tmp_path: Path) -> None: + """Pi import follows parent links from the last entry instead of stale branches.""" + session_id = "019f8648-2797-7170-bf73-837f2655c47e" + transcript = tmp_path / "sessions" / "--repo--" / f"stamp_{session_id}.jsonl" + transcript.parent.mkdir(parents=True) + records = [ + {"type": "session", "version": 3, "id": session_id, "cwd": "/repo"}, + { + "type": "message", + "id": "root-user", + "parentId": None, + "message": {"role": "user", "content": [{"type": "text", "text": "start"}]}, + }, + { + "type": "message", + "id": "stale-assistant", + "parentId": "root-user", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "stale branch"}], + }, + }, + { + "type": "message", + "id": "active-user", + "parentId": "root-user", + "message": {"role": "user", "content": "take another approach"}, + }, + { + "type": "message", + "id": "active-assistant", + "parentId": "active-user", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "active answer"}], + }, + }, + ] + transcript.write_text( + "".join(f"{json.dumps(record)}\n" for record in records), encoding="utf-8" + ) + + imported = load_pi_session(session_id, pi_home=tmp_path) + + assert imported.source == "pi" + assert imported.workspace == "/repo" + assert [item.data.model_dump()["content"][0]["text"] for item in imported.items] == [ + "start", + "take another approach", + "active answer", + ] + + +def test_load_pi_session_preserves_tool_calls_and_results(tmp_path: Path) -> None: + """Pi assistant tool blocks and tool results remain ordinary tool items.""" + session_id = "019f8648-2797-7170-bf73-837f2655c47e" + transcript = tmp_path / "sessions" / "--repo--" / f"stamp_{session_id}.jsonl" + transcript.parent.mkdir(parents=True) + records = [ + {"type": "session", "version": 3, "id": session_id, "cwd": "/repo"}, + { + "type": "message", + "id": "assistant-tool", + "parentId": None, + "message": { + "role": "assistant", + "content": [ + {"type": "text", "text": "Checking."}, + { + "type": "toolCall", + "id": "call-1", + "name": "bash", + "arguments": {"cmd": "ls"}, + }, + ], + }, + }, + { + "type": "message", + "id": "tool-result", + "parentId": "assistant-tool", + "message": { + "role": "toolResult", + "toolCallId": "call-1", + "content": [{"type": "text", "text": "README.md"}], + }, + }, + ] + transcript.write_text( + "".join(f"{json.dumps(record)}\n" for record in records), encoding="utf-8" + ) + + imported = load_pi_session(session_id, pi_home=tmp_path) + + assert [item.type for item in imported.items] == [ + "message", + "function_call", + "function_call_output", + ] + assert imported.items[1].data.model_dump() == { + "agent": "pi-native-ui", + "name": "bash", + "arguments": '{"cmd":"ls"}', + "call_id": "call-1", + } + assert imported.items[2].data.model_dump() == { + "call_id": "call-1", + "output": "README.md", + } + + +def test_load_pi_session_rejects_an_orphaned_active_leaf(tmp_path: Path) -> None: + """A broken Pi parent chain cannot be claimed as a partial import.""" + session_id = "019f8648-2797-7170-bf73-837f2655c47e" + transcript = tmp_path / "sessions" / "--repo--" / f"stamp_{session_id}.jsonl" + transcript.parent.mkdir(parents=True) + records = [ + {"type": "session", "version": 3, "id": session_id, "cwd": "/repo"}, + { + "type": "message", + "id": "orphan", + "parentId": "missing", + "message": {"role": "user", "content": "partial"}, + }, + ] + transcript.write_text( + "".join(f"{json.dumps(record)}\n" for record in records), encoding="utf-8" + ) + + with pytest.raises(SessionImportNotFoundError, match="no importable history"): + load_pi_session(session_id, pi_home=tmp_path) + + +def test_load_pi_session_migrates_legacy_linear_history(tmp_path: Path) -> None: + """Pi v1 entries without tree ids import in their original linear order.""" + session_id = "legacy.session" + transcript = tmp_path / "sessions" / "--repo--" / f"stamp_{session_id}.jsonl" + transcript.parent.mkdir(parents=True) + records = [ + {"type": "session", "version": 1, "id": session_id, "cwd": "/repo"}, + {"type": "message", "message": {"role": "user", "content": "hello"}}, + { + "type": "message", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "hi"}], + }, + }, + ] + transcript.write_text( + "".join(f"{json.dumps(record)}\n" for record in records), encoding="utf-8" + ) + + imported = load_pi_session(session_id, pi_home=tmp_path) + + assert [item.data.model_dump()["content"][0]["text"] for item in imported.items] == [ + "hello", + "hi", + ] + + +def test_load_pi_session_preserves_images_tool_order_and_aborted_state(tmp_path: Path) -> None: + """Pi content retains images, tool position, and interrupted assistant state.""" + session_id = "my-feature" + transcript = tmp_path / "sessions" / "--repo--" / f"stamp_{session_id}.jsonl" + transcript.parent.mkdir(parents=True) + records = [ + {"type": "session", "version": 3, "id": session_id, "cwd": "/repo"}, + { + "type": "message", + "id": "11111111", + "parentId": None, + "message": { + "role": "user", + "content": [ + {"type": "image", "data": "AAAA", "mimeType": "image/png"}, + {"type": "text", "text": "inspect this"}, + ], + }, + }, + { + "type": "message", + "id": "22222222", + "parentId": "11111111", + "message": { + "role": "assistant", + "stopReason": "aborted", + "content": [ + {"type": "text", "text": "Before."}, + { + "type": "toolCall", + "id": "call-1", + "name": "bash", + "arguments": {"cmd": "ls"}, + }, + {"type": "text", "text": "After."}, + ], + }, + }, + ] + transcript.write_text( + "".join(f"{json.dumps(record)}\n" for record in records), encoding="utf-8" + ) + + imported = load_pi_session(session_id, pi_home=tmp_path) + + assert [item.type for item in imported.items] == [ + "message", + "message", + "function_call", + "message", + ] + assert imported.items[0].data.model_dump()["content"] == [ + {"type": "input_image", "image_url": "data:image/png;base64,AAAA"}, + {"type": "input_text", "text": "inspect this"}, + ] + assert imported.items[1].data.model_dump()["interrupted"] is True + assert imported.items[3].data.model_dump()["interrupted"] is True + + +def test_load_pi_session_preserves_active_branch_summary(tmp_path: Path) -> None: + """Pi branch summaries remain durable context for later active turns.""" + session_id = "branch-summary" + transcript = tmp_path / "sessions" / "--repo--" / f"stamp_{session_id}.jsonl" + transcript.parent.mkdir(parents=True) + records = [ + {"type": "session", "version": 3, "id": session_id, "cwd": "/repo"}, + { + "type": "message", + "id": "11111111", + "parentId": None, + "message": {"role": "user", "content": "start"}, + }, + { + "type": "branch_summary", + "id": "22222222", + "parentId": "11111111", + "fromId": "stale-leaf", + "summary": "Changed auth.py and found a token race.", + }, + { + "type": "message", + "id": "33333333", + "parentId": "22222222", + "message": {"role": "user", "content": "continue"}, + }, + ] + transcript.write_text( + "".join(f"{json.dumps(record)}\n" for record in records), encoding="utf-8" + ) + + imported = load_pi_session(session_id, pi_home=tmp_path) + + assert [item.data.is_meta for item in imported.items] == [False, True, False] + assert "Changed auth.py" in imported.items[1].data.model_dump()["content"][0]["text"] + + +def test_list_recent_pi_sessions_scans_project_directories(tmp_path: Path, monkeypatch) -> None: + """Pi batch discovery extracts session UUIDs from timestamped files.""" + monkeypatch.setenv("PI_CODING_AGENT_DIR", str(tmp_path)) + session_ids = ( + "019f8648-2797-7170-bf73-837f2655c471", + "019f8648-2797-7170-bf73-837f2655c472", + ) + for index, session_id in enumerate(session_ids, start=1): + transcript = tmp_path / "sessions" / f"--repo-{index}--" / f"stamp_{session_id}.jsonl" + transcript.parent.mkdir(parents=True) + transcript.write_text( + json.dumps({"type": "session", "version": 3, "id": session_id}) + "\n", + encoding="utf-8", + ) + os.utime(transcript, (index, index)) + + recent = list_recent_local_session_ids("pi", limit=10) + + assert recent == tuple(reversed(session_ids)) + + +def test_list_recent_pi_sessions_supports_custom_ids(tmp_path: Path, monkeypatch) -> None: + """Pi discovery reads safe custom session ids from transcript headers.""" + monkeypatch.setenv("PI_CODING_AGENT_DIR", str(tmp_path)) + transcript = tmp_path / "sessions" / "--repo--" / "stamp_my-feature.jsonl" + transcript.parent.mkdir(parents=True) + transcript.write_text( + json.dumps({"type": "session", "version": 3, "id": "my-feature", "cwd": "/repo"}) + "\n", + encoding="utf-8", + ) + + assert list_recent_local_session_ids("pi", limit=10) == ("my-feature",) + + +def test_load_kimi_session_normalizes_wire_messages(tmp_path: Path) -> None: + """A Kimi wire log imports visible prompts and completed assistant text.""" + session_id = "session_20260721_abc" + session_dir = tmp_path / "sessions" / "wd_repo" / session_id + wire = session_dir / "agents" / "main" / "wire.jsonl" + wire.parent.mkdir(parents=True) + (tmp_path / "session_index.jsonl").write_text( + json.dumps({"sessionDir": str(session_dir), "workDir": "/repo"}) + "\n", + encoding="utf-8", + ) + records = [ + { + "type": "turn.prompt", + "origin": {"kind": "user"}, + "input": [{"type": "text", "text": "inspect TODO.md"}], + }, + { + "type": "context.append_loop_event", + "event": { + "type": "content.part", + "uuid": "assistant-1", + "part": {"type": "think", "think": "private reasoning"}, + }, + }, + { + "type": "context.append_loop_event", + "event": { + "type": "content.part", + "uuid": "assistant-1", + "part": {"type": "text", "text": "Done."}, + }, + }, + ] + wire.write_text("".join(f"{json.dumps(record)}\n" for record in records), encoding="utf-8") + + imported = load_kimi_session(session_id, kimi_home=tmp_path) + + assert imported.source == "kimi" + assert imported.workspace == "/repo" + assert imported.title == "inspect TODO.md" + assert [item.data.model_dump()["role"] for item in imported.items] == [ + "user", + "assistant", + ] + assert imported.items[1].data.model_dump()["agent"] == "kimi-native-ui" + + +def test_list_recent_kimi_sessions_uses_wire_recency(tmp_path: Path, monkeypatch) -> None: + """Kimi batch discovery identifies session directories by wire-log recency.""" + monkeypatch.setenv("KIMI_CODE_HOME", str(tmp_path)) + for session_id, modified_at in (("session_old", 1), ("session_new", 3)): + wire = tmp_path / "sessions" / "wd_repo" / session_id / "agents" / "main" / "wire.jsonl" + wire.parent.mkdir(parents=True) + wire.touch() + os.utime(wire, (modified_at, modified_at)) + + recent = list_recent_local_session_ids("kimi", limit=10) + + assert recent == ("session_new", "session_old") From ba9c7dd9c06656b8cd584d71ff2386a94347fcfb Mon Sep 17 00:00:00 2001 From: scwf Date: Mon, 6 Jul 2026 10:29:16 +0800 Subject: [PATCH 546/546] fix(setup): avoid termios setup crash on Windows On Windows, `omnigent setup` could crash as soon as it reached the interactive harness picker because the TTY menu path imported the POSIX-only termios/tty modules. The user-visible failure was `ModuleNotFoundError: No module named 'termios'`, after the setup banner and preflight warning had already printed. Route Windows setup menus through the existing numbered fallback instead of the raw termios path, including the legacy wizard helpers and their back-navigation behavior. Also remove the remaining POSIX os.getuid() assumptions from native bridge temp-root setup so Windows installs do not fail while importing those bridge modules. Tested with the focused Windows startup regressions: python -m pytest tests/onboarding/test_interactive.py tests/onboarding/test_wizard.py tests/test_claude_native_bridge.py::test_ensure_secure_dir_succeeds_without_getuid tests/test_qwen_native_bridge.py -q -k "not rejects_symlinked_ancestor" Signed-off-by: scwf --- omnigent/claude_native_bridge.py | 13 ++++--- omnigent/hermes_native_bridge.py | 4 +- omnigent/kimi_native_bridge.py | 4 +- omnigent/kiro_native_bridge.py | 4 +- omnigent/onboarding/interactive.py | 5 +++ omnigent/onboarding/wizard.py | 39 ++++++++++++++++++- omnigent/qwen_native_bridge.py | 5 +-- tests/onboarding/test_interactive.py | 13 +++++++ tests/onboarding/test_wizard.py | 57 ++++++++++++++++++++++++++++ tests/test_claude_native_bridge.py | 36 ++++++++++++++++-- 10 files changed, 158 insertions(+), 22 deletions(-) create mode 100644 tests/onboarding/test_wizard.py diff --git a/omnigent/claude_native_bridge.py b/omnigent/claude_native_bridge.py index 5a6b61a80c3..744ae71ffcd 100644 --- a/omnigent/claude_native_bridge.py +++ b/omnigent/claude_native_bridge.py @@ -677,8 +677,10 @@ def _ensure_secure_dir(target: Path) -> None: each ancestor from that trusted parent down to ``target``, creating new ones with mode 0o700 and rejecting any existing ancestor that is a symlink, not a directory, owned by a different - uid, or has group/other permission bits set. Wrong-but-repairable - modes on dirs we own are reset to 0o700. + uid, or has group/other permission bits set where POSIX uid/mode + semantics are available. Wrong-but-repairable POSIX modes on dirs we own + are reset to 0o700. On Windows, where Python exposes no POSIX uid/mode + ownership model, directory protection relies on the OS ACLs instead. :param target: Final bridge directory path to ensure, e.g. ``Path("/tmp/omnigent-501/claude-native/abc")``. @@ -694,7 +696,8 @@ def _ensure_secure_dir(target: Path) -> None: if cur != trusted_parent: raise RuntimeError(f"bridge dir {target!s} is not under trusted parent {trusted_parent!s}") ancestors.reverse() - my_uid = getattr(os, "getuid", lambda: -1)() + getuid = getattr(os, "getuid", None) + my_uid = getuid() if getuid is not None else None for ancestor in ancestors: try: os.mkdir(ancestor, mode=0o700) @@ -706,12 +709,12 @@ def _ensure_secure_dir(target: Path) -> None: raise RuntimeError(f"refusing to use bridge ancestor {ancestor!s}: is a symlink") if not stat.S_ISDIR(st.st_mode): raise RuntimeError(f"refusing to use bridge ancestor {ancestor!s}: not a directory") - if st.st_uid != my_uid: + if my_uid is not None and st.st_uid != my_uid: raise RuntimeError( f"refusing to use bridge ancestor {ancestor!s}: owned by uid " f"{st.st_uid}, not current user ({my_uid})" ) - if (st.st_mode & 0o077) != 0: + if my_uid is not None and (st.st_mode & 0o077) != 0: os.chmod(ancestor, 0o700) diff --git a/omnigent/hermes_native_bridge.py b/omnigent/hermes_native_bridge.py index f1a642a4d18..ab26b60fe57 100644 --- a/omnigent/hermes_native_bridge.py +++ b/omnigent/hermes_native_bridge.py @@ -46,9 +46,7 @@ #: Env var carrying the bridge dir into the harness executor process. BRIDGE_DIR_ENV_VAR = "HARNESS_HERMES_NATIVE_BRIDGE_DIR" -_BRIDGE_ROOT = ( - Path(os.environ.get("TMPDIR", "/tmp")) / f"omnigent-{stable_user_id()}" / "hermes-native" -) +_BRIDGE_ROOT = Path(tempfile.gettempdir()) / f"omnigent-{stable_user_id()}" / "hermes-native" _TMUX_FILE = "tmux.json" _TMUX_READY_TIMEOUT_S = 30.0 _TMUX_SEND_TIMEOUT_S = 10.0 diff --git a/omnigent/kimi_native_bridge.py b/omnigent/kimi_native_bridge.py index 2964f1f7e1b..b1d618f7af8 100644 --- a/omnigent/kimi_native_bridge.py +++ b/omnigent/kimi_native_bridge.py @@ -26,9 +26,7 @@ #: Env var carrying the bridge dir into the harness executor process. BRIDGE_DIR_ENV_VAR = "HARNESS_KIMI_NATIVE_BRIDGE_DIR" -_BRIDGE_ROOT = ( - Path(os.environ.get("TMPDIR", "/tmp")) / f"omnigent-{stable_user_id()}" / "kimi-native" -) +_BRIDGE_ROOT = Path(tempfile.gettempdir()) / f"omnigent-{stable_user_id()}" / "kimi-native" _TMUX_FILE = "tmux.json" # Omnigent routing details the kimi hook subprocess reads to reach the server. # Mirrors claude-native's ``permission_hook.json`` (server URL + auth headers + diff --git a/omnigent/kiro_native_bridge.py b/omnigent/kiro_native_bridge.py index cad699ee101..8af4e06ccd0 100644 --- a/omnigent/kiro_native_bridge.py +++ b/omnigent/kiro_native_bridge.py @@ -19,9 +19,7 @@ KIRO_NATIVE_BRIDGE_DIR_ENV_VAR = "HARNESS_KIRO_NATIVE_BRIDGE_DIR" KIRO_ACP_RECORD_PATH_ENV_VAR = "KIRO_ACP_RECORD_PATH" -_BRIDGE_ROOT = ( - Path(os.environ.get("TMPDIR", "/tmp")) / f"omnigent-{stable_user_id()}" / "kiro-native" -) +_BRIDGE_ROOT = Path(tempfile.gettempdir()) / f"omnigent-{stable_user_id()}" / "kiro-native" _TMUX_FILE = "tmux.json" _FORWARDER_READY_FILE = "kiro_session_forwarder_ready.json" _ACP_RECORD_FILE = "kiro_acp_record.jsonl" diff --git a/omnigent/onboarding/interactive.py b/omnigent/onboarding/interactive.py index 34becf64354..9139bc3bdc9 100644 --- a/omnigent/onboarding/interactive.py +++ b/omnigent/onboarding/interactive.py @@ -35,6 +35,8 @@ from rich.console import Console from rich.text import Text +from omnigent._platform import IS_WINDOWS + # Reuse the REPL theme picker's palette verbatim so the selector is # visually identical to ``_theme_picker.py`` (``_ACCENT`` / ``_MUTED``). ACCENT = "#F43BA6" @@ -401,6 +403,9 @@ def select( if not sys.stdin.isatty(): return _select_fallback(title, options, default=default, selectable=mask) + if IS_WINDOWS: + return _select_fallback(title, options, default=default, selectable=mask) + import termios import tty diff --git a/omnigent/onboarding/wizard.py b/omnigent/onboarding/wizard.py index 0da48739811..79d92167fda 100644 --- a/omnigent/onboarding/wizard.py +++ b/omnigent/onboarding/wizard.py @@ -26,6 +26,8 @@ from rich.panel import Panel from rich.syntax import Syntax +from omnigent._platform import IS_WINDOWS + console = Console() # ANSI helpers - used in arrow-menu labels (rendered via sys.stdout.write, @@ -75,7 +77,22 @@ def _arrow_menu( # Fall back to number input if not a real terminal. if not sys.stdin.isatty(): - return _arrow_menu_fallback(options, default=default, disabled=disabled, multi=multi) + return _arrow_menu_fallback( + options, + default=default, + disabled=disabled, + multi=multi, + allow_back=allow_back, + ) + + if IS_WINDOWS: + return _arrow_menu_fallback( + options, + default=default, + disabled=disabled, + multi=multi, + allow_back=allow_back, + ) import select as _select import termios @@ -222,18 +239,23 @@ def _arrow_menu_fallback( default: int = 0, disabled: set[int] | None = None, multi: bool = False, + allow_back: bool = True, ) -> int | list[int]: """Non-interactive fallback when stdin is not a tty.""" disabled = disabled or set() for i, label in enumerate(options): marker = " [unavailable]" if i in disabled else "" console.print(f" {i + 1}. {label}{marker}") + if allow_back: + console.print(" q. Go back") console.print() if multi: while True: available = ",".join(str(i + 1) for i in range(len(options)) if i not in disabled) raw = str(click.prompt("Select (comma-separated)", default=available)) + if allow_back and raw.strip().lower() == "q": + raise _GoBack try: indices = [int(x.strip()) - 1 for x in raw.split(",")] if all(0 <= i < len(options) and i not in disabled for i in indices) and indices: @@ -244,6 +266,8 @@ def _arrow_menu_fallback( else: while True: raw = str(click.prompt("Choice", default=str(default + 1))) + if allow_back and raw.strip().lower() == "q": + raise _GoBack try: idx = int(raw) - 1 if 0 <= idx < len(options) and idx not in disabled: @@ -293,6 +317,19 @@ def _text_prompt( raise _GoBack return raw.strip() or default or "" + if IS_WINDOWS: + raw = str( + click.prompt( + label, + default=default or "", + show_default=bool(default), + hide_input=hide_input, + ) + ) + if not raw.strip() and not default: + raise _GoBack + return raw.strip() or default or "" + import termios import tty diff --git a/omnigent/qwen_native_bridge.py b/omnigent/qwen_native_bridge.py index ae37074dc71..ca6d3b1e1b6 100644 --- a/omnigent/qwen_native_bridge.py +++ b/omnigent/qwen_native_bridge.py @@ -35,6 +35,7 @@ import socket import subprocess import sys +import tempfile import time import uuid from datetime import datetime, timezone @@ -51,9 +52,7 @@ #: qwen recording (resume would mint a new id and lose history). _QWEN_SESSION_NAMESPACE = uuid.UUID("6b6f3d2e-9a1c-5e84-bf0a-1d7c5a2e9f43") -_BRIDGE_ROOT = ( - Path(os.environ.get("TMPDIR", "/tmp")) / f"omnigent-{stable_user_id()}" / "qwen-native" -) +_BRIDGE_ROOT = Path(tempfile.gettempdir()) / f"omnigent-{stable_user_id()}" / "qwen-native" _TMUX_FILE = "tmux.json" #: JSONL command file qwen watches (``--input-file``); we append to it. _INPUT_FILE = "qwen_in.jsonl" diff --git a/tests/onboarding/test_interactive.py b/tests/onboarding/test_interactive.py index dd34e92f1d8..42e8a2e7197 100644 --- a/tests/onboarding/test_interactive.py +++ b/tests/onboarding/test_interactive.py @@ -93,6 +93,19 @@ def test_select_fallback_returns_chosen_index( assert "2. beta" in out +def test_select_uses_numbered_fallback_on_windows_tty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """TTY selection still works on Windows, where raw-termios menus are unavailable.""" + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(interactive, "IS_WINDOWS", True) + _feed(monkeypatch, ["2"]) + + result = interactive.select("Pick one", ["alpha", "beta"]) + + assert result == 1 + + def test_select_fallback_reprompts_on_invalid_then_accepts( non_tty: None, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/onboarding/test_wizard.py b/tests/onboarding/test_wizard.py new file mode 100644 index 00000000000..762a414ba26 --- /dev/null +++ b/tests/onboarding/test_wizard.py @@ -0,0 +1,57 @@ +"""Tests for the legacy onboarding wizard terminal helpers.""" + +from __future__ import annotations + +import sys + +import pytest + +from omnigent.onboarding import wizard + + +def _feed(monkeypatch: pytest.MonkeyPatch, lines: list[str]) -> None: + """Route *lines* to ``click.prompt`` as if typed at the console.""" + fed = iter(lines) + + def _fake_prompt(_text: str) -> str: + return next(fed) + + monkeypatch.setattr("click.termui.visible_prompt_func", _fake_prompt) + + +def test_arrow_menu_uses_numbered_fallback_on_windows_tty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """TTY wizard menus still work on Windows, where raw-termios menus are unavailable.""" + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(wizard, "IS_WINDOWS", True) + _feed(monkeypatch, ["2"]) + + result = wizard._arrow_menu(["alpha", "beta"]) + + assert result == 1 + + +def test_arrow_menu_fallback_preserves_back_navigation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Fallback wizard menus preserve the TTY path's Esc-to-go-back behavior.""" + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + _feed(monkeypatch, ["q"]) + + with pytest.raises(wizard._GoBack): + wizard._arrow_menu(["alpha", "beta"]) + + +def test_arrow_menu_fallback_q_is_invalid_when_back_disabled( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """``q`` only goes back when the caller opted into back navigation.""" + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + _feed(monkeypatch, ["q", "2"]) + + result = wizard._arrow_menu(["alpha", "beta"], allow_back=False) + + assert result == 1 + assert "Invalid selection." in capsys.readouterr().out diff --git a/tests/test_claude_native_bridge.py b/tests/test_claude_native_bridge.py index d853014772b..8fa3648e831 100644 --- a/tests/test_claude_native_bridge.py +++ b/tests/test_claude_native_bridge.py @@ -73,7 +73,11 @@ def subprocess_bridge_root() -> Iterator[Path]: ``python -m omnigent.claude_native_bridge`` accepts bridge writes without inheriting pytest monkeypatches. """ - production_root = Path("/tmp") / f"omnigent-{os.getuid()}" / "claude-native" + production_root = ( + Path(tempfile.gettempdir()) + / f"omnigent-{claude_native_bridge.stable_user_id()}" + / "claude-native" + ) production_root.mkdir(mode=0o700, parents=True, exist_ok=True) os.chmod(production_root.parent, 0o700) os.chmod(production_root, 0o700) @@ -336,6 +340,21 @@ def test_prepare_bridge_dir_refuses_symlinked_ancestor( assert not (attacker_dir / "bridge.json").exists() +def test_ensure_secure_dir_succeeds_without_getuid( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Windows lacks ``os.getuid()``; bridge dir creation must still work.""" + monkeypatch.delattr(os, "getuid", raising=False) + + bridge_dir = tmp_path / "bridge-without-getuid" + + claude_native_bridge._ensure_secure_dir(bridge_dir) + claude_native_bridge._ensure_secure_dir(bridge_dir) + + assert bridge_dir.is_dir() + + def test_trusted_parent_accepts_qwen_native_bridge_dir( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -2354,7 +2373,10 @@ def test_mcp_server_initialize_omits_blocked_channel_capability( Code would refuse to start with that capability advertised under org policy, breaking the native wrapper. """ - monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", Path("/tmp")) + monkeypatch.setattr( + "omnigent.claude_native_bridge._TRUSTED_PARENT", + Path(tempfile.gettempdir()), + ) monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", subprocess_bridge_root) bridge_dir = prepare_bridge_dir("conv_abc", workspace=tmp_path) proc = subprocess.Popen( @@ -3280,7 +3302,10 @@ async def test_channel_server_relays_active_omnigent_tools( This fails if Claude Code can receive web-channel inputs but cannot call the Omnigent tools made available to the server-side agent. """ - monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", Path("/tmp")) + monkeypatch.setattr( + "omnigent.claude_native_bridge._TRUSTED_PARENT", + Path(tempfile.gettempdir()), + ) monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", subprocess_bridge_root) bridge_dir = prepare_bridge_dir("conv_tools", workspace=tmp_path) proc = subprocess.Popen( @@ -3487,7 +3512,10 @@ async def test_serve_mcp_survives_handler_exception_and_keeps_serving( ``-32000: Connection closed``). Without the guard, the decode error kills ``_serve_mcp`` and the ``tools/list`` read below times out. """ - monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", Path("/tmp")) + monkeypatch.setattr( + "omnigent.claude_native_bridge._TRUSTED_PARENT", + Path(tempfile.gettempdir()), + ) monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", subprocess_bridge_root) bridge_dir = prepare_bridge_dir("conv_crash", workspace=tmp_path)
  • >)c;$=!WwyqD+LM0&+8sMy^iR)>yBkYO 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%0A^}Hn+YlY!P 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

    b(H!xUzjy3j9gu|&S@r}jvWavYT! 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π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≷EdX_PJp7s;XZ`oA-Hr4xC+sC_fO>gWINIPB_Ps$H*xpO|S? zSSh6C?zqKjc&f`DRXvxZG+vU-x6&PNxU^b4yq~u-Rc!)76Vb(E{|X(4*l+0~0af0KqwTeDe|pU>=A?0&Y0=OVRSdggR+=9jH<2) zI4Tnrurf3q*5b zzOP7-8J(vQIlMmd5_UevmuWd(q<=oMXw%T-xs>qA)&59uL&FA*fGku^vh`DG^SRkJ z-+)@A*VyCYKI#QpwY)OVrKy4q5wD2nGKodHb;|hID*KXGH7eiT+Q-FA9oe(g#pS*$ zSR1r;XE(Y#_82qEn?;GDRhQ^mO+;m@hHhHMGVP8v7A_hnrvmun<$f-Ix0O?5j^^;d zREUVPi%!ol|K%Mo7?%*_q8X2nw6mb)=uk5JIeN#`6~XU%(qCB=0K%n-Qr^a6p%dldrSLi@mo?Di)f;&7xdT9-oT=XL$BJjJtR`SS~v zh^{vt7qS7l*^V3Tdz&W&{>WGhjo3x%!{XZ7Gtrjg6~<_Ni7&(TWm^Y2h8g0EQk)I0`)tm*@%L$^7xX-U-M6|Ef7p*fYu7Yx5_u8uCn8gyAnVK z7r4E4$RWw~+c6laiC_G3rqI#)xUIY^uIF&d^VP6N#(IcOak1 z{81EIkdW(Kx1Nusvc6~!e$Mc&0IQEi<=fAuWb-q8<-Er}Rq%m8d~~8vD8Cc6%KpcE zNyX2>UF>GcZT=p8gOPT4!1AD7(5l_xIS|dkXkEk~85^~N0Mn~t0%)nUr4~KE*WlV~ z%%+7B>a4E9`iW9a2|YYKTozWMtJhdqSYBzg7{w~STtDDJKzY6_1S+)USe|Sv(}-6x zx4C2(;pEn6fku(p)0Dc1^Q)_ag&RMlzG=s_;?W|7MwcNn(W;Xp_og@-x8lgC$de<_ z(3tyY&tiNWpS#HL|I)C_2UggCz}-gmWTaYOga^G_7l@QC%=N8N_}34g{}3Zp(HRI< zvCQ+xR>KgG_W;5E5?h=Wujp%dHs=YoeuRw7n?d52so0J@H#fjXd|P2?dRAf%H@Y{~ z7}(94Ed@MXeu}sU2aop}`FNRMya3j{^@C?OmqkB4U@ceEoF!;|k;Wr2R-m_7eG|Ak zCCTbTBf91-7Q%WvMNP8MMmR+%16Dn)S3G5=j)dRsdaPzXnqI+Rxs0oK45TwMKNGy4 z@h6;EwJ;(H$I!bGq&^cvA_tEh^Um~y*jnaTVAYeEC37yHrDQ#nobJ4@suSTqQ+X( z;=fuzjpel4mLQ*OvcSx4qp=(UF#*Jrs{sYzNmVKdH1%x z!*_}UF-gBvj!SlGd$e2vO@SB>y1-z-GJuBHq#SgUmBw_@Ko^S=^*cH}wHW7Q@Gs|q z4M*?3<6G;Gw*1(1i$M8EXK!B3?GjWR?Pfb5n?T7fPPdrO(In>x1svBG#(~s!vw@?l zB%7{wRpl=~^FcJP#0k6+s+#w6+Y-vks*|Cd=CLFKZcu(L&J7yv(UII%AtAgl z!_7C+*fP{VdTj)ufeL z*1OsV{n6bLKo$tAPj2ptAt8x@pFQDl5~>(>RhXj^c3n}r>CX7LxUr{LW8lyrH~Vh z)ZZo8%ROnE(5EytzvE#LJ<|KRcq7|gb0md@JlDNuGaUV#k@N@-;?Bx5T5cr+9#s9au4~`QoK0|2Dh)jK^DliiF zKv!26=pZIQ5~x%1QIh;m7yHN9Zu=l1ytDOX+r+Z84Cl*fgyej-a^JV6_>Bz>4g&6_P98dHw_TMda=Wz-1ApC;iL{-M=@oU2>Eecx$B5n87Vy zlQ8yNNd(2*>D>Rppo}l6s8&yt`2yhB+B-U|rx6$2ZwyAG>yR|KFD(t)G z`LC~;onb72wGL;y{oc36s&>7LRT&ReNnX3r)ZopG=};+YfI@hX_4ojmCm|uZG}4%G_Nx%XKrub3W~Rsp^zY$y-5lNI zumrMr?`@GzvF5<;>;~F3J+Ir&_Yc`p(a7lNCbGiVmXjH8TOT6C5oKj%(QAA^o&csb z?gC(eV$aiUHa=SIH32+!+|^yJSkd2n?L6q2mzS4u50oXrd*jfUS2C1<(gIA;RZz*$e;>m#KH#*P6y|C2eOJ#4^2B)=0H}}xQ>-3I z&>nlQ<3+0S@K(XVq@^oF_S{|ZUuB23BF*F|)nQe+L^*%YvebAWX6G=P4&^yR0;|p)cq#MUt3>>QjG;Mu{X19I#GNQszyNU{p; zc;8p3yDx4BIvtOcp_))`J-|Q{dsXA-czcrAbeETxj5^(}+g zA=>u5AaH+4&!6c}9EDDNyovB=k zVzceRpMX$;jUs{Kr2gfR-zyv;L18zpheq9RJ_Q6g?M+3eSX+xYV&3$|vt`bEC2rJb z_v+T}?{LlcCccydquE?tkD+X(Tnm*#$-+jLjo}jlt~p$G>Z11=0=X(*o>x+=Je{gG ze?~}nePEi(VKK1}Y_pEk!B~vfnyCU(F${d(i^5<~+Il+St3 zq$_eITBlJmmiFz@2IO+xvC-|+=(3QZ+Im`)jz&H0b(O_f_50b&71PwAp|H=N)tl@o zi5^U;7AJ5_S6aM90h5x%VMZc8$F%m0j11)Ef%#^ed0 zFlR6y2cw+zzLD=LbY7@5O3!hhJR!hwUftHnLoJZ=h-78tfp3_1IXbJFokKgn zPY$g|&}1Nov!D)5A?DNXCjqDa+>MMczM4AS4u!(qumlB$B$6nH~W@k zE*$GLTU_t+wCfI~tfm!Ln(+lwXa@_$!Xp;+rjmhd(1dFIInRG)H#;`ZKq{7DX)7os zB*g6!>Px3q+Mg_#iV4sE-FYCMZLM3Ska}F?Xk$a^7$(MBX`@^2;o4oWJyG>#AlIT) zy)M$84iuOMFi?|)d1!{9D>OB@(je$T6C>mg|HPV)P+N*brz5_iM4(~QeEsedh(B{}?+Wd=E4sh$%|MzPK}s*L>(2X6id?h8NB^+cr273N$KjeFzFkJ+k{hWfyZCrKfhnE#+v8CGi z>gf+*@#OBOfXvEUtb)P>HDK;Wk&ia>7bWM|uSBm3c0H<>E!J!>L@*Wsz?0u-U(X^5 z*k{~_nVRJ@Yvfyfr3H;7z^(N>0+m>tZZhMSNWsy>J$M1;lMMiO5t#2=HiI` z_Szq1G~jmK55uMS&!455y@}Rvw)9iMJ;{9v+Rb0(hVOWtOO(sWIA&?y3MaB131=4{ zbw#~2ocD}9d~s}QEB@=-N)Jt{@CVMb?`4&`SY$*h7HiFHZjMf#XM|BqG3g9iHN%!N zcu2vP%7v#FYPvgfjaSAn6fups*3xEKF^(eXc&4VCghTR%LKLw=h6q8yY(3O7;hRhR zQ@M01byFE-pZy3r+(YE4PxyUEGswDWG(bEageQjH2K66M3eujIh4p;-FFB1x%m`VGQxVtvD`6wgBk z>@+lm=^J1@8V$~Q{ytSr=fd-M(dg(XaJ$5C)!Ki|%*k(Pd$ecwqDm9xIqk8>V%iG} zuOofG3+ux$#5bF`!o|+CzFrl*x-Z4zNDKEK8wg?vK)QbNcxyn5= zPrlu;AIjLYp%NBr85jsR<%+x6j5NM{kI*dHzPy+r$n}0ke1*UdQR49nuu7liAW&|z zg68j^p>m~b2Vi+NhVzDH4+fZ8QX_)HOrOyLtZ=q)+ zxy$|Q<44N#^`9{HhZPV&zb8!qwqV*7%T-{qgs8v$PqTmadKI{el6Hbf(SR?@Uo+jA zT6h0XS~&3RC?+Oxg~xkwTM+L%)iQcMXhP+0pYcHEby}A%P|n{6UbatxfaTW9+}VYy zmwT3fr6ePbIxzrmQA4Dn3~Lbt0s1Q~=l$+0*TdDWwt-&VI=2&0svF&onKi9|B$6i( z6ciM2X5ZwwhCWg=e35Fg`;$~qAmAY!+!E@cm}0N9(3G=9V<0?|6c?+KPA*TSY#Xxs zR;pCs+vQmS86w6muruaK_+o6-7TXOju^=OB9MUB+1$CF*AeJIfA>6Co`m3`&LAy;X zm7vHMHq93~iw`T!+d?JF>&T^f(vVkrNCZ4;-){_K0A{_i5{ow8an?V*ppI9C-ZGR;P|-Y7ch9*U?u?I`^|17=hrWBfnf$Q zu|rT-mC=A~QA9!zO+yTZh8BI-OBj;#@Ss4}d|aF}?n{V14bu%4E}X6QbU!d?cj1Zw zlKhdKtvL=E8F>X60H%L5#6sp zJ#LmxmXrYyA32G~-3eg-(F{N{aY(cj7%3>SY}18-%$ps<#_^O!?Dg6anB%f=rE2=k zOUJ!t@DPx01EXAR9tTDFLX$o)s0MJeObB)fY^myHW(u=YRoRYy4;=^$Eot^Zb&k(YpPbiE&29FDs-YirLd#DeP^y>?f1^Kr>>`p*TZ=W zM`e&{mpjh$*uPS50ghHg_QPaUUNdjLnaVd5D}YeqG{Ec61d`Ae(SihgDVLmqllSJ} z50w%rv@iW)#QQ4L{y`<*ml*o=P(_F2jjODDsTaORIf} z$A9qo3q?;h#lnb_`Md=hzpQ`A75TYFN2q@@xXPe)o<`B(u2#>Pt&o=jXjMidA1<-L zxn#@vEXrx%WqxtZN|=hp99 z|Jjtr=(P35Cl8kooIH)rP4BpkX!WB(VYX>~D3EKPE!n8MUBe_McUQduGGX;}hAoN& z9-8FVUBF$QWX20hC;d7$OgJ4n!%9Om3g4X_x0!h2wf+RAQ7-HsM#TH8hVq@0z6;DB zU_H3FsoPPCRgpmG2*%eJB(w5qMM&$Ql*kCJTx%&x&RG65U87TtU>`#C_-BzZ5^FZGf{Njo*8O)PoIyn*3E=5H} zgS+|5pcQ5dY{r`qJL}o{8Xv6S`ElHhSo2>^M4CSsh02dx$}l%5G&I!X@DqOJ<`vUm zGdE}+hjZ|&t0Pp7yvd7kID}O(8`8DdFsfdwUy8t8ayqoJ&64#nc}>vknmc)J?VY$+ zJa2UM$;Hc5aTJPm8<_%ooOKenZkk^hX-_p@5=pmxrQ!a99$SagQ^r~u(qI{a4?2f{ zfWT&RrKkqYIVb6zK*r zm@_3v*nSqbh18OJ`uZufy-A(lj~vFw8p%zHZD4ti>+aD;(Be5%2YWp4J#cKOKhxw8 zIMo3t;BuxKXaf)<3nnUK7hk{Zm)wdQw?p9YM=>}9@c?JT|Kram6Po})Ee2u;x}7s? zoo?$J_u$WdxI0$eBBbs93cd4Wp*E`-=H7)OAFqMd0cN;!JM=d>}wb zcQ=8wWNM^H9}L&vNz39-=b3T`ej`xImGw*$djQL~SFy9R%NPcLl6k<6Rpnxw))RG5H{2+`TdEGe`Q0w=xbz;s3@X$!&g^(&EPm0yL72^JZl48 z;5Wa;Kwf9>y4U5yXo*!B-z-b##!KcGFkn2UbH{kz6jYQ74X#_&D|BjXKD|cGtW&p2Y+_ruqabsxTC@$un7Sai5cQWMFr;ohske%T*gitKtdM zJfUO(>VU&zDTH*HgHd|a=eAWHF&UsKM$?eD`p+FUflohhr zK3K7M2y{Lip#i5YT}Xa!pl`A3sk#m)3c{S=+H!jUxKX=8Ndm5&n{gxpCXXM4Sol83_qnQf5?v)3euib=WYpnX&DM6E;Nb`QH%m4{unlXFQLXnaD zn#1A$F5Vazx_mmQ^z1{oQm6)dDncjNoIT+bC&2`8WFyXBl($z<7t%^4d}(=ZKR5yy z0DR`NuBuKb+YpgB7ZgdY*`RJ?a|*lxYO@K)F^EpSY8yP2vE7CFzxxrYII0xMDoP6v zd5Jvmp?20{0X&sT3l$!lwwVR-%fvyWvE=)Zg&SQ{aBNT#^@g^sOgdLBhL=;FiBpQurdvec`B5v_#7p3@arL!98kmS z>e#_T!c`&`Av@Jt3_t$s(*kW3V%9Ut|LKC^0+t`YK=EaLuRu)7viS7&ld6~EKfgz? zR>9hXt{BMTbDO@lv%@DiuYs$GG(JZuX!8WG+C{Ar(Lerm+P;#NubZblKiW@|+Lr^| zpFpBl?ff7Cb`o#wT>ho$)jihjwdiZ~CI1-$31~hMdB*@{#Ll{K&vPPcaFn2MeT4q0 zCcc3>HGj5Es1z$ua<|Tne;PpTsS1FkUsDAkC&qzm05D4 zfdA1YKXhpK8myS;e^qkI!g+}}#}#@{!MDYL`O$W_>+imL=f%LU{n%w;fVm0m{4(jq z|NaLIB!BV$=kvREth@&eir3)~NTlq(<5IqCjd^f-JcI#@BV%<4{qd`$)_thUhz13B z2Vf$`=U5_M6v$7rZVR`Z{Gy4hPx7Q0UQNnuiA$Fl<9~`*TTL1|x66MBpSOku}E+9C1mPo1=95 z^L2yJ&p}j1zZ=!8BPL3Bk_Akn%2Q`4nkX`?SP=hz?D1`Q4m)$eO*BCr}Ghs2` zX31Eqgub{*XKK8+wfA;Q2m&b}w|{FXnllU6`tNnT3>YBp;2%HlZ%y`H&Qra`B5k++ zWC>6C*K6QHIc}qSpMJHg+h*c&vwj#wMpFM1bg*FoGhkhmiMER;yGSSI)aj|TJ?)SOXAgHvsUH@Sjk?U|hU$f_t zD3mGK?wzZM zEvy&J?9bOv98_@k4OAXX+OADkOx#@bTXut{LAzKKOTK22OU-+DEe@uQISI~Y^~Ijf`yxzQ9tg zG1TDYZAyl@%l%`*Y1Tmq%BafKhR*>-cOQ?q))E>PHeq*x0uZJvd zqmqV`0E?P-+vOLQMZVDd_y|{2+$Jy+nkhbhtG9HL#sxWe#_lH|{d@W`hv}{VGH?RG zeDz8(SDwPUcg$?8Z(-2=Ci3idFofs9YWoyB$^CdZuGN=8j9UG-=?>2f{?A3_O6|2< zL9wxStosY@hk)6TxIW@gL&H0*d${xq`^;gF1QbV+Wk*|!s(8bROjTc+k0%SzVN$hs zFJ)^K&(Bm3G+snf&y_v!)=yWs-RRgQX>gSq^mY*X2Ly1qp3izrTua+|-S_?Ca`vjV zeWaWHMh(c1WIjIZWsPo4H;(y`u}UVeiLC_!&9g(eon8<4?z2Jl2np@ceJjRu4FEpe z@IriiPa+iZ6#D^LRj6JADm;7!=akzryza+Wqh2X=6zvxid=yc3lPrh7N)hI3kNfRbEcy%G3)SX}jNZxdULret&*% zW&?98B|Tl2($$9DC>ZqZfy%|$*w{t=_eH>o<3EIiQY35K{uq?P=p+K{6&6arm1UqL z5+qR|tb}cSp~K+cS^yp=d?&CYK*3|>Z3ak^Bxo0a>NFzKMjng(eG900+VM6{a9?e@ za&Lf=XxhVtTMVKCF0;wmTnJBAdiu~5pZ~+fz0gS6H8Rdf%}wt}(hjp}u1X_4*IL2_ zw+k9(y8#dhg*@#0X3}V;URfZypOoL@{Vg;D&Yw!WFvCyc6*M>?x~^9crS9$+nx4{*jRLEXh4vGdA`qa2+Ar( zLe2|rPU9)9LYJcJbgF#r^NkO@_otweQ_r;Sj+r~2x`*PT!%EZI6qL=@Y0>OI)`mhQ zn5T`EG_{~)++V1}tk#qWy>R4}*(X})B zs{+hI2@pUOmq4@&mC)wMl0YAffZuat2m6ERt2&E@)u?)D+BB($iKqhQKL-`_3VHJP z=Qo6nI1itBd3jSj;h+E|4}$RC36sf$Qnqw((RWE)S;f?^t=E6@6cX+x7Kj%Jh_^#} zoe5o!@7hEF!?Jg0H5e3E1)%9TNxqR=sm5EU>%avyW(#IR-bRq%0L@EOmN){9`)1d$ zl9SzF1QnK9Faz9&M&~M8F>ti(di}l!E!UJ?aW5xZo`=iUx{HG*Ujmg&Q?qwnjSx`W&@wIOs;M>bAj4;wnl+yM)|@WU4_}_DoHcMdB+1ULbq7; zQUO00SIvyFoPO1+knuEw_O^~=nopkuMv_FjMUJx?FNQnAg>e`h7Q2@C6$>>Ob=%aC zW`D$5pS;Q!me?(pEvB?POjjq#3I`Gp?KzGt4*NNky|b3W$f`#c&F-3&0hFG2td_~Z^~j}f7#!;~s^!_{bG1NDs#xQ* zLMGL~to08Z{&fB7-d?`}6o>=>t^3`*n(w($W2l^*ELgwi!#74EKfxo}ovLeeEF+z< z+fDbawb>;qFutba0boz8CyP&Gjm=dLxO6~SP^Q!?kx6Ho?n9&CNhW^nsd9rhQ^g^N z{q9hi+Jq{T$T4*E zvczLPl-L&$aAukUQML~fj&MxEk1k#pTY?ZhdlZ|eWpWAXr^;M*Uqm8O%KqZY-MX` zXpl|hBM~K6Z*b9To6#};lbuK=WgcsNE1+6;7s9iVuUsGL8IdYM;>pf$jec^IhgRi-NQHr3$#f?Y>>1WT0nPo~Q z$ux-eqwLL88h`XHriW$GEgOw|`y-B6huQM4 zt({ec$2~Iet1GA-!OjovzoZg{y+W*7;gRteiXTP|2FZE&+(#H(+zbsP8};u&hJJfG zHHtS?2JK`+4ml$iXxi1cs_9vUfvc7C*>?M>E6X#u&$Tub(#alzR96NowLI<*OziAV zZQ1lW*B@ye3RC2}(F75(QgirkqI{6C#YKDpiJUlBSEydk49McUj{c(aeLV*H4z`aE!lP!BaZrd0B&Q%Yo z^The%bsd?4xSqbt^)Lc-%eKOwy`uYkbewmmxySS48*a(k?*>22n1BRX|6Co^=x7D7 zk~3dF_%`^0$BF0Kc(m&i9}rSN4M%lt3GViSb&v`TmgqRyN9dgH9SJ1JsCtvA~VN%>auA;o;td z=Fn|&auYalsf%zEjQW!37J%Lu1b?>d_*^WvLoctiSuEyy;#dYR7Tjq3msfK}c^-^! z8}1OqBFW9I+^NE;3`#}&IJwuKHO}n?ZKbxc&<+afEWfqf-K+|ut0J?2@9FOI6AsI zjP=8wq!SlZ7G z$AIZL)+0|V0aw1?LQs)(!)(b4nUSRO)W|Kozg0LQy$VU#izpg7_Ps6_majuiZs9vd z$^X`Sga021VqNLyTapVlS!qn(6dpXXYM8rc(p_Wv=wnf-nRvPNdZ&Dqy88n9hu%<<^s?sY z%uqURe4!j)dL16aKZT=}dmNeKvC5?a&s!s27fk6!qzfS@P38mO0eQPfyn*>J~&lTg3vOxK&CVxJf8L@^dzKS+qY}FLuN2zLeTfbRMaweQ7g26tBWq`dxo( z&R#3a+(-$tKZ&uoRvv@Zw+6M`x)c{NLOM7>7SX@NiwrD3C}ZaIqk-E418!SK2Ps*Ww0^TCDbp}eGk|9P zy=Q4NH(FRI8ai5F#~6`_NOyVe&=9o`<5n@JpMOszQ(m|3_muB#ZL8_;5~t|v#QFGa z!~?JMdL$IPf#tJqZpsH6DlU3b=8nxCJ5|!-?yz_^tFDXo&8@*i!O^$J{7(ya?^m*k z4$859S;=~0)q4y+q(u)*orKzpz7M1^4Z`96$Ob--3eIEZ@8O&LROsK*lfhJ(#lsDu ztJQVi6sTvq-(8U*5m^h1S#FM?<2i{Z^@m?vcfVIdP_?cTv-so38F1>A4IWRhKgT21_r7csiD zBm{ay{@1*h!MzH@IwR`dH?#XWWHz#9u$*S&EQlrfACM9$bP~LObnxiY{q1}(4|G6s zme;`VGfR@*GPkR%3%|~8xNTR;sXYluTI}X(qoZGYdjmGO?qtC|M(1<2!_g17MeB%o zSrKr}i#OlS$@WFC%i)j1#eaddv}g%HFZ-=qI_Ut5`0uGB=dA2<|3?W_4(a1h0P^-H zSCOge7C@5VE|)3h{RS`@(s%FV^QFJ(p>#^cAD*00sbwNrV}s@my4&S9Pk0BLOR|DTUJ+ z3E0z!v0Ep7d7-2_&(`n02H#jS8H5f)$1OfB>~x$?|fB7$7>=T3fOOb4RbD zdsDiwBqb#7mUoJr&o-N-jvqc4$%5QhV{a~Q8OTL^e0@V)FFtQ=sXbg7900Wx_hnOW zZ@@h#fK5|L#5x*IB!S1urs)GnLJ5Qfq;VT;^AthLXXFVki$w!y@5B**I?Ni3;b3W6 zXcW5GFFyo+G=|ttlAxDdXS091@D)ptU5U``(o$Vr9bAAnr~CNm=!_>JJfBbqCEZ7! zoNinmH0;b*n5>CL1V*N3UQVoZ@;KdI0>+jw5SK;wa4}WTU%lGlYimak*rVXe)9igk zL#dG8b?SLfpw!rw%)n^9?#3G{xz(}44Q<5_^Ui>nwjFB$uIv4OkAaBa&?bk z-n$OM-P3T&0VL$ubXH_ajeSJ7cg`0n5ntfTI{ZW`i9168YI}K^JxL}R-<4wFAl4I3 zi;s;hVkUXNk<#kBRTDlpX}<_b*rQViexc%q_kj!U8?Ekb0zyJK>Wch>K~Pq(dQ#cg znULKoU7=lpt^cdVJ7-A(*{Y!lidc5afK71~dTQu< zzL%2_PiLXg;~|}Q55zDo+zNr~gZDdh(;su5kVz({&mv|BuG6Wrg0{LU<{#g4^Oa1A zK2B>6F=ruUhg@SousPEDPyucr4IY^sXc`$^`IZhGPYg-Uw#SSX3g`OOsz7s=iJLod zU2Y|*6iG1P)!ukTUPuV4g3-bKT{y&eCbQqm7ZuQQzW;z!QJiN31ABPS=EoW|)xC&3B}_%Zni>`NdzCtU z30)TOYnAqJ*6e*t@k6d|8PxT9Y>o#b52 z23J>CK~6gbC%`%xz{k6F2eM&_`tZKrsnr_dryod*MBi7vPbkkqzxl8MXufc`WPRoe zx10)*8V|r+;}(9?mu2^|LJrqVRy@iX&B{>_KdCaE6Wus;YzzVVbysi#16J0pMu{$S zGeIWJ(CibV#ccJ`!GY8jR)VC^-jeghfwQx-QjucNc|z=IdDBv>FNjA0gg~wtN>Bi} zzzj00^*#})6dnqcN79kC;Ja$OrrSw>V!iW=EG5&n;x9JIq8N1dXO~BS@mY37AfIRr zBU&flrQRD*BNoD$+vDL~RY1+<9Sjtez%WDPZ?dOc9UGMI<(r)Tcm z%~}*ia(nQ$eT_;O=_wihVa3ik$ipW4`BdCcPAoUCk~0+<3neh+STGn*bgwMDp8XVTkpyP+rVb zINO8TCR16Se6E1O3Po}tB;$RHF0qPqtX5P5lk?KDplUMU3xVsy*s6K1h3|pPQ)Ez5D&-19q{mbkk0bHe{1`X~WZEdU+q@)mP5Lk32Q%c> z4>leU$=f?n`YL&3ee*oi+gGo-bjghv)++P~x33glYQdhzjvE=w& z`?``V1rI#}vnOi9{Z&_zd)Hz!#_mF6Qcpyl;?Wgo^rtXTQ1~?x_AODwm1c+my)KuQ z4RY{lVKSn6ot?xN1w}jngT^1&X7`?$A3*Qjfm&>32>*r9V}#X5V@> zRw`lfG_(Kc{8--zi!}YU{AD9-M0mLAr1t4}NZPOX_&(ej8=nWTLGEtOL7`{{cvA!O zi|iai9312F2f)iumzv==hGu|Ey_{ED`>mK%wIzE(uww6O=Rk5%T8#iiK_9yJ81LJ1 zEE+_N@QpDE*_@zwl}&CE>b{nhNZ3wa3Sq71mNmO9{F_$s?;^KWsMZ{L z`U$6@8C<@*e} zS`k4m{GYz=c>0GUKHy@{%FTUmG~M*d$QtDJEhDt=D2UX!C4e>S2$ue}chNQ$tuW1C zi#H;m!F)p;RT|W5fMOW{*O~D-=0szN{b3vzVBd!%vC0U!*LwkPsSG~a3>Jx;BXN^V ze|lHC=j=d+PcCAxrSfUB&24;Wh!)UU?B?om;{`>Z8zoJWE6odVn1X+%2~ma9NvVCP zdZ+0(HlOKDfyLW-Wb5%{{H^J z*ZZHlDIh9n5USU7s{y_kJ^ZN*t_c57R^6STKAbFYIWzS6Ssn{SUMI)A@1gi0q&vE}fa*t@?(ZZDv+VY!Z!3uusFc>Np_$f; z8NfwtY&VN|Yh{JF5rZ5!L}>(*h(%prUk=HEAn9Tqu#YryK9Nc1J4FfXcIm4vE$spI zf8jriaE%L4+$vJ3I|sFXZuU7)nhK{g`f`neAzy!ZXenB8_>ta}?Mb-ObcqQTSBCO% zDu>;Epr2ni8XDgTg`l8Og-&P4Xh603os&1yp*bla=5CB{FEtJNF12=*d*X(uKnhY% zpY{n#cUWRT4}lbcDIt}4MFsMV2-d8tqS-|jtU8moM6OuS{HLcv);qTOKVX}v+WWg= zIN6&dY@6eaAbxM}UT<$1&or0oAE>7rztV)ZOl!J>WOX8@XGp}tpFMlFXbp5Ytp||M z@Nj{GW%JKJMg->rTR2{VXq8e3% zZqk#CNYmV3^e&r8kOt;s$d>!BUia7)gR0K2 z&AQUD-@gku@noV}|NN-^W8~@SJ`)~#^IHJX-|YJmZmK(F_+x6aen@?di%Zq`O$;2m z7S9>~Y*K*y4vaDw!BcG|OL7M-3(Pd!x~`~Irj)g-2x>g0E(&~N!Vx~0iVi5Lx>fy^ zln{K8+rxv9Tt^?Js3GlBC+t0<(Z(O*3rt=)ycM5oO;wiO3K~!3cc1D6-vf(&peZq6 zjTX#J?5^?$N5y%R2HjOdTYLMkeNK-neJ*V41T{!uiJyDY-fd2RG&k_S^NC0=w zF>_|mNGW2CcI(XXNg+Vo`e(mExNZFy_>K1@^ zUzsg5B$Q%S9n7XEN!JPnJfY*V-YGwQwt2dGl2OPT$!83X{-$B5C2=Pj66v|NRy7hM zqnw11ikW;zFf}OV6W0hcbeW;?kMWw#X3K!+8E>i*WIq$58x2o46wZ=L1l6Yvlql_hp#M{BU!|bN~MUCRR_n z)C2!o`c|VtH+Rg3D{{3lhKNe4=)9l*e|QJf!&>J#U8E;9cC)<8xY<^|mbTzJpv|D5 zOKKg-BWDJQ@1RN>HH0iC26B(4QZuuKskph#r3gTMNmFania6;_G|6rv{$o*U3Dp%Y zrablXtAG8PwzaRXyfn+F{<&iNUjipp`~S*mBL^18;&+8iJ}fmc;x1>}ue!j5b>HO>g#!CtQU*`d>uoUF|0Za>jR$@3_ef zYS@gbcN+6bucSMs@7Vf~Rzf}yRJFG?FxUKyj;@W={m(dDd(o;r{77a z47S;+qLqKuOsbH`;d2CRn*U|vAa##OwCGySSD9CF@i9xe(bRV2!@%z?BYS@nIKNF# zbh`noipMJ6E(&0QIKyQcpEi$eaI_BAYPos$r6%H>lm55z0lYntzzmiD44i|2d-qVD zG%iRyV6f`(ORYW!JLI?7yWC)f2#OqSQx`sMQA9YTChVsl4?%=?c7$?z_GKK5e7n;b zm<2r`e6Fv{Q)Pzmi%7urNP$YhDaUMQ;-+C6^se$TMP6NM)gNQ}V zdfzKGQ*PQ!9sbk(D{|StwSaIu{G=a&jMc$JJPSnbJ7t3q?9``1Z%J4{Eiima4~qU) zV4;Dspu4CXVmb-34m#ejT&s$Dy3&tpg16>Gq}=qRZ~QXu?2e93{y1heu0iku-Fn`U zX0?&L8@@8WW)$;CYAk|d3kJScNY2hkaL}suSwv#c_C#+a6BVqbPZyaK=s|PXH!pkn zqKqbul1c1N6$D&ob&`?t1c@a#b9Z*@A~ zr6~9U#5llmcMr7RfyMOI6JV&V0fMK7hx?oC>})CStG0=Ws8`Onmq$^WZfrK2L#2AX z3?AaUj-JGO_JJ`3cw0mFPZ_Nvd0>wRoc;r6A zic;og87|^O{$v_(P|!6V%YHGWnS_R$lzvRH@W2_&jD%-L2G?J|Sgv7A`YBygusY_z^q5>;UIG(uS1{G;6RNkjw?ctxMb>-} zF3Ugyhr#70NO>H2QIFDn*un{G9FE??dLTWI4ELlSuq-u17k?F(od<=q0GYO&uFmBg zF{nLS%R4H<=emF#IZS1Cu7EJ4F6~i7RD7Afih;SoV?JA3uRjM*NC?VDAWXyJw-9r( z9#IRQ&&H+|w{UQp8I2<(>u&xd9qi@yzPKC@4}R}SU9kl!N{PZ6LKm}D+j~? zlt5BM+MhDpS@^lm;TD*fzoL+Bn{66i10@KW?jE2-))EX=OSKnV5ST zRKUy_SQ1_k6BFkF*r?XVs7Ly>Muv9R%nS!P`6ifw zH4+b%Tr?0na4TK#*LXsmMu(Pl z*MOD}X00Gr)>BSPirdC39&2RIdmgz+K%J0z4PK1Sqjme&e`Ish{;hkojMOyHW|Om( z&W1R+Y$3@65N%uy-%X)$}_1-N=rfwWdI9v>7>af_X_$Io9EJGlRE z{zm?o-b8Q@CI9HUz#k@2ZgfCQLL_sf6XAmH>5XVznt*6elJ&;LK2NrG!F79&0tgLa z`A;yd@NWCN7qWYPPC}G?f(5md*zfF<4J}i}$F|kJb_V0i`+WcY{*J{;R(52r&K?;Z zdluNg=TOV0|M=iLe~OIDoSp@6wtBEIz|q@-o2%K#O~N|l2TKI&`EgY0IzJ9b{u@zO zH-`Tt#Z_6sR+_S&*wDVeNcE=#qbx-It_`qhi@+p5OP${#9pif(+&4}wphlj4H9S1* zjEa}Ob;0v+f7)6?6U+S4GRq7C$=lquS^i&6;&*gt=;+{mI)ec{V8@)mNyf9jBqVgd zzr6y{a1?N1073*GDKNEbpw@N=s5!uam!WT6oZh^ZIo|^i=Kax3QJtOXAf8of zTyDA6R4!)T_~#loVtSdqJ0Hj@K=4$n6wfAZ&Vk+NYgkKygapZOe9mZQh%E3Uzf1zi z1wcx;Z8rZQVcqPFWq1i4!*exNs+R|b)I`yKzC^{?G7uFO76w}t%vS&d%nYkl8$51z zxPc!%czi%BL=Ow$2LtgGpFeB9B~|`}kFOpJsP(_i4Sh7;crE>yjWsXeJxX8V*1-J{ z24;SKz$jdwna73ao8#MSddrLRAkfvjKbrV0HBUHN>!`EN3#NvGQ<&Yt0(cD&C9p`A zd5MOLvO4d_^V#tq58Mz&0N*DUWfgq`j{CWrqlpEcASbJb`MK|xtnn02)n@D0oY#&s z%sB4%PGxt#9t7z_B;SGvcBBSDCf(x0H&K5%K>f(d{xxpHhE&Hv5%S+~7RDc)97SB! zkztKQ5bkKJQlosQz6aEJ$B$I;Nua*yFYPGY{V0zVQN!;Q9_jbPUrZPY>Cj-2+*M=8 zeNn5!Ud_@3=G(`s7q6}s6RMnS%ni(AqA%N`zo!%NBoRFz?MGeBwIz{>$A55IJ(MB# zjZq_IenV6D5@YdTB~1sCdvqkQKyWfJZuUSX96Ti(2Otw*F%G22H|$b85EVJdQA z`?8!%n7N~9T!#X{u*oHQ2g=c~g2 z3k&u7z+HI5{qcSigcej+rX?~Ek7)$P)V;@rFdz(v%7(X-8t7M3k_O<5KGN5@9M?VQ zW#_iCnz3Bct489~rMSTYQ$gL0;(9Use0(l#E4~X7u^qbyDyC2B0V81nul3yRVuIf{ zgq)gx3Yn7Z_PtE3_2JOVdv$#T^sL)Ygm~AN?;#w?c%8XtakLr@JrKMBu{9$(h@~@j zlA#jkl!YK2Oy<5zBlel7!f@RVQJ`cej7L@%n2;2Z@y8Wz?36%SWxCu#aks4UZyTqD_Puq{S8*555R~UA zo!zEW$h0JEN2fIbeTitHvMyis5DnAa>T5Z3n)`HFcDFZ6(7Q$akeU*gwRO(%Ir|`Z zlC=T;HZ2mm0~|Ss5J{rfA7nBPAl4)XTNmGn0{(9run<#+vL5Sh+DS2E4$9QFE zW#!+(B^MeRhK?Qvy5*m*G~zsoqhR*Zv&J|Mu|>dxDt3DZ{Pm&+1-+8Z0LzGSW67LGIv? z5XKr_pzIS^MTcLsyo`s{bxNgHq+J_arWOtVu^%D*hw^3em+qJV8lciwcI}quG^VEI ze#4~D_lxEOIeF7eudM}MTxcu~I{F3cv5}Ri|1l;a_(Nl|!2PgdurG&}N_-qf5kQExBwUE4_b@3?c$J(04!EH~l0 zpLy7y?X$+~b6E%iX7}_r!V)6%7j`JmkOpEnEJP9OKZbt(9VO(W`63>7@0W9mX--s> zeekT~inh*M0xm5GEi4#|BHjw~{MpA>c?X+|BjL^(k3iVUQ9+y-Ja(EoBtc{#ij2(K zajdo%4NYKl?j~dz9j%|~MM9kr7?;Xv3x6U##x9--uPsW+Bu_9~m!P%&Jw;CXl}9Nq z+{qdWqK(%t8yY7@te&K}Z=bT6l&hsvpy<_PxnOLLrHW{;z63~1Hk0dWvQAD=lS7nc zwcoll&4 zM_PPpm6gYLZfZk17jB3n9m-i6lOLuj#r4TT86|dV4zQMHvTL^3j4l;NL9nBP-n$ab5wVPO?%tzO9&hnsW$0_QbI6Yt2c=|W`k;q;4bEf ztHNSKabjr@U@ehBNCxTqhlVsQ@Ov!aG*(|*nEAKFX(b1hD_l+i7xv&~2nu`UBm zHxMV_Uuj<*ue#29JIC|($9RE+=pW=)<9KJR``xwk zsKUFu2=0pDTS9tf=H+#b3LW#I7RW|*olt~%0j5nCtumkTuQrCW!B)Wn<9gVL)%1-X z%Rqc%j}Ae#X|PwW*)dkxFacYj0c!o1;3)eJd&t+o@`<@c(vQRRU+Yj47wOkn$<0U0 zC50`CtVIgX_0JuCqx*)0NX?9cF$ZQ0H$^o&Eau$S`|75qrqa^VQw$lnEXJ2sgNYP5 zsgfHjYll%yn>uTR|43)5!x6{@do8c{wyrE|$K-{&(^L;&d3UDKAtQuf!6x$>ScXbS zb1#9>-mRNDG}+3Nn||VRIZkTNBHt;>?+4IZ2bnqU7LUvhP<5Jr42Kh6qdjcE^>MGY zB11WoOk}S&>KzZq@UC;7JVguWqw?N@N7@`zZw0MGFr~4bp=9QQ0H?pk`avdUv8C^B z>ZX4eN&kT@C0$`Bl7>=|d=PllH^6mS0FyMA2cs?{*}x+K@$vzXCn6DzLMO^4 z3WuSVCs*^O$^LA|mOLiQr8b*JpgNbB`4`C6zgHMuWIB0w4gUr4#$rt2N;gb?U3G&j zr(e3kg$+KjYu-ZN;C&rkc~*I2ABw|t3ow-v8q}mCBJ#N3*f==Y*%6~aq2r22tqLnH zE{!3pYd(Em0fJrzkG`j$|AFEBMasjpmbHW_3atutQ&Y@AwelGlf9fg0#eRFHLrK6y zm2V^C&C~p@$NWcO6i;fIH#K@M9qw0Ro_)b5bZ8@v4?>*}gP82*!fy$A1}uX)j$780 zAc-i1RuC!O;UZ!tvPFqV#S3_8m0B1ufI*akyu&UwI;P-#M3ElYG(8a%9qv#?X8+`x}&rdplvvPJrNKSf~9R-pLsE?S$C!fpzNm0GWnrR*en z4r^L&T2o(2Hnx>2;e&6DPj&U=iGgH$N1L=bNqZqD#McSP6;doHBaIHl{O~Olhaha?C%~69PI@o9 zza6&5Y%~;`lW8W#7rJa<(&SfJmj31 zJW~9XNwwAA=ljacvs)Y8mI?ofY!qpwEr0L7%{{6gMpE z(K!R03Q@flPHgr@vk+!W_0lc(%<)0Cp{%%C2mN;CKDETTz0c6EQ{n|b}(?pkHH6` z`i;{V>(oy-w#R*rvD*_xo%tjUm&Q=G6HJ&z`e1uFv;@YA(i;^5OdP_%_^R|FmH*aj64$*zt%ui z0qEm1tosi16B`$Ekoke_<9rQqYs6~buLLx_fQ>;BNl9+2)5#)61G9ya9p%e;4-XHd zB#wQrlGgdx;T-m~)-TG9hj05z_#KZ|^&tej(w|kUtj?Z48yI;Cf*~slc|c5=#yV z1@DA5ZjMNvK9>LuZ<u4(aTm4s0i_l2?iJM`x zUf9=4%2E zZ5-=p=MEO6ZW_Sg>3jW_3!wO?RAbMQ5l+EP(^UVTj!vxk+@O($3nR<8XO^R?1Cgaj&$#7K;7bEvFS;ZK6sg+r+$1g2A1I_s}W zPk$OIjeHni1y*E}gUjrNuEu5>aKvHnaZp{qKB9r&YmIHZfSv~f5Omltw zhz`MqCjl&I%UB_yD3HzOlOj`72KA&cjDEctm;;kob{$`cSd&WKasvn+AZ+^A@^DF0 zrGkv2mhqgn)4HBs3uGBU$#Xjh2zBl8_tx#y^2mdJ`!3h1ucHh6{sBmt9_w*g~=asmA7W%tfC^) zTWKdt0au$Nc9h(2npZ_b(ZbU8#6`#Gc>OWl5=Bx>g;#ul97>AkI$7~-J8!&D?YB)k zaHq?}>ujlUXMkY6v(1!_F&wf>qZA_zU<(sbGD9bcrB`}hwev=Mm^LmDc_Z4nq*>eb z8ubhG?Ij0$q2c*lCdeTjadhclw*CCM*uzE@?ACi}UJ51~Bs1I|{<1Su%YySokps3k zj$u?3nzVsEz~9-%@Bs}DOni&2()xN)n2-SnBsdhR>6S~ts2u+SCdnF{`$N0swvHA$)AR=?$8{vlg0 zK%e7bj`6N4W+CVy_rb(fV|y}!AmPJN9gy09BX;DI^=j7uMEz$pw@P@R^i&!DeZrT| zQpjRcI+}qoy**u^S+;Jc!DffJQ{@IsnZQegSyIMyS<he0)pB8(Is9=<1PwaO>w__C*Y>Zzi;D^;MOS3l;wmJ0EbdN}wf4Bal<$0OTZ3F!?c@nCmm*OZyA1Xii92d; z6#}z7PR#~q5lV5*x97P;$BnETas5suU!7Gc@p{7I5rZPQU5|NjImi7oV9UUZ3TB@49oAAA|>H8da3c%#jQjzyfOI7B_0(SPMKh!{x7$ z)Nicy`^7~?gZ+ZKad3JJ6!TO`mggl1Mn%E0YbGNE$=A^eK*G025O%Xa=jmT2CIE4O z9L{$)T1H3|iEVPRTv5h?T7GZsRUe6Uy5A}kZX+>it1>1 z#*w7`nzNLP-PnS}dncX1LM%A|>(4K$d-wrijWy5*r{Y()2JZUhkH~3%B2|{( z5fdNZ&uK`Mf!GFwiK@!}319IS&Ht8NoyHrHNJKHJ7Dy;<&=@S{Is06-v}7(Kfa(xT zLUjI|`qp8X%o~Qa4V1IJJfG}2`60qWieV9gn%^w3p(KtCy{~|Q(19!2I$v1)2YeLK zYxA!Eo}^Y&1g{h?DvJ?zOhU=*X>ERQkV?gUCAxhN3kGe-Q`qC3!13-+SL}QM#7Z(4Lq`2C@^QS(6%+}#; z8sm=DD-z;WzvtGtO#b+z-7N$++I{MZp9nbeKR_eI&uscsiAKjCP9Ntf6k`HadZIA+ zSfwcVKGu@~s5xv?7DjqwWJIhtXS>B#nos{cqP(U&#|1xvr^}sNhj5{9F&3=TCiIxy z<}ZQ=@DkHm$M-nn;Q#;M6(T*y-!`j}9UtQ-@QI7V`hGj%CZvzX^1h`V%xxMo8j3ao zp4?zqhuX!#ygp>Q`>@{>^&8B+p4cl2nQ{p1kYu6@+%c%{Y-|kTkuxkG72r;2r>0>; zCefkqx=R-oB|wxk;E(Cp{PSmyb7KbqV(3t_kRDsP`4__c$SnnREfG;EyYQnpcgA#z zsp(0L4?%fR*dDLOe#h*+VWz$B>OP_WdKA$j`W`}qL96)I{hMdsQmNkH$I)&b-(M?` zVk$cN(IgGnL*CR1XwQ?b<2Z&=j6>p;0)NAc4utTj-$~Pvz@!@nU2ax;T`;RI0&B9& zf~O4k8jcVz)Ema|O)E>;=Y&QZuH|kycrC_jMH^8&e%WG6ps+t*C0UHe1^y=avC(E^ zmox<_*C6vnnZtU_46}pr-_tXy=^}Q1V3gkPa|hoUu!LuH?m>%muan zCf|82yY)41_qAqzkcTrs;+E82HmyMCX-nxIbJh+&w@s5fpcLDoByM<36&`Q6wEk2j zU)??HjPf7Q_XD=o60~ym>bc2)#NJ~pMNOyND2nYx$(%s$NQ(8czJ0?hXGl2=reZyM6ecrS8+2=jSKbIfZ zeB!>xGww02@x8vi3HNMAs{U{^u=B3L$mZZejh2K~>gp2wmh6&OJ z)Px@;Hk!V-KHv0;;!`v3T>jC6)gva!30`svU5Xw~f&m87p4_a{x<56t@H@OGBgh?E znkq8^%wVt2iDU*Q4VS-~lUmhqI*;pkV2M9brW(>i-5+TJXe|c=7>g5o&=xryWF&=b zoOsGrE({|idpnxJRuZl;=GP6SH?M?#bm0$k{BOan#-uF~MfjbR+^K7aB67iuipRwj z1}t9z@f_n-RAN-Sj_AyG>fcf$e64F!)RLj3o)wuz+|uKU%82Hhy;e!v&kNm|Uua&| zKfc;VS)N%D??B?^S_B zfExi(5~KPpBpU~ZY`$)eG^Nf>Hn~WpTWO*tL0I%;Eu-#<6Xr>UV~TZHj;t?p{VAx& zv$F1YK5)CHc`z6NjJLqbD*L~$tlc`+BL=?c55VkcWTnmI_EkOW_V)21muy@;k5jP` z($~N64iw1T>0T+|Bs6cE)s{3f!TGC8)0T0ZdmO7cXc$jY`F8+=II|A3#bblHQwQ0n zc%ws{f_=^Qw-0muK6wKft6f?CyE!scoj`(8se1>qG=ojr0_>W_mA}h_WakUTCKuwq z!UjTpCTej@LdPkTV~4X?4~Bsho6dILbMknuc1x#R0Zi z6(6k++RQj-aGtTb!6j zgHedTeEE`N(zHK`=#{YDGtZ(SlAs0d(ovs)ENlXw&W!*G3ZAxAGy}zd>-InymAevE zX6LPA-I;}k@zW%I5+7*APUU2>qBKX?h1sf}{m5C8uqkU$EY39wG|#`p_4su7Vpw72s}_}nSGD_Q=yKklOSA_Z z<#Ir2VEwxaw8`jAB`|SElF_Qg}QHv zi45oGY+elCcoZhAr&HsJUOlI#zL4sqN{r7tXcllp7Zhxrt}-z}!h(5YU@2Zkin=2I zoF_VUeqvHV^Uvol^J0b}&NY##Fvy!gmehv?raTK8CE9K~ze}If?Y=3WHqFR;ZO=wJ zjVXCFaS_X^9XkCZEz-?Ui37Qu(=%x}DSTQPcnUcSwt`OI{rdr16NP_}NcS!XNrR}7 z+_A#`bZWC}RzIQ%+ihXR-;EPb`T%4_y0@|-l<^8Y9AypB+99S#AqgyP!|OG^7^KuM zUugsXW)G)z!?Wz;qa)aOSJM?8$>PKYri2}5N=jKB!#7ri(>}b-fDWV`8oSo3GT80_ z5-C>4E|U8z#y^Ki{GzNgL!)h)|25)xTU4JCV7*ES{rwF03CX%CEWW&Jg?^)HiPzWn zX4+}R)9?2?Q@`h*d{5+E4;8rqtx`K|;ll~H(HU%Swqr9ul!5~F0~!iC-ff>=i8_=W zDVXz}eg11^;5Oc#d3<%l&FtG7#Zu&tdPw31aB_kd5Q{q(59p(6mg;2_X9A26BWIFERX(;JZp`FD{}HXS~lT6E5dF7 zqx<@&W;Sb>B_2xJzYz@)XOezv&r-+gzcuw8u*_ro7wuO7YW@|7Dq9w^I>~viNBRw} zPD_SoWLwWPW=cGq1JUs|#!78~nqt?-O@q(Ue*~8-jgTPi30YF8cD_F|o$%W7E~w0q z?CgJrOYvA}*i8nlqQ{nKRanl)Z1X(hgt|+mXY5Q@PL!x#pSHGu{xvUwEgI(H@QT^? zc--2Y^ilFBWfoFG(3Nbn2>ma^ERb7X#pVy-%= zmFZm%3JvlfUc%w(Ky@b>Nq$AFC2jLNg&(#toaG&zB^|@ia=BFmWH+z;PBw;9_}y$r zvLE^V1*KoW{6w5i>Z75c_#m!f04NW@<+>GyT|k4>6G=hwu4E8Y#D9Lg zH-ZyBF}R++rT88&1a6CB9+he(S|{tk3-)$Y(&`8IYXF1D;u^v^UK^M#P|gE=7IJ=9 z>)n}}ii!#{eph3pRN;kqB`2*JSNxhIj3S0Ih+=VTbXi*iJX7rCOHf_y{&W7Pw~k4& zE_bh&$laF!PZ;5)b=;KyUJrN|1y|z1Vf#E=M2eNhQVjjE%(seC0G3qtr=q3~j3TSC znzWs*Wfo9LfBOm4ap#-dl*v1_=b76;%@h}KA69-n1>VJ6%0MwEXYr}Lob93tkgMC* zM{~^v5>E$(5bxzX0aX8)(UFxFtCzY)#FPdU3)s zXXrPxa*a2G+l}uo*VPz$d!4td0_b3cyw49nl@&};T2uoCA*l&FAS=inf@>-$)@i6b zG1nI8PKAxtyKGMY--QcN9@{y#261V#otYX&Fn9NiF&K-iuYgv-a}UI$2dJp{EIJxD zCqSi`%Io0It&%DB2zAk5g*J#;V1S91m)pWHYB%pU=60E*es&CRt74g$iuFNKm@FDySO${+35E6gO2f);N{#>gM9Oy^s8pg8$|llURjQP=>d;TtI{~cJK^f$*bG?efQA>KHp>bz1fM*i)@+6E@sp$bZ>9}L zM;lz8pdlY%97+7h$;-3>^1k@@?<&7kYRFydI_ZB6DILyNgZ|{MBiNHsA>+>l9MIc` zmyj6u>M}^tq6sYM_e6kaO-Q)0yxcLuDq?V{)4^EQ%mLKR>FJc4Ycw9mYdKMlI3vWM zuY3Vwi!>~3eAC%?^Zc<~p=Q+@*dgs59rOwYV-#X>f9BH3tHpCEZUXKZllwEN(V=K2 zFTM23>>JtgsmOFbYJC_AVwNy_ECoTJq)pntWSqMQMz$#iPrhn!*;WHMJD?IS0t`Jr zfb#)BjkBMhkU>z2jm>x^38>-(%v#Q1!}P^4Dvx9+W=S-;?)D{e0gGhSGJQxZm{J3( z>=EyaYh~QALRG8TS{tDD24;!@!=hlyeDM?ILp(;c6<|4nJCJt-0wCb4SBRuuib4KN@bIe_}IblLg!xxf`t10F^4n3b;dn z8OLl$*kg!W=n|`H+eDf%8a8=$C1BZHDM%AVv*=y|W^(jm`ilS9r%W(y2lVA2fTB=n zR|7N{@N5K~3$Q%+N1;YJK)gNjdUZ19ofDnHHY5^CTik1ua+DQ1KJ%+&8bi zU_H%TA_fV5t~-tJMd7s6c_NL~b^1}iM2_7ScDqP2PH7LRQ#-Hn`OT{J8=yB>-P5fK zdY!kq5(41 zYW=&WHToGqXGSZr;c+VW^Ew!YR`~QA(JG8vjy21!5{GNm4(Mm@{2E4h0o%|n(U+0o zA(`hee{mq$HAZ4t>pz6z(Q6+9Q&5THbx>Z0>-*8tP}9G0yV&B=S2!0IOj|fWk7W^h zlG>JXg!GDM@3_nnm#I6iQq>a6!)dIYx}T}>RVwKl=kqKD2=L7YP*#EuY^cFYW<+L- zNF3M~X8JxB5tRJXWtC&!T;MNeYFS65tLF){6N-}61jLHHVrR%-s9h($WavI)FfzGpVzWN?c>u6UC?n62~f)%Q(NEK;lj`O9d+K^@cwVmRRGd~|SX zUDE{v+Dxqlf@Ti-KQ=$yWu_k_(K4W8xN z%-mXsYdLl$x-ieUl*lj1MV@NQ*O(j!k#Gg5eMVSNRPGEw|MP=EO}&(kG0nbO2u#Le&#-OpQo|3;|M! ze(RfkzoY57z_11P7Q~l~0QFK${*w|#z{GBH+Z5}^rB^9ehuKbxROvK22Ks8J@pJtREIzffU5IrJKJ9t>6nH;**v**%d_j63tz6PVy7(;9bIlFe^9(9U$q3K5d%S%q zUN9D&RuSROAGF9p5gJVf%99bh=mZSB=I7e#rMi)!p?|{^!yNu*>nHM9L^E4WK-QSg zt!~=CEvEk=n;8}jM91HlXrKZ!8?;A2e8+CghSGB~y*1cME_4mk1kS8vWBXiv9Bx~O zkN2Cz5+e5fZUS3P^*0x|Ko}nxrLUP>9=`#3(e;6!(d|>cVPTN<7Q7#Pu3x;@!-G`+ z(kX9>SvTa|u=u?rsI1}OZWqt#{(M0+q3;1vu@!;)JhfiEY&7DRau`Z1ZWF7FVIN*FG?JPm6?qiv?2M zW^avYg8Fc!8u9 z)^T3uxpC|aaK4G>{jWMuZXLWgqo{$SU%J-31h6CVYS}Lu%g#0_R4e3l+#tdDSs)!t zC%@XOZ1E@pF!W&?Gl8wu0Fi+~inxwcFN3Dny}H)SbgxKBnm~E*;Jr8H)svV>yE#(6 zOTQdxT>bOO;yZ_H`6F+Pi0bC< zju3W(sI`R(hq+oKp2h6W)TUm}?&DdG?H(v?#||VZMg;U0YeGKtIp}*2Aevp5Y2-$- zVi;A@ITT8hRn#7$FLR>f(Y-a1cH|HRZMf~^o0)u?aN+~d%eOI%Uj*L63?=&;(Nx;C z?I`GPd`a7obfm1UnPQr0pXP8+6wj7>&IbW_<)vUAA8?HDu8=t02H?KzNh{?1xpGTQ z(2c`DO77W)!1J!$DDcd#0aq!UH+}n4y63IxfxaxY1=baMt)$skwEiA^jlC?oW`H~q zO`%VfLpvxa=xlG;W@ZqFHT8`W7?EEsnt0nhYtKHmsTWloRAtw^|SOLePIYxc|m z9l(8m5Sk01&u)iDs%0l$*EN&Dza|snqko^M7atVJ_2b)NQ-I;;%d2|+8{a{sH5Am` znoHnXdXc6S@cz%>VA{Rc(BJn8;br;x^%Bw#hMxcH%?%XwjeChcax zO^6N2p5eCE5c1na^vM9L^`~}z zGRjPb7+airhDW5l>0LJ-j4iQT4Q1LrTet9oj5Sin0Ni$xYgZuU!kh)0p$ zycTDkyr3UbGX7NaZW`!{AJ%krcO9KpTO_!0_3_(9UafS%%rDRS6}%vG9bsj-Kf6@u z^dtJ9W(aY<`t0l#eZRDlqY(Br<>yEdPoEO;neL$Bjuq?e?#+84cBW3dKl(D_55Tvo zB7<8O^e-An)L3>W*YWzy5nOinkdfAh55My~VE4hmx(Awb^+IxU{)R8z*C z3Ce9nWB0<$FEZ{9PHm{ay8wdpFR8~4)0&>jsofWhzDpNNh{y7p6*AP_Z7c~WS?8PV z5F>a+iCh-oh*mu4W^6ZF*@gWhcZg=TPO3>ElrD6)@g#k5eZL1Brgd8#xqv={&(Sz< zMI1yF9@}Yd_`XL9fA#iA!E!O*u^!oZH(+w_%{9TmSDC-s3chYPO8D`|5dY}pU}R5M zD6Z{nVc^O8A3>NK0tz-wK+`Ak)oQ>z z3o6o|ba)vg$N-87FhO&PG!zDk_(iM(4)!CRFSpOct8KthtzLu^A=HQI>r`>HH(jvu zy9*rhToNp-#7DphCgulv4xhzHJrX9 z`@TTTz5?K_h>a>%M%Da}572n0KsuVAeZ|p|pv`3`p+9z#2Z11})BH&J_eOmKg7KMk z%mGCmGH|Kv8QRNwt8s()B5LH9@sWct;({nqkkD|VjDFA+JhP<4;qeVQBD-Q;1a;7DS=ub*mI^jo;S{>-7C zZYgPhU+Daidm}ylP29eM-v8 zfH7;*pNO|sf!d zUpB3^5-Qt%;BS33)9Ae}5SdT94)_9-$Eg>CP-9@q3OM48`ybp_ALGsB=%yXDYqPz4 zdEUj99dqKO1)YN1$=4QVNc0!=U&AaM(aUmB;pdnbvq8 zk>GdYZ(tC$D8oq{6vT~?76ng??911eZ4XG%z&y9Tm;csCV@#1)OI^ z>60^uu=)>>4AmNkI*|^}B@HHoJO?OHOVuLhy|JZLTV0peDFxV3Z&2-pPR{fy%oo54 zX4P%X9QBApXPnJ8K)&`O2U6EP=jOU@KLOqoc3+))^*o!uOFw7>)y1j=i4q+c6?q`@ zjM{acU|ZTLxrTM(376%8HjL>9wV8#r<7DxdT@6!A%&Q;|rFb8;!94lqxso;@ zxG&n*hdusC`^*?_oZ3w`&8IMcB)ymfWOAJYscB0(?tVM zFbzHVwzRz5ZCz)v;`YtZM*Sf*o8mA;l}XeA%2U-?t&02@vq_%{A4v5K0d?jN;)TcB z>~kKgQ!QU^OO3g&Z|U&|8n^BqArHpye%y9UuqGeRn!af-Y&fXcYFNAa(XEmf-}j?-HXFil7GPd$OSz6b1?!S)gVFJY{HozMG;Pcj7us}AT zp{8a>itB_XUxgQ9KnWK6M)v%%KJe%b?xz_Z=o9byTBd=+-Sy>Z2!r)h`6qnSGiQLh z)Hl01e96#f?hE{o(Ut{uYfPD|zhS|}V?5~?oF82bGDR@) zRo_Rh3j`i9I{qINcXq`+Fk@d3|8dixpE|R&;Ag*WXai81VwCdeLgMS|CKb&kf0X=Y z=her^_EP^0pq`VYLN4y<$b|~FE$yyWAoe~w<(w|QGpb|f&3`Iy0tz3TI*w!cirxk$ z(Sqh995&{AXmzDpo|6@Tz%F=oUbQAiVup#ym8?J?3H42L0T^A7z;Tj@iAjuH(t018 z0B}kCwZ-kV8(zND7)g4YVfJ|!6o&G4TiMXJ_Y*-=+5WkQb=k<|EF%jS*K>BM^?h2e zu{kU+hJW2tys$1m{fJMN{S+DL7KA=opc~c)f~bU`M-;P_8tS6-hRw{b$d~`r0;KHZ zV{ar>W6y#jjZW4S@O)YxyrOEWdHFKehZ*i^c2$G%0cGBg%67^LBLa+j1GZS4g~4>F z>KmY~@LLYTjK>G#pst%cA)wl{rZ!hSv-q$!4Y);TZISH$qgAj5%QLgu9A{=f?87l? zF+KA2_1~x{qi0c9-aaFPKhPPrmfhDDy!$|93AkHXiuX{D@R9maq|}9Slcx~w50&mF zx{$QO57!U%eY=r#9F@|F3d$kYgyW~p-_PSb_fCeAkdk7nU_0)jr#cN+MDnr znLE+hOt~a>1dvptp`t4JFAshl0_8e9Wj$?BZw5n1XxKYxK)(dQL^8+gzf<(9e3!*# zV!5m*fGdIRt96ix*o9x{1NpEyF%Ye#353Ps3wrK|%|_^=$6h3>Ext#?L=@iU}e{Z5 z10&_X>f~l(?!7R)oOa{kJJ5zAJ2y*9i$;1GD+=fy^%l@SmYMN3W)7EYMU4fHYQWvB zu(Oj^X)8Qub#d(G>kTx&E*~U|(|~wjio_D|U+4jX%y8033im|@x+Y;EJi3RO$}bj( z^!XlsKxu-CQ-hKe)cbeq_0@J`kB@bYnC7+ z2t1Tm2nVsw)qeG$7QRBK9xULG5E>G)Ii1H^vl5I!Oh|@vJPAFvU1+RvZqsRva)Tl^ zY8F^QY6kw3%~;buzygPD2CwHUwh)(;0PvdA8q4`f7PgPKGZ1+%R2)Mi7br*w2qyCo z+dv6Q{@Q&J2t!-1ZzL0sH|s$aEcI-Y=V0cPr0mQ$A&Tl64Krpk!4YSdA9etU)R)H! z=8P)4i;IhH5-n1U`!{`7KpO9B)mc98b%sU!`UNjc= zpg1Y&8jO>V=M;Vq>6jqH<*J}c@N&xcGqTWgv@{(2p6IG6Ev*BZKhiTUWt#V|#xQ=i z#b7h?`;2rN*hMA)UU@)pWb>u;AUHZLfebD7e<6FEtYz2b_pOi2rY>A5EI-?@=JR=R z|H^wKj3}B;bQWvKw$qyGu@B=ghUu^Uf&D_pn#; z8r~``417mi_vg*3L%_+)`r6Fve5nJ#I~OW#_y;z}mU48PU|{^>stoFvHTQTxN=_=H z#P8$=;kq*;K8@K|g+V+Nxu)zWw)OgX1E7L+NJcKW`}_nd+6*BN*C2n$I&-%1z$d@D z_5L{6z*A9Rk#Y$yhS0S+tV}t0p8T)I3fz37_Kbdm`fsgfD}7ynB$du%xW;V%k{~O0~u37=v$Rm@J8;wx7zD%+aaoTXwmeb)8+0 zSc0P(mbrT{>Sb4_SI^6#$W5_C!jDyoMl4g%$-$n=rAANJ?Ifua(##@H*zE; z94JMaj?f<6{ShzLktE8Iq=zGTPb<`SE@5V6Q;ea8aW7Os3vT550yBUbj|gv0?z^rd zT%GFT=B*unSWCHH_FL0aa1+@mT^@hSH1s%52W56yQ}N)lN|%|qU_JS(&pJF ztM4mWd8m@^eLxPS?i`hSiu(4~7YC@(k1Is1wE4StzxGG@P$4)DOCmF0?aU@il)a!g z5PCiltJW*e%*waxjAh>fulQ+zKYWb-f)|Vo9g?=3piwQityWo|*|8dp`|J1*?ujen zP@~zHXqp0_$$BDNDrKg|4?bVh!AdLJc>2f7BH{J2_S$7=kD&CcZ}o+43LdjXWk<)H zb$FnRkm7x|I;rb*=5sO5Ll=4L_NOJ}*A{7<=6gduvm^i$5DR%GU0gr6x8~R+JQ`aE z74W=ZEdm5T;Z(S8L9xm9guazZnZz{a2&nS6cr|2BHL?0J0EfaEyv{w(x#jai94)<1 zbizkrTu&g$BjDSG&~00Sv39gKK>YkcAI?)L959}b~? z62vN9-#R!)EywEPr6o9#Kz&OWa{Q+1X)P`9SHYSkgQdZ+cq`O!=#1MIO_^q$!7^y5 z-eR{PUm4F0?D<@tq-If|OGKL$(TivrzLmCX6ob-Hs$^E78`3TNDv-+q8YHP9kdW$ia` z()OHwRRc2_tA;+Fr_mIx>CU~GD93a$Rn8Zod{7_6*0x=VM&;0cA3(0UTlV(-iBP@D z{kkLND(_n>*t+GI6Nikz{QzCIezpsKutmh`Tc_v^`nR+$>DaSFWwue9u*I~sQ}nd@yQQFXV4K5 z7JapXeZ1*qUDYp^cpk6ANa*NMdzHd)zlRXh`#9^FZ1!o)B<_CJI413zE15F7b)s>z zjm1xBH+<*A=Y#L`mv7`lud2!dQ1yuogvm7174e^AFG7V{{`YEIw% z@FG;&)AJI7xOTg)5jB&8o5Id);$oTkmy+nKTNUioX>KZlDZarjx{Fxn?XPQ`#a%WI zmLB8vK~66jwI|AQPbDMC0B<-88q=qx#cfw>2lX9HF0h&?rJYUJ6DT39X}+&0LaBg8 zSn?|w4*;T#o_>{i?7(ChXZ^`oTH7$^rxs&9I?jYa)$i!rq#voAi~YjM{@{w1=go{Q zDZi^Xz^U-eyNklXR0LS%{TogD*#;NYo6tgZ{oxFO7Ou-SNgDaoqpnFDyO!ex6GpXA zA(NRBnLJ>n0uv;htPq7u<1vmTMyS`jx~~hs29vqW_L?sQ%C>~gXP#n7>g7eKzwd>U zd+sbN2<9Cr2qB_8?SdmoZALRjzbMuLTCVMG<7u>-`FJK@3B?Zl#<35PTXJ6(Jh($9UQ!Rl$@>o9fJNG4N=I!nPfGB z2RM|8Z=cT3iB7y5(@?m&O1v3X1e@N-&U{ndFOH)h#FIMnlT|d_+^%31EDOO62Nzn7 z>O|f^G3m8)eQ`|sk%`@TvZun|v89N5$>Y4IE&LK$e+K|fBu!Lk^%;brwi`=yC;?UP{Do3HEKv)s^8j9gOdt@ z#mstr#r{Nr?(DvF&TKU7;_r`0J+JMQCJCXpD0EGSd)j3lA}roCd5do?JWw(pl)!sr z9r)FbCj2LoIp?&uHHd7>U!PvDed{)CCoX((Uh(dp`jf{+``Wz2Ti%y5<0E?(l0Y zmf3S(NgH1|I?{%J>Wy;c)kTanRCy;vF}(vo_J~SCEg>A4A#I<_6N5GyDyjhMCw>&4 zUE|}{=@c{T2R-`)aTVlplEePg0u$EUF;PSR{ok+6fN&@_dFQxV+||`p%hMBlG{bZ( z+HsziysWf{hRWF{e04Iaprv5>-cpnnb`Oc| zGh^G+W!AQ(>+~OySoiPIP+E&u(I$b^s4eD`qPo_q-7<3&PhlT>2*txeZY7x=}5f%$L9;Q824`aIq9 z@UFbf_q3EEdduyZ81p z)rFUT0KFc^GS67}>z{I(qN?R=-R+%!qaNupZ5`FV<;W?iylf4`DUr_t;etbg>+Z2J ze_2Ku3KIzr!$*2Syh>&w`c4A%_;tu5@{gGKZQ+4LC(zW2^Y&f#e0+)zk`y``!9D-k zO8D*rZO^rTgP(py>7iYa?=|JCF5Nzn9Q-%H6|AjBwx0iHVLfI;Lqk#w9W2pI2_&kJ z%|SjHDy!4F;j}^QJ_&nd)#1vHJUJe#VJi){LC%_a*6)0G#XrSA!AMExuCbl$k)EKu z3k}3OeIQB6Gb#RNX+djvAH6C4&;`73fAZ_t>yr8QfBxq=?fxVs zMG~Wrs*6zx(4%47i1}Qyh~?wD$0^A5%A7~~ozY>zFLS23^9-D7*&Ixjbnvjj>kd@Q z3ZmPjGBl~a`{}nTpTKJQqU3Em;23k(6h%}adS$KQh4P0xBNR*Gi;i4bo_i@M?<(y->Wa&95PrS!l`WhJ0 zMUB&_F}7-R`*AQi^+BiAN|7k(Fg5vVd3WpOe+AHj@1r7#u`>$)rxx(V-2J^IQPlUg zW_d$@v=6&P=$so60i(mfS)=#Nf&vksO>+Oa0gXc;wBYUMSS#Q*%wPJ&l8|i_ zy5?6oK_(sqq3&{UM|OSYQV~Be`hLHZ5?2jcXP-HKaZ*El`^CP!05LrF3rKSN?Ta5q zA*gzd&Iz>kXd4Y5aCP|cO_MZaHArg1i0VsgI~|p0>_`Z~>(4E^+i97PCrL?D3E~5g zKW?v=HQ@3TyP*!hI;il=7meRtc$pghA-B8jcQh8bd(mbil=;3d z=C{9&)c?;*^bMwEBJ6k~N=22!X^HJqU5|->7SbiS`@g?+6Wrfz&zD;bqhH>x^A)O# zsyFYDb)}{_V(_OM{>vtRkySBOsC&X4%T)8vTSpa`r?UOeTNhJeLko7q$BMXp`QG*M zs|>qt)SQL^e;YFBt}g9@H8eilraJ}#!9^1>?`=;w! z7X?mwg6FxqVC?q4HE)!|d{C6r*X>@bh%XN+eCW%h^b_xM`xYEuzWQ}q zYi(zrxy9|{{`e*P0~Xd=f*+MWGE(Nf(h+|(Gxlo68j_`uK&zg1Yn=b{E`KZbmme*- z-|%*qB0=1m8v6R|l@-w9n;KXuo%TvlN-`K;yujD4kzxDpT%#JBFtQ|8D>V46UTp|%ftt<6}3`6jPS3~@`VDh;lJD(Q?&T->M(Lr!OsX}gUtT!L;j|J>?>^AdkN7t#4Axpv|tg)fpT*edFcMF_$go0-4KS`-zx+JQSB z&3Bn9{;K%T4L`YEBl!{n8&4WcNO=E0?&r7Qo{?CjMYx7eOWP&GG;jz*#s9n4ilnW~ zix&I_h6*ClV&=!9>hy6)lO?;@W{B3%$C9qrVIk7?*>^sGs;|YA?m?~s7?l#v0Ot80qiU+9j98<&O zAC%-NNp&FJ8a^V0cN(U-8WhXqG1B^7@P#mI*R>#qd$7Y>>s0&kPbwXb$u$pUv@ENodcUf?oCRn9o*SRVieXn*p(Cq)-p> z{p+~*vrUv|-hseIe0BkLfJhPY6UKY%d%)Uy)C~#{B8H1KROEhJdGea9v0HE0&DQC2+5ei|_pzV<*0##R3}2{^#AkJ~aaq|I2Gyi* zP6-JK)fd;;LTa@q)IOfB2-C~2d8uBXCX_Q*X1?>3Y1FJuDDs@_Ot{R{Ri(P`dE^cY zfvUnnbFazE%d<@vk0$P;SqlpbyXIrtvb}j}eWk7AM>rOWViht^yOb1g5ngK-EGebM zdNqAhkv(oZ!+Bzd&j5XXy4hvc2U}pvmCI{^Zo!)~~ElLSB@`bg~X#xIHbYO8+_ip*U;>a~-WO{>(u~QVcqBVzweeRfR(FAUxJzUR+N`%9Q`t@5Q}? zfw6>W4h@L+t(`0#|4@cZB&f7xo_xC63*K*$+r)Sl9ZxyCQ`t}BccBSjpq>)l(L|h( zYg0pjR{{o)QyEr+fbYIB-qIB5R2M;fF;QgnuCu30nPquwle2)HzO40JCwxnFd#q4y zn;u?n-NmZa25TxD+!FvMFxr0WB|PdSN?po^eV@yS$BMk50&Wv_6PBZEC9pZ)BnTai z*A)@`jHJcOnS$%+QjP3&jm`W~|KYOvs$ZVMU;>X2Ezf=&*LLmo8T_+C4-9h2pq`Dj zJ{2kCv31x3)D#-LLtC#b$+#JfRPOfs!`mFJ-uyk(o6EDG+a3dR&E*-+8zieijd-as87aZ=f5u{oo=2Z z?E*X={e{|+9kFdSW>6&rj!fMEKH5}#2t)U~+zBfi)UAKpi*Dj!$9$oimY6t`u1Dsn zSu48<+;UQ1yM$It;9exX56|w`*NQzc0ajz%MQWw86W`2y?Nm{DF)?^8zP?>ONS2v@f5vX(-#;k6;Pg-Y1X|L?BLlHMkmFud; zXeUjLT1%otzO{J6At6#nGU0Sb@Yt8Vwq92!j{JlpB z=+f`*+xXt6ep`u;R2^6a$zDnz}V631( zlONY+k|aelE6~#Gk-zrl<)@uZ`Nj5law70giC$A`B?*ooJlhYtQ0+V49o}>^vt0H@ zo!e5)H5NkI@FLV!UoP=zBOpsziB75wn@sR;gotJ;V?@2WiyVuU7=na1T|d~>Cc$ea zVwqN6tyDu%QI_@<8JTawffJ+U(bZnjswX8KdjE=dv> z-m~ec+g>lqAlElQtnsSvmm-F}q(dHms1%CgyU-(F{?Kic9;{tfX6>_5IC&x;yr}`o zx-o7y^ljVDnU_>zhW=aNrlkzizv)TPZGxb4^1zpIl2qwiD&@nKGuQHR1*X{AuxPtu z=mzMRnJo*tFVDjy=Y46N2DYc+Ej)V1xO8-}`xWpUvq=;q!!Fy92a|P*?l)`Xv`4V+ znQCi?$r2vF%kj@?_^g}G;=!#gUN@DdDS6H9!``zc$1y59O&3DOCMGU)=g;w0pE?U! zw{J4L;qKFCy4MV0hIo+A(G3NjjZ~VL%B7ra;3z z)bYQ_I~xJ$+Xot^(szN%{b|ojm=I60LkQkg=wnCVU5}kXyy2<7lqFvL2&3zT(J?qL ziK;#j!lz$+EMoA_X-`34`+uG`H9`!U585zU!TpohOQUP0bPbh`*?y+QFC{~-l4GJN z#+>cK&`}2!2KwDP%&%ar{aar!pq3Ztr%VNpC-mVWeszX+1IOIh=)E=DQuIr^*Z6J! zP41ok==O}-$o{(ebYbk934;w5IBF^6LCXG;oJYOto!qWE>hC3 zULXt}pz|31pR$7Hs|KSUirKm!_4Z5c*y#A#d3i;o3}c}1s@t$daO$}{VEXf>#%yo` zR7m^J$xpI+A{tUwB=AjZubYeEgg1+VLEW_jzuxILdV6TipmcfW7TL6I)mULu@IBj- zyNw@D3R^ZkjUdgcf@>|7@ z4EVrdLRV8+Jqn~|8O8%MT#Ra^lof8!)|~#hT?BmJJ2?6e-dmGa(5h}peYJ`!pZumk zgW^?_!)Q0?TwBH+3T$}6c3t=jCf|Yh(o;I`M`WRr(dB~?w~v5-jPUYBZuk}}F`dI{ zHW&z0>B@;EIyHsq!dw~4%}$dn*BofrWHaR!KQh4Orwtyn{HGQGyaJ!!9kGKLOW~Lc znyLhk5Ch&)6`}={#83OLx(xr!aN?T|A>Q|77QeF!S6T7(X)Ic?BM{_H!yiZ*FVg0r z&WX46KzJ?BUCcLohz4WO>z6in-?-ez@7$pAT=mmCaRNwalCiQLf9bHbf%zV`oP4Zo zXs^Fj!Xvp!HWW;)2$b$J$W5c`^`)!L4BPF~6}t~A+P|voZjUM-+5Nf9C(FQ51q$Q( zoS|WCO%4u``H#tVTWACXeEO;jz+=f$4=3g(_1$%ml9I~y(`>Ok-Qoh($3o?%v-D2^ z4AHFnRaw9-NFhmZ_t$V1pk~RjH0?AROm=s7UzV&*9pX@Yg&zzDQ?O{~TAP|qfKJxl zR0AgsO|HVNi>a**u#AP3&aXrjO%z{v3}+@MlOC;flSnA-?uV1fYy(k2(?MQZck_AF z;Ej6er3tXvQYp3s<9c4Fwo~c%O48UY43!K=5~|GT@{=Uz#aij#mPmI_^`}GBE_bAfZopbrrbNEfAI){Kf=R1WwH(b@ zAyO_QBYyuFeB(4p<~EV9cU>mn_kA6_=0cSU!$EQZVAsUZ)MC&P6og4!s#VqK)6_p( zZE-z(ey9%HZF28@=J<8OdB?pA2mXg&q(3_2bg=cH9gpnwdy;E7)UOP3ByDJzxz0ACol@Eyweb34A2ME~ z+H@F1(wZXR1=IHf!#_KTzSk>8fA^k`;t3c}fsDR|8g{)}qr z3jyZcYivPY=X3ksDC%G=a#>xG=?K8;ooY5g7ncrB#Imkq(XHmrP|Mx&g)d`21OC>; z+{Rxxo)Ech52{z+#OzI^MJp8(+SFL|1-( zmp8p#nnSlq*V;w!Z4LNue5+4U72!y+CZT73nyu8u+;;88I#A3La50m$HkM%yI z26~Ckxj|M8a^7FGemr)1HI}NqXr9;81Zhoq9~J@PC<7POL<&{MNe(o&qO}!G z4Gu@BUAD)FINT2^jC%6%FPQtJ-?LR!#BHHKzl_Dk<@s)VcJB`hoG$hii#@xP_cZ>FFB%#e zs!aQ#LVu&@(~V@%V)6TYz6BT7!k{EH3Kyni;Zv-TWYmMP7ZkoHPshc#3tsf`jOQx` zV5xh1H&@DSGE3trIu`D$sw*Ohj%Tb^NWzc9@^a-<1XEr(!oD4%2&_0Z1OvlYk>PaY z1pY@N!Cx}77=TH(xI>99M6cBU=wWnjL$06S%yFE)pWJr_vifFS{@3@TX_VIluWUsM zzMYcrD80@FmbbIM*D)jbzmpL1DVZ?~B?z99ya`$}tgB<}i-~%J4iMj2HEX|4^bbhx zit}fu%Jy6pJ;J#gnhkyN=d?Tst&S-^fQn9i8VSkju%YhHwZ(GNt4pzu4SC3&X|`;Q zPULTy{8fRwZ<`H4?g+|I%5fr><vgEC2} zF0*#^fwg$=m)medRq@@6Ps1M@z@!9~&yoGwC&_yS>4y*Cv zRX6q9)@!d>n#<-`gCFEZ@E*8}le{!teZ}W>o2v>!teGTm*gXGhZthy$q*_+UWnW^A zRgo@_j_cZRFnMmuk8i=FlbB7l9#y6mYcRL)fe=-$%>a48lDswI^N6s;_zA=%L4MIFIX2_L?n= z`YXfpQ{Se|Rh!wf&GE1kFl1eaxLDV9n|q3bqYZ4YDkyy2|E~0cR_Xj3%jnGDojlYW zauI$NZaOZa9QnN_*g|KgSYJRio!-?IpGV^piPT>OMI&)KB1E65BqKq*p2xv<(dyH? zb~>Qk9ja1lkqSIo1-7bNdya9VmrB$!zML)$CT4Wdm1zcgc83!I7;d$1e@~kC$owVO zCiBtmC-`1gfNWBpneltX<}fqDB5T&RA&`UVrMY_5`fmpwVX3cN{ujr%=<6%UGZro{VLONWHhYvS%sSnH{PQSR4;WH0ggE)rkF*w6wmDH5s3Frc`uteWN{~_tIV1j zTJJ4srA0@-3s^xXU>WKk8;f2!mO}T{vEMqecpN;!!#6!0s*BesY!`lgfcy-(bhB($ zq_mFPZayj{<@&Vq(Gf$TPtTu}BOD4`cW_3CDbdT3Hqit#)&fF-G6bx8$(AYLIvF_+czm8nF=qDNXnk^gU2C!%mM_=Hx0;ODI#UPJB!@i-S<+W_=zPPC? zQK~5{2EGq;8E>cWjjXVh^bHJzhle}BzF{s1bP{MOV6I>M`NegO?j$jeK0@TE7vS3d zKPda^xGKAC>y3b*fRr={A|WlHbV?{C-Ca^r(hVXlA_xdl(jcvLZMwU=yHgszwZXT} zJ?DPs`nSK$^Q>pBHRl|2j4=bBSG9uS$YR+*^$bRWbi_R&m!O;6T@w08nZOSPaivOq z8j(b<&7!5;dcJ9Va(XUQdni|t&cm(V_a{Mez5n$D@NeiKvxXqtX-^=-aQXTT89<|Y z;h&o3hUch%p`Pp9}@;iCMo zEt00fXj{vHpgZ@Rco!h3Gzla!vZVogPs;ZZu|AVFA559o5+;1ys$iGS|6xyB2uz;& z&v!sp7S8kyms4qtV^Ln=@_1Ec?}%e=58WqAvNrUg0Wf*gkci~y&v`|}x%gcA= z_=@+{Rf_Q94+HUo1gZjvH360tRMAgoDE@^}HtD@IRo&!iLWMfiVwA;jA}2w**4p0e zsB6(ut@o05wmst1)%*H*n9fBR{X!FCMtOl@u?7cWWGNK{9RRA1fZNdcs34zFDqLJE z`rG5FqQPq(TO)#xT1!VbMm@@7q%z^0W~AzM2(kLn%n8ff_D0jQlmO266K}HL!ddmR z;Fc{} zTI$WJ`<%{xWGB#_ck^BpQZz0s7%*v8OX~A8PPv?I?k2c%fnj^JYe9lH6i9F@=EN@Xn6$iRnAg+ zS7dyU@ccU%Tn@XYrDC3jwkPn}q=h@Y)8CI|K+M@bHSGq|KY1;CAEtKuY@A$PoMlFx z3m|PSkL=EazfE>gaweNhyH$TZkB&rdfq;D=5)ML6Bv#KNuU6v!F+!odMWib6+cPhN zp)oex+R_xV=DuR7@$BqukX{w8=S@Dr2N)% z1FQXCBJC*Y$4V+(H&5;8+*JYdHWYBjP8OB5MoT=PEM(=AQudSOhlLL}Kizhbgb$r$E~ zj%e2VnU@6?{ojjRJ}(xQ0QcH<=^v$aG^(`R+-Jkn?&;ypAkWyG_kN4Mr%zQfVQT}( z%hU;OOYuJ%vfyZyvP(g!2w-kQqvcQhwl6_(;||=TJJpK;MW#LI3;m$6PMS)6;V^4X z1Px6t^?moV&64^TAHmcHlSU?J#$BMvJ})#5?bfBys&p*+u4vTtwPN!U9M-SWj4xb`d2=2r zx0cuuu@%Z1Xz^X`wS(8Wylf@~x%xMXSAY>hgY%p_Jp>*Gg?RE`hpoVABlz%b!oG5m z4qi+QWPpNwn1NGqX{tj#B~4LT8_eW3T?hs|wUgE51m+`*D)Sh&;d>-YeR8P5gVb)U zCz}q_iC@6XY3S@JCPnVrq~Gn}rwX4fbBJpAtJXLvw_7Vk z3T~U>q&rU&(509xx6M>2==(_G(bUjj4=SZIQrB;X*}{?^D(R-OjJlnsJ+5Y7%!5N_ zzUWo`T{@w#5QU#fO*mPDkQJSPr&Q2wc;i7}-mBxM57wu**T7{Uujf;?4d(ui?8KUh zOH8HOq=O~svDVo#d1hj{S4o6ev1f88K0MsI-yS)ise*X~V)5!s9 z@=W`juUij(yo=mhY{NOgNHgvU$q>wZga+N>S~Cf!(b@8zX7JF8sLQJ{cEd|E`Ah{i z;)(rGcK_C>Vi8+nWRk)A^LZK&OG~Dmv%24qX>vKFQN(Sw3r3-4( zUz>^u4aLO6`!d5Taci-kNuyN2;kd~8ks&T{BwL7nCj5=0>nm@C+M(&DMu=MJ60w`* z#u-R^zi~(VprjKmzDI6z3)`RLdvbL2sKvZ{A@l)YRIYljM(^}JUBDuVe&@YvngZ&2 za1WnOnAr*!v6-yiYw?Uz4TgfhAoXw^XbENbTC!7-Q$PFd(dU&@#xP60 zmZxgG!js>^Uwjjy;G6VC8uI{lO`+71cNw_3)zcF))UT=v^d%x_?b@Z-Ws!m$9_3wu zt*;Euq5_T7i`=J$U>==RRJO@n@Tu>^)!!X}rCthmk1|-7T4Hemcm&b5P@#N6^(v@sj9lEtUU$Zy;cc zDXSYs(~tn-il1Zi-SP;`Uf9C>KfiBdReH6B*HPm|2Z4~2p}lH`rbD{mc-1zr<3?UuUww_ba!dSe zQhd*fONIMIaz#(}O9^5ajd{f25ksu89bwVEm{-wlbB ztNja2D+ChS6d=DWM?u5J#z^JM7`(#2R4tlxwemaxkWq*&pC9l<#>ISzj1ck`yzR;Rjak=J?zx^TF)K0` zYd#T8z#SSfILZx3&x^Ls7VFN+S=-K9!N|h?+0!qx3&3I;Bj7#-ho1ff!M#kf`dQ5Q zFmX3b%+&xKO8daY$cpU?_u3GOt?JF8OSe4JiF~hud$=D}3fY!!XQ3x4-pPYk8Vw#>3-OXrKWD82Hjy`>nE-Qzf#9T-5nYo z7qf2F*N)X1Aa>{WTVhpCr)Ack1mKAYyUM)xH)1OfHZ+{YGRwmnu?QfEwSd#Vb?%D| zM&Dv29=p5~dS%rDz9}AiSjg*N5Bsn&)^;-!Ro(>UUNKew0#uTaJk_dGSXP^DU4Je` zPR=Ov6!Qyy+j0&dp&zX-Q@E>EeqynZ^yEq3Kk$)UDsRS52n(+P3%jbyx!00!ySul1Ab?NLq3+CPE%k>WGIK@KRH zy7CMN4P7J@qH1o|6+nK2XxCzJbR@sPNP7#c#>4Pg!p-8enhQx&GJr;R;8kc2IO}CVwDC%B%QCGreiGT@PkKM4Cdrb| zo%L^{&7^J|e0&y{?|hKZNojrnz`47vGuiChyR*RJEuR^^N0gjiT-0{5el^00-@8yf zHNO$R5Kl6@_whqE@ofQQ8Ng`Phn^WiAl@o2NQyI;-0XAo>@M4*D{&@*>+HDLL&XKi zO^aIK9QkS;Zo5Qm>d|nq)p7_KmF0f?as31m;t?BDsjLBDJoYJFeK;5Uuprw*#@v~8 z7xy-Z4i0meaNK7cTLm|TI06V!=t{geRkN(E^z9mi@6;SAw-7;RjKX{Db;jiV!nEzj zC}T3s{hD@OpyzJ-qK z^$F`mxEILx`ee4X0Ltj! zsjA8TA%16>Xv<-XblTwJ0JxyD{R_^IMMP|UdyVf~yZH~H4>Y;)&Mm?f6p#ivLzoqv zV6jRV^%w^S+|Up7+TRxmqH@+LjveS_n5KbE{?(uIaZuuSZ!DC3JPDb(yGr!VR`Se# z18;Rn1cTf;E^~;MW4Ap@^$+0NHhjZCf!I@_;45AZ%}ttSZE*Q88;=0{17^GXbhN0j zrA0$Hip)cMV#}{Cw@QvDr1&HP_W)3^d~t;oGbUNzCzev26$MQ-h1n@#4dF7LZXtGV zjT{aqOY?LFzH8eUDN-+BkO$3L@XVZm65?s>gQ7x}Z4P1A8)Oeao90v+{Uf`HqQ!kp z0mE9>zMI3br!o|1WGjC7#gFYK+uhb50G=E>O#*Ny40mUbr6=V>Xw!8HHJM=HN?@f1 z8-#wkFp=wEWt)AvWE~phEF@@ls~@SaxWKrv@%)4oP^1}0Z?%ROH;8)`DOur3ec>?j z`$&9YvMZO?dG)|$A@CgnQUs6f0`l@&J)L_^^!LhA7(V3LI0MW1luFOq#+!fisJ|9p z$7NR2op~8ZB2L)cmuGmXcV|Ihi~%=o-8`i*h^g(lsd>QQT@JO~ev^gKr)(BeNk0h; z*;}f2e^takBVxDAQA)jQuJ%L>>fStfHDm34%^1MNo-ecXq6S=HMhY}Q@`*{bm+!gD z2sV#-hJGmdLvUK>R46pf10rngqVQgeBh|{q&jG8!Y)H4Vtiix2+T6=oY+}iR>vUP; zeuLwd`gIIPxj6fb>2B1wBBdJg9~hf|W>oi*I+p|QZ7!zV?r*WUU-Rsm{hbucQC{-U z`UqNgRdF@@72?^_v`r%Wz`6at-KyCyNVjvCZpK z0XVoo^I{dX&CKyFvHMS=t-bi2vdnvH@>+l=3`9DjzgJlV+}GatZ$SpQ+9)$92}^)6 zC1k*6BMJl!^#_%uQJA0?h1?I6KG!>1i-RGCU6J^tF2%R$CsBu{78GekJ&iUm54JOO z9?kI_G3)>swby5QyiB8mMa0R?9KNA`&*ig9{#_W(_n(-)ij_hO>SBSJN~z-A7Mi$B z>a(L2N~Yf5!W80*-$KI_G{t{xJJE}u=uB|@K%a0Rg}p@G^VsF>ioH=Zm^Nb}Iid^$ z@J+`d7CkHxk)d@l#yjf)WhznTFybPXPKBero}aG!L-*M6BA27ER0+`TZ$$+lMA?H$ zm!}!p-NruzI@%E8k!Q-)TExQ|%2?Uheob)$bs2{(cnYnq5BQwvUNXB!AO-UDH{9mK zGY|t702K~-TBr~m7Y%3<`PJ*dy(9S@I$DS@pImLyP$XcPtmF0;tn~zGM2*fH+#C<& zCQ2U;+#-qMn&Gmf0|RjUZ{`R9=s`M~ZBW?W`&*9jIB0DtZE*T4-OVm~SMj7xzNZf~ zwv@G9KjI)pB!-0r;VT+T9+N$pb!dDBA=^6!E%K12kSmkkq^d1qKYDyf&94R=zOskc zH0wKBRzY%iHy(QF0IA0B(8fao+ zd7L8>&T2LJY0^w1vp%2$c+?*br>CVkD-C7K)~Bnesf__3FoXowwC;Wad2@%7<#EwN zvy)o1ZV8Cz#crH;eKJ!Wv=btX7sj8(ZY39vIn4G*0bX;P*0s?8nb)7Jgut0*azqU; zkC-npFNzHU!l;TPO5Sb@?k)e;Smg%?1O$f|RRP~TR}sHVRwD-OvbAo_ivIq7FjSh5 zJ!86PKjKj?$=C33V!Rit!72_SYLhPmKkTa*JRc1PpBH=+bATHF557I|gi&??JY+&} z%w(W^@HM$DtEiksf_*=GIoDNkY>-s2G9-Ka{ zi;8ExM|Tmth;pG?ue5sllALUyhQBr@CAQh#^yoC~%%Vcn$&J`g57ri0=S4{+Yk*{F z#`&F)``hFlWd)5dtffEU1X6&)VuGvvKHrMjyqB-@Uq!M=KF}{WDs5s(f{QQ0FwK^u zbsJ-=1CjEnhWy)xrY7rGqiCW-yh2FwV@Z6pbELF16UzlH)X$#Tf%OTmGagI1Q+2+1 zH0_~UBpz>2_&S&nFb#gF_NN`-MkFG)uc+%_Z%qOch9RpBh2&{T3OtQ1H zhya9tGB0kuk2+{GPBjGxAKml{q$^eui8vN#7I-CU{!@A}cz1v>+8xm`uMrFM=VHzXh3Q3c-9 z|28bh=DfV5NAF;1bU!FUL$3c@+93O!Fi2VibCS`SNFq{=Dm&+SmaGW(`(=XQciLXh z27;rY4iW2uR>$Bq7dM(d)q3X{PscZWwOfDP9sSc_^aritcQ7z798O1KAwgXCdo%k& zCi}v)T zR7-NfO2D*lp`qw)q4|n1CkkqcoD zB>t4t;YGd&)ZaDlZ-kyr|IF^9mn5&&YieiSgEK1uuaNNwHw8r?81xZ=k@v- zF}`~|xL#o27QQI4kcebRPVtQ${*)MTdJ?+r|$WhJ(0W8P{0 zIy#^2<;=&Z`fGJx%B%+&IJSw8d2)Tpuf;zaRx^|Ng5DxQc$T0$h5P#MnD;bqRyK>H z8;PowX3li5<0tXAaMA-@Ut2pmCW>4f09h?>YrF*bYeTcu7Nf-q`y-UwMuVrXj*~6o zxWEmJ3OVboP|mBGZw6P;$1@}OK;ih1Wc1hgh&8W{6TIPJh49I76>qzceze}z>DcXm zVt{k9PlBJ#;fUB)te9`83xYPKWvP1uNCl*0d1_&)z{!Kjiv9=ZTdMR?p6^iM)Pf1K zZNiau!`k*t(at|bMzYzR9>i7H0|G5zPQ33USX+Kpd~v#-Vp*MdFyTL2FE1}o0AZ`& z$7p^r-yFh7+q`QdXb;byc*`?qhaU+EM)ut6Z4-W9$D(Tve+&JU=v~XC+C1tdJ-}lE zx z9DmCwnz$BFT)fCaJ#?ZKXw2A&E1B+sH5O6;PHl!Msl1|I5Be{5 zw#tCnJ1iLeF_4^>ojAy#{FYc>mZuG4DXDpSk?pmPo6Bmw))*;_e4ghv1R1Vs^0Qe> zx0&OEQ5{5d3veTA2IxaPSA^;4FzIg!js6ul8GXG4LtCo{;VJ{_G#BjmgF=XE1Hl!M z+4S8WGa0N71maoE7n==o4N|pC75D3vU|0NKL zG}S#Fit-o9WO##T=cG;+-KV7LHeDBzGVWE{rs$H;3|3%BZ>P8h;Ta^B#|4hDcX$Ag*Tbbah)Kgi=DJ7?oE5PnOV)gX@mp7N9}adV z)c}Igj`RxiZIf&TLG0i0`w0cRFj!el(l3KUMb1(wpZqrsC|mEj*B6EAtZQjyKK^wo zRmT7{xy1yg?1(vCddAA8>~+{E!ykQ2nvRI`c5e5%IsC` zLl1#O_gY0*1F?o}1z0{~H-I z6c_NvckL4~)ee2X1Xmv3OVD(KT0P7mWtkI=Q{h=uIM9eypy$LH0`tQIPUGA?sE&7^Uz~1YIbF#ae|*q zd`Ej(YljEDQD7F@7#a})WL9LWd#?{@Dl4`;jDhD&rV!y+Yg4Csy!&OU$JJQ~9OR7v zDm+A(n#+NPq2Tu+U|?Ds`T>+hnDHtc{S_#TrP#Q0K+rM2Nv?gIt5&hK4UdE}%=&AW zI^i>eY!XL*)2tQGZpS__KuJI($jxFXm2OlvRP@H*m7 z&U~NU+1>5nIOug;xr#rw|D|!;6Xyfz*;Wb@pWDn-1#l5&J)LdNU|(8V0{Niz-U z##LO&IhVh_@O}8m;?2N!&5$glj~B+3{i{5V$1l6x;a78<_!&3G(x-vyPt0R#HtOZ^K2v6t5xULtmB4s$c@pd7Zddz$)j(HEi{t7bd+tWBUonZ@8Sr%4OpYjY z0>3M3msh#VX6|@6V@B=Ds%US0_EA$|wZvw5((cMTSWS~}?H9L)>d&rYYt#k*pp_nm z?2HJ>htC~U0t*2I^U+A#dGS~8QTr*_VHQ2-<{DAe7LMxWX<|O;t$BGi58ew>3 z`Pji^lzqx+`!f?X@<>{FaQh?4aFs+l0QV&LiRRWGmY( z=AJac?0@lO=zG`2!5~c6VqA zbY>TZ`2wuj>uvy3q8xB)ms2J3R>#@t=*)nW+JeY#zD+U907*|@kJ|m0*k`KXHVrv0 z*8Qg23}ZdKRqMW}Y3r}s_zI234yu7M)4K$ZXQ5aG`>$W+s8_H}V!0l)rXOugu4m4* zOY?nKPX7h+=ZPO~={e)>sTXwU{e#Mf1h!IFN(^5=Ge*Xq{~AHR;v zD((9=bUD|;?MyoApmWCTv|U}(+Nr{_`jm%?>a_sW9X^kU*=Xwgig)jmQ9=_D)nhbV z;lMWvyImylHfpv%Y){&N6w2**Y8(iW6TdtI5AebFM@{%0ZMF>MqiUC}?CI>ov`+Qy zvWuY?ThK3UG9K;+UcIOgiA?zl+)@7G3M^vy(<34_M;V|Leo2i-_iIdj`~0H276@Pa z+Q*7abI+Xw-@av81y)?2FP3K#&mLSt%ZtpU5{?zYxGGRKlY^0_WNNz;!3SmWL&HM= z^e`8gscl8z?bT@k8hzRAp6@EI6Hg4H#py1To9BEP&GR;T-1i0uhBXEqfXQNbIzE@9 zj{7_B0>D{{fi+CRGbdA&+r~m3M$~_n?tX(a>Q}kEIy^B)Vo+#E;$8--4CB6 z6Lh*uYqK_Bl3;G~__%+rk^i!AxlVt2u>MgT|(gkuxO79_XG-xQXGVo|cvTnS?u2yC7bZtDOmW z>*Y#XfdQ~`_TIvv<}?20xHdp@+cU1ErG-qr?ScZh+AlGw9ACWiN$-vY76$j^V@}I*^P9r@F z!fRQ2KEREh?pGsSO%(jJ&6Ep|k;e@R3O=kj$+E2Ogg_#(=8s8{VL8)PfZV}LuLf-pZhwO_@u*q!5P2R1kXId??X@D$J6 zhJicUJVygp7DUhFRNr2tb*if7J~Xs{^{f1lXniPm^MPpjlxBs6x5gguOt^~U8q5%c zpS?Yk$oW-QJt{Pe{Nc|X0B)!Y{iF(Rb>ADsdzH@0&c0p0n{d}6Dl{5gVB!5nT|9fo zpD6(U-f3fG*O1P=Y(Fw)sVjt%QFG^ACF(vcEiElCTBld7-UH$7RC6%hW125~pmcuf%o2K- z7FTh3di;|KPz{tkn2(Lc-0e@l44QeURc@^8>XR;@lkH3NiJWMn^SCN*#{(`PzvJ=F z74L!P@}v|0xypBgOy|Qj$*89hTEDhN>mA{ZSFMux99GTH=g>&w_tg>hN55!OQ!VnV zD`bhHt#!ngJU$xEaw+Mimi)Tpo&t3(lL(q1a(fD-?EC$5soK)Al(d@y5vQ~Mvtk6l8kA(8&UVU8xIw|kzuMz80?y`k2xxC7qjnm+m2Pf0= zIqJp66ZvrP5EVFg5kya8k)3-K!_`LkFs1!<6K;*(*FM$$5l?}Yh!ga39CYH8TtX>^ zpNtrC5^;?j_M@|F@$*#A#85HIq2@q!kBgKwrnct0p_PYQy}?9mDX9L<$w;CVX?Sj@ zK3~uua6NZG=65~nL@`-C;@{&iWWSBRa@FH@bR1mIYc2W^FeSIEPR0T}uBz5Ak)ZL% zoeb<#-(0`^5?Z`EI@p(p%){qAFL&80?Zh4zO5O%m*qi;`P}`-AXy$JI{#>an$VXYS z7eg6KB8NHT&#>*~DpSg)h{NN}c`Ylse*qVcPhV7R_C0Q~J)Ale^bZccczf=r?Z#p; z;J+U4wzu2OcPe1SA76bD#f^P5^0}Ls!_glOLo0(n#OWd@B5UYib@bw=wLXZhqDy@J z?&p`>)UPgG`biu-E*FO#CY^u2#Xhn03IVPF@yCtS6c%OcQ<6NP2%aJ@%c40a(5gQ9 z^sfS)JHachYE<`36wj9VhD;k8IaE!(KA+?T-?FPy+-@b_J>};2K982RJ$LqlO zj8epa?;hnr^XbINeyVunf$Jv|sZLQT%W~ZYiEciTqJA_nX$GK6BVw6r3YqpeTH~t% zqY|}~gm)o)mzYgw^G#6ptz-};d*)C036jN}m0mg9zY^DT6%RAAK(>1kkh`yfJW}Ct zm9FhNc+y69ra2hne5Sq~oZ35o^fOI@WdRuDm{)(=fSI z#(108jvP*mac|1PXizXQP}ub z042ia+}E?ubgqo~wh^)X@9F$SF53biT1f9$q7bF`Yz8sDR2V&2{O+JxY|uJ~H(E1} z&5cLbTF8wA%no(Ud7ph(9RG1xl1C+#sa?{w%4&3Vu?@A*~P90xy$*8R@y_Gi@ie)1S_%Oos; z0Z=-#KvSqcKNs`VVg%gT$>@(^Aj?wCG!Y~TODq4_t^IxR&>!d|OT6(DF0$d>X=8fH z5|ZpdpXfkJe;7om;(HQ4TYA_O>OM`Yof|vHz_1Jq$k_N<(9Dr@Cu_*pz0IO?KRRN~ z&4Yy47xou)k$}Np5>~5=?$mQt`cmP}8ZjM#R7R`=uiWw+;p{t-q%RZVR+KTjKtus% zdKDS%<1p?(+u9k`yKc@NTQQ>eejbY$UQp&iKy$g9nD}@eNx-%S1y|xbTVlN>pce{Y zlykEN7#KXsi-^KF(B@~vwKD$guD3adQqufQ&DGN!n`g5Vp0U-(&XJxK=Dp~dLD}_d zm=XfPemNCn6nqI0lTg>=^}_FC6WV-jb-R2@@z@wZqj4%xrd1z0LEuAE#5`9?@uWlc zfzkL@9QblSMARG>B4pd*O``EZa1X1=1NnCIKM?Cja-$c#lUPsgUzOK^SPS zii%7{Oi{tePtAiFe8=@U6Xge1V!2shb+55J#v3z1uuGp%L3?Yq7nCF02lw7=s{isBYKEC){?s?c1W3+ z0b1@g6f+=2YwSG-Ric+9gZb4(d3HZE2T)8MX9pAE^+r5Z?JoX->)D@NuOSJh9}@G5s697sbyX#4|TX z83CSc=}z;;_4rwTNgf+3tMgEJvcMU{Ah($`+hc&Ez#~6*Fro_mfVy;%M%!>P95@UN zCcIQOrU6Lpg8ID?X+*cFKbOam^1|YAQoVjO4RD5l2=iD^;mKfK4!~jH{3{S??mN~w zgKArqwbKh8k`E`~*m60jW&P+6EI_5hPVyfP6#QWpFBT9Hd3?+r-C;Bc0n9&k9dqFg z8u`y%*L5q8V>zsnI~gqlW(R}N#~dxHF&hGGDI9hz0{{oLs-?4&k_elqJ#fW87O#5Y zdP%Kny%lalvc93*hWStHLOdS}{}`>aEy{simN)zFf&LAn!=qr}yR7%+)pD1na{=gbf}ZeyEr2Q5iu?-goZI%?KoyMFbpB=Y;VOFUzda60IZE36rLgM`KP zp1aaFC6ZCREX#F$@@3gM^sih)5(r`apT3Ia`a<3ahyzUx4KFQ>Q-R%#c-Z0*q->5a zr4$$#;t$>(xUQYlXSp(JL5SEHiQE9?f$sVzNqn!VV&7$;#s~BGM0}-G7pfVGi!KsA1QP z>#c@5TY3oo*^fcj~7ND^kIjxH^o6W&i; zqICWC2UiH-n6SS-}#;Pa9m@z+!|p;G8qdU+&f?umgU- zR{jWSZq~mA|9`VRYMFQL;b|xXZh-Fftw{lHP4Tb*6iPq|`gGHg=%}?rdZ{+{c0GU# z>$a@#yU)zdmyz#5uz^_^{?_RVm%s>;sesv*l|OqTB?_2hut7h_OxDLj$O2F6P_lbb z^mb7Uf$^_dbbc|M7ZP={g`6PKebCkVtv`h;4l<&)$6vy$vqL&p15VA@j^v)}Ydh+0 zdEl2kgYI27fXGeM#bibI81wS##RgDg5pFB2kqeTpe2-B8@{0qnrwSq*`nr17t?wCe z|JF+iK5}kbzD3Eb$~s^ zjbAQh*il`j&yOh1(yg29C6w@AvKOI(mJuGnXq3UJ4H3Cp`c6g#bhN|i}C*204K#iKuViPo3@a+jd}&XPuSG%*U|4oMpt@C>dk;Ez1e?L_uXgh|25o@l z5(~?AXBx5?$q`+?Wm(AWTIl^Rq#HJ#^j7>q*q1T9rdxolT$pqs{2mk#vLJ#hTOAZP zwxj=G?@c`LHr<7@KdAjPvHk7C`+IY!Sk0EhQ!tnZ91_C`!5HGGT>`@u|LR}&H-GYg zululOFhr&0v#WwQaHwGTfot1)LSnlGj> zxJu5@I^X}bDCc~&a1ei63V9IhxOHyY)i?DfLKem!jSw9iOw6F1G0Lcy&@F4a> zxfmi)JfKs+(pD8fVjE$Mk3(RO6zF~X3T7F0OBw-YcYR}shp~I}Lo^xSw9QhO-hOop zp4JYNW&~c-?sK36wonw`C*b>QL@!Ct7~RvD#At~^AwNb7GmkHr<&6a?mfRzLsi z{o#)UW8Fl64YFzVFB^pGmu}mzvM2&3d*;`Dp)&}`~i%}t3Zx8$f9YE zRC;myl^1#js$R>#vKfFeOR?z}9r9JvEKtSOgZtEyg&fwvMy7*N-eDCEUU%c;;lW=Z zvm>IgdeNb4Y_O22(6__?>Dq&1B>lR=@k9BmxT(7fW9QI@-o;J(5#~UU=E#12vm6s` z!YyQNpfMqOb?@b$)P-v^^2bTzB9{8Ca^QMU9N9UidNg`;U1<4VbNs|z`G#kqWVle7 zrL@8#m@Edv(TZh_3a3zT7d*$`c*$53Dz`-1Io$x;{D$0foKvj~+iMM;FowE#NK!O4x})$agg zNN#{(_?Uwr*@Kiq_dn9#Vev0?!{q_2nyU<;ITlJS2E>0Kbt7OJ_%mueV)1(S=wz7| z)}Pqm*>7Fau~X-*OtKUkkNyadPx~QouDl{5G3kQ+!3U2dXf_i4ZlVd^!z=Fx65p)k zfvbO`TBGq5YcC!Qmbt!1Ur5RxaRK6!zd>At3Irz1XvPpj7OW0=jlZ1O7G8g{2zfu#bqbVBu24V;tI<&^Cl)<=;H!+{(F zWHDAosVgD8pfO4qfhJOgCV%MeR^5BMksHtjQr}=aEefh)#>DD={8d;z1se843`zQf zi9rnQ%DIF*dIo?YZba(3v+rP|?R>hLIo(z6ydslcui8*B;Lu@7k7$48Id$7tyJdYC7WZL0Y(Y@4wpPR_FQo!`>h6+FPyofhi4&gbupOX@1S*|6?N$PyI!pp#qqy|O)K9)bwAXy!j05; zsqe~JLx6x!F;-;q%(3R#UK>#HgI+l>DzHeE(0*<3TJu)XYv)d^Z&#k3 z{n}*s`YiQJk=dYHnHA{U{`k@N8aOAqkL1&^zW!CQ_FdN-$@yM=cpmhPOv#qp(S$0P z87&o4GG#Mv4$aoA*esc*cIB$RQdACAf90pYal9+ddzrDZdEqd9Sl2K8wkxK1Q^4fQ zcA9i*;CH2*^A>FB&GYp<02%<3?Jji-_svwiiTD9x&}>~$S&w8uwe_ei))54{c^IO% zn*VQNu0Ka3!!5ByI0z;hQ+{G8B=}>H>|bTxeHor-J{vwLChpUd)%w(_Y!6AxFK?%KVLS__DPk?*>{5{P!cBkN&N3ZKHU&>M@v~Xb1&;tS-q2&G&sb4WQMD-o)8Eh>^>lkvaoU{K>7(yp zE*Ld6bsTi&>yl7l=w8X*n_PjZ$hVZhx63g1g#=@ce3pHV+B3KIm@`L$I>!q9R|4;O zaIe+%`Nel(WjDoHJdEU~kqruOLAlG`x`fA|@ZQRyBmy+j@|GTiqU%INhlfKfjH}SR z0>qDqEG37n5m~Jhz-(y#NO}b>Tqd)gPg)iul|C#eg{`@~E`S;WtSys1RIC~Xw_1$- z*aXd+2|biiFf^VOZh`oH-qqd7lmat;__HH0RFokAD4BrMPgocW^{ZHg=DB{tV4|mF zE*C}h4_s#4%W~b2?kQxd&_E4BN%;K{rdACGNQ=lX(4FbMk``lOlP#`ASX}2y)4&!0 zQp0jnN~QGK=flR0@TmRHlv~dQjt21(v@5LVGb*;K=hI>cpS{U9-5djVPm%G^z%yk; zea+krj>{k>z$jNul}kl$@_25RyEUm^mUVA)TvIJ=BUzg7aNFZ*iIW7F! z`&GGgC8;0U(w9^;oF*eRUeyKpuHyyui-r4udbTr4{UAizpLG8;W|e>dnyaa)B3iMo zn5~j1k^V8S>f)7nfx+8V;5#3gb8L80hbSfS?Dc7!rXJND(+Xpy+2lt4AAgMr0!
    !~pU*o(x(l8L*@2%i2;`vLcce3t%^&}XUUoOT3YYq@S79`T*N)MB-r@hoYsB>!@C zI=_2#HLV{`MK)Qg>m7|v&GuazYNZ^jsaC$RkvYw?*YG)n@9=5PisQ*5Y4yH6;C#g1 zqpk|4pn;mUn$g>vjQ#-4`T68-KIoa3-(76ty9xR!_{s$=hDK7+=PUBkllwjO2R}v) zzqnnr9;c*Gv{pR|kXVE0>*?_ThrpG2q|5EAcqfw&m08`*ZX<=(`uxp;7rmH;dx9?sag;ko+p&KtCl$m>d1 zhah~WC2iq$>NAa_g3`~0+|XHJP)i^;#?5@v(+5_&!T8c@LT@KKmwrSriAE6OHIPXH zf0uDntK6-+P@TTj8VxH^RE!YuTSNpx;8JR30wBHuR_|A#LI{KWs# zK_V+*V*Z0YOU=duZM~kOBY>O(W2R#UFJqH*q4cbj;xCqu@qskZ8)3_z6V*=gU#y<} zhMFdH4h3PBD0T*aO`8_fw;F#nk_jOT|>yoYjT5e!?65<*;ml8=iGN_QYsep9~V9bJcV3djmOJdO9 z7_$Hlu=#p3kHxsF)(eUY5q=skZJWXLl<7e!&EL4#1CvrcEFJ}*uGqx=J#I-v^(EkK zsKLFU`)kNnPptQ&@~cnq7>6$f!7QM0xQo9P!D-)~DfoooZh!ZEazE(b^#6oAPhdbL zGUpyKapg>Rh#ag#keg+0SeA>c5e4Q9-H>Qib^2NR03~Y8mW$Vng@q-OQ9Cj+o@&7N zozkW#vfi%#pk@Hl1a!dSc)pv2`|{X^8yn_pD^;NZ#B6?uD4^}G8Z}d1Zd~EK`4vdW zKk+%P^mGT|t$uO9P0i?CxPoT)%ht~k998n*lOd3o=RzLa51ZY$rvVHu+1W*KbuP#we*z{XM^M{6 z=O~yiWrEN{ov+`w2osoKPfSQqwopMKV5pLhW~}cec}AiLrXy1MIQ1GvpQ{d!fN#bB zPmAIs96rU*+a3CG7$9-hAEV;0O{@QoTWQ zr!5mG(hU&pw)JZ*UYifqa;5;@R-V10jiRZM-L0|Qu`jbPYah+NEonTNw!JuI*Dm^4 z5YFS73kw2pPk;w$@`|8RZ)Gfd`Cynq89E7?iHT$j%zu&z{`ECGs$QcIUFp zQDTfu%bxO?qFD)4ncFrt`r{*)@mglI5Np!(>1m6CCNls4HfXek)UCvbw@0Q6fdkFkeRrtYfZswcmCx^Dt=J$o&+N7@s@Z`?Ym`(qTGV%?b9Q%)_H3Olv>82y z1SIgS{+Zn`dfB)MN~%gwbYOW1Asv@q#Y2lCP+S3eI@3YNnB#5()z0{gFJkE^0OC1i zF+t#R+?r4bnJP1t3M1!Cz_iOF02?Yi0XJrf(km5Xt89jaieTxjeky{;8hn1kdPv+E<5hj6^b!4iXHzz3$Gp? zIEx`K4*_WsA-9keO}x9oLJGpHW7oOU@{H6!*3LPDZO$Oy+A6hYriMz`D$~$;wLr_( zFn#Z7I-d9GCU7JAc_E|lp;`TDiHYM)dQUc)X=_zsqH*XU`2;b_=DT3mAif3qIgfJq zu-+U0aER2i|IZf8*Lx9}D4}DMxOU}-UcXk|%OvJ521L%E*H^5rXdFT>-v%S^V! z!9*N$XNS+hsKdCO?!wZ+39_UiMfL+7kE?o8gz)R6#J(aP$H5`PtI+W zbp#V|Iq97~-m)SVdbPL+VsSW)%HMQVmeE(3At!I)fID{m#Nf@J``8Z^oiAPq6ec{Y zvf0+Jw!%>fsJNS6`kSNl67ltlU_W;0Jx5SP##2u<4kY~gMOG zHQGtv`(lt@s&h#>m0xo{WZd%gD~(vQ<#uHN#moMUOQWF3ICvoyLZp20nx{=ZMne(= z96-4y4VGdP5)=?QeO)2iochmU&ss^ZTJW>TOy+qw=>L*B9eY=5gnY(4GX{fZqGs1N zTQc5U_j^Y(YR#J9($mM^SVu47HHAzIHetM^G7x1n+4qjG}c|k z-w2$&B=N|(4cwB63iPgf}_JI`;S5b1#>%NQZDRg`dSpwg~ut5Fmz7&9b?-cvk zTZ=!!1h)L_0}O7LpV=xJ&_7SrKDy#$c<&^G5EAuqf7}poH1k|eZ+*eaRnLHKw52zC z43%dkxjI}%&e-P_X}01L7<*gXdbR-aCR7_oi}bRcbGy;h!jlCSN^7((}*mD zz9ay;och>h4PZJulR0N+A+bZIO+PnVp+14wA6BE8l^E=Gk93cw_vMDjEq zWi?kZ2BM=SoQ8V9UOZ7~aEgi2xTmlBA)J(1rF)=lP(}6@+*mZ{>1$enm;RT7A%%ra z`QGXm=N3XqFfcXh30Hh5)v!qB#~Gqj^Pq>cVE9zV}7Y9Y7#wUzhU7k86ZUTlWnBf zmS6FrQ&eraA&&KYrs2Y+P&*@CZEY2x{#h6$Of~L@E=QwHKOc2}iD8(d7l5aO{4xXk zPgR6|<17?X3Zb~iRD=EbQ4!RS1G+)(*a6^_XqOs`TsAC$oR}P`houfvP3JD$Ei81Q zb5Nv=S6m(}UW`)s3A^q2%?UXM>h;Iq9T@sMfZO-YDyDp7BJB8gcrGb%hduE*dZ4p+ z&*T%r;<#YULOokQV$60z;DCC^r>V%a8Ta7A*`jC2F_Pz6n`RioXrV$c4D2S{Xp1(b z(nxC%E5KJggJHtod^6t=gj-^wnsQ%9o`2r@eX2=yqmqVfreVF(gq@-4Y4k?FFhQlJ zriNek%OX%S#m_}gJhdP7L_~@i9ZIhu42_J@16L?bc^dRM9S+v}XQ%g> z3u&4YaqUcK00nJ$Q^o-yh@sL+-W)6JX^=g(If93WAAOfvqvAVm{pg&OXjCgPM8tY; zg-f+~**&Hz8a!+~_X8{5ZQybHCIyanv1m0-{AoChe~okxD6sFEZtHl4)kG?Ip@B6fCqh49F5 zkejvCsrfXKom}xzOkox3_f#dQ^%5pDO}kyxQ$8Gtp*^8bXN_3+`9o?5&M~qT@v1=3 z3HUPv{9L&0KaeS-_UF26pqAx%gEE;~#Cx=(uW7bvvhFgU>!;cBiif$G`HIKt?F!o} z6UVds=NW-~&ZSYOMylMU8xwHwDyQ-}OqY2rbK7bkZ;Um1L|vBdB(ZW5Nf^1ftPuI* zH*5w~;hP&nwIGF(=QlwN+_vSk$`q-?b~2B6XiU6l+LCmC^tWx(5meWhzFfsQ@O=Nc^gGEW9HS2cur z$Bt((svhyLRw5XcgyI{4GJgI={yPHDto&hCtWYsUrhP>?q zJ+3rEHGw&;kBuI33|}9J;@BTJ(>zZFcgE4t7mDd>%?Bxenf>R9BRj4o>bV9zQ_%pP zQTe&gst=AvrkXtWa?6}FY$;r5(sP-JFCob`hg|&L&|p+tP1-*-Jr)X>YqEGkv$>uD z36>p-SJZ9rO{S*E6+YRSV~3)HDmbfJwEOg{S4Oq2oEyDw2=)1rw)WYky1?}fu#wJG zTVsI#_wL=}ZsvInBErB#%dzxLw&8|~;}^n)@5f;0h?$|{w|ld7fuv}$+JE474?dl4 zxoPcZ22zi~FEXW2cQE~+aAyMW*jcb88BWkD=vu4&pCv{O4WHH3Ug&%~ZpN

    I8Njrdl}K6aq0F(4H6ibP#XyQP7VP9;pd2T~fvO*qJ^)z~}zl zo_BFhTp7?XzCNKfDafca6oWplUFeQytz7K5mT{a^YkLaztr2ZdBpTF$a82b@TF4$f zZ1S+NyC|T9XJJljlmF}#j-K~La0>FwHMog*9helvw)5Ny!Edxf zyxxqD)N#GE9Wa5uS=gB);i4RM5T}P8Oj&|NiY$&9fUIBTVcp?2MV`z!@D@w}zp(tb zI~k}gw!~P;HeABHd!Rk#wmJF> zK5MZTq>N7#sk^jwlf}OTl|s&CR68^5R$Z_tCB_BQEDkgq;UO$Z|Y>LvwYkFXbtX)Aq4<$h%5lFFy>s z0aUX*$@TDM$KCYPczMoG^%>;`_Dx>Hqg@VgG@*;Cyi=>2Yi;sap^?Yr;wRuUbsS0T zJBn>TnK;^pnFKY4jg{yXYK0~CXmjNS;ikFSPIPtm9~_6CXy$Pdd=;e@i>!zD0`D8v z+VtC;qi4K~$1RO=apv(gnPgS9GY<6$<3#ra&U$W`bg_e5gFSq;X7ZzM@%LWj49npf z$SmjpnFWXGaI~m+#_5(%^{{0HsMBn~%^I|>K*ZtYq*PSJcW!P^C#_x4^#K?RzKEAM zqHqm##5gVa4?=!6q-SkdD3f#R9c&lr|TdFxk?K@ry`7C<3YaM1UX-j zcakr69K>BWMxQ-?EKxmt$j%((b>CxqHm)-hz2M$c41I$(AINCV?TPGlj@muVlxLq-D03~!NamEn}Fvw z2jA#HoNjqs^d{4h*Bxk=m^9c;?t!jcl_)Roaf{Dx`ctoi4p=8$gmdkJJC%#yGSlwt zIk@G+Y8El#ulxGZ&QDzGsJ!X%8|0n;TJQc-4nN4L+%TUo`{j{WnVyg7Vn7 zI3xu5t{#2@i;IiiML?{Zlo zgOulDO}xt8SrAt*ac%e{4morBmRM{6gRbepBhOPKz7rkQrrIxp@TR6;MAdAGX|ri( zb)FN#3_H9O{U$7l8sQ{QQDQgQzC^Hy7h`W>^IoAbsEz|}ZPyl!THZDpJ?7I_Y2H?D zWP(g9A63mdcNIB70GteK@JUi_=@OXJ48^v^J9_QcuO|?Fva0`a$(sfdd%@s|^W{*p zM+K=&YAs@Dv5+JtbMrrM){`r;lz^#Cb#VcP2C}0!$9#>Z-;{s z@~fzI_PKTsRiceX(QgT^3Tm}drlvAU;ZBVAjaq?@CCX&H?(xX41LF_4c2+nBE71n* ziSN#ER#Se0kfECsU)&%G=j_X7%Re=KR$ZRj;Gfc)p)z~#jJz-GEEjQEzkRvpf75&) z9!udh&+ga7h&V+zd-WuO1}F^fxCCncV*MIdIvb?F*SAZ`UI3@0YR{jr)jPc{&pMf~ zcHU1nTw0nGE8RgGWzu9kpRubj1>`@qy_Tm$Br#<-m*NHK5$toZ=d?P-D z%?!o#T*SZ28%e*+WaqIWVm5%z$(k3pM^GjUM_(y(M>bH7*}mw85k2sfmT8>4((quq z%HlAlRqjg&Xg7?EDR8;J@l=Vs@vmi^i6d7d8xTkje#OI|Dvt8y zE>q;QRN*%eK}=ZmUqQ-;{D%)}Z*@Z>{~54hMxghLtdOBMQID{RhvXCWF?Sb?yADvt z?$BeiRi_33Il8ERQz7N||6@)83GX^Trl5IrfHZ*PV=exCyJcgbS5gRQG>Sl%^B1#c zW0%f%r|KWb5lD_A9S`0%!~Hk71+aEEFd5ljA7SFLzlJtoTl4q0R?{q=G-}%H5ZYit z@ozl?exd-a>%hc=Ns9R2A(-VD3xPEFMDLyBK`h1{O6U!&_f~bakwyRibJ;BFze?AC z6s&)IhxY#?7Q~(tZ^*%9C_Mgu=}f&0 zh_LEB7ejmGoPd_~aRC9%O@Iz7+%1l{v65MEdOzFVU4lxQ<3n3z+*k7(O-7?1QJDs} zqN)xvJ`LjhW68u)#t1(Ay|`69;vIWR7~9=qx|~E8?RJ8#ax=Z}@j9|G9>T zsWmcK0k%_iTVoFWzXPK|05m$`Ua`O(?FIhA_rLx~45G!lSWas}-;~@?XQtcPh_mhy zMx6QF8vG3NFIsNXih~7n*Ht+Tg)sVYFpI5GdbEVmE~XC4)Wp62Cx8Q^c=f-B8QlJ< z@ay)BG;@Jy=DKx%I>e0k#V0}67;{YM;J6%3$tPVLWYiyfj%mQ()a#IYv|XTcB~Bdu zu+hllt<_&R+L__R^ilu`MbZVPgQfk0ea%&{k|vTmgkfOG^-S3XtP-sI(;om!H!MQo zeVB517(K{wz~r=eozc_#5Qx76PeurM`L_K96>>&vDtHnZ!v)s$B0*q5+S%mCGm!o8 zVSiW#vemAJ-R+3*yDI)P8w%01NlokE5Q1O9x+Rf>b9CO@=d!l4mTUP;GnO>=r`i5f zyz9Kq0aE9kxTlaCSO7&0-_x>DDtZo-_ZW@t*ePF?K>ZVTZtjjHmYB4=VN|?4Z}~IB z8}J#_N$fpy(ob&%F@5=~Iw_-w6-th|OQ#eTco<$ZJ1Z<_VwfPgdA zqG6iwe^l4xLN|B3_?@XE!;Yp1{6<5x+1NH-KE3h@_5MAyiOc3_;0cCaSM(rjzyS-3 zO5#~0e)2OtFT!5%7rwi6CFa)k1<>)k0spAv3ih^GM9OdD zUg!BqS6HTJuXy+}Z3~ztfkFAVOnJ`PQ7?5KsaOTB=Z`{j=*5Jg#=sU3ed{%J#gJPBEn@uez^ri)K7>KuW!WeeVVGsX{GR27G5 zU5_7ozQ@L)`HP8cu73C#m^JF^UVK;HD4RCSdm_r0XtaVeeErkc!H|JvcUNOe=Ovd+ z4e(BKWgOB@j4c6a3O3f&2clej6HCuuW8u&N&v>_3`EZ+HG}e6^@O}SXt^|1UrZfUh z{pl)}8LfI8w-;0&zyE|A|2W0}`ieP)*1s@qj#>X&tO35ja{DgwtnOoPnsCFr_ur(b z?@xB#Al$KghGGNj=zm|K_o-(;*pjy5%=`0fauu122uTE9Bc-)o7m@mOWAkqg{TsaW zSZZ+jd4~%=E!LyAuM{zPId*u!KXp(&i>$p`Yw}b*>T&K%=KG^Yx|i8l0sg4-AHWKd zO7q9x21s!M!M;<`P=YJsYTrOC4;J}t?2WMFtMOYl2;I!LL$aC<7KF!g(t}q^vA=CQ ziN=3u`TLA6jKQ}59_#OhOJqAbZI98>Xa1200mUFCPWS~7F*4GGGu!@t)X$9!kNcLt5*ENVR7Q=){%gHxr+2s( z{jmhSe2lp?vb?i}yxZn`)ZUhB4KzT4wj?{Utp1|0RJo7pnq!Dyw`%RbQ0aTFq!Ins z>cA?y3wLoW3v>I5B_4m@notfHYzTqI3Rsh_#M0oYsN}`PQof=IUtJM&-VbxS`s0t$ zvYm&VlvE1lS`kc2{MPq(*3G}10&-sFs}no*m{ zmEs53_&YXzs3g|~sRZt|ACCsN#;kk$1}3m-hhH&e zA$Sput)LEec^B4$O4v&2*jmZ z15a+(ECn%fiCbVyZM^;<_=DN9jg<7>qiZyR-A-z;@@6XSyw21%U|C4uHxc60cATp* zUG)24+?#*=fL~?*STl%Oty*vY(~Ua9ST{-78NNhNU*#tZVPGHlV99^=oilYF50BYH zt|4f6SpTzv|KsKbScvDrL=$D;#;XCGfeEV(`i}nsFv-M;6|z`%?eE{hxkV!z68DT| zXWi8E(Zy{|YvNZw#I#K-XXDqt2~6nym2(q+`}2oU!WzA65V|vmkyfwfShc1pm;E4j$r1AIi%qsJ+T(@3FUCoSW}t_qn+czj|m&qk>V zEaLk=DOwaU2B*1ek1+@0#eDG1Zu_#k`K~r>__*=KEVLA-G?O{)h@01kRFuTeqFgkT*;wjyZhu%YR zJZ1*=yqpCbxkYHh*#Gstx%x-%{C@wLK=?#v-u7#Zfb`;zc{5>u=-b<2f?SfOj2r>P zSV1MJP8^GyW`{`yb&D~*`Hw?d8tc!#EOTIq-NZ#e_fO&S zo+vGT`qw0XXXk)DiwW)-afrYn1;=c;OOlz4nkan`UV(ZmF0!;G^xAEO^7nU6hSnOM zUFV4{BLrW)tH6zMFOK&hI1t>$uJioe_j}Me-=PQBw3eu;_4S*XoAKp1?dlW0wdIo7 zD~8SdGMy=fZ%@|8U4wncmO+&2e)8SY=F}^@J_oaxq7#jJqD|l?rG%6I=U6Z<>o@-f zB#{69>C*k$EXB^vEhlqv_w4Y1%MTp4SY3l(Ss%tXcI1=57snYkyHTm{l^VL1R=;ZJk(SdyP+ z=2<7bR6P8w1d*WY#W$_mj^}L9^aOWZk zO^qj^=iYz7?Br^a&BpuQMv~Vt3l3tO4qdfhCy$y*LL60~CKZ*Hm36MxilGCI=+Ai} zJG(0SvgAE3K#iL54`sk$3IBEJqHoj#%bc zzUt&=74$v_`7a!^;3D~9Rpu!6z2T8mZ$&n#?6?EtqmsM%&PItyR^#^-`38-GZ ze!b?vV!b<_7y7_XeyG^2>tMCC`Pe=$l14MMo|fYR@l}jS47CELt%X3XhtGboeynh0+5i$?-b~Qd^G^4e_cK3Itwj8b`S$<51SB4LPExuIu8pj1OHUD`N z#ed^(PpVa_U#wc1QB`HX(QR90J*J3}O=@{<0Z+$*@484eCGWP7V`p$gsxTEq$YG3M7yYik+&Q?p zcl$gRwuT$I$CNUn(NzbzI$&^cY5DL=J6hy$_L7cb9*}8|4yKaY)fBw89M*ejJkxcg zPlEAnQ_~izo`U@8ev%$nx1;FQ43EGoFNrt#M!oK0oqXLTrPio={rO7W`#qo2NVy9F zw*S7rjPX6BK3iV|OT*=+>LMK}=}4AWCFNLQx&JxvB6UR3Sia6zE0?TfN-B5EI#l3x zF1*^_gc{ET5?^@1X_bSe3G~pe5i*w+9leJPxRy-^sp?74nfEwNA9>xMRrIY}2A^H0 zpbk+g;?N>g;}Nyf8bXLN`<_#XN2H{v*aQG~VM$zcLiWBh+QmNZyA2)-sHJRsR+Al# zY4_u4H;=i~ml*$$j`rsK00QV1sQIiGD-z(s$FpIKO7VC3JO^VK5w`;MTLIY|XACO(Zg%8>b@;Ce5!;WJtzOp24S*Q$6!6iyh zuWsut-|rRK8k3Q(bR+W4Ww&gV)EurD^R3^32hW}=?BkvNY#PM7uLx|v#anVR^ZZ?*ZeUt>*${nYnk(wg=D^q%hiSSZ&iz)GLI zIhxspcDCtPzu^ke%h!;pI0n+_G);yn6Il`peab1kcb6V5`d&; zK&(g798fs=63JZA)4O(CWRXT)TzdRmZK3why?nDw?_T!B&wvW5*gGx?W&x1LioOrq z+lHL!nL~!DJDq?VCz$8kH!X53;+Vbn=<8wseILVxfvn=>v(TIPAHlRgT?8r$x`r=` z^@H@n0HE8%F$on0*#bc z=qn3LR*{^mLASNEq^s6TN!pYi8IXQh7ZD;ZIt19>@K-TVU$~m~;x#KY@(ITxY6!`X+cgF&UUE zpn!+&%jMO)Wn^rEw+SB4pT?o%oKT#;pey+#(XsN+tLzFTK2)(ehS@Cnvq3*-n|9|9 zttmeDr5zvVI9QDZp3b=(M)2QB)QV3syQwPh*xt{pu?7}Ng;;CcU2yG~tEzB-&F?nt zhfa?GS8}?D&&g5;?4jRC=WU|MmZTqj400v9bV#sJe?}a&!Hw*RInUr12>+8#6 zzI)~9E*IZ;PJ`f08)rT9VIx7e&B;$lfF{SdVu_LxIl-v47J4~FaVosG7yTCdQu^ru zF1hwUEzR$Q>-;qA*}2)oIx7WwGA<`+0{mXeYu*#W0$oWHTwcp$H{5#I@dugo@hpy~ zj{`#%9OnkVDM2n<^b^>Cs>$p8m>)q%domZ!ig^RC3|1N>iOkd{aNO=)uSAJD?tPP^ z+a3Mn_xKc(rs)G5i?3b&!?CD$&g1#{Up&>ehpb$DT*0{a>^~w7zB33jRx^o3iNEn& z@=-S=9)aDu@Np7kFMM7wIF?~3DkLNnrj+zK=d20g;O+ejwUcA3(Q9*uaLqih_!XGRhx)me#{tq#Sw&Gr@c?V%ByDF?w%eYD9YFR^l7f>miesb z=7V@uIQkSOlyzT($l=;7BoGR^ev?6(6g)k5RL8j&U2bOmZr>k@yQ@?6`5|Co#;ZmK zs%Sz^ef!FmfWTOj*>~yuVTOoLiOX(F;KixWQZh5^!sCNPAv);Mw+&cq~|L+hvh~TF9n@o~#1d)J&}%vVW^o`->U`y?|HJxSzQD zS|#5wpCnzu7_!}Wjt>ouih}+e3W1(EnZk@k9H<|s70lFGY;^#;SkGoPuw6B6oy;`2 zZpyO*bgv7eVxjUvSoM>J(XdQhv0&J?0{ZwS^+P}u@M|>Pw+OxudID2=d7ATZ6 zHjjIcSqwRmePO?aA^fcABq?yi#h~`L(w@W3Lh8c4e^DC)D0f7AC75_@Y z#S}q6lZGY#%5nF(s61kY=YS#fC1@-0u_q6k8Xp-|`~dh>p?+oK<%GGHCC_VYbFt!G z4B@7l;4{a=q!oqSH$2-)p>Hfp@?9#x;kwu7(LN6yFsco2SehehciR=p93k6Y@LNuh z3W*X%^}1y6sEVM60oHhLvDu?^rtTbYrmg+lRS1id*2y4?LufxCk9h{7S-GhQB2m<< z{R*;pH;WQx9@IU^GUr395Le22_Z4L!_wCO~a#1u?pc2*A9%ttkp2#$AmVYO2^xI@0Ql z<`qlDs;Y*y@5g*ijV#xEC-#574YQ_g2&{BifG|%WZjcq6t-#Skck6(6?bf4RT23>m zw=qO?j5NUZ^(GsaK-ZPzU*%996oL;25GMD|h3B{R@?}wTRukOMeKg^aX6uFilO|uk z-Y{Uyq5NKQjtU6^0j3G-WTI}HN9>``{N^EU&lg0S<*CKIoac$~X?=b{1`L2~ax)}I z%umq%Hz?$Xdfe^wP3G$xFUQ06T)LAQkHVtFa&GnT>YUCsTkaM;bAkD6-bf-k-i-8H zS0>q>sj;4}0bzXrkY8wt=$RQx;nHz0GOQ;F6SSEq0RILU2lQec^y@+k3U;9~h zjTQrFL9^;vPw=zC*{GJ2;;HbOK=6qOC++-SF+L`Ncn@A@q8ztkpgNzN+h}2+%>B>F z#GeX?@{UBlMs8<@h{w1ka;kD22xu3*rZVjzxGS+rid`?-js@XEJJvMSJR$(((Ss!h6-Kf4amPH=_ewd{yRud>U?KPlg74U~}M{x~o z8B!lFfDLsP5`w{hLo~`h3s(Ka|>P~zA>(}5}SHn}`$ha05G-1;bmOil>35zmfb{o6CYPcq=+{`J>6=Phm7iY_qRh;Q1zIYFV( z#C$I|At2SmYT{&hpas+%`lwO+lDR`nG^7iSmU7Yxk2Hl#Aj95?+%Vn)&~?%86Azx#%LnKv8O zLO81_0!H(0Zt;;Afs`^_mWzk>RPf-Fi|>afy1JG z|5_#J11;2%M3%-yG(?4 znLNSr=ZtZFQdv*6CVJ)ezu*O+6kxoEb$?jEY18nrLTH#9QaWpks0jhPWoh_~VOeIc zb(ybeFQNe7>>^GA%B^B)k)XiIWc+`t1Rn3wJ?QJ~GIe^K-xjO;P8?fpCU@xw3Hgn-W}(eh%l+}0z>*pJ!o+!~f4B7Chs zLnJ9B<$Md$q6jZ{={rOk;gr>!efv*U@H3363`WQSXxs81K-qP1F$= zb#>LMcDpP^5}&`1Z6ut6F9@*-MDre7(-A*>%opHIMvXuj$85w=|53-QGD3mwQpW_+ z=0*`|n@D^)S!@7rfi!!U;Dijl3wz`-55cP>DL%}A+>^58%r;0C!jq-@OG~9;0*z!B zXBrA;=TPQ{*X<>Pmd(tq&Y>s$SH&YA*nTY?=bWm${Q!sLD#b%jf8w-7$k~!3^(;N} zFPej8YHGrcrJWt>k`P>vs4b?{VuI?-n@Y~w$=Cz{SIbUFNSIMwK*cJdy8>8up?eY@9;ZxH4o%y9 zGod2w9Af(`ad5HCU*7_A^UljYiy(WS00WeP+xG%IT1TuWt1cU$41SfttzY$}yga7< zseP^!UEkTS>IoBvFAirvd$`*pX&Se<8gJ3@r6qeKBlt6XQ0Fx@e5-olsKZ|KC^6KI z&+cQf2}ARU=F@4R^g-o$AGFB$({cCLE^xmSGN{1F(2!T3+e9QSuMK!UJ?qcPqTj(5 zq?=#Hl4|WvJss8Nt##+Hw=FLGQgjpLbVZ0qkbG~bYkd75Z{c9TCrCz*xZ1o88`60D1Q&VN+Pw})z> zSMcXrWa*lC)asT)%e++}0g=#(8XuFT6HbkSWq(Rn@UK)m!Q(Fsk*}^?=kr(PTz|6% zd<t)mV;k@S|fp%x`RC;>)@55C4mgH-qx`r>iWvQC?*HSsyzKL3x z67q>!s)sMocn*RuSJ1ruR?W)DfbiVV)PBeUU2Au;1bNg;1~k1?a8YBwARbM$ckosG z2URsS&dkZ*!yg4HkbA!zy1DiUzdPqLjSaE!QjfgRw8vNAa- z&Is)^xy*tHrv19*_HbI4ExY^>o-)GojXW4G5v}WJj9f(l42rrYk4{^Ok)yM)MWTLX zTl!u)b>e#O=Thjgyn-#DPBO8HUVVaf&_ct>P?tRey;9^!&(==imVHgAU}$4crS33T zF&*D!g?6jrUIvq^k}qCrm${^S&xaKxajjZd%;9EK+?BJ_lCkxe_@0<}-jif&@|&SX z+|SJdrr(<&FZ!mIm7D!_EMdU=J-^4oZ=&wY69uwB&8+KanZD_AKYf^Jd`H1-gYn>| z6YsMs=;OkTl3Ir1@87AF>>&i+IMUfsG*8@H3Ha0MHh*;4mLj)GpXi;3(D?~CuXG+y zn83O=X6#RIhsj+Rx=JKiRa0H@4V}*ns~Af(sQ1C4vaVe0L?L$g-G5Ii^Uc2+c3`R} zE9P^m?y^-i_B$TaTUy@`Z*>@&Cf+oywdjz!!4IS4v3=a>aMq^I+&dfL<#)fJ8)iTJqTrNRlU!6@Ul>4=$~G| zO^<3863OFPO+DlBs%d5e&NKJh` zR{Ve$4-Z}y`UFqzh}_ftMRU$WW_eZG7N6LsUJ+nu#r$5SCv`zGO58&;If$&TKb{V9 z7PCPIu6XDqz^#re4Y#2cE@fwD(bSy4Twp+;}U`9qQtXLt0 zG~EeHts#Dg#n30mw!b^b$hAy|?v=oi{X+R72)V)1FTo+0h2Jd&-ygA=`Z}Jn4v8vJf z_o^`hmiBBB?}J{noUc)l7b>1&XPiEekUIX}<5=pfs;t#EJbsd*m?lm7LDp7IXJOh# zJbD;weNj9bM=}64`%EBfGxF#+n7`Rcd^ENJ-+{(`;g@%EQhTH&W+LogvK1chaVvWj zHBA5b5JtiMY{df)&u(vHMtQ+~%MOk}H8$=xuY-453rU#Sa{EjdD(IJwrXc4Fi9z)85K~o!Z?N z+v581-_I(!5fktLn|lI|U%v;{u+rYW>0kEoy(a5YpQ*?b&%MR-LyA2vTWf{Y(Xw&m z?7_!-E5^tj;>M#_Z-Msd2@4mGZoLPJxdt01(XE;i?FwjtLkmm z#z7in6Dr*y-LdJA7U>pgDUp)y?r?*IAl)L3)TTp}mPP@QknV2Y8+^XcIp;aQ-~0NX ze%yPnb+4IgX0Ewr7I?#=@BSJ?@nmhLk?)TmKh6$$RE&K8JX+_*5#4uiayoFE^Eqh` z1MdT9#U$c%Tqq8s@cuTEuSn^6N_{jsY3S#(+@GSPr1VyNud6<>`%G+W^C`XE;npWZk7hD;o8aWb;Y zcp^c~6zmah7eei)3i5(75?WJvjCOnBqLYQEEIH9s@^trBe*bQz$~N(=9ods8PsW^g zqf%6QQe^1-v^RyUZQJ=O?8Xwxde?4Sy!|Q$8L|7(ie40N*kB)pSN^=nY<@`Ie&j z#L!DpsJ=6Y2y9tJi^}8W=chQhn2Y)ZZhgYY=jzDM4!&pacgC{OIWaNu@q_X>wDH@D zM0h?SKC@=&x=b9|15t?2t55nrg$X(AKb002l@u|m{1l|_;$!XpOFOI3EbWkr^bjK$6E3yZ@JzSkyV&dD6j#^Y!IE*q{chZ-W z_*cOx=AB*Y3khZAlw_C5vcA4Co!F$c4+tN#ZR&+#9S4eZ#@gAoj^jQzFfgFF{MJ47 z`E%{q_&U^1M~AS{xTttvoMwJrj*_CG;~O}&x3;Umd0xgSymS5KxI%TYIbo!)PwK~6 z>JD?!pZ#P0=Y^%%*u)q<^d{w1D4dOr?b3~r27sJJK>Wdw=-Vy#63b1Zr|j~g_o{P) zjy%5K^A^IXancO7>vw>>R0JdJy}is0X|;7cD#cIN$inFY?4oL2oL#D<;w*Kj!x0+0 zN?18QDJapB-Y8nmS4|)ud7Ldj@e~w@!3FqnZ_sC|$jPBA(XN@;rM2*%GUNB*<0tfv|P!O4C4O)*ZU^c(NW@R z{6y^A6n%L!GNRv7Ao3}3v_cj&{^lhK{%p8rE@y~b=s^TGY51&OAVZ!%!1*Wf)SPsy z^!NxgqBNXcsAU2V&;qGM@$NfN$=}29io^^Sd-R3(Ag9pScb)~$yMsM=O-IwjZhhkj~}YIqwF+6i4;M19$PVyYEufbf&;?-^73-~&1oyYyzLG; zjV{)WRlQcR$;_=8GUd{SCc^=2kfE_+R37GNs1Ghhi!~})aIA_JePxZH^e-wYCgU;$MIiFpkGhg2 zc3!VD6vhFQ+$o2Ip+p1^e?4{$)@pc3#)13frHk@Ucutt2OR0NNSa`Pd(>?ER3cozw zhOUPs&l^&s2OhrKWnc#RNI#E&2!^qcN|@asr2N-2&ssLD(L1EV490;49(u>{vGny# z3&UUSAAD|z9o$C!Q;7M<+_ zSt&yNgFIUFKq@>&BxFf^G`{PDq|p0tE76AFIWiU|1u2(K!mg1TZnQCN&Cj1}q$sRx zcBs}6BFobfVTXm^9~h@==&0>Z<7*DnZY8Rn~UvcOpS6fPy^u) z{D3^NyFbznNR4wU4p_TZig10D!z!FnMfEYVJwM`Z+bAgD7ZaW)k{0*&vB>`zS=Ro> zM4oP-m|QoG*3aA1`=jtRF{7<+oo0NMh=l{L%-hclshhN7v#Q10)PkWz8z}F`}DD_toYlT-iWfY?TO>TiQ`zY@o~v)MD0N_gQ}AJT6l3S zyecQ>cPk=B2>F*SasxKoEmpW%=7wc@FCUZV*z`~X{Yd)?JiQ<91&$Eq8+^ZCPTAR> z^Yla;;3y(c?y{u#!6~Yrf&%Ja66Az)`T=hMzkWG(#TK5M9Efbz{P5d20#kAYk(`{H zn~nb0kw2xpjl>dteZ9qfeQ0A7Ng=3Dr??YqYD&}7S^I}=^;Dl$ASHzuud^^mabCy@ zaJj;YgcbS2402ZWRyrN#FcmORg9OxEZr$M?(QT53zrw_fd#?Eqea!3eGGR-O;lrMu zITSAJ@d0yQ=DuEC#sCzm&zahZ$vTf*MTTgojJno*UKN9U%K!|`Cn^h2RWSP3USFRT2kxE^8XsQ5$MKpRMei-+Y>Bh#5 zUWc5G*Cw;NNn%ujFsCuIq$ME)6W^kbeYApE3>ha+1G3V^5Cf=@C1DiNM8DgQ^U58}owd3q_qnAY;2h>GUOiNQh4PmN8* z+#JK$-wjBD|EI(CdklsAm&alL^*H%mGO{>&iK!W~l;ur_tDYju=>^W_F<{F15~m0~ zg>tSftSBE5;3dt?DlU9fjwa;a;BhV%dh<>uG$>R8YDl5f5d|G9$l>40Y7@$_ydXB!&bLpuq>0<#jkH+HJ=L9;wU)~ z7)>YI()jqtrkL&R?c-zDzE{<(eX+eqsAH8G0nbEzACnIjDYzNP$jJESl5%m{Mfno) zlys;D<}hI5D{>^=KPiPJ7;}a{(2~PNmgF_|zddQucc^ODrlJ`1n;#i*i%y>Pb9l=% zGseGb#WE!+{>07gt!t>K*MqhWJT)qZRIiOvs&kRV`^f`FCM#)-$U!wXllTn@jQ9Ww zw_j0Xyi(1a_@-+BD8fUt5YS}9@$%4U2O1F_mw%(jUkdR|Hwcn(IC(UulFG9->w^C& z$k5`*FQ6jYFCc1a$pyLZ@cy@QUEEg>X!NCj6B<=N4xft$9ok{nfguy_=TQc#TRR*< zLdSIYt%oD&$vKu15;Rr)Di3qq1IjB%5)yYA$T3JpHtdxHqSpgg#HZEH>SXz5{++?5 zsC?2z{}Hg(99Np7zpFOya?h{;7nKeWSCfPx=cM=`@j5xYq({6waFkA|?mXhj&9GGo zUdw+>t;vD%87VJKP>)&4jgItxxl2?Qx#oBjjUb)lozIi`4=qW7lJekqMKA0=d{yZU zMysH~7D9v^lELUj%C}2<=<@OViO<{Quzs zf>p^Sar1P7oNhCb%4+BfjNd~m{?85^!#xLAe#;MH7mqa2KL0J8FUS>m$UFA`w)gx0 z*t>N@g6hih_gixh@_OWB7rTq~l?nuw(OeF8CSRjN^`e{)ecrfhznx~;Csd)Whgk>I zg(vrmt&0rZF}j67bbO0=7ZVl++o=b;+AZfCR(|8U9Bken6%<%_I(ZWfZ~gL5CifFj z{i9$+0v!sv#7n0s`p66yv0I}=-tsOs-EVtUz_Fp-GkLhp^vIMzwZp zmsYI6D!WIpji?K7=@M8^7GOS{(t$a&g8rfF^zOfSyaOr?Iq#Atm?5^s?J-e9et!t+ z^!|md28Df$`n0*ST+s~uj-?d89lm_2Y^@wkr64J4Aw?I~-8%*@SPHQRQFO0K2NQYg zkhqoPfI%>SUnUB`-UH6I04fZ+*N|9ez0)Sr<7gt)EDvZGNkiB$=#9)s<-2A7km8VF zGQ_3@$wX&9Tw7#Js!Nq7-(75p&i=R3U)lk9oG0B~^fKi}LyKYx24U0aUhu#`ob@0C zoRVj)=);2K)L_--)r15d`H%Qa2<;Ih{^4$nwDg|0r=*L#m350()Dc+sL4qayFTMin zdSx7w*m=0`zo9XDJVxj!H0;WaEF7n77A_$$1B~(2G!e2n2U|a(AsP znhz?sPQ!d5C=l-yRLr{*zPzSvofi_9mrL;>lw19gnCLIN*d-#k^!{q5~@fgkkStLdy*XC+cC1D7mb8(RGf zu81Q(bB^d!pW#Tnr+l@(FArJ0&888Mq`+k~AS2xpa0}oIc!Zy<%zj_8dMLY()gs{J zAido%()gWq4@)&tr#T$&|7s*pN&a4mAtVgeV8V|Uw)TQl?Jk_tb*7QqHFB^2IDYY$ zl?qf#cp8@{k%RmnQTu_nO?;a4o{QW%lo8NVt?{&t4lKj_7q^f5;seH_Foe3Uz{!bk~13WpI>uM?Y> zGzh8_s#yRI!p_6rkU3$Qe3*47;T0C1w5%vUws{`2w`bEVSr1p2E=T%-KGP=I0%F!( zm3@n4R(}m94NT2!7W{u3484n^!d@f+S0FRIp&u;{A~v~YitTcsc$=1b4rQg@rapHW z^F!>rGwZQ{Kpt3Uq?-t#$-P*sh30PWWD9sQ$#YI%$*M8S#4G0=;4{2Jp!v|ru69^* zU{Y@%LT9z2aTKVatO+iOPVFy+T9n)=`B8*2On^rYmXR!u;9&uQIp3z5ytiby*;)%> z;8N->02v7U{{WD50P(FUgx}{tWO&zUC_B;Y@`V-Szym5hD^b8+R+agob5Ee!&???p zj(0I#-IhA=eKtikb-a|B`KxgFS+@U@e}?-n7L-8O^Jt@(vv+CgXE`#s$isn%x5(H4 z7NFbG0$lTwz0df)iPSV^MvBA(5=+#q`id}>9;A|c>jXWR#jEq`1}qfo)TWGa>LQeH z;-R|*Adfe%*I^P85=yEAHBoI%U7ee&3%w}|6?+~i3IM-WP+QOf3W#D%+&sE=gj-V2 z7Bvith9)<`V1`=_Y#9d}lT&UTzIgQOusKQYd zP2Q(Hvs%Nx7F0Wm9^f6r;^=ISY+ImLBw#f*GhD4To*zkCt}R=hh^>tsoDm@ zmd8}q^`~fZes>8oDTA0~#>hi&-TLB&eB0}s0wW8K_j^|}aX}=g(243cZA-Zo7zkb9R+_mFiR-G*l4 zmF65NsiVNB6th-4pO;y!naWFMEZ)^zoY*4JMN~LwWu8Ajes2B6Br2KT@9+zbc(uEB zt57Xcg$h@9Kmz18LeV{;T5)oCFaJ*ruOsDNzmAQ%EAB*Bmc|7Fzex*mIOd>IRS9n5 z%M1zKK5F?uS1~P)_e;{_Jk{|+LLr7u<6QoS5M3gh1MJ5Gr(hM&${;0Q z5zaI=^C#xLI%fF@|68l3d3cXUpvXvvhc)m}UC65=vfFu!xkOc%XOTPBJv3p11n>x8bX)E|FfcN z`A(gEhDD(MbeYYX9VpLpNku*nr-p_+fvdlb=F7>+3B-=K8AS*cd`eNfW^_JR<)N5_ zvo+V|8u3W>J{mF;|J^01hYJdf)GDktk^Z-85V$ZtVXB^-Pt{t0_qK(6c?h#-z2;uhn`3NdRlT)ymF#hL(YJt@m>AZn0Ipbk%TlecVkhTeK_nYL*xPG`A3IXXpxv2% zj)lWZCxcf)ld_sAwRn1Nd9xBoqQde;cWscV-}wyc5@VIjJrAA3|3_{T?6(%_2`a^4 zDCMx&BE zOs)?fRGMSdlM>2f7@ndUVA9GZ~SL;YU-wi$HQ6av^8N zWyb^o76|3x5l(AG>5%Jv37Dw{Dxhf~@e!JIw7Nqv$YutoieC14mMow^c+}y;Mjk=c z@c8)J?)07vG@;l_K9;)Mvz-pu)aj#b^;mN`uR@u%{fWVLB$D=kIX%6qmkMWnb1toy zN?)N)xw24c-RPhY`S(l~6O1I-%^}Kux z2aR5HX-|!PKJ*X5(Kp#V)tY?YPjI?A+iRc2fu@T5{6ASz zZ;6_FyD-3~N7-u6T$Z01wrpfvgeQUSKN3jUF>YfcKHlSz?<$`qgO(Ry44bf(#os&G zM1HQhGy1aa@F|R*{N1`WnwhSsuAsMKy@}8{Uo^}wSR5^fg{_yi!wWM0bfNNJ5w)Cc zsiouoReiV9#pH)I^(#FBw|CKFgev!~5cK%|Fn=n!9DvS2D0qwfT7A9GxN_wT{Ei+y zyV+nWH1gSMJ&RXNX4Cg7<)ds`QNI3ZpV_QC?*hGC8K0bcX`ktRD0zIGcEJAfje^E# zdl-352PSQX$K^aNMD%oN^jgg3__en0&J~W>-WbnK!}huyWzz}x*@o53n}sC5>p#g? zCs$jdZqHb-8UNt|W;%?nE=~aF9f^#3Yq~F$+tK@{{BcF0_|Y!72b^uHL)xGf$Q*p} zyky!lZf$)XepV~|qh=A4HgxXdx7321+nn1?nb(f+tBzN~n=O+(L*hH^e!parU&F_1 zOT1fya4wdocihjwN?euGX{HAgY5ijG)sa57zCh>HZqEq zPD3}7wx_uk`~kbby9m?D0JuZ)uE`{T%ruk^XHp$(K9#SJN*Ra>5hko z!^q)Q!!!har*mFA)%)+1#s0+imFs%UJPjlJe6wNST=NGaTxtIN`EwkR!(B8=k3+YM z9bu1BKK9cU@u3Dzmz&a>nxn>@R!dh@$Y$&8g#MiGiD=>F)idpu?WLNDRNe?C;n$4= zvK!@C@+9k2$~dOR&Cj$PdX7t&E#|Jj ztkE9(fklzmu4^OaKt5$;Yx}WBRfhr^+id80rbR&Cuvq35D|D%ULnz4^hl07 zU52IQ{IIh%!&zk1ZQ9)OF^<6L?&8g5U9ypf?Tycgg*_qr(?3WVhjkhntxhStz|gku z0n0>F=8mLNkXA_jT@fJUNU66oTH|I`CIe(Cbyy}F)WyD8%)xAWJv(bnr_VFF6Q^$IB+GC_RU%Li*sUD=hf{_g*+6>&;f zT15El&67j*?3^6a9Z|Q|%VL{rr&%qa>=Aaj@u{1(u$kjVIzO=l+E|M!MpO;<#s%i% z1N-Oq=5MxV zJf2nk84_6>W61PP`XcuD%HpVI^7#W?WG2-2?EzE@!M*Ryi>&c^RVOXr;Z^XL4SFo625#Jo;5VN$eoA$(NIUkFG`ku$36~3;YkA5qzQ0moZ8%A-3c5JG4|9Il+ z2ng|Bw4o{ZcZiI3?gPh4Z=IY9M&D<$Km^yM|1 z6g@HNmAPr!EOonf1Al3#_Jy-PE!2r7siE=nYV*Aixtfx?tm%2IG4o~crnP$c>b#R( zc+26i($CahN7tmQU$1%=3_#6)HS)bmWFj-vM<<`q;edk{xEvc$|9 zUmcxB4as}XVnbUF_dmg|Ti&;9@rz#{@`!65t zn|+oWR>aS{-pNsTeydN^_FOEjYlOq6{#Ym~ZqO$iwH&9~lnDza2JS6S+aF}+cpq%l zx%~p~{b<~Vcdvh{u4~+CT1;@R>h6$<0ur1)yOx0zC}9Ub@~)8)Erw)sJJ}I|%YTup zFGY5a1gwfOVpzFvpK|Q9J=8oGo1vtt*OB4!ELPvnS^U-WpNHRR-&U4Kf3Rz~3UnG` z=itEM-UqSyg)2Z80~e1QM6kZ(_&hW;^pR}z>U1GTlr|}F4?i_Hmh+M+vL{A-Ot|F_lqh&fuxbbd#REk!}ok!-=0G3^(w(Af8%xc zhl>+ZPS^e-ma>NJYL1rEPBl3Qxlph{D22-=<&AG|4=dH1V-&(#R{NB_!4K3mDn#NL zJqH{Oo4Znw)G9>7(8nbB^#4gZ-xYQybP0pLmur@pl&-syM&3t9OEvj|9N=G8I^SM* z%957&&3mkn0Z6uM@ciR6!zDMAA!_;7=Q!-}EzM5z!33B|?(S}>n{mIQRJrVfG;X%{ z4xL|Ev#z%%L~#h5{O)}FbHzVFl3=!eZEmt+hn!&XbdsKs;%23F_C#Xc(b)3l%2}J^ z)<$oOK!vUpND$8Qs5RzsV#Eh-L^V6rA3q3kltr7ZQV7=sP~N{ZRQ>1_uc*fEy|}yj zK!v+u^mw{~elzgR(5!32aVTl)+3~bE`Nc(nxsuy2r#U1X@%}QZA)&1?+q$3eY)+#! zI+R`uMz=wkS~PuH>SU&eT{h+Vw}D%l`~F8c^5paR!_;?=XU}&wUcGvS&a`Op#q%tG zYgwaEdDeYVj>500v=keO3`0)zSjkTQ(?yNvATK4xlfx_w`~Y>*OPkgg8t`}8#Cup% zHi6k0ZYkaSeDRTwie$@nrkkZ8x30Rh zN;BCxpNoOMBtAgkRfIP*wuNq#pR`UIWeD16-}qjwudln$pK59Q9wgqd>(*BQZGG47 z{6~GcwSAby7G?nrlKGEOTaEwpSM0r48M`-L&co) zzVMN={2EP3Kvr?U>s50&*$yPSQx1QoZ7HwsH{@DusSSL!6|m?hay1D;a(BX<(Qy+w z-cL0{)6&u|tJ)Xng=L^A$JlY6YUuQTENZiFL;l;7Ad*b#LVe-+{R~FCw%JWdM?g~iRnUE( zSN#j3!~Loz^e!S_xH2%h1u;RCU_pU2b$epTA-x(4INQf}hWH9lm_)lO&b!#JDMwE< zUb#u*!gX@&08(n*{34>slJQOnC$hp9b)F%*^l%nMQe$U`&3`bK}zikD?hriQC%vbsOb=LzLd7MDtOPqZCI4C2? zFx)3w&$!3&D5K&nbu)4Cs@`l4f2cG@Ns(ldq0-(^*qR6Q$)J~5^9rbtIrY@kCWbNi zv>pwx+4`(+ts;>wOiWJdlnYe23}lZFY?05sB>i1P^y@u$&4Ppl!Pg;~03!v9P48OL zigZMEVOc>c`N}&6I_|rmz$p!{F(kI++X8&dxh$p#pX#+8B;I5YQYXx_{zDR|g5MQq zqK}MHsold(yEJz#3ezbsKwpts@%3j>0<1tqdq=CYjA%0Vl}5sP(nRgg^~kfRRCoU% zBejIYL~J6?-;*)2+N>KzR(2byv&fZ&fOQ}GA zX@rlazP_;Y>h~Y|dNoIhE}8ussiQC1Y@jy{m^iixe42I7NJikpm|*jbIjUp-3-uz5 z4)LK>BLi7Ivf{Eb2$VqG`HrsZO81fCK|>R;Myp~kAFQ-33EEOf6BHU5!dl?S#_k&Ad_dP$Fhg&^=lCvR`Ojeqa;B#Mr zS#x;6TrAvqx519V>7`EnK{j`xEMzDEC8xq@l~+@xr?)rq91b5WD{IG2r1ZJI`i%WJ zV&g#g3`{0iZBN&E^na%>Ed}z-By-8fQ<0AzJ^Dxn3w`*^lHPSTq9LzG`YqNUZ$QNd zU1(Oqw55D}w6q>63q(F7zvDTmO~C3paZ)@Ny*E6M;nEpj53aiP{^0`FmVXcz+$koZ zmSb0>k7Ernxo1?66)@ul71P{_Dfb|a(rQ@;CZ5JKv_k>=FvG^qKWBr9Ej{!+%(~TctEwFukzX)ks8Ha|A*ZIu!XzM6pcs(l{*9y^K9-No{KO2qH?8){`gu;u@5}e z1TSwz=E<@1Fw?I-N`Zj*{6Qzh%+sqjjD!=oMkR?0I{ucrF*B#`0fy4{&?+;{8J?Jl*5}RD9 zqU(ZxnDp5tZ z{=;tvcH!^WJ7q=+)FJ)y$votINwZ{*k<5E>48Pqg*%d@b|?t2!bjbr@Y-<5a7 zIdzwE{B97*$QAWhFKATo&I(Tk?n^!Y58!`I5*o^+xP z%;^>TnVH>OU(L=vXwX`em5#i3%i~0-dXYfHtZ;h2Fu#PUZt>nPtF`3UKHCp7GFQ9W(hPzZTPLP!)L z4jaQkGON#Z3{DtivuH#3<)HWWw6&g2iqn@eO5#Hq;%@En*EQyy z=aV{`nvl)8%q`TGRaxa%(lGZsbViS}oI`M1kEj5!SnP4uBTKO8JlepY^K@5;K5$#U z?!#Y59En4Ze<+`TfXv{RLhcSzK+eE%;_VBK8@pOY2|O~zyUu4>|M8T+1n}xs=H_3T zP_V)vj2m;qHT4Z^QTD0g>s3F6;GP3rSX`OR2Vdy zt~JWVHjeTM5(>wX?+l+EDqt?$zdlH#?)cjx$S7rHp?w{$Z)DVJ+}=@O-WRH^1*Q_ht=bjtVndHexPN1AU%HI)%=_}*xuMFf=U$oXo@!>u6TECZ?I0Dv$DXLt{a|O z9w(ks)@>7`;GWYKj^=M<350HMiMYHaru{*yl#Q*Ft+bStIe0IIB8CIC{a0nZtbwf( zz!m@-N=n#yl*veytD-|!GMG4bMuqO=kZ_D3Ec_qH2CFx~T;nS;ll^!5Fgoac2GXBZ zDfWEF7F4-%2FFWTslzJn30JTzYrv2bm$mO+9`ZMZoM5Z27^^ObZM|_cQqP0G?Lwe= zFrjHU`{v{tY*cQ+Hc-v;3@Bq*X19lmE}*)%>`vezgeE;%V2@j@AE&X4B2ack;tvKB zhy?q5B9tSaG>l9Txv=m5(W(-@e4%1sJawhOjT-clyoXk=TUb|>woaU_od_)+=PvyZ zX24?fgIV7KjgeEhP%9+6qedRK?bK8FiI_mr(~#~~rOoLuYR1{R`^ZHqf~pyTL@hAK zX^e{;C_eKDVY6A^<{Bz_Bu6&>(DAnQ__nFU95pFE3FpKoI5B=e&VhhLCN!UL8jQ+^B%%)6)cZH&)In6543 zOUB>(H)v2@=(k97qU;|JvW8g62iM%sD>sn2s6~HlRXC&&&XBQ`&`W4*^3CX;3|(IX zw$tsYvfZ6(&P#{-Nu*TP`)V9Muai5M{}GgkaXp}uAClxq7z}wFNQeGUo1bVv^_bgv zI7VQllSoA-BEGzRZ$ng^H1n@Ml>XgrGvfzN$>{eokWMlpa9ikiw}pF^$%_`I0yMC) z+Aq13q3JfX#mME=G^??PaPfC{)oGJ7EH5`!U!fpN3UqOrq#B@joS&n-#hy*?CA+!l z&swwgRaveLs@jX#M=72rmlYApya{xsgY4y?ECuNa@CaG%9-(y4 z?{IOnu=iJZFbq>O2Xa$O{cxe9!yI$Nyv2vc@g9=-%F66d-WV>w8&T5B|gX7aPTDHtwO$cyILBzX; z6>v=HS?FYF(N{2K6IM^~*DE{eSn{B=S(tNel8yI(AYI-S5xw(PxpDw>))(PH6Zf!Q zeFicKJpvQSn8^Le{r%MSv5L|hk?9p98u|Fu^;)x*75(cV?SmEcgHj4qpZcRHLSc{MCI4NOwBbwQBj0uLO_B9-P+vTy__J0Tci7gz zBP84i#-X#D;+u;o2Y=p*_}5(qTw0q^@)QM-gG zg={W_Kjr3DH|HYhzRbmxB1Oj!NIj$;O%e*!&C5+E&(_k?S~t~0W#a4(c!_jpJiB=a ze{gd88{;Gk?DZmrTEh!FO8cp1SBh2c+W1ZK=swJmCcg{5;)0#c$HZicNp+wZCeNsM z%}sB0G&l$nror>`bCPH~DtXq^fKiL#5T!n5=1nHSbH1_2KHv&W1b@%((9g9XlxmB* zDZtLTj#F+@m6(=u+T?>RNv~wZ#=`6eyjpg>g{cU zpC~oh?;8WCezf6Gs!yN^O>eyVG*520Pbuweiu-UE(524r1 zsyxmPshOUiBFaw?JOJy39@4gqxemJIoMdB%2LG>S%3BgcQjau`@;-uDVdApk}q!Mn(^qvH$RApQRC;%BLywd zg4gx<`<~&X8FflSVa90~_>alo@@#}WeiaJzo!Yn42n-rhV_t6xWbH7B*NZ6k;v?TQ z)rA2)D5dt#AmtAZ0g{+><#*|wyikJ#0OZOg!auIuPhf`UQ|w^_jV43kh$yLUQYCR- z@AW{)ffl=aR#zKoED2^QBIZ(n|iSUs$(+et`D?QVuaN zPjKt=sIu7E(zIQp?M7)Gg}CqKtlJzIGTy1!AI_EAshzen>y9=#-}_3*QTFDCc`}=B z#c%jVnSQ+kwO3;|i4EDCZ`7dX6& zX0d_=#l`EnZ^ht$lJsBLB)@p0AS3fRn?`)X8xI?V9Te2Nuv2u=`mT9Ck4<`DF`VHn z#7p&Ycuf12-?Xh7M@T=`t2B?eX|G!Fz#hIy{1InfAiGe-FAx%K*PkI8CIfxH4;C7b zd4ETQK?g-vniM&ND8ZK&Ha9l|nSn~C7%P8e2PVsI2Y9!m@(2E{L>{6?D7 z;Mw)Xj-K;kKhID}Nr}kCW;K|XH>+&}Xi3gKxIUEB+zkr{ z>{K}Han?GbA5-AbYkG`vBm@&`tvg-Jx-#cjbs{bhcB{Ww$XCN%Bm}UdIlFEE+t4${y!G#n5 zLr>67IC$%K8T3Wq`l#Fw5K^Mx)|%2#Xi@JIRRB}b($Tru@w-_Jd30}c>zCI~^G?e} zQgU)N{g3_G+1cB%{a3GkI({YBns||A4c$ae;~I z5G+EVFgo}Bg`en67L9KDH_!UM>US-dcMv9JlxrsaWi7mHF%G z<|32IT`zYPLv>V+M>RY6$;{rk=!s~>WJco_XgRxGpKO=&SXRX^FE5Lp2!iq?M90vh z1V8E@F2Hqfs?zLu%JK#bb$cJ~F`!YrIjyfWyLRaT*TgFH`*-J5eBCeK6~Pq)cfiqS zI$u(9G%byg+Bd{J8Z@f-y?wC_R`%@e6sGr^($vkkqOpjq^z*!*v3Wat+ZLAa6a`BZ z49pX?z`ARGla64(>SIG^zSp(oDZKN&+>gcB+-;-T((=mZ=Nv`97rH6NVpg8}}fnzwV$m!ClHl2WYc+fM;J4_#_ zUT)pBw1uD$oe49&RRUvd%l74M@Ox|CfeA5SM#xC^1fo2D6I@Wm^44-uQuUzF{X$<4 zqx3u9%_U|%uhnJ-7jC z{pNf{W|RGVsbntU?BJXw)0-hZszIACGDeO zu->IS!{<7LXGp;16C1y!Jp-5x4J9o>lP@nUC~^X&$sU zA4XXfj;axQpSYugpa~`;vA(eZrr2kJBVOi2j(PN-kx!rp+ zsb`rEK<_Q3xbMLL)ZW@!aHFJJ44A$Go*$3QpScgJi*v=_8O1fTPd^dRJ-kaF@$8Nt zSieHQ7-`_-ag>jrlX&1f{a}9E1XgC7`ssnA9fYLdzqp*x*RlraflKT?f#@L~myJjjPJxgqLvyPLqC;U)o=J83juK!}=PK#xT+1KdMLxFcH{d-5yxf1xg z`!#6*kkydyHzK9%|GJ>$JnPaJI_-}o7fof!)h0XJbQ=A+8oL6TDLn+_`z|U^SZ9CZ1jj-qJAu|Mq#(iWf<~Sxmx6^lp3$>A8BUYn zSrGcPEQfS{KKgal+|5}zOA48YyMtM)ZxC8OMfU#eiFovLM;Uo++7-$3hWD(ImF_+9 ziQTaPfhkX)D=8`JQ8(JQ96z_YUYjx_7`*=Bvxjmjpz)<@p)ry!0trW?0*u>@C~}x* zM`|bweu=RIM8#m5AlNIhV}Xc8@b-JzMdr=r%E!VCQ!mhf?E`IF95td zySW{_i~(_hkC)f`VymvSv~+80%gFbP{;kc7=SJztj1!K2^mxYvxv(p17tJwsN?|wy zIFP%%&~-WZhJ&Gjh|0ncc`g5K>Xn+1iZvqr-^vtb3p zcAR26;Flatry~{`P~tlEel_}dBG8?hsm}w{clTzThPbL(J=N9JxB%U3Q`QhFqe3NW z`<3!~7p-8kdPodF@gD_-^sYmYAtaPS95IF#ziC~gEIU#?m$$TO6pf>p(paxt3<%kN z&PpUyfrpE{X5DWvrKux3bwb=IrzzWen#gi-&)D;o@w3g>kb-IY9|Hv@>%5k*7+Kk9oyTl#Feu)Ws1BvLyHIkPwqP0!_JejIeZyXAr6n{PmBjs`a0~KI?7K zV9-A;gxTWeu=GM8BKPgpD3Eb@Y>X3v1^1;7uHWo~H5fy(p_b{6%r}y~F3=hGAAfWP zj4J#?nC;F__%_Px>?|!O_ZY5@KxDAYENh-fg}(3_e`@xX%&jAHWyr{3h`g?%qpuvk z97<|`cGx+#$Z6MnHF&_@^zv|3ZAkQtkxJgR;+wpZQp}D5V+l-LZz3dQcPhEQ++lCw z%hA!1w;_&G601$+Pkr~t;Za0Bs z6L*h{*cwOy>g*}!eAd8LO+NmoE1j`}gU7YUVm>6#URUuH3sKSqCWwi&wLkHt9>$!L zYru5q(nnF?Jbmy8S>dra)z&-Wj7WT{ElOGBRY4da5coOehXOu-u*$-U$02=on1dV& zq&c{BjR?Uex06z5wM!9rvz(t{^eqyod2)KMOtj3+wazipAA@pntzq|TZ|LP~>8tF> z9#eGuyZI&j`BC1bmk@|=oHCVkox(4KX*<)Y|1n4fcOfm4i>#qSr;)gup#uHN91D+^ zbRrhnN`KmzF!1QgcM;IO;YIFRp-_P3PJeqvv(3U8aV9wz^j#xZXAj<{MUMvE4plL1 zZQuXC_|ySv@z|hFN_u>NK9K5%nm>Z{7!sTpVz+hr)q5TLwlr`5?L@xoAgdZJ72{?_ zOZC1B)c<0S2_)koE;oh5&d;O8nb*dAR6^W@4DRv5Em8kUb5Goakiqc$q3nOFbs|iN z65Cc$0}~)W7h_R}-o6`#1WKda>Tgrr>#iaSM|E$t#J=Sm%W34e#g?-ek3D~1k~{{) z{Nghh=bC$(9#~li-mLaR1kA~Js(c&c{Oa)c8Y{mqJ6aKbZ;^qq{%zkmS4r3{|K?1( z%$ZS^MITY5T=^;#c;P#nfro_2yA!HQd%{@_s(Y$-IR7>f=pw#amo|xKr9sgiqf+GJ zymWk8CMJVRr^rp=KmX40H;xwU#ocr$T~4DB!?J4dhTc`2ssY26+!X4n9%8m<1@a_H zdd4_q*GTTm+@*W?#b~qW?2sq|ww7&isPFL^p{gk_n?Wzx#p$SuR8l4DMnY@m8PF!& z8|mXxX`~ud9J>_;VVhf1%QxPp>pN=RVquBl6I0z2WBiBwLOWg|1z?cQY%p zj4hZ^-6gl^eV+aM8v&Fsb#!&kejJH+v?Mr&bf|y5V|~C6e;@hX?WoL!7vA#EaJLms zc^q4JSOyZYjU5C%-L2CZq-SfNwab(7rNZu8+u8YUUVE`d`Ce_+fu%k31k*dG8&D&_ zr9a8>nE#S|v(ph(pfvlrrNyqz|9Z}sJ2yL9%sbJh>}JSK+`ILHy%Aw#l=|%EBN%^; zc54~H@gOM+e*c9NE$|`go-Qq-I9vAF**L}*%4({6f5mdkHyn&g9m8KfM@q*vti?h? zE)MvGg4fq$@#S#fXQY7H+vj6Y`uVaq;p2zb-ly%Y!d9(5dh;OR=5Fxz0HW56n&Si| zStVWDp(~ur^$q(|WEZ6^Z$9lx@Dw?Jov?|_H4ZKrY3ZTYn6Ck|$9 z;iz8iwW~liU7X;B;z5-z4&N?NWiyRl>0r8BAjfVrT;aC-okj2gGX2wF;p#hJnOz<6 zKHk)Lvg4ViwB+AA45x49TvnA;84d$Y+NY`03TDG>zz*RGUXy5t0uJl$Idt-mEEidnK;`09=LP+tVhf+ zD15YlJ^Szw_b70=Kx(^WI>M^C`rGfYqw7?!YjY&m`J^=UqwnVM?+^SNd5CjNItSGR zn2OE6fc(gz&+1|4<60QzIA2?5=fStV2cNMJ5_Jvr{ig9{TmTcesiAw@6aCz}28b>I zzxgm;ob}`E{$jbTpkUq3?Cv*UyL~2LiluXszLjfsj7KbQkn4L3%v%pK{DeO=k&459;q-nWLXAPfS!F>UjYUKweQ#B4vd?7f+8fwK{xc4yAej zO7X;Mk~`T4Drx0cf=u!V3GRe6$~{l+e7o;5UX6A~VcT!w#`VyXcG`jqY`v(Lgs`UW zN0VHrg^5B!cRW1tF|a+DFpzeKjQ+spSp;7jpUK{_l6J3n-#4%UL991!(s0*-g!>UeQj#%8vuqp%3Oi(6zME)X zM>5A6C=;#ZwERBX0Y(tks4+5^IV$E@GK)_aX*6-}2O%U~w`;cqkM3mPj+l>hTA=)R zxU@U6P;uaHxIz09nCGJ+8=n%3t{mh8+R`e9mnOpiZitx~&_aOZP&!UcUV08*6!`kD z@M7%UbW?iWP6`y}rb&CDQQD2+pv}}B5EDRC{`}qcZb*b%Z~d_s94k-7M?}RjzzbV^ za1xr{`kC$ao3+WNZn~0P_`F}+sKfH*dSg zdbO*{vz*oezAppOKf3-{NpC;vJ=Mr;W-4Sj%;K}UP!1y>HamMJXg*Od&*QVcQw&|u z@(yOs^?7J9ckMf$H9HB43;Zf@?mBT!ulPrb)UJLKlf5J9V#A8Q*shL?eZ_FmM$4aO zF1%E$P(#TdF4&We)%ELJYpiLW-Vx5;-sb>X6lf!FZZi8%3+QbB+WNJ!wzalZm}^z; zzCLU?aTmh{voIQi<1`Zf>zs&OyaR0TL1~uqMR5S$0+A)tOo8KKuoxZ!ZFcEzn15;{EO4bG|PF84PVJJSp*X?;T7-uc*E8&qum z4j|gCf40RgkBSVKyst4II=fYd+n>(gMH8wKiAk;}NW;I%dbm*QiK?o4$DcwO0fpSL zmD=X+LI5p)+Ig$x4?Kc6Ucehp^X}96%m*8D^VM_`*ahR~uVq_u89Q~KCKZ-s+`C#0Ji2BeOuT{K^T3tAXQ=} zR>z4X-9cVd15LWCE!^FrQ;+kTziijYjMK7vP~PL==KckN_`ep*kC`L>xo?n%8lA&dJrhg^v1VH78WK5n^Qe?(%1VM?9dmSqaYC! z=>ud>f+{Q7zCIc&{>WecbD?P?&g8K%>Sm4m^foxEoR?B5t6TeAMcXcynZkxxNRqgN z#3oa){ak-HGPKscUwS3{1=QTa;wdj@4$n#C8SJ1)NS|VPT4cFOJ$Of znbn7VBn6VNz}m}d9G{{_?P~OcmEvT|?t}jHMx(h&{NjyouRPKZH@3hAdjQNC6Jcw38JeTmif!{IdD>fP-vx1ecQA zIm#ka*YCId{p~rWfGLD%_T%L|iT>SM2@Q~6926!OvfQ9(J2<(?v|IMvf@UhrA9def zbZp!`W_IpTkXkoh1KFRt?V;_-FTVJuST2U9#q!Xz48N6$sv3vy3CwqU-yk#!jVrFC z^s`r^mMk2OOEP}*mZuiLSm@;Ga4ejbEC2Pq1)y_|jAwC`byVDEVX{xLaU0$-z0dI* zG6|z}oJhjv9yCum&YWSWg&UPh)oWZF6L_LF`=3!jj6^;Ht_z6M*L-JeQ}Wqxq0orz zPCWLAAY{r^Hr<6TJEP}}Uk=C}eLO{EuuHyi;BX1U)s+N8{yhEAu*ha6PNq0cA~}&5jo-8@bfa zYETg>5#eSxjRHHKvUaR92_e?h1P$M|gwV3tCcDqdqdzLiO3l0ZVdk_Ahmv<>ZKz3Q zw6X1#;j~y!a#-Rqbhmh&jEp|9KRvULA~AEk0q=0kFrH$8NbKKTSf}Suxfyfzuif)c z)%I)D^p9t$UB^wN4XOGUsncV;+ecqC)z(Y;a7@IoJAca0>iNJT z{90CXAC37jbp)Nqvwn(HN3Xc~kHE2>-ZPuychN$k_p#M`9%s?Ut53cO zxaLunLZpt}zBhgJW99T({+=qzly9_t@3#Dd;e_`%Zs4^Q5c~jpWqVJ_0N`&iEiI2> z@ou|Bt@4dnQaj$Aq-h0_Ju>1Q-otXgtedzx$XWVmu|jt1@eiPbgqQT)G2FXmEoq^B zlfyWXYgnM4-jAQ3{AtqT{lz}MeO`)@OL6E;N`8ta^@EP4$Syo2RJdAa0|+1cytt|6 z*3mnnHeW{iwtbgMp5piVRhl1F$wzJaC=WW8BLtAJA9b3FHxR{#XJ>}W8OQ{LdIKnw z4G}b(EljH>Y0bSrj7?;S9zwqB`FEcBSLwhN@1&I^1PH=zESgPT0o|yteol^1WmoLK!7jW+&rtZf`+vbR5^cf`2yUH1 zXRS2{4$$FJUEk|ZV7`fGD}r~;nJ|t*+D49V=O@q&#X!t>mHj_NmyoR9&WIEiix;o* zx^9&Jb2@W)OX!{+lA4ZX_?+Y_!loyx?I#NwcovL-iIKu&2_kir!p~Ag~9++xU>(-{6E3b`FJZL;TevsW(ea05V|) z+voPYG{`{HIVgL2*V1w`;T4jpr8zCpk9~tT9H<@((hj`Y9;+`n7K#HOcNVW2eMYQ8 zpv0Y#bmRSa29z9)QaptkBq$BU>S0(^M1Mr6-e!~i(r{eu#+al2V@UGFJ}AH^d>Lck zS4!dzcj0ppoz7U=2jAZMA3v(ApmZbJDLkyZvvY$%n6}|!-h0ehZSM2kp&2Itxwsev z$`2=O?0)vn4uw4#*n!5AN&g?UfzQ7<|MzA2|5t4Ym4rCql3rVVA;sbz9DfEiC*6AF~Q-5lPgPGhLg2Ca@PI&LHFF%DVobukj z&D&U+3$*n@P%{tEyW~;*1?+}Vv8PYZXbF|w1o%JwL0+$!zL;LQ0 zokZJxFPBLIlQ4h0(l%a%Z##&GP8=}LQXZ=o>E=NY@)B&>uSJl*wX{&DU|zDow5RAK z&#@6KFFjt$oulBZr*2$C2vkM9#qVKOM%Zo)SZ zbij#uvhBW>b0qgy8ZY{@wY-GK=tGkze;N-MF1$>nLS}4=B*<1}q@l-+6-rVk=5MeQ zyk(AHSLHG(7XHp6@{d}9g*~0eo-MXwnr))hxepoq8 z#Y!d{SNG_2bv~ktahLv1rs*(YDSlh^;H*ib`iRsJTeT-+qSMS;BicnG=Km8GcZyn& z1XJt0^SM3zHS%mVPn@p~0-cjJdB)if~95 zXR0m`WeJ(9xg2-AF`RwhHnv0ne<>zi29Wp|@cw~c4W5zbr?$`-2~vAYshjrKvRKYf zGY?-jb?{+A+U~*IWB1;A2hnvM|=3UE#m2U zviR>{)yUu7QSmW>(E+U>%rkX7QXwh}9ii9eg%V&)l@x+2xU(L3O|vOBfncVm^!q0! zlg%rD42HQ|L&-MNz?*l-+jjpb)?bGj24Z%=UB^1|Jfe@3Z?6Z zz6lXIJ>)dsrzfO&-Pg1t_K)0pt3er6??`2X6iu}Tz*50~<0^G`~wjh8_dP|(HE~mm-yKEMCe%N+v zEMC&6uTB5XwyOjG?j8~h#-o)Ns$qG&_lS!81$7Vj5>($Vgyv>e1$Qhg=pV1Yx&S{( z|MEgN`_ZguAm>9X%1B)muq(2&Y|HYXyDBI(Y2OOMcxY>#!VTCj$Qf^}v`IC{^(r56 zo4BQ{W$n%kl86*TI#2f1u@?Ys$C;&nb@Vfr)qbPvz>oBUEqZv4nLI#7#4{LV$6E!V zyIH+rg5d(A%ZL-agU0|p(mrl2PJu!Pf9WkD(YLvAe-aS7^m;h)LwFlp;D1-} z-{<@Rcj9sf1OXr@y$7OoTf_qvUQEv3dr*c+X!ut;$zmF`M9P1dd^CfJTdE-*cfH0k z>_L58Wg;EW=bOyr>vW43lW7m z#dEw1PH4>D7vE2(QTc95A2=0vly$msQQBR08obh4!g`-n7z8yexzNz#f=iw9GL*q-Iy$i+N-EIVS z%pbNy4tJR5QI)#jixypiV>YzwL!C`)oEe-Ad@Pm!4A;^nIoTV}pVPPMi?ax&Y>Wby z$3V8>QTA<#e+Q4BfPGC3{ou#X!jX75aMEX z^?)PF`3t|272&~FPKI!ZhOOT;I8%X?RlhB%vzCVxdym=jc8l# zI(JOjhm_>h;zV9FLBojAOQZ;Rz;uQ5x~~PTkzH8a*)nB1Aq*>_+ZxYa5VG@NI{a@!W4JD$nVg)} zJ_sz$9`Kj7L{0dIQ7KWiUo@1v#P9{^IG858r~UNTScQEdXD<>cW$mq+fC8^*I5`?d zGBNRw^n=xDM!cLvs`L}qubd|AtG0q8HHg0G%d2xbsgVz8(6{|@^{&B<{#Qx()IO3M zAzb_TOhj&CHC1p~xf{b@?{e(h7cnnTlJAsZqMnriDvJ=Xxn{erygnU$ohjdW3S<5j zdhQehl$t!Af0+N&q0Y_sY1~Vp3-*g-h0kEnbEdF>Y&hI2Z!QK~*c$De7HEy6v>iBJ z@$=@dh=*MfUz{UNx^N9<6Nj@?h&l1By$nnX`nzBOnc&2Nv-{KB5P>2jL+X7@B1Gu6 ztzm4Z#AqFbwT19uXbGwG76(fm#b1{lT3`}AK(Q>@QaN?9BgB%xt%)#R**&<3E7@NS zv61tHp9y!qtkGZ)~Y-EP21^vkged}8Ub6&<>c(ei|P+9T2fL> z!C)l35;odGX!$3dr5P#%_7hJ@C^2LYLj$$pIuN;-L*j(|d-qFp{3qXgVUy@}D$t@6 zdV2ct9ea5a2YcYz)PSqj;E9s6eC%68+L!5IF@OPDg#N!Yv`={W`DnO${Qz~a67(+m z0vz8pvuSG{w!T`4nIi@YYsiaUG!X#HGAxSMN)(Az`Sl;vt#T_jrAn7!YBA-V{?b)` z|MCcdKP44Itl=WW5n&3vG%I4LpdvsJ%qx(tnYt`fLo~c5Y8>{xvzA=}HZ#AvAbZad z;yZEXG_d^^AfI)f?wdS%jM`{lPNn=J5w`PLs}p`_{mXvhZJX}JR-N#}Mvzul<>ISI zQ7tW|&jCobm`b-{N5xx(rkn9@mL0STC4;svuJTvyQpdyH3d@?_At=W+$G?)ia)>#` zq$Pbau6Cf~%catdH@=#zOJ19&j@)3I_P#R?0%n?`boRZzI@Wc$woIDcSR^+i(P;&H|LG_3Fbg4G87b5(OSj91VMwK?O9m72Jg@AMi%RI;61DX$lh&g~*4JMTun z%sI`lv=pDbh{kt~DnFMRJCFR${j35b1-~I;utbz->FnnMlkv{Ae6kZ}BGLH3r;$#q zKn!{)qFyXDhE!u`i)u^3 z?ochZ_nc;Q6a48h;1qC=vPBzUI#6Kg1ue4*Y347wAf_6t6ZXDFvyAD?Idci54?+}8 zFAMz2a1X05*I!Ig!`c&Y9BHsfopJ6fQ>$nB82su|Gy02d7}s0dI)(4yzFm#16skh6 zY|5f}$#>6_533WbT(!5Z=xq^3Cto^;%yM?>i0et1WLXH5)CUi}-bdl6$HTU%rtwa; zSM>+;|NTZFkLC%9#Vzc! zBl{yoy$qo!#lnG$55%b~G|b0_TdSb++o+?NBMDn?EQH@2GoR+c3~FmPWmOU+{5WN7 zFKQ4TY`KR}q#Ni{!&)Gs-2|-;v|x|$k-FbUe!WN4?dQclY5Q)siN@OJqAGKWZTrFD z`i@b@?x(Ew%0pwBMN3CP7lUfi2UR)1MFx@2d}T76M46lMy0%u|!_wGx{?Z@z;3hD- zLpMY?W9%fJ-XrcGbojKI8iXF-LW zpDVUhW5dJm_Yv-u;ao|Ah-=qOwk)J(ShLwb=8pCJm2^`t{>?yHX5pXJ_zdb>J9u+k z$90y?J<6(~{>-)S`!f2WQNL=(UDhEBFU;njR;3u~5E<;-khB!IiJ9h!# zF_xiBs{*+R|0u^7JR;&L{{=QzZGXN8-kd@a03Hn|#QdG$>y*50KI#hyDO-vOawtXp z7ayyAH>shJ}u z-)%Rl^x#DmesHj5Qj*^j-HTh~43q)g3_GdcNzbyCvaPWyjWPvJNZBm zlSQ;JL&F$0NHY@>op?{ME~@;HH2hVREQ~;LAu1Bn-*0s{9pQ0quI|v5gg^jdn*e2- zSA_Fv3@Iz^b6NCj(j7aIwB>y_+zwZzE3C1v?p_J+Agq>^#2qkhR7B9O#ejr%1yd>x z!6usNdbq=6E+yj%mqiWb^P@*;~vrq7p8q>m$k#zg58>Z74 zmt_usjQfY~%1^?be*S-)DnN)p9H`jL_4;CTh4?vIMDBIAF(k2gJa4t@a6dVSbD`l4 zW>nNQC&C7UkCMZyL#*5YrXE7QY}Kl?L%89@3r%%4ja+)nUTYFKaqHgNA~iI4Y&Gqz z2B~u8ZRR7c=Nr`_>~3#oiK|XaH}W?k_>DrubR=`(Aq;heIry1+2JSAuF{?TnMeZtW zza)>`sJZbpcwTJ!ve}iFEgB0l`|O|3Hh!Z>;7(YkIKT}Q-Dj@OO)ua_anx^VLwe~d zYQu}X@?b4T$3orseNl?z%h4pt3*n*0jNRz*4gR(SX&~7VV>!Uj z61WWxZ(UUvOHW9#73TSK2%>nr!jgQ9eg?-!y}D?Zls!ahC*V`C^LoI~5?vtX_9sqF z`l=0bO_bo4NoKG+t0M`GBSX|d(9}@B2D>|Pa6OtAHiE=A($vTLqXCj-(z=<}`?w|V zea?cP0I`ez>z79aAyG+Hm2aHv)aUaq^`Mt`F8%UX&a%3U z*uzq=icFJ}WaINSUKy+avLL+hU)K@Mc)$01mQ9*N$Zlid$6^a8K5eqG z=YdQWEf#r#?iw>;P+$~M9;}_b%aj?DPjwb(3lGaK%6Vkvq z=O%=tmufU%6IE3O)9$vSQ7YA&)e&c*)|N{psBwKLWe#x-SZ!&FwuYA+k67*6i`#bE z?F{>^I2^1?bL=tZX2@;NR>o4M*{jJg zbEYvwRYJzyJ(;zNcESgb3tic5o=`O6RfE02&+UdXiRTd$8at>%{JH!-n>P|!zKwJ; zw~FnZvJK%2DG@9YOGdUG{iT>$CgBqrpfL9^sz77(C+fCcn5ZjYV;DU883l(veD~rx z;IU%*pR+|Nw#nD06_hM+q|gO=X8mOopMhj(a*b%b_kLvlrv*^qw@`5~SnT0ser;7`%f!+NRAJ5so z3CqA&E6NT0?50X`CN;)$TxqLP7iG}G7j1&u{@KM(kAoE`HAE@Zx?uOkq2LQyN!<(P)^qYDEC zA{p}B5k5UiS0pqcZ~sEJFj9r;ZGDbEpfl70@neC-{ew`76~ZZ%Ep73|3jQL!Xw`o8 zImTX-6p@Qf?AD`k+Ro|EBZ)hc;%`PZy))o>ZT$p;fnbp@F~4}l1JpVdAv{eaBB=#P zS}IKtnjd1`G<26X(RfKxrN0#{Z4xO67RDc?<6f$oNxAA%!j!);h)t+I>g14vU~{+F`S(f-QuF zPiUS-hh~a{{~SxT zDUjked4QoJTwUw?e$>A&RQ|d4OmTqPO#JXLBu;gz%P(JTp%#4cjMwG*Lc${#$u6#X zaUCYP+_{lbvKOfXGNx=s9xC7~L`aE1IZBH~}qiTm&uC9Hk6+&>)B56%3j>T?re^j@1;cf;!-!hE`^efAbsH&X4RuN^tmCVV4t zynGSl0l&L^o61TMFC@b(x#(Ri@O=UL9nHZSvkas`!byrWAUQ&YARa|#vDF;jOetph zZL-!;MQ`XqJyjj26gF+>;-&><@l{TF@hMq7SsfA&DT_?1Ycd&wg!K}II6e!<;eF11 zA%u*f_ZUt*B}0BNa1ojDHDH#rfB$X1tKI9WnAo~?Jc{r``zXSX-3CaB5>H28I7Q;- z64{@05-q@QWtW$^#QJl_V1;&1Y&fwR8V$%Nx2=PsPk4<0Pv;aCJHWt>AEPWPruCLI z5lwAFVEUxO*7w~Ko8g-YLl4=&r zAAradEZyzFTk?x}0+mH_K-8B5v*>}oP5rhV10yh4$m;DZmD<)bBq4OEStHT?o%cno zn}Xy&5I`IfXC3($&#n+O6>v4h_r2ZZe8^Rap(RW-n@YO!Os(Z?!ko;vpN!XVi6JF$ zDhk6Ys&*jp>HUHShB~xkMvztU)bi$e+?IdG?2R`HpX=9)w5rs8@kwmBA=6*aLSMa_ z3NZdlUr5+kFAMkEe{O7eq@UsLX;_rxfmfI++&Chh5>{a4_y&V!<~>L&POR%j8~8#^ zD#=o*nuZDe4%J@QMpO4zDQosjpGluI6Gjcw1r}nV^*E&-JXvJe!&~8JJ4xe{266b; zUT!wjF6cxsfe|a4K%tc|f#CBLhc5SN3Rb@%oXyL1mk|=ICKg>0;bt|zH*)sTL+$k@blTkOAbFFbzc(~>FEe&LQZpU zWs+==?sSxXdLR`ob0B;`W;KJ4j^vmcMIhgSLpi)YQBw;_lEFjt`hY(`Z*CoUi*4oB z;rF~vS8~_yX#Ty1QPD`m#8x}*lm@zsB}Un+hiGQuRksH( z@qwYMT{UYfx=^J0H^`MNfPKB6u=^pLKgitcCswcf;#gz#QV*B=NSpGDS$h2+jWR*^ z$LoxdB6(Ld5KKs;5xRuUIOW5SQBSyhwiinxpal_wrtgJ*aD@+8Fv_0Jw#sG75sNSP z5q@3)_j8&Sy7s0_@F4FzxIhIcnk*BUidZ;#to`~MZ;lV2+St!<2k#MuZ^38Dbrs}@ zVE&Caxsl&j&-lE>3ftGczWRI9o#Nq|>DQ}!;OPQIq>#{Bh>CQxLjhgEtsNe5n5NA%(| z8WH?TQbAvA?kABem@4(mW((~zlFY+<_M6}3a#=Qpw=|X8KBJ31n_NpF?)wOa(`b}8 z9msBaxI@gJM6};F6Bu+AseQu>CXy2Ip^UJ>vz_o?;%uaTvw|<;f~9&nL2ya8raJoq zud(f{v=bXmTMxg+OdC9$5IazO2hhmIM{{N09grDt~>O zSJ>);jrM}ww9DHP2M5kB8hdqlsL91m)N+7k1HK)&wTY|~ch`MIk7}+#XY#{1F<&{M zf0?`l$5#m-snM#~lO6)}Pm@t6WT}ZD6!`^zfZKkrEU(|fjB=j5mG(>Kn>Cjq4o<0M zk@Aw9+)i0{#oKJ|Zwj(HF@fzQEiN*PgoQZpGfC|c0iI4({u6y*7E!U&(s4r&cR*7q z5`!4JS2`Y74FbXI_^c;QS%&t2B=L}GM$#>+M5lb#BH)CDb|~|!u}*j^cEnlC5|}b^%^+`#G1E2OE}uebH4U*g3;Z zjGDCmk~n@JGx24v$^di2kly}D@r5_3xGrRte=VK{_+}g96%bIghr7jREWY#n%Po;C zSY|;qg1R5WXvYU%P2K|~Up2ILl@V-W62GOjxp-ow30LY z$}`5J0XS0$M)n}Yta2nqSA#_ayQ9M>M*q}&IEDP%&+vXI@Y0CB7_tzF67mhRs_^ly zc(PaZ5=B_Z<7nq$25%C`2lB4ofJS!ozFjE#+ofy=>1p%|6c(i?-u&JxWTB@t1*1@p<1Vf2YiVVqc$d+H)W6(8Kg)Z zliQ<9UKmo;1dh(t%{(C^UR^kAcOE7O*!4(P!wnKz>)8IKnyH^X;MI<>X$4kE!-ACB z%m?oIQB4N-txd&4-lH-#NI8#}Ap{>JUy8T^D+6{1d8RAaE&ZigLgC|Q*wH@b!=E;R z8i7Y-ysS%C$06rxa`<(tgws)$=UaRD-;>Ny+dJ1?X_v~emodz|0+tx~5F|(etO3Bp z22X;u#zqZuJQ70c!;Vl|C!xJ$J|t1fLH4>Csx&~o)U``e#M4p)(6~zp!EpIGa-(A% zd@}mD9%bKaS?mN4yPRxwCJPbW?O6Gm;l#1T>rrwl>EF%0M$k8gFPsb5l$|H=n}NUB zU)W^98V{$dN4`rZ`R1d~E`v?7xNcQT$1|#h9C#wuzl`&8>#gWd`x;SqCTYKUe@Tae%x|a4?hD@wr%2X}Vq!mdDzC(Z9ZJ5picKSi&aC8j z5Zd(W5N)IqdXDXlXu%{eW;t{bvFH_vY`j2qqeENp{AYHUbiwW&&kEki?ciC^0xnH81QXDu_OjA6? z;D~mpM`M(o7PSbU)Y*zn$d%1cHx!JieFpuG)l*g0pF+hRqo9)GodJXppedDokf7xV(bUL)UdQxw@RSUR{$r2_ezRRYfd(oDmk2If^C zao)(=<19Rl;#T4Skv{Q^D%!$y#}+gBeZvMhpKKZ)f?$fH%eY^Z55G=yP+C$YM>2?& zk3w9Q6zsi*be{#=5|W%Ag}8TH1^%Ekd#wmBcI@*4KKdP4>ZyJ!kPwy0TmQyId*eOm z)>KjS3AHf8t?bt;@fQvY+4~3-S33O9t;EV(j3gyPRwZYr6&e&S=RK82xWtvR*|^Ux zn_ygUDesu`Fe4HQj^qgVS&a*yVt)f1RZ2D%j@PUASwy8>u5e-+Z(#>atJj-$MoOJ1 zXq)TH62W6sj`|U-Cryxq{u&l7g42bQ25N%|qhS+@Aef z@X{q*v(iQ9YsZFV%(0gIrB&4yVN0P;DE z?Atj|ox#DK^ongtYDjMep?Qz1;PEqgXvj-X{mo6`e`4v;Sbms|6KrTQIrv5R#tSqC`$*?7dNci52_f8^%|Nmw)LFDH`H31B%Y zMBXeBG!T?5p-;6cL;!5B_R6k-4aYo{lwi99tUTE*q&XzIS(K8EAY)auY+g*+MdXc? zz@-4fBv8a&Xgeyx%ry-g{Tx!Re7rYE2cvZYOEa-AQC>CB_j6#Ex){b^@>7E3EfW)Z ziBGmA19I?oz4>$MfO+aw_mb(S+!9wGwe)yYk{P0d0KfJsFskYy#88*Er=a0se7{0A zcV@yKI*F3nzdaBB`fW3rK={=c3yVF;375^Wd!Iw*k%qTE+)&4O>sEa+@LYYW;iJx2 z`cjRl3Z`AE+n=qAJ{{V*jl^b(R_^Q=)7ic9OwX+=IC0%&Z~TJyIy8Y>mGZMrO2q8L8w{5Vkqz4tY8hELJ|C2GY%d-M%cD2A;V!=C&^?$FtzEI-=IefABU_3&9G(JR0AyU9HFq!A+Nfi?A zpcM5e9n$LYtUE+0i>-JS!z~}8k!|8P+;w5CtEXXv-EIO|kiz`Lx|7c94jZuRsnlge z1$qD3cTCynjI_z`WhmhK23ePtGp8XEB3bNxs0*g}A|9t)>IZn>Ked=?E$LNq^{DEL z1Yf3^dPNYIbfxxph(l{FAN^Nb>;xj)0woGK7nu^7QW=mM%P(k;$Y83%-|8!s;YV+MpJvKnmP3getr4X2#ox<`bs*y21#nXb)lq{#iKDKBBugtoLHIGjZb{=1nVD{BKOox%-`BmA#`daFh_Q_HMP{624?&gezXUskr%+^+7Cl9yimTap<|G(y;2G%euxcUJF=8h5w5i9l+n(dU1Ug1>L;kr} zrv1dMa27}vMbTX{0K`0EAgUp$0@cpS6GcbbmMrC${TTEjZfwc$OIhm|k+yhzJGJc? zP>+bVvsGar*DMYZA=8J#IX8hojOT0>d?@6)^ z7e#%NwyB2|&OZl_ke;pCO15%05$7j0(`gAwA**u77d+jv#2kZ6Cn)j;QrFT+;HM}O zQU9j%QQb7ca}X_fCPhhTyM{R?=_0_=z2Qlvc!$<`Ih;~7!sLRfFo=h}b2zALFnk7@ z-N+n@BDoVvwQ=13#u*hfaM3y8e`rAij$8D(5JQ7kVhUkWna538=`>{>1O?WR*){u2 z5{`d8%5+?O*(A+!d>*~q1x1=q(uo?6HH<36-e}$xD%03EE>fycwxpS9kgh(g!0}Cm zL%5*JmlA&LnGKVrQeq!0s6=**s9!Utv=#^~1Kg@ew>eO*vN!6xVQxAq<_R2_>gv$mAnfaS4gYz#q6DWrHXf#3Z@(<9sUrvj$ z!H;ll&EYLbgy3Ujtab1jyX*&<>yu@LE#k>`PvA&PIH1*jc{5!3rXS}x6+|6VT7+k}Kn{LkBhX2RyO#NRkn6-5 zYbJJEBw4Qe{s+;%|1{gdw<)iw8NB4Os0i-JguZRsz~;aIJo=D!V2y3NyK7>9PA202 z?Jj9-(U~CjOW$|D0TW=XxGnpnc0t_^JJ@))%7wA9K~alP`9yW1&P|%9d3NyfIy=ACnR7`)oGU7TbM=gMRr>-~5)~kX zLhJ^4+fEmh1uy>$IgnHJu0r)UiP|9i8Xrk(qj5c!LTBhR z(fl==Ni7t#Y8tOiyQnF-hYHFGVzip~CX`F~R7+rPeIqT+_eKM{_`%Nzh;As!pj^RV zp&$<6FAGpY>d%BYUg?nJ22Gx;A}K8Gjk5~UhO}iQigs!>WyOOw2C5T2YPzm z=PAYx8c%`nv;?D$Nb^WglumJSN;A_lrt%o*&w9!cu*9Fz^qzHJbG>&)OkYsy z@e~~6dn^F@JiKh%OhWMb<`-QU6e}xBANDNF+JPYaG&eK}nT4(O>|{&d+u;{%KGUXk zCpVVQ^L&QB_WQ%xRu=%nvfpFv>1%1%%w~ z+SYEyD#!YK{a}Z2ys(6UoU856io$-6ewZX|{J*oDRy;1D-@Xk4p$^9ft;g-fCU%`o zAdlX47FpJHceCeUG(h38-E(`;F$;wE{ibc2>f&6Ny>ctKSMJuG#^I5~ct2@N3UhGP zGbj4*s-=o!WkN$z{;aMV@pl&7r|gZnrSgDK@{!u{-B_C(M{3~U%dSTGywPn=HdGyGe~Q9 zjWr{kFHI!rgKUVamO@2_o(m85%-p45!tJnzhQa6L`{x_P@W~lF9_y_+&m-$Bz;tkb zKbCwgIi<6?O7~fnwbn~6y(5vWSQ0e1E@#h(&qF>nJuQ2JZZxJT2Z=x)ms9xKc*G(z zM*1BOZy5Pqn}>*xh|BMo^oJ(S2Zy1})HrfZ(=MM2)7Rn9OQ_$U^7-<+)O5~y9b)L5 zZ<|eqPD-8s;N!Aa6+pi8+HWMibv}z^K4_cGxxYELvaz{8xIKLI9=0gA@9MY&V&O)3 z@u~V=`|*glOO{cihn{g;OQ}#~$OPtm>cy)R*mby``3Ft%Rx67|T zF*ax>+Sp0?K775quIxpC+|qxBFJ*OI?J0%p*Jc@b-6I6b_&tLNXjvcwnTt0veKt~HS zrd>%VH}FP4(oG3_U79<1Ttn|~U$y8Hi*g?7xva!f5{!Wlv&NjHCn83x+bLh�!s7 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 z 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 diff --git a/output/playwright/theme-color-randomizer.png b/output/playwright/theme-color-randomizer.png deleted file mode 100644 index 303d47ae9be774ae4674963878b36e82524b7368..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 97852 zcmdSAg;!hM_66FOQi`U6TM85?PNBG@IFtefN-6GE+?|wC+>5qYakt_GE$;4w;1(db zg(NTEz2E)4_bCo@7E8-)(^YUlh9Tar?3Wy<3hBqU>L2hQy?x^D z)_u=3{f6%E<5#_}RpZo&aB_2VDDNvVhN!=ed};GGZeHae9ogeMYgG6p)G^p#XW4hm z7Sgxq4>8Oz@U6T!bS%%{u%idLq=$6KN-K}W(u?@qU7wVfmy?dY$x;Td1ow=gv)JC7 z5NM|UCH-h@xerNP<4tqk(-*|PgoLh+pvp`5es{ahEaX4(`1uik{C9|Xsm6<0nTHt*IW%xzzJ1?h2SGtMoAUA0 zz>BF`hg|saT31)M?TV*iS5G$l*KPT~ChJPiB}fU)9>(Ms&!bUD$YJ%Tif#JOLU2)+ z>waFq(l1Bivx;K)um4!dmj3%gb9Dt~A5yK;K+uTEL-`6`B`!UJR}0|4Jz8RDMJ6-s zxRQw|@Cz$!|KrdCCz|gx4vo(9T<}?dkk@R6n{D_SaYQHm{Dj<NJcSFw{dTW# z;vX;mGbdKb#NzoVFXvd(T)+}%!S&;S<31oPxVD7&YTQg#I^PjKsof+v0 zq2X&*efP5ct;{Xe{ITbCe~#;O)wzPCu76$H?y!ph>JN8?cv=$yJd;XMgyQBWAzfw) zvFex@;$J?M|NicfDUoJ1bYSM#vS@A6&a2vV&fmQ>BGQxQFNfg2&d|Kp20*1v=U(II z>k&doYRwp?x#a>|dxQEnsLR|kzrGWn@y1sG(&%8KnMP>kt`Yiwy~Vb?BK`9_ zG2N#*?~(c#M|X(Pna$PzVv8h^VHE&66oc9Xx}ZG&4)=cTl6qKm{+Pk4;9o;Y9j+M4 zNlAc2K~(d#0sls?5$C&HL(iMEvEcvN+Q{_%PsF^tA%7+pi|N#(`F|ZxLYnF=&o|QX z@6S9_;K*oL*6`Ut2s$z=Yv`M-zd+qmvlwa|Fe$4 zBTiThhy~w02j1)|fKYoJsuCB|XJa5lAfOSmZ8>46Ow6pytS94zhv>xDwE*fE?Y%`rmCB zPWRe*3@}re4POW?AFl!Y8*#v0(L$NEV2sGla+9*tqD|ohKA}agdzy!FC<)yW<;^B5 zXrUUMDhj<)Q;@HlU&t-okry<#7bUyk0BT_JCPV~1qZZKREcmatl1->L2uyzmqi2oU~T?aEjz(){+OtxsJbod1cGCi{Zhq}A^r!u_q;v&?zVf6Sj?YyH zd4u(>_f2K{x&fluO)lRc*A45kU;WrF%h3h63gr1xXxV9F+&9B#pDos_y9;w2 zi*eK6OYvVw5#OFL;{CA-X**j&ILAT|P)I{}3z`@_9^QJ=O9Q)6L)@m1p*i0#Unqf& zQ7|FK!$3d_riTLD+q9FZXX?;;F-zf(0eJWy1KajgSetRuChW;1&$9vTNC^ZN2#iRo z%=ynoyasbD`<;ydk@J0!b_dHT`(NYhQ;~hmS%~IyDD;MnHES}%$p2yndZP!stxt1k zZW6oMc0~71`l^G1Z%Ya#>fjkqf@{abHJN^h0xSc@oOo}8l)|k z==r-{K}F0t2ECAlx-lXGK}ZgqV^KK>-?cLcuFm^ecR9>PA!Y}2lVn5_;Gx~VdIr2m zQbQ4t&POM42Xq8M68Zv8C?qf-&=oEWnNyKM$L*eqSpuU@#?WPNYT7VC^tvAUJec`D z?9Tdo)3!&Gg$~Mne)vzcG30+mb~0Vpg0FT}xgiIq6;L>JzJtO(77Gr^cbgxfxILSc zJ#7@7E(GS>`jFUN0r#hDpgH%X(PcS3pHf?D2ZK}F(n#9S#Eu8QJ$3;8yH-9npc_q0 zISzQ6%F56wg^>O%l}Gq0-L;6WIjPG5P`PvuJ_vR(d!~AdB8rUOH_^7x@^GH*th zPj>>)6qvq9@S_9+*AW4Ol*`$MHk3AI3?qrTI}7Q@vN+87!47I53k~~G1`k-d4t3l| z2W+JnUGDN+-!W*sf(G0ctNHK4+fS$-|AyzcZQEv{6~Naa$dy77BidmQ7MIpwUS7La zl6gqDYTM2Y&p}8J?6Mp-$pvV;ovFMzYxg?Kl|-Cno+h#a2AYYhOlbYEbY)7nD+{$? zyE_Lf&tI+)p zEbgVb5U1%Od@)ERSm(ruO-O=+gDjkcpW^bUM%mjy54%;kOIznsB-fPNHc#F?9vcBhk!SZ04(dU`R*IAgJ6b)Fl|E!fnS>7GI{bs2Gncb7Y1-ORspn^?M=dQq;FozXX z5U8aQgPv)}ESk)mP#E3*VHi@wTxIzzo3%HJdCzyz-*)q0{_tQS%>ArNyKVof9fLHS zC%%u(LP*=;A%e0(@>&YO&ZZ{mv})ooDe>=OgCWT!W?PTy8UxY06S-Np)21I{VwV&B zQ8%;Xj4qo&BMIfooUyI6_SrvRYOF9x@oYS`U{5$Z`4Nr;{{WIUR|DQ}9Z zGELl_vmHf;;I$TG5SD7|lQ;_a2b8AWr~WtyxFkV9|B#%!B_e|$*WKgw(XMtR1pGUI z<-ESd_m3m2E!!V5*LviAd_D#7uxxZ(zol{b4UTqdfI#bzxox>V!)EkQdpG|BGoUXN z4jUJ!FXx0dV%7kdbZh{@*H|uP!R@YDQ4vZr3cPCUL!a_s20^XxNf`PN*1Gv45>VkA zP1?75k6g2mzrR;3 z?KY>Mo@K#OW>L+DZ*t+-45)Uc9YE4h;jxBn!{8a6HP4w1+TPMlJ9r+>SDRg1ugK7! zzNk6zT4~xLLFK>C%KV9?qT?h`A3&RCE_`f|vRNE1XEaW#;zA}^z%K=3|W zu_FZC4BwRI{_?XFZa;c{c!zngc6GUMUu& z^6Go7NrWT}@gOOqLwuK+@((;lL-c~)?ONWlUMQz{?h5IuV2k-i)OQ}tDbHby0n@Cz zF*E?&Ks)EIDuZ{)HRcy`bFhxZjz)*lvb2#qEGVPQ=yN^Fn|m^sPGz(+n%4vqX7fs3 zBKsP+3+@{@3ZcJh3!PA_l4LrYA~uBA=;lieQNPwUf5Prmy69+`@5Zs1I-ZYO!!k*D z+of$$JIHjmqgj1`>DH<#bbR3&fm!3?%Yxmci@|nQuG(G=dCx%(zc(ytBj>jw4G&EK zzScrT-fs=f4(i7xuUvsja1w=By4?Sewy(Sg&Tg9AgOzU&SoF}jomb5}5(t+RLP_+A znp^1y==bfXv0m5XHvY#lz}slpbvfcL#QvT=@7S?vw{P$)ICZUwN}w2+%7)c)>w#bn`tF25cnSm+jV->KuAuiwdHL_Fa;1mRg%JjTyNFXxM-6Mr$srgK%0CxIf9o-Z9 zkH%Qx$z{g^XF$w#ALukb7MtV2;}_%Dnq-ixVOihM9g|onenW~<4X+D*4uNPI0xz)W zHDPqfV(tlnuM4I+$U5m@NGitsHEU!d@bCc>IP5jgI~Nklom|{IlzkVQ33H9PbWFz0n|kmWF~P z%+g<%a8PLet=K7a53O+YI;W7V(3^sfV$Ne%ys+G1s&MZ%lL9lW+??QGI5SdF1jei- zV3BWIg;wTzg~D&F#<=WcG6glMhMvP5B^Y3lBw%AMAb&Y!LZ!Yrro6?0Kt>4Hucs2T zU*+o!&z4CMH?CHi)!9ukyo^je*n^!%nV6j6AbdT&eR5v_|Nc`TVvYd;pnni2_3x$3 zoUTWytG0o^yO70R2o!CW1SeLArEkSn;0`S(1?4w)!~7&3DC_IC1qX&|8TTm_p%FpB zQAv1NMOv!B?GU^|v1Q+AS@9g0)f3Ey039KWv=!>ZVrQi@J#ThX1QB za)eaa@BM01=5E_P!q!vaN3G4{_GuZOE8bTSm_MZ0WbS+YO7aTB-tlrf&7RRY6Ua`Fk z`0Fr!f-MOeFOv1f48PYUd0w0T#mbTnLy(aFiXku;N=xGg7>V6~*yOYdY}1aU?y8Gui(? zy{Vtq-S}TIOw9cMC&MQ=EWKb3{)83riO>X=%RA0ltDusU!RB1&O&GP2oEer5u}o@Lvx8 zp&?qE@u+x86zQd^J@?gwO?i{lhKT%<I)*i_7!kO#ruVI7aSUGfiX|R-Z8a} z)!92Ni62nn^xDm}Wy;@A4oPL#Nle~4(VgH%rOUTk=soa^y6<}UEaMky0C+q$At5&kxKl%LLCpC!sU9hS)URkWjTO-#yg`rTBaM84^Ykjv`TMsgh}dZ3Nz4sp7S+u zJ(NDeLR9n0(EKo&7p30QsA*nF+F~wGwl9@IHk5A|M<|tYuIV+O1^3dA;*Kh1DU%w# zqzL8j^JbD9B-`cdYl@~ieaGz@Ur+foWrKEfB%b=jpIbJ66o34WWkF!ez%z?Qs*{c~rwIUCczVqKv??zJ(1=#KuLRGR z(|Wj89+_j5kg6hEU&CZMpO9+(#wU+^!}so8cRBB~Y_f9tcL^STVN&{r6l#-It#1>e zoAMY&x*IBEl$k!VU&u0$Mpu!wbI$GeYfsndQ+H~`thX|}Zz=xX32^-qu_o4aTNe~^u^C?>G|2q%u#MnR6yUX&fnX(f{JXUvPMQmBjq1|#uHJ?%@VZepm%sc4 z;S9RCd-#6Pz>FzRt)kxymi>vD6q5l>ug{CeGS9dQfwv(QLBhzgnz_d0#(E)VH>g$v zVutSO5aGk)d-XC!=82I-A}Aym&Gaq1R>UQVP4fOyl>U0gEB2H7yZ1!Wm>{W*=>)Gr zQ}W__zRrTvH#7-J`kwK`N$wi^Fql@zHzhOC9&bJ%ncsi9A*U&;OIn;$?6sNP!euX> z!qh23O95_TXEb(+evo$bd^_yn$?JMQuI{&_FUkUwzPpS@Ul*{_1hO!mZU)k_XjRTU zUAM~BUYHhkz_ESt`b1KjvemSeK1I+F#sNBRsoDx81Jyi3d^YXxAw^x}q<%6DSD~Wr zp))g^){ru(6yK5)Is#klu~@5^*z;Fm&)C+m2n}u123?hO5$L#!D^}RNFj}zCw%y7j zBYqJo_F~&SHnG~CM#hY@epI3=!=zuMcY*MF?Q8xwKN`wBI^gyLT}A3C7H7kk#0J0r zxVzs>3=h974;;fCUHd#Okgxt->RXOCQHj31R}izb+N<^^X#wqIALXGV^A>AiQ6qM_ z4*jK>x_9DxqQQ@~_!J)0ZS?VS${6lnh?@@_Yr523FQ`Y#K2y*~#@_L(8eK1x~D75|_g!W9R~+GGen z?~1NS*j&Nik3SV@x|WJsK`Pa1n1zN@i@opR_3>oQ_3Z4vVry!A(9JsEO3s{Aiew}c zfSCC8kJ`9=&C$q~#^aKklF%vim;9j6>EoZT{b1NqFz&-s((xCxa+$Op2GT?!p+2O_ zNdnIO7Nt240&$hLWZ&3PC9y8#(BS^`)(AtAokg@2?*{NI&3A-5^X_y0mhPY5CMmdScT7 z_X-;C-;{3^bRXPj z(L9=aG5?fqa`o7$?2MH%YjSX7fnoCHE4X-|oXcxvZi#W00Ek6gw@!7m61`AMGSy!*79ZX!Y-b4YZm5tAjY0t#mzbBb;w`GO*kPVCSVWy}iAS z#ZR{b7MjujX#qfe%x7;i39By-Zo~@rEC2kQ0;VwSWsnI|7j{O;tiFD7p*9HY6k!Q- ziSLSwDq*5}Qj$SGnG5;EDoP(7ANP#%|4>eSKz-GJ!XGzVX6y8( zIfU@Nt}m{FAZN`BO=N5X9jL;VZ0uuQxJyQ}0|p~fdox-wv@1%o=>EBj$gZQ>5w_A4 z=4i=$w3dLIzt;Rr)X9M3Dn}!M+A!$?nj!oQuw{^`T+3)fiZ{>nC5k{rs+(+Z=NqNV ziJUJK%Fxh<^BBh+Ep9Htj>F1fiC_5P0Tj_TY}bu|D2fo46b(By^> zX%f_3^0_r8x=k&$1R?4+%r1U_vKNjsF|T=~@s3xxrKmUVX4ReFQ}xg&ez9cHg*3gR z#uqmI@IFjisw6A)WU1z3H{8}HSRv6S$A3pSZCeA$~Y7NJi48X zj*gb_z2wEU{H{?ozrmP!)-=!~f^ZURvj(p+i1rcf*wOJR88g=uDfyaY_!P9%>c!q} z^Rq^u)K@iU@#$YE=5^P&FUsOwDH4=?D+e*U1b=7xj;p4YEYGCP)?I~Yw^kQ=OKKl-HX3fWMxykj1rQB`nxI_;rx1|?f zbGQr)dH)e3N+rzC2kXpeb-(?|980{QM`l7JqW(LW=#f#DgymGI4aMgHoVt5k1HsHV zpGtct)R=5 zn~%&=fQAb*L}eV~{FK-!LQMI0)48qQcP( zVkT&@7>-Q7)kuvm3CD{q7YqLJ6!|EQIt(|WiHnE*E=+9O_fz>*z1_zrPES(oYE#w1 zL^VXWO=ZY+Pif~<8=ROjlac%iO?UDIKC^>x+ADL1US=8*9>(L>X~rEm_?8;lS`o22 zLIvy|%seh_-MlLAQqn0#@_r>cd)&OAudp=6R{n>yjd~#c z@oxyGgY3ZUwQ>AM%e%IYWZ}RLnRB6Ipd>g)`jOqtI*Os3jP?ktxX3 z1kvvYPwKmZerpfYh%3tRb{|hl@IGvR7_{Lhuz6CEc`xKxha*SpwH-xMtaf?UtK5N0UG@eQ?WXYH^&wX%w7U?+6*mtej;Lvi|F)IU6H5cMZS;WR~3Q7kdv3uK- zM9;0zkv{8@pE;K&Ymvf1rxSfL`PAb_i*{mbAVbKIdSwT-xVuv7w7PKbZw-Qj+sy%k zPQ|q&l07f=F83GGYI~uNl@y|Qnv%`pSVBzM?i*}+{P@f>{JWa?b&^#*IxeQnKJWE( z?yhcc-sR&^vlXe2KH>5npAxm-v;+%(0iPb%k=7j?y(IF%C~e8ngtJ==={>`dq}Xav z)h)NMjJ0}`A}yT7LtPb>M|j3vK=XUF;Z|8M(v_{RqAo3{W>_U+K2lhJ7)}r~bpPg$zWIy;Abb(gmA;Saz%4K%O}NO8F*>^lbU}Q^qgTSL(nn z{T_}-1~rDK?^mt;h*}rtE%w?@%V`|5m%|T)557F|xVv6gPNJdV4|Ml~?ryQawZ5Qc zs0(JaE}sU=S`t-XJJN}9NDCPC%*K7P(~RtMi1s4IN$zL7ZzJ>We)LP(rN3`gE`OCp zl>U79UEQ_3khovnG*Kt-oUaWLEZ-y&2oGB4Hhx@#*6z;IEzM4 zl00U4qb6OT!axiy7>?DH#VW1uK3R_3H=nIt5+lgt_%LeGK%{ zQh)yFe#aJVONO%!KC5Y2ENX_?5rluJdfC##9H`r&!^AUC>kqf7HhvW!^w=*A-$$8@ zMyI6Ll_+CjmU4s3HJtJfv9GQiwI%Az4Xj?iMM%(a`?QYes+xH0+Ra|-mt*&aLO-DV!{ zMJts0RU@^GyLh&x{ZMtum$ysRn;C2h*Foo%OM&+G_7kfU<*!3bIs9P16U{}nU#R>R z;dng}KRm;?A6N2G?%nTSx@Ke@9~iqzYu|}W6Gsk9g|ezwXt@bA1$*UMj3A_=+={*G zS;ER6DcBZ=K+7EurZqmuxKAv*8t-xL+q|#H(0SMLfyA%44)d~3_DAs%nCr5Jf-oo& zdkA@=Hn-3DeRzsqrQM!i{QeT`TStwY&`gGBs5kgMp_TvGWP{0!tD8aYQ{`-{$Oqy- zN(-Dhy_LgR#^mGM?VKkjn|tG1J2<{YJ;RCb|F%JCP4-<>;?Jfh!Hy)A1}n*NSboez z;VVx@F%rmrM+gJevN}tbywXu>Qw5r!xcrA-<}8(+U;FHE6Gm-kx)5mI+dTO7?O;II z6X*Jb4~F&moUUH{>n%o36+1n3PDYRLy#99F(=6gJEAoy^e>=4TXAxZ9b4^-u?eCFh~fTbVZD;a zuaZy6hgU4WOdTH$Vj)Qx--&SRFyHBy9oL(YhUT8};IDFsI%aJnXSjl-(-bQY$AFjOK0hD@v((`%7;5KQ9{{X_h7#O1zVL<7P4)2fV8 zx;$IcwW`JZ;}^lBmbSckO6gWVYYfqxt+?vj3o)%Vd}E#RJ;%<7vKZ}{PCshanh zc+!1-Z7uMi_#}Ze@_E$Pp>dHqV|UUIYy%usTB<6VeWW_7q@b8U#qEt|M!TWcPx5{j z=`V0`2&xOSExn2TBzqFI6|ZQyvRp^`P?(2ZL4*57F7*Pm^14*R;|$ z3|~?=MeuvmJ&Mw%tj+IGd{JkL8>y(mW36MxMiDDqwfT_zjs8eoe)2pc=(t94$x}CS z>#rQ1U`qMMnKI1h0vUv z3krJAx)am1yw>9cj@E~e^-TLP1Tfy&uggICBtPXpq}Q8b-=QuQ*n@>|J?@zn{cZPg zEPuF)W^0m5L#4afyg*-cWu|*Ri)~Zo+hTT{HCJ9dVID=OxV#2iA=e+vC6CWtrFp+C z%0j-&6XQKOIl}FIOjHoLlxl*r{WtqrE+s{y;zLj#-!}g%V{eIOp8anxHe!>THD@3Wge z)MwPsR=Ift33ES$-R-efqR-0>&O3t&K_^OTe*HM~-kl;>Vq1BxLROJ;7}_kD-&wXA zcs=5(bz`y}do*r%bpn7LhsUm*&bGIl#vA#KqUgPxoXt)jorQ4Dk|VVr@s*K8&9T{Z zchNE!H}r_$8V0KTsg~n==+oAbmt+3DS(A=)F^m75mi<1|j-?)z?QuvMYZZYb6KSqn z@+mVy_G6Tdyr1B3PyE?n{}Gbp!T&3|+JpQ~HM0(4uoC9bL0s=g_GdAg%0tQy%(}_+ z%3X_%i&3Ha_n!fLEo@5l^C$El$wV?!b4^@6(U8d?mm`shvx9IB@DUzGF-LoA(NjOR zn9fS^i63kv>Y}7M)E(a}Y=xM$7GWfSEQU}_U&D4+8T`8X28cl`@c0jDLC5ewn^M?b z510?eH-L(Z5P?3XF+@jTE=E1?o{wDzX8CVZfVQ~Pd>hlEvArJ)y*YplH!Hjm;i3x* z>&V9nLlAz#5YFJycF~ZGIz=YR-n*amU0uR7(O$8grZiVO_6&Z^6TH~7TCH|)?#ydsS{{MANG`WTamkfc1JMe;mZ`n zGj+i`jqgvD#moR?b=RJ{)M z#B5)+?%qJp;dj32ciokcB^#@wrGwk5XgQ#KB#p%Bo4~GhPJ7~dEnMC5&Io$Q$ETG? z!?oS#CC*kOUthHoqteGqcf6@T`ZcQ1)fN9v(xJ8?wl2#w4NsXV$$lZ9h!6F72Cp_U}Ax8(s%4=MLC5z4zhG7z^z*fR0rn!-EFj zO9Ta;qAWz(k}W7%T)w3$S@oir)aNxu*D{qS=8yH+1qZyDC0MmBwsQc|CpQ_xT>B%- z5!?C;!R#k9k8yDbUG%+`y_66sGk%gOZNI1ssW=ZZ zrXw1E{H0Hkcmp5k|DwvSBp4(6H8iz#*zbTVy?XC@oPWTWa+GZ~;&haP_{x^Ez^HF) zKK=#{K9kW7*q`O`I^Stu1)?8dYoC)PvnofG4%S;~CH(Bu?xMz1y zHoxIR-j`sfUlh_gDcSurVV)>hPnK66W8&8WG(G08$ydlB`%E3JonyUVx;WaK#}QkX zYk~84hXCla$apI{6LbF83~Hd9{SQjZ*yT7#RkO1 zZ#>X3D|oe`>T18aLT&kUzclvFJ@0i!Rx9F+TlmIiKAJ8Ic6SH7*$4aVK(s`KZtMsN(Hgi48@XuBR8SBU|TBlC_CI#L2Hr*_dCv zVrk}(s$@9|^XjI{{=h`z(d?qeJwN=dhzYxNjQ)IbT%9oRm1QU;2;vzP>cn zrbpL!8X#M2WA2D`0<~;dk4l)p7o)cA$cQemE~AiDVau*JXbQjO99Zd($)5v?$rH*& zQn0@Z@MM|wk?(sVhoDsc@aD*DFutn)and(}BJ0hf(_}9Xuf>aIeG3nrz&`S4EPAe5 zClg%w;#0zTp9A|f*q`J1{;xLh>24@A^)BRDX1cHc;-pd!*vN|4&1;rXGVkRzi;oK&TBAtK34WGjED@4^d*VK zuAm^;&G?8A?LXbq#mMEuGv!C)a=YF zx%!R9+=G`4k8HYwxalaW-OzVU(35FRwfw_SJx2q-2p3|0-ut1Qg7S5P)_lVI4Px4S zR58E2M^6IBYF*hrYQ*Nt18mv9E3hL1YMLHz*iO`x#RWbai}p&DCJmU}`V=ld#9@Cq z-~A>J*NYF79GcHbX8BOJP0IIt#w=o9M*Jm*w=(6}gqEN}41)iyfyKI2t7jq}*nC6z z$HI&2PhWTXsz0t=JKQ|&3gWB}Aorg?>G40qb|KGbgKx*Ny>h=5#EVv3y|%JJ$oY|W zu~C4C15f*wP^IuWt6V=5{rN@4d@0EPTEY=h4`+jLYu4X}j;{0YF!*ju+{(m11N7tf zYSyS03#~T@=T+x=hf3@)?TK#bFlq>pP!Kj=y}rzB`Uav9u{%~gCnnI~EJpCy1`vb} z>6q#<(!cu{!noDO{%Rs&1z6?z|_U}elOS$4 zwJFBvTW4Z3kG5Z(GiU1qQ%a{xN3F0G=eDTO!!|3r4Pu&2P65`qmS6c{C7_4>3U_$K zxSYg1aj9a2+&#bjI5(?Cxa2rTBJ*E)X$HKCeoMXPdv35`7Vt7$h?AK{$?_3w8OB3Z zV2(PsoyedyItUnnq3TN^b#0D*w+-qfp)uf78KJvLK4kC8MIcSUTCD^{R_)KruC|js zY{Ri5=JKlLqB#=i=Tt8hImbdzu?Kq5m>OZP-ewipVOFdzgOkP3$w(eB2^Jl3qJk+$ z{VbJ}T?+elu6##ZSwC;v4$r6saX32ParTX$GI#x8pxOVG)A;!vwPOEmlS0wd0XG(d zXG3+VClp?pE}$4|;-Gy*I+MVycG`r8mMY~lh7#|z4dea<(oEy7TU%Mor|{7jNW1q3 zMm9DsDo^Y4l!y)k7o?L;lNl-LWfvddd!Y4Euf(5-$XJt4^HcuRTs&)$Km33Qt+UF1;U{h&*Fjjm@I@~pF{}|9bmY@nc zTrK2ozxc&LGS+a*TA09-@}PeIBUVq94*0CuS@0zRYO0O9XJ#qFMDp!J0ddIw@UsSZ z;-3O}Q2OYqwZ{)}fRuPXYmZ3dq!YezI5SH9W1*e+G@I-Dx)(!4I()Ao#I`>J^HbiZ z9GQfLY#giU2tNt)eEAaZi_)3)%;%nrlyry3+Dm-+CXsFY%U=_dj{FGC1@k6$U5%Hz zp2Szmzheo}&kOlgVJTQ2D8)D*!yKHvT!mxorM@vK%^*EW-0z%&_de&G?fX82K;3(4 zp^+!JKc!kBdjj{-my%T(X4>V}tnc=8DMKJ9nYjds8;;D+KisD(O93ciEqX=jyEqFT zlg88WG=$5bHn2aTiq*g8IT(d~%wYns>^Iwlv`VY#QJuG&uo*TdqmOGPS5~QkSm<&&bHH{GP)S7J^_pwp7Mt#L&0X`VQLJs-$1t)$fdBE1 zhs?6Wv-<1Avy&d`%yZy)zwNJn8Meyi>cqa!j8_7ie}FPi&xl$H%vvemWt0o*Cw)3R zw)+6pD^Wl!70k-|8e6nS8;E^~CFUXU=&|-TFzdVFROY=)73`*+2}qSKXG}{q2z4Ls zI0b+6NOlQM_FvFEEdJW^VetnvgUmx9V&;&9pyDPXq2NU!x=cRt&147c>`8K@bt3Ru zqo$pm3T=Z~hF-D1P39A4LEs`T4) zc#C;cCDC7R3;Hwv@2QW`{^1OGCi2$N{{m?OeQ0-r?y{{ApGLL8y|bi5^ue3047vvT zW^9le7gW45!e?;mQDDZg>P?+C-GV6gkvUH@Gs&Ikg`BHj7Q^3@`RIiWSD3{*MI~n0 zTsZOBlzSu->}3mD)~~aF3l!;-5j`{RKz)o5Pk4ea5^KM?NUrQ!%>^(N5dU~8J9p(G zV9qwMVDMQ6FXS@tbY`fjWk7vRu}4<;Cb&VMkn~v0Vskb68v{`P;a_A=zVsNCtJbd| zf^ob(Viyk`8mnp7uJ~*{Y&KbuDYWZ_^DhA8SsT9sJ^dy5cY%XQI=Y<@>3vni6$>VP*TS zBjyqPFn@0a9rgz!$+FMBT|nn-=cpeBn$bUQ$uW=7`N2-B^XtCy=S@=6H(D=PK5}ZQ zSuR?9-k_&+9uJ)9?bq7uL~6Q5;pee@SQt|O=EZ*UjExcGRkR%@WDnJMTHV zdEFyvbAq_XcTf8Yh)>LV_h;YfJ0*}c^;V=mEZ~ril6MTCXLB8aSMud);Ji<%rd=R6 zWM%Ie2Ft4F3OZ?=uhY3v7kyCK8wxHYj7LjQkS2c^ppf9YO#klj(%y4Iyo#Z_&yh8odUbXMOJzYqO%yZ77T%HM`FC85(wU3|$a1i^Og8-!!q$x9T zAC$+O%hmAT`?)N5H&x@4delzX8v>zEDFlgRVH-r{tuN+NYl1mG^ zk-vYAta>})Cc4C!{rlw8YuWiQwRG8w3_jo2HPe~d)E|v2p&`$!@wO`geR7@ge1}dJ zb#t+H@D;UqiXXe5TBqXIc1wsX*ZOC^Sy)uF($3o3AH2+iwVJj2ruUn zMvtE_aBus#V(`;zYI$G!82$W)Dt!04O1pS&e?o&OrMqgvNmuh>`xR^MN8===uhID( zhGaV1*``6iK5x19@OJS8tR+OoUqmtrkLb%KFWP@bx*7{m$BsWB!CR_QEO-B67mP)T z7liCTCX%c}RU|q$Oq8nz+jA;@uVS-j9^vD7IWK=pRa36;Vdw7*^-lv9eT^?^-#;gM z_e4ebSMt4Vb#hXjH^%SVRg|Ab>x^?~xh&`qM1~W6VAAf=Ft6pI(M%|enWm&_DH@yO z*~%~c@F)x`+JI*Y9b507BRJ^h_mJFem*bEsmK@iJ-dW1_p*uyL~7+vwxepns!i|GS$m2FX|NaXiVGFRo7s>5rLf^9YX(_$!)6ARmn;+) zrCMoI`tZDkee@mY#42O;aUK=Rxh#t_<~yBtpgVcXTmPmcIQziopuPGWtPLHoq|3#3 zKJbVV^Om>%b+70o=Hb2HElc5hOe|)qmny3Km-H5Mhz0B~rAa_kr)*u*N+VotY^<&Z z<q{~van#k=iwRQ^O*b0cj6srCROGI|(3_aHS^KQd!vj;Z(j&}&aPpZF_ z*4#-8dUEUl#K!xza?-6wzc(wrE#Gpi*coxtF1Sw!N!5E-|K==~>B<&uyk-%I6VF_y9&)#A{o zrCQuJy{I*ZE>EfrbD*Im>DKbjzxyf7{OVV|_S>HR6GO@ad(OY}fzQ3-mcOA!Z3-^0yA;)(@Lth@{D5x16eV@KN{NS&T2IvAhgW6EWUQWyQ!5G8t0D(l#x2-r z4X}2Qu37kiw+hmn6)9AsmhyI_NFDl;dV>;Rpb%k*s_o~y4eQ+lE1=A}YPmY{BvUG6 z5!#=d+Gy6wS=m(kjfRM8rxEz?#Z(3cGZy%d3u2|6!z=`ogZs7uYcQxwp)jtO|Tw#SlO6iB}X+xvi z*;stugwHr@|DDb~@Treo|MvI3{xdI5FO<5+!LPjH=D$%Wo3;z~r7k@9HRZ?K-us&C zufP7hyB=69A$DD5#*H5;HkNWMr7n-0SHS+0=z5Li_ms z6H4WJ^xI?U87#8TV4>*)R9T0ZVuGax;WoS8l}~j)G&e7v48zu-0U{aM#7p$V-fqKs zp97=f&7ztl(>#?{vW`!w#@8&-7%IiHz3Sr$aw*~ZNHV(DWwMJdNCp{?PeBEZV!-hZ z>GCFiM$2nUN&$O@Nz%yC7Nkv3N<%cI4$0_z z6cKtTffqGAlS1SK$k~>tX{mfpV%vsvwY;W2uOCX+gUvShK9`*F>5t#?(wF_hOaEu; zzz*!W;C^R4>wQpqv9zOlg@v%UvQpQ(}IMGwsQ zo|2HD<_tTdF$l*kWvZlQi2{E}zg_$0eJu1nY4{S2U~}_Pm&oC7*D3436xB7IwE7Z? zl3MGVt_&=VTdGlxZHDze2PW`G&RQ%gg=8m?yqq8sl^Zzq>rRC0ym~>Ij4)OsCUSEK zY2ddP&@T~l{;PXDWpFJ}{TTd%lo>TTP-QNd3``+yH2Q2xfE*z~L9o0nt$Xnafmh@}kjY8_)Kl*?@ltgfSvjpeeZ5LpPvjSW(!VR4m0>25zKBw6vWf-&?X zv)OE8gElv2_r3oaXPmw7?QehQoB!cAN|y6a`{H|^`L(3Hh_MLab71#6^XV6y_W1Ji z&;Q}q-v0J~I^*oU_rCw>v#$Hv=r+OnoJpkNTnY3xh9{XA@wi$KDAlG5VX}sgB~g0s z(}F1#gzEm&5YJ@2%V<+%vaJGa5>ZsN7IF^AVO=N^_jnc-!TFMsFBLh{O0{G7n?V+j zYPaEp?t!JVXkA`f@!0%f;^;^wrZwk~G%kp0eSm>T6JbWX&Lmqwlg zD;P?|Qadj7BJZdL@EnT(in1G}bc?TXb^Xa~bH1_35Bi)t|M?p(fA({p`^RtmoU>2A z&mi|62cNoUcG^E*^}i_Q!D7DnPTz^W*Ht(C%X6OdyghsPfuD2M#wK-tvzZJlSh9Ey zY*;}|

    l5z9f*qV_1c~8lA=J2LOb~(>r4Kn$g<@veiL(C~dX(79j`Jn86omRtq)R z2vX`O-y)^DpG_vxKmwbY0ly?4I{M{o|LM@&0#R`+^sJ z*DwChSMS+4Wsr8kX90J7vcY-osgJtn8DDeq-nw#lZ0qJ1zUVu; zN!PtEIsL->p4zGC&b#ii-*}s)4f3cO5c%k$R8ZO>CYV-6};oOAg zKAdimd~Rh9#FI~ElAo%K-i3%BQ$5Z3`EWE12;~y7c0Ye4*EMpZMN%pts?NHdiX~Gt`XgN;QlmlyP%wnUf?AQhMP%6?*eK?znrbU!`s`{535Z<@LwJXq zQ0s0(v{Wn;BI|QtoaiNO3(}Di=~@K?MaW|}&{rYzF!}1=(z?a8F{5s4YID!V<{o+Y z7oLCZ)ra5tj(5HAho1QpKlIC`4?B791(%%j-S;@-X;Gct zsIpOuXKDG2I1i_42rE;nn~}c1PAT&kM9p#xFdA)nh?G(#6Yhi*Q%*6N?W%@@xQ%-< zRe48t3L62*S3)HM3<+BN>^!Vv2DS*b^59RMf+?zRRPW+bJ$Xq3bBi(u3Pz)3XFoQ; z=m>oShe%P*6*lNxJgoJNR$kGR1=`VyH!=-Cf7aJtjO$ToO;;E*j0zME- z*pQ1TaB_Bju2rQ=BQ=p-%~DYVKRx?7U3Yco^H2J` zC!BZi%)NW|(xU6y6yE4|-R9l48>RI_f$>`CErjI?dC)0aD-Ft*fb()`2k$~uHX5mG zqrOCt40o67^pUi<$XjS-4Ai=+r3uJv&CCRonOIPCTg<@LaTj97(|50B=u^I0X8cOc zRr2jGQVpm3FUb}Y{e8GWK3pB*INJojDW@0%@#R~(!WLmouOHTzUPn4|jb}J!1Z2O1 zEICc%Ytc!2{){DMB%Ha;G-Ptg-#nMF6)w!xic=~=%TmUczDry9rlg)txg593g5VXY z?Ah*tKkF|w6Yz=rRBW2t>L;SUvPlGfQi`IHS8Ro)h+7)I{|nqE8>O4HlrP!RfhUtj zIQWA-I*+XgS@Q`9hwnJ!#-%|>$rbwNz`9+jJsY#ly|aCLXZ!bWoN?Cv$3OKR_rB<0 zx9Inr=REfr-}Sh+yz8~JLU_x&Uh~c0`9;ru&U5!{&hB;b>5qNNUCumv@1A`d-3)A_ zTSDCIZ=oMv#1ZX}vuX{M=fsKFWARuOQ{r81Dy&wE;E_`r zhVmNU_WnErt5MX31elXaoewv(D}=0=L~lt)a^4QesoffIt`O`Iik)iiFz{7ZUfun3 z_St9A_QNMXwRlbEO*h>*U~bQ}%Fv$7CE50F7Umk4rUX($RFrE3+cpq7)rE&xAX(Vk zEpLEuWoZf93VUEJ8e^)ZIm@9oa>Sqnikgou=VwKPVM>V-M=YsiYZ?X9D1MPT+mi-D z8mUt~`uXB>Dl%Vu73}zYYfFwEogeeMXHWnD5CBO;K~z1ub@cG@BZs$+96tWx_ucUB zw_bhywMXV#^V3g1_?1t4@*^JpsC(S)qJyWLzjt%7J$Cf?&DY=j$&X(C-oN|%zk1~> zUvuLPH*}M^ZpwB4hn;!hXPmlk|3>$>cmJ%L*zMWN8ymC!F2v#1$9|!VX?Gsm^06vV zM%RAU=doi5Ma?WN2C;bJWL{U8X2&g3qt+9(rL_=lx6OR?sh{YM=bv{zZ9iOn^)NsHV{*Qd{l2$a94?j)dstoUn{DMq){8U3>i@0W5c&#SZt!w(5S}*4N$`m ze0f914bo_Awz3I~f+T2yt(l^DAOyf;jm=RhGFPd|9*tD0_CBj&FK`Le>wGf6 zPh;hkZL&w(@fI58HeuFp9+|_AX8BP3;u9;2PpfQo9T;sL-#UJ*>&A{BJu*LfpuMc>#x4zQ2!OmJh2@R<%g0N8)-8->zw`fyx-~fzGTsL?b+K+ym;@vjm^Eh zXV0wLX4~x64>xFo7b`vqAuDXAW`*q0m}3OPnua-wwDg_}UF*rUMI+c+im2^=LEhEM z8wg~}9&!Foec^$y!H@jrhFrjFX`#@z^LKv}qa$X&G{yq1q>$Tyu%>K|vs@SxnK` z#;c!HcV4;&Mu4!jQ%IumpdxXGa4ZD`>iCu|P+(vwQQ#?J6+P3yn~z}Q!hcE-Bt)W*ivMGrosTMxYU>cdxj?3S(@ zyYc#?hi*B3?C4gPty^_GaPsD92lsYe*SU8+aK_pD_U>IQMRuK5w;H+8o$TT6Z?-{; zhnHtUsm)8%FT8cRR3(Y0w&a?El93v-rO@tF=wf(oPuF^KL<`v#NhR57i$rd3o3uga z`chO9%U)2kr0q+iUGjVco4cpCflk#LBH$2?yAA7k4s008aNfQlI1V?XaK2|2o`tAK z5&d2_oJ8o9yTPj`VBn@w~QX0i! zlX^r{_4yyk#N}hcHc+YUKKLGmKE}O9jw}T{aB!PgwWhoGEn!gDim~VZRyoJK!8U3s zgB-2E6183}?)U#n;wRrQ>;tqS>vsb#ku{)h}Qn%XJP1F{%wynNi zFZQ%#W4Mp8n`3nwT^oyDZL>S?j$QUW{T(9x+c)~RL?i|MV_?zQP@ahAU*!32q87%15_krz^NNE;3{RvZ5~LyNzUS@ zkRJ(98G9kNV&5h<$3vB~eG4icJSx(thI5c1j@p4G(lW;^7_nqHFS=r{c912)Mu1-$ zmTFp1V^GY4R~WC>DRW3oXg5`w^XBGkSQ0$m_dT1(`S{-X*74b%t;Khg7dvLMksc;X z>xb0O)o5dLw&?RVY4ObRW*^ygh26^G@FF3?cHej``J?kOLT_>QHPgAu9@GxYRhzvg zD&s#{fbtgadlb|Ts)ejJB4`y`9&6%bRNiOABBZG9AO`fJfCrbJBILI27*xg&cNW(2 zATwoj6%woSbSid8U{aQXmPe+-GUgGa3OEKB<3_Yb&T-;~HIRdBocU3ckuDT#g_Q^V znIjE&HAG?i&VY)Xf-sDBJP&CUM0M1JFhu=BhWX2;K->oJy*<{{Y+%G7{XI+2B8^>p|r<;3qtBkTWU#$8U|Lpp?#eIc5bXPQ6Jn6NV zy7jZJeu}o}U`&tj7&r+2;=U>A+b90lH2B5~U>)RY5{gVmM)KtqFjC` zNQEW9X$DUP|>W4lJtq=Ah%;=Ca7urtEqNXSyflaI*W zcK4r#j7wQE$YXvCOD;`|J)zjL$OEmyQkAwiq!ww>adnfd#oMrG&#aqr&1M_jkvF?a zJ{QS%aSYh{)kf)G54GsYX8n`VeN6wV53b#%ELQ20ZVo3tG`4GH_=MkzCDDb|ER~Zv zU@-E_KSF`WdQ{#+d<{kW5@KUDX_`kEtVZpz3QEIcy$9ObA=Y6AzynKj2{7RJa-1b! zr#!0NhPCa14Z{t^g{T2v9q**!aw$f>J4>+7#tJ6*?0$ct--k(C9#j^7a|$y_$m9JX zX(dkK0rOV_NgYH1>1hcWQ|E1Iww0kDa^$@r8k!0b#}5u3JwLUfVNbonQ;JsCP~0#q zt$HVQ#9*)C-Y`-8@n7;4414ZV96GbDi4Yu7^34Y))+tHhT&$)R@e~W2Uvook5YhPTOw^) z4x3AhJ1-XZb)(VHHaiN7eeQx37J=7UX{&Ka}~Qz`k{|Y%c*G?>|&`FBKkBsJgjVr`N^L zt^adb^HT9t_nIm{Snu-v2sP9+%FiXuo6E=JO8e7KszvcaIOnX=laFMLU|(aY-G(*p zfl)BF%5H`~az!pyfcaL9*CVnzq7oFC)|$!W7vA*Z-I9(&^V02SsI=I<%5QXE!Vrre<$32}=+-IRL9lLTD-S|BB`({Qxf-w`2WT zbqZz>WTfgM&?U}N1ABpI(K0{Bl**+9%A4(5d`w%(mP?%c^xL%(HGjkZ#TCXN(P_XU z+Gv$Z#!FgbwWCG2^RTWxu=KBx8Sj^YuY_GLYu-v1UV3bat|onEDCg{BkQ9(GaPlNp zUG06innp@F8c4zb7}61h1ZlY;ClAI&$j-omnyBMN(_305K&bWwwQZF|GQ(+iwLRJj zHUP4I?@Oi#bB?_QO3-AFUDf4Kp=p@7rbiuq&(Esk=>7dM8iz!b+S*c(DJR_WMcNXQ z`jbI;jAVEvPrqiwg;pMF*`g2I$P*)}G-Qcs_o+^>a+g(aqilGOF1n$&T+PIKZ>c%L z4K1nOQlR@OXku@)%9hqZLQ644BCEhu6FI7qP|J!1i;xIPpHuqe5q!kxW4Z^tbY$o?2)2L434I;GM4%oWC5bXt7Jw%4oML{9P6`Ic1WnQ z1X`fI9=RIE3n^x5VWUC~c3%2DFpDG}55iSl!151DBQ@tp3RZazOv3U4j&1B9dN0aJ z>uwxTV?N|(W~(6)?PDN3P?S;muW*$F#ACih4Gd$unBGaKS}H~&c6Fqst1l+HCyS)j z(V^w6%^7e)^{)0lg467aMC~q-SW8gLh5$k%A8%NOy&EQBdHX~%kO2#m7f=IyW3+9jz z(QOuwBjIM6q!BQ1DR1UM*$aaJ5(3iwfU$+a{0@F8FRBPjvTR&zGU{Ltjs|-b!Wjui zS3yw=1A0-#Kmi5=12L33MJ=4V;uI&uONc~CaxTP#^OEGINmTy@XAJ#&4LN_@Yq;I8 zWYv{0^5?;$>V~JO+7U~|TUc|!6x1mq%@w+9(eCOR>k2Ln4CH=!3>D`P&yoL7V5!iT zRLkS4wDKQUr#MH37+5>AWr@I2t~EdmsT2mG#=zhjY!mJxtYrx?4YJr0N~GEmQ*ey; zPK$}{T&idhoxVNuFc_4I{vY$)yT_oAki%fGMHo!H#lZD#4q{a68Xh7fQmUKNO0w~S z*P=VbDh95Ek6<8!0r*Kt#9B&8SY|0xoF#0K>&zOKT9GBuJP^LcJQ`G0&v6(kSKGm0 zY(YMOLr4Y##TZB&&T=l&!mv1J27Y8510o6W3E7!9Iu%jiw=1$lF4>$YzD(f5E)7f)1IDMG!?m?74|wz4D?`KQzyoHa{*rh)`U=>lAVoc@v+eg>tCA`JdfmWo$k zC$F_L#J9C19;odt15EUmTBlQFZeN|ZtjH3xVgtQsk&M%=h%x?*Dq|3ZM1Y5kVYn@{ z;v8m%Tr>telsAZr@uJF-khN5n0}OD*&=;y7MVDiGKzUXk^it(5_mhhDA_hwV1_2DH zUF_S|6HA!~Njzkhl)dWr0SrIDgZid{03bpK|W?Q4Dt zTFE#IZv^$oSc)PZs9^B^EX7-_1$CX`#L}3hSlOD4$EKsT)ULxizsQVz2Bk_$@N0PH z9EvFEKZ3M17II>G#;Zy{XxDhMq?Ay%k~z{K9CVRGXyXNJroF7#RIU!u~(o^8J0>e^e)XqwQ(9y zIBy~F76CTUzSWx?OC)6^TjK*Bz_8w1$nc}RrLv%XPw}JP!9jD2P)QQ&1FBe3F;GFS zd@rm8Qtx#HOMwjcb`0bJ`5B^+2PL1y`)>dM5CBO;K~&fLhx}NUDt=w32oXXW0t~d~(_3mg}5X0;dOnYD12Pyt}gmN*Oy7$~UFfDlc`b`e)1 z40RI%#frVEgyg1%NN>y_1_^kmSEE&Lg^j9qg%B_hViCYC$uCiN7{RV(y z5PqVo2xcT$c_%yk^B5f23WGCn-7KN(Fy1Q=gZ0}6hA^Up`Kx91wuEGFVUvt9 zKkad^&=>}StI7l!1BV9I7}6F9QwW4343;v3O_D&?&ZE@n+#bVt1EPvd5kHv+M(xtr zZ7S6{$8yLL2Y1hM3m&Lb+NGggH!RGh5<{{EOA3^xg55rA^^Nk!4N*G|Yup2)_Vev9 z-l313j@a6eJ?Fe%*X`Wso|=_pb|I$<;-6)m6Tv8Q^(1q z+9`27pc0QkVdIL#=T$|J`cq*ez{X077$K}+n|N`m0}MrPZ{K=PCh$XKxkVu~iqI-G zM$r%j{9Jqn(fpuF$GIw6OFNb{!hjhDJabVpNn?=6E6Q&PwL1)nbGBYlkIo`RR88iB zXbeFD3Ivj=p~Y`q>fv-wPKw50{Mo3MC^Gpo>4J+k4P?O|7*nw|FtnJd$Wc2*ZAeX0 zb%pJ-iHhCEVAIGwh9T*=sO^RI?1AJ3YfIlax7O~n34-O$=VX|Jc zH&7L*@kPE$rSQNEHI9uhFDoB+UNI?xgqivA>N*)M6-mZo0D~g!fN)5nDfEDt6~c+7&$Utm}jpb!)H#~ z`ksJL+fo!`s5!*dDXHZpQ|qPrn9`1R4D})^YzN5tp7Nr_mrp7-!-n@3qBb_ei8wYS z_99Zdwn57iBM0pi+KXJT%$UR%m&r%{VU?Z)oHiQWT~?6;+e!;=kV zv{#BENDGnKw-8drEsN0t$9h%tRTNw373+XRYUgPY$5t6{(eYWTOHBG|6_7A+ zBLP$3F3I;uvnF=qO0iMIvH?~142oijb_kUF6?2m=sX)ADg9-N{tnhRO7`!=ZEkg%@ zt0CvaHVz%YYr(>D+wK;F-a;c|h|st4;9u!A$#os9`%aR%bxk}?z& zR_*bVE)Xz--9ixaMQE6WZdNi9o_Z0*lcKn0OTWlT@7L#9oz+pzZM zz-lxe!Ka7Q*8Y=FV}z4W1a#SID9wE-g~#+;0_OyyGUy4!Ha@Dn(ugOY%e9y(3#e8n z`D$Du+0j0n=Dacw=>i)`?oLO`8(j5zJ)Q`ODQrn#JclwiX_r@_GOj2w8wQ3T298+j zc@PI$w00v-c{{2A(^F_!je$&dwp@8szPt(*=px4h`h(>MFnEO4c3P;kOt0-r4vE1K z;Bg5^basKlidBx~r3MDiMoVcqM?UNcfpi^I0(p#$Y8r9`kkdt?7xUfeVeii3=IT+r!+C1tc&U>Nvh4t zh#hK1hGtx#f-82ZJTO{W;ejEc)ahfdi;GAp-keL!Rd2<^O1{OzPA}p~#TSmsJLqCv zUbG#ScQ`tq%vYHVLS?l&G}g>hWgDmz7@1a7dg5%^sHdW=Uy3?J_Auc=LA06(473Hy zTh$JG32EHwc*q#UU=Z+JWLvV;xUdL#(hj2Ifiebz&ES!^7EpwNGZgfqMZD(Rco$b! zR`z1RP`%4>-WO5XanNOXNV}L+o|Y@}IVvx^@}h?`_?Xf=sEd4z%j8h=P_S{aU1WD2 z!BF&~{{RLyd{S+JQLn<2{Tv=QEd{O8($jBIZK5KX8!Ta)munN3{W+{nx2G9c`7i$> zD2J5spzF7iHT{^lrdN%a)blK*>XNLhV8QtEYzhGe@SGHHp2<*UDYq|_@&X2Oy2j(d z&J}~@C%ubI>i4%q!Q0JKcKf-fbQIF@UKHd|8Y4efS9J|xU8m|%o8DLnVQ_w9J)^^C|0y}` zRf7_wRZGD;-3_G`k7J3^!7~zW)G0K2%B6h?8xW5CoWREnx9X~{JA)HqMJ(~fB}4-t zfXDWW>(EVvZ_q~x*|JQw!Blu$GpksoEW~>bB4NTd(<7nGEjb~S<&4#-?S}O}2S#8g ze=KKGj-pyj)llY2SR(yZGAeY_rV>bir0!ls2}I&GWr?m@B#;cFYLT_^hNdBS>wH1T zaS;gjet>*s2+>F)&I<<2xY8b&E=V#N*tbN|Di#F5{!$$fD3unEQYx9BmZesRj}uU( zQTeDiJmf4zWC{@TDPw!OWS|zaLTXcMOq(ITAY@BZBSjxY=Ms&_`I6Eo&9cPtQN3;= zfn3zyl{EYWNW5jy|3xcmwFWs?=u4zEC~T~_Re0JZ3J2mSB#3vihD&8W8fgmZV2K~U z1ArPaI}dBQ3o#9HZS~O*C3&_;SssCSstt_|xkac~Tc6A+Z>QF3sR3g+DUpb~MR7&K z!6k*IPlq?8)eZ)viv0>TW5vrUMKKuijQV}qgm$+TEDgKe_#<(0K z)a=fzMpnsUB%(B8D^ULxr6)!LuR`F4A_C(Ey87eDR1(X@8V@((gS=W7h4UD3`CVWR z5->@W~wAbVbQwc&8&MMW$(d?}E|RhNra8G^i`kw7Goy0wp1d*cSTbzyl~ zoYMo