From f7ea39481a4ca46aaa11512d379b8e18d8d0c1fe Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:59:07 -0700 Subject: [PATCH] fix(kanban): dashboard estimate calls declare a relay-affinity key too The dashboard's estimate endpoints make the same headless auxiliary call as specify/decompose but never bound an affinity scope, so they still sent no x-opencode-session and the OpenCode Go relay answered 400 MissingSessionID (#112043). Declare kanban: for an existing task and a stable kanban:estimate key for the create dialog (no task yet), unless a scope is already bound. Test: _run_estimate captured header None before; now kanban:t_1 / kanban:estimate and nothing leaks past the call. --- plugins/kanban/dashboard/plugin_api.py | 14 +++++++--- tests/hermes_cli/test_kanban_aux_affinity.py | 27 ++++++++++++++++++++ website/docs/integrations/providers.md | 2 +- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 91b0369af1..9b09a818cb 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -1019,7 +1019,7 @@ class EstimateBody(BaseModel): @router.post("/estimate") def estimate_text_endpoint(payload: EstimateBody): """Estimate from raw title/body (create dialog, before a task exists).""" - return _run_estimate(payload.title, payload.body) + return _run_estimate(payload.title, payload.body, task_id=None) @router.post("/tasks/{task_id}/estimate") @@ -1027,7 +1027,7 @@ def estimate_task_endpoint(task_id: str, board: Optional[str] = Query(None)): """Estimate for an existing task; ``{ok, est_tokens, complexity, rationale, model}``.""" with _board_conn(board) as (board, conn): task = _require_task(conn, task_id) - return _run_estimate(task.title, task.body) + return _run_estimate(task.title, task.body, task_id=task_id) def _cap(s: Optional[str], n: int) -> str: @@ -1035,7 +1035,7 @@ def _cap(s: Optional[str], n: int) -> str: return s if len(s) <= n else s[:n] + "…" -def _run_estimate(title: str, body: Optional[str]) -> dict: +def _run_estimate(title: str, body: Optional[str], *, task_id: Optional[str]) -> dict: """Never raises — config/parse/API errors become ``{"ok": False, "reason"}`` so the UI renders them inline.""" if not (title or "").strip(): return {"ok": False, "reason": "a title is required to estimate"} @@ -1044,6 +1044,11 @@ def _run_estimate(title: str, body: Optional[str]) -> dict: except Exception: return {"ok": False, "reason": "auxiliary client unavailable"} user_msg = f"Title: {_cap(title, 400)}\n\nDescription:\n{_cap(body, 4000) or '(none)'}" + # Headless like specify/decompose's _call_aux: without a bound affinity scope the relay-affinity + # headers are omitted and the OpenCode Go relay answers 400 MissingSessionID (#112043). The + # create dialog has no task yet, so it shares one stable key. + from agent.portal_tags import get_affinity_scope, reset_affinity_scope, set_affinity_scope + affinity_token = None if get_affinity_scope() else set_affinity_scope(f"kanban:{task_id or 'estimate'}") try: resp = call_llm( task="kanban_estimator", @@ -1051,6 +1056,9 @@ def _run_estimate(title: str, body: Optional[str]) -> dict: temperature=0.0, max_tokens=300, timeout=60) except Exception as exc: return {"ok": False, "reason": f"LLM error: {type(exc).__name__}"} + finally: + if affinity_token is not None: + reset_affinity_scope(affinity_token) try: raw = (resp.choices[0].message.content or "").strip() model = getattr(resp, "model", None) diff --git a/tests/hermes_cli/test_kanban_aux_affinity.py b/tests/hermes_cli/test_kanban_aux_affinity.py index 56b4d45a9b..3b5133c2a0 100644 --- a/tests/hermes_cli/test_kanban_aux_affinity.py +++ b/tests/hermes_cli/test_kanban_aux_affinity.py @@ -4,6 +4,9 @@ relay-affinity key — the OpenCode Go relay rejects a request without ``x-openc from __future__ import annotations +import importlib.util +import sys +from pathlib import Path from types import SimpleNamespace from unittest.mock import patch @@ -46,3 +49,27 @@ def test_in_turn_caller_keeps_its_declared_affinity_key(): finally: reset_affinity_scope(token) assert seen == ["conversation-root"] + + +def _dashboard_plugin_api(): + mod_name = "hermes_dashboard_plugin_kanban_aux_affinity_test" + if mod_name not in sys.modules: + plugin_file = Path(__file__).resolve().parents[2] / "plugins" / "kanban" / "dashboard" / "plugin_api.py" + spec = importlib.util.spec_from_file_location(mod_name, plugin_file) + mod = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = mod + spec.loader.exec_module(mod) + return sys.modules[mod_name] + + +def test_dashboard_estimate_declares_an_affinity_key_too(): + """The dashboard estimate endpoints are the same headless aux call: per task when one exists, + one stable key for the create dialog (no task yet).""" + from agent.portal_tags import get_affinity_scope + api = _dashboard_plugin_api() + seen: list = [] + with patch("agent.auxiliary_client.call_llm", _capturing_call_llm(seen)): + api._run_estimate("title", "body", task_id="t_1") + api._run_estimate("title", "body", task_id=None) + assert seen == ["kanban:t_1", "kanban:estimate"] + assert get_affinity_scope() is None diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index 2e110e4e39..12a8098ed0 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -61,7 +61,7 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro | **LM Studio** | `hermes model` → "LM Studio" (provider: `lmstudio`, optional `LM_API_KEY`) | | **Custom Endpoint** | `hermes model` → choose "Custom endpoint" (saved in `config.yaml`) | -All three OpenCode providers send an opaque, per-conversation `x-opencode-session` header on every request (main turns on every transport plus auxiliary calls such as compression and titles; headless Kanban `specify`/`decompose` calls use a per-task key). OpenCode uses it to pin a conversation to one backend so its prompt cache stays warm; the value is derived from the Hermes session id (or the Kanban task id) and carries no personal data. +All three OpenCode providers send an opaque, per-conversation `x-opencode-session` header on every request (main turns on every transport plus auxiliary calls such as compression and titles; headless Kanban `specify`/`decompose` and dashboard estimate calls use a per-task key). OpenCode uses it to pin a conversation to one backend so its prompt cache stays warm; the value is derived from the Hermes session id (or the Kanban task id) and carries no personal data. For the official API-key path, see the dedicated [Google Gemini guide](/guides/google-gemini).