From d85531a910923b69b61b36b66b47d855af91a5f7 Mon Sep 17 00:00:00 2001 From: bbolinger <14969048+bbolinger@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:27:00 +0000 Subject: [PATCH 1/7] Remove dead code: orphaned slice helpers + unwired form-gateway text-fallback Drops code with zero references, left behind after single-STL was folded into the kit workflow and the model-free form landed: - u1_slice_workflow: _cmd_prefix, triage_stl, choose_default, _trim_option_payload, write_slice_summary, DEFAULT_OUT_BASE - form_gateway: get_pending_for_session, mark_awaiting_text, has_pending, set_notify, the _FormEntry.signature method, and the awaiting_text field. These were scaffolding for a text-fallback intercept (_handle_message) that was never built; typed answers route through the model instead. - u1_kit_tool: check_u1_kit_requirements (register_tool has no check slot) - u1_config: _ConfigPathProxy.exists_path - u1_toolmap: unused CHANNEL_EXTRUDER reverse map No behavior change. Full suite green (1040 passed, 15 skipped). --- adapters/hermes/tools/form_gateway.py | 57 ++------------------ adapters/hermes/tools/u1_kit_tool.py | 7 +-- scripts/u1_config.py | 3 -- scripts/u1_slice_workflow.py | 75 --------------------------- scripts/u1_toolmap.py | 1 - 5 files changed, 4 insertions(+), 139 deletions(-) diff --git a/adapters/hermes/tools/form_gateway.py b/adapters/hermes/tools/form_gateway.py index 721b251..60b6720 100644 --- a/adapters/hermes/tools/form_gateway.py +++ b/adapters/hermes/tools/form_gateway.py @@ -10,11 +10,9 @@ 1. **Native UI** (button-driven): adapter renders the schema as inline keyboards, handles taps in-place, and on Submit calls ``resolve_gateway_form(form_id, answer_dict)`` to unblock the agent. - 2. **Text fallback**: adapter shows the schema's ``text_fallback`` field - and the user types the one-line answer; the gateway's - ``_handle_message`` intercept resolves with the typed line (sets - ``answer = {"_text": ""}``); the form tool can route through - ``u1_form.parse_answers`` itself. + 2. **Text fallback**: the schema also carries a ``text_fallback`` field so + an adapter without native UI can present a typed one-line path to the + same answer. Module-level state (same shape as ``clarify_gateway``) so platform adapters call ``resolve_gateway_form`` without holding a back-reference to the @@ -49,15 +47,6 @@ class _FormEntry: schema: Dict[str, Any] event: threading.Event = field(default_factory=threading.Event) response: Optional[Dict[str, Any]] = None - awaiting_text: bool = False # set when adapter falls back to text intake - - def signature(self) -> Dict[str, object]: - return { - "form_id": self.form_id, - "session_key": self.session_key, - "schema_version": self.schema.get("version"), - "fields": [f.get("id") for f in self.schema.get("fields", [])], - } _lock = threading.RLock() @@ -151,41 +140,6 @@ def cancel_gateway_form(form_id: str) -> bool: return resolve_gateway_form(form_id, {"_cancelled": True}) -def get_pending_for_session(session_key: str) -> Optional[_FormEntry]: - """Return the OLDEST pending form entry for a session, or None. - - Used by the text-fallback intercept in ``_handle_message`` — when a form - is awaiting a free-form text response (operator typed instead of - tapping), the next user message in that session is captured. - """ - with _lock: - ids = _session_index.get(session_key) or [] - for fid in ids: - entry = _entries.get(fid) - if entry is None: - continue - if entry.awaiting_text: - return entry - return None - - -def mark_awaiting_text(form_id: str) -> bool: - """Flip an entry into text-capture mode (operator typed instead of tapping).""" - with _lock: - entry = _entries.get(form_id) - if entry is None: - return False - entry.awaiting_text = True - return True - - -def has_pending(session_key: str) -> bool: - """True when this session has at least one pending form entry.""" - with _lock: - ids = _session_index.get(session_key) or [] - return any(_entries.get(fid) is not None for fid in ids) - - def clear_session(session_key: str) -> int: """Drop every pending form for a session (e.g. on session boundary). @@ -280,11 +234,6 @@ def get_notify(session_id: str = "") -> Optional[Any]: return get_form_callback(session_id) -def set_notify(session_id: str, callback: Any) -> None: - """Publish the form callback for a session. Alias of set_form_callback.""" - set_form_callback(session_id, callback) - - def invoke_form(session_id: str, form_schema: Dict[str, Any]) -> Dict[str, Any]: """Render ``form_schema`` to the operator and block until they submit, returning the answer dict. diff --git a/adapters/hermes/tools/u1_kit_tool.py b/adapters/hermes/tools/u1_kit_tool.py index d4ab363..f6870ba 100644 --- a/adapters/hermes/tools/u1_kit_tool.py +++ b/adapters/hermes/tools/u1_kit_tool.py @@ -395,11 +395,6 @@ def u1_kit_tool( return f"{_passthrough}\n{_summary}" if _passthrough else _summary -def check_u1_kit_requirements() -> bool: - """u1_kit needs the workflow script at the expected deploy path.""" - return Path(DEFAULT_WORKFLOW_SCRIPT).exists() - - # ============================================================================= # Function-calling tool schema # ============================================================================= @@ -448,4 +443,4 @@ def check_u1_kit_requirements() -> bool: # a runtime-registered toolset can never satisfy). The u1-form plugin registers # u1_kit as its own OFFERED toolset via ctx.register_tool (see # adapters/hermes/plugin/__init__.py register()). This module just exposes the -# handler (u1_kit_tool) + schema (U1_KIT_SCHEMA) + check (check_u1_kit_requirements). +# handler (u1_kit_tool) + schema (U1_KIT_SCHEMA). diff --git a/scripts/u1_config.py b/scripts/u1_config.py index fff6bbb..01411d0 100755 --- a/scripts/u1_config.py +++ b/scripts/u1_config.py @@ -166,9 +166,6 @@ def __repr__(self) -> str: return repr(get_config_path()) def __eq__(self, other: object) -> bool: return get_config_path() == other - @property - def exists_path(self) -> Path: - return get_config_path() def exists(self) -> bool: return get_config_path().exists() def read_text(self, *args, **kwargs) -> str: diff --git a/scripts/u1_slice_workflow.py b/scripts/u1_slice_workflow.py index dab6681..bf7c463 100755 --- a/scripts/u1_slice_workflow.py +++ b/scripts/u1_slice_workflow.py @@ -231,8 +231,6 @@ def _audit(request_id: str, event: str, operator: str, **details): return None -DEFAULT_OUT_BASE=ROOT/'artifacts'/'slice_workflow' - # Mirrored events file for harness/recovery. Set by run_workflow once out_dir # is known. Both --json-events stdout AND the human-readable mode write to # this file, so the file is always a complete audit trail regardless of how @@ -570,49 +568,6 @@ def _shell_quote(s: str) -> str: return "'" + s.replace("'", "'\\''") + "'" -def _cmd_prefix(script_path: str, model_path: str, args) -> str: - """Build the cumulative invocation prefix from args that are already set. - Each subsequent need_input's next_command extends this prefix with one - more flag + the option's value. - - v2.0 Phase 2: includes --request-id so agent re-invocations pin to the - same on-disk request. Recovery via content hash still works without it - but explicit --request-id is more robust against context loss + faster - (no hash recompute, no directory scan).""" - parts = ['python3', script_path, _shell_quote(model_path), '--json-events'] - # Pin request_id so chained next_command invocations all hit the same - # request folder. This is what makes the agent's flow context-loss-resistant. - if getattr(args, 'request_id', None): - parts += ['--request-id', args.request_id] - if getattr(args, 'orient', None): - parts += ['--orient', args.orient] - if getattr(args, 'tool', None): - parts += ['--tool', args.tool] - if getattr(args, 'material', None): - parts += ['--material', _shell_quote(args.material)] - if getattr(args, 'profile', None): - parts += ['--profile', _shell_quote(args.profile)] - if getattr(args, 'supports', None): - parts += ['--supports', args.supports] - if getattr(args, 'nozzle', None) and args.nozzle != '0.4': - parts += ['--nozzle', args.nozzle] - return ' '.join(parts) - -def triage_stl(stl: Path)->dict[str,Any]: - tris=parse_stl(stl); xmin,xmax,ymin,ymax,zmin,zmax=bbox(tris) - vol=(xmax-xmin)*(ymax-ymin)*(zmax-zmin)/1000.0 - return {'dims_mm':[round(xmax-xmin,2), round(ymax-ymin,2), round(zmax-zmin,2)], 'tris': int(tris.shape[0]), 'bbox_volume_cm3': round(vol,2)} - -def choose_default(options: list[dict[str,Any]], supplied: str|None=None): - if supplied: - for o in options: - if supplied == o.get('value') or supplied.lower() in str(o.get('label','')).lower(): return o.get('value') - return supplied - for o in options: - if o.get('recommended'): return o.get('value') - return options[0].get('value') if options else None - - def promote_to_supports_variant(profile_value: str) -> str | None: """Pre-v1.5.1 behavior — superseded by apply_supports_override below. @@ -1693,36 +1648,6 @@ def _bboxes_differ(stl_a: Path, stl_b: Path, tol_mm: float = 0.5) -> bool: except Exception: return True # if we can't compare, err toward "show both" -def _trim_option_payload(opts: list[dict[str, Any]], keep_keys: tuple[str, ...] = ('label', 'value', 'recommended', 'material', 'loaded', 'supports_status', 'source', 'has_supports')) -> list[dict[str, Any]]: - """Strip large/internal fields from need_input option payloads. Notably - drops 'path' from profile options (multi-KB file paths the agent doesn't - need — workflow resolves by value internally). Token-saving for --json-events - consumers; reduces typical preset event from ~3KB to ~500B.""" - return [{k: v for k, v in o.items() if k in keep_keys} for o in opts] - -def write_slice_summary(out_dir: Path, slice_res: dict[str, Any]) -> Path: - """Write a terse text summary alongside the gcode. Agents should read this - instead of re-parsing the gcode (gcode reads inline 12KB of base64 thumbnail - data on every read; this is ~300 bytes).""" - meta = slice_res.get('metadata', {}) - moonraker = (slice_res.get('moonraker_metadata') or {}) if isinstance(slice_res.get('moonraker_metadata'), dict) else {} - summary_path = out_dir / 'slice_summary.txt' - lines = [ - f"time = {slice_res.get('time', '?')}", - f"weight_g = {slice_res.get('weight_g', '?')}", - f"layer_count = {moonraker.get('layer_count', '?')}", - f"layer_height = {meta.get('layer_height', '?')}", - f"profile = {meta.get('print_settings_id', '?')}", - f"material = {meta.get('filament_type', '?')}", - f"tool_idx = {slice_res.get('tool_idx', '?')}", - f"tool_rewrites= {slice_res.get('tool_rewrites', 0)}", - f"thumbnails = {slice_res.get('thumbnails', {}).get('ok', False)}", - f"warnings = {', '.join(slice_res.get('warnings', [])) or 'none'}", - f"gcode = {slice_res.get('gcode', '?')}", - ] - summary_path.write_text('\n'.join(lines) + '\n') - return summary_path - def run_workflow(args)->dict[str,Any]: """v1.4.6 flow: dual-render (source + auto-oriented if different) BEFORE asking questions. User sees both orientation options visually before diff --git a/scripts/u1_toolmap.py b/scripts/u1_toolmap.py index 0a5eac2..95cec0c 100755 --- a/scripts/u1_toolmap.py +++ b/scripts/u1_toolmap.py @@ -28,7 +28,6 @@ def _default_map_path() -> Path: EXTRUDER_NAMES = ["extruder", "extruder1", "extruder2", "extruder3"] EXTRUDER_CHANNEL = {"extruder": 0, "extruder1": 1, "extruder2": 2, "extruder3": 3} -CHANNEL_EXTRUDER = {v: k for k, v in EXTRUDER_CHANNEL.items()} _UNKNOWN_MATERIALS = {"", "UNKNOWN", "NONE"} From 5be41c645f087cf01741573428fa1f74b4973ab1 Mon Sep 17 00:00:00 2001 From: bbolinger <14969048+bbolinger@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:06:39 +0000 Subject: [PATCH 2/7] docs: drop stale slice_summary.txt reference in TROUBLESHOOTING write_slice_summary (the only producer of slice_summary.txt) had no callers and was removed in the dead-code pass, so that file is not generated. Point the profile-verification tip at where print_settings_id actually lives: the slice result metadata and the gcode's own metadata. --- TROUBLESHOOTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 5a7223d..0807e8d 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -95,7 +95,7 @@ You can also pass the original human-readable name; `normalize_value()` canonica 3. **Handwrite a flattened version** of the stock profile. Open the stock JSON, manually copy every field from the inherited parent into the child JSON, drop the `inherits` field. Use `examples/profiles/community_merged_*.json` as the shape reference. Save in `profiles/user/`. -After v1.4.2 the workflow **surfaces** `slice.metadata.print_settings_id` in `slice_summary.txt` (next to the gcode); the **agent** (or you, if you're driving the CLI) should inspect it and compare against the preset you requested. If the names don't match, treat the slice as authoritative about what Orca actually did — Orca silently fell back to a different profile. +The workflow **surfaces** `slice.metadata.print_settings_id` in the slice result, and it is stamped into the gcode's own metadata next to the file; the **agent** (or you, if you're driving the CLI) should inspect it and compare against the preset you requested. If the names don't match, treat the slice as authoritative about what Orca actually did; it silently fell back to a different profile. ### `warning` event with `kind:"no_supports_variant"` From 166b0ef7a66d536f245ffaa5da41e3e0c5dcfb5e Mon Sep 17 00:00:00 2001 From: bbolinger <14969048+bbolinger@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:45:38 +0000 Subject: [PATCH 3/7] Copies: +/- stepper instead of a capped 1-9 option grid The second setup question (copies of a single-part job) was a fixed grid that stopped at 9. Reuse the numeric stepper the advanced settings already use: a "Copies" header plus a -5/-/+/+5 row, dialing 1..50 inline on the setup screen. - register a "quantity" stepper (steps 5/1, range 1..50) and mark the field - render steppers on grouped screens too, with a plain header (base = the field default, no "keep profile" language) for a top-level count vs a profile override - review echo and answer_json read the dialed value from form["steps"] - parse validates the count against the range instead of a fixed option list; text ("x12", "qty 12") and JSON both accept 1..50, reject out of range - drop the now-unused _QUANTITY_IDS Full suite green (1045 passed, 13 skipped). --- adapters/telegram/u1_form_telegram.py | 61 ++++++++++++------- scripts/u1_form.py | 52 ++++++++++------- tests/test_quantity.py | 84 +++++++++++++++++++-------- 3 files changed, 132 insertions(+), 65 deletions(-) diff --git a/adapters/telegram/u1_form_telegram.py b/adapters/telegram/u1_form_telegram.py index cb8b471..08a731a 100644 --- a/adapters/telegram/u1_form_telegram.py +++ b/adapters/telegram/u1_form_telegram.py @@ -266,9 +266,14 @@ def _stepper_state(form: dict[str, Any], field: dict[str, Any]): base = ((form["schema"].get("temp_current_by_material") or {}).get(mat) or {}).get(field["id"]) rng = ((form["schema"].get("temp_range_by_material") or {}).get(mat) or {}).get(field["id"]) lo, hi = (rng[0], rng[1]) if rng else (cfg.get("min", 0), cfg.get("max", 300)) - else: + elif field.get("advanced"): base = _num(_resolved_for_selected_profile(form).get(field["id"])) lo, hi = cfg.get("min", 0), cfg.get("max", 999) + else: + # A plain top-level count (e.g. Copies): the dial starts at the field's + # own default; there is no profile to resolve against. + base = _num(field.get("default")) + lo, hi = cfg.get("min", 0), cfg.get("max", 999) return target, base, (lo, hi), steps, unit @@ -304,6 +309,14 @@ def _paginate(opts: list, page: int, size: int) -> tuple[list, int, int]: def _selection_label_for(form: dict[str, Any], field: dict[str, Any]) -> str: """Human-readable echo of a field's current selection (for the review card).""" + if field.get("stepper"): + # A stepper carries its value in form["steps"], not an option index. + t = (form.get("steps") or {}).get(field["id"]) + unit = (field.get("stepper") or {}).get("unit", "") + if t is not None: + return f"{t}{unit}" + d = field.get("default") + return f"{d}{unit} (default)" if d not in (None, "") else "not set" val = form["selections"].get(field["id"]) opts = field["options"] if field["type"] == "multi_select": @@ -384,6 +397,32 @@ def _field_control_rows(form: dict[str, Any], field: dict[str, Any], # its alternatives pack two-up beneath. Keeps each setting a scannable unit # instead of a tall column of every option (live 2026-07-15: the flat stack # read as "a fat chunk of info"). Advanced fields never paginate (<=7 opts). + # Numeric controls render as a +/- STEPPER so the operator dials ANY exact + # value in one row. A full-width header shows the current value; the row + # below is the steps ([5,1] -> -5/-/+/+5, [1] -> -/+), clamped to range. + # State lives in form["steps"]. This handles both an advanced override + # (base = the profile's resolved value, tap the header to keep it) and a + # plain top-level count like Copies (base = the field default, no profile + # language, tap the header to reset to the default). Works on a grouped + # screen too, so Copies sits inline with the other setup questions. + if field.get("stepper"): + target, base, _rng, steps, unit = _stepper_state(form, field) + name = (field.get("label", field["id"]).replace(" temperature", "") + .replace(" density", "").replace(" loops", "").replace(" layers", "")) + if not field.get("advanced"): + val = target if target is not None else base + head = f"{name}: {val}{unit}" + elif target is None: + head = f"{name}: keep profile" + (f" ({base}{unit})" if base is not None else "") + else: + head = f"{name}: {target}{unit}" + (f" · profile {base}{unit}" if base is not None else "") + rows.append([{"text": head, "callback_data": f"T:{fi}:k"}]) + minus = [{"text": (f"−{s}" if s > 1 else "−"), "callback_data": f"T:{fi}:-{s}"} + for s in sorted(steps, reverse=True)] + plus = [{"text": (f"+{s}" if s > 1 else "+"), "callback_data": f"T:{fi}:{s}"} + for s in sorted(steps)] + rows.append(minus + plus) + return rows if field.get("advanced"): # A full-width HEADER (the setting name + its current value; tap = keep # the profile default) followed by the alternatives as BARE values three- @@ -391,26 +430,6 @@ def _field_control_rows(form: dict[str, Any], field: dict[str, Any], # repeat it (live 2026-07-15: "Infill 10% / Infill 15% / ..." doubled the # text and truncated wider labels at two-up). The default option is first # in every advanced field, so the header row lands before the values. - if field.get("stepper"): - # A numeric control (temperature, infill, walls, shells) renders as a - # +/- STEPPER so the operator dials ANY exact value in one row. A - # full-width header shows the setting and the current target (tap it - # to keep the profile); the row below is the steps ([5,1] -> -5/-/+/+5, - # [1] -> -/+), clamped to range. State lives in form["steps"]. - target, base, _rng, steps, unit = _stepper_state(form, field) - name = (field.get("label", field["id"]).replace(" temperature", "") - .replace(" density", "").replace(" loops", "").replace(" layers", "")) - if target is None: - head = f"{name}: keep profile" + (f" ({base}{unit})" if base is not None else "") - else: - head = f"{name}: {target}{unit}" + (f" · profile {base}{unit}" if base is not None else "") - rows.append([{"text": head, "callback_data": f"T:{fi}:k"}]) - minus = [{"text": (f"−{s}" if s > 1 else "−"), "callback_data": f"T:{fi}:-{s}"} - for s in sorted(steps, reverse=True)] - plus = [{"text": (f"+{s}" if s > 1 else "+"), "callback_data": f"T:{fi}:{s}"} - for s in sorted(steps)] - rows.append(minus + plus) - return rows alts: list[dict[str, str]] = [] for oi, opt in enumerate(field["options"]): oid = _opt_id(opt) diff --git a/scripts/u1_form.py b/scripts/u1_form.py index ee8ce98..1dbdd92 100644 --- a/scripts/u1_form.py +++ b/scripts/u1_form.py @@ -252,12 +252,14 @@ def resolve_advanced_from_profile(flat_process: dict[str, Any]) -> dict[str, str ) _TEMP_BY_ID = {fid: base_key for fid, _lbl, base_key, _opts in TEMP_FIELDS} -# Numeric advanced controls render as a +/- STEPPER (not an option grid) so the -# operator dials any exact value. steps = the step buttons offered ([5, 1] gives +# Numeric controls render as a +/- STEPPER (not an option grid) so the operator +# dials any exact value. steps = the step buttons offered ([5, 1] gives # -5/-/+/+5; [1] gives -/+). unit is the display suffix; for a process override # it's also the value suffix ("%" for infill, "" for counts). min/max bound the # dial. Temperature fields override their bound per material via -# temp_range_by_material; the process fields use these fixed bounds. +# temp_range_by_material; the process fields use these fixed bounds. "quantity" +# is the odd one out: a plain top-level count (copies), not a profile override, +# so it dials from the field default (1) with no "keep profile" language. _STEPPER = { "nozzle_temp": {"steps": (5, 1), "unit": "°C", "min": 0, "max": 300}, "bed_temp": {"steps": (5, 1), "unit": "°C", "min": 0, "max": 120}, @@ -265,6 +267,7 @@ def resolve_advanced_from_profile(flat_process: dict[str, Any]) -> dict[str, str "walls": {"steps": (1,), "unit": "", "min": 1, "max": 8}, "top_shell": {"steps": (1,), "unit": "", "min": 0, "max": 15}, "bottom_shell": {"steps": (1,), "unit": "", "min": 0, "max": 15}, + "quantity": {"steps": (5, 1), "unit": "", "min": 1, "max": 50}, } # Bare labels for the temp buttons under the per-setting header (mirrors @@ -285,7 +288,6 @@ def resolve_advanced_from_profile(flat_process: dict[str, Any]) -> dict[str, str ("1", "1 copy"), ("2", "2 copies"), ("3", "3 copies"), ("4", "4 copies"), ("6", "6 copies"), ("9", "9 copies"), ) -_QUANTITY_IDS = {qid for qid, _ in QUANTITY_OPTIONS} _TOOL_RE = re.compile(r"^t([0-9])$", re.IGNORECASE) @@ -459,12 +461,13 @@ def parse_answers(line: str, spec: dict[str, Any]) -> dict[str, Any]: m = _QTY_RE.match(tok) if m: q = int(next(g for g in m.groups() if g)) - if str(q) not in _QUANTITY_IDS: - errors.append( - f"quantity {q} not offered " - f"(have {', '.join(i for i, _ in QUANTITY_OPTIONS)})") - else: + _qcfg = _STEPPER["quantity"] + if _qcfg["min"] <= q <= _qcfg["max"]: _set(values, "quantity", q, errors, tok) + else: + errors.append( + f"quantity {q} out of range " + f"({_qcfg['min']}-{_qcfg['max']})") continue # Advanced overrides (v2.3), only when the spec offers them. Each maps @@ -1082,13 +1085,15 @@ def build_form_schema(spec: dict[str, Any], *, submit: dict[str, str] | None = N "options": [{"id": s, "label": _sup_lbl.get(s, s)} for s in _sup_ordered], "default": "no-supports"}) # Quantity (v2.3): copies of the lone part — only when the workflow - # offered it (single-part jobs). Rides the setup group so it doesn't add - # a screen; compact packs the six options two per row. + # offered it (single-part jobs). Rides the setup group so it doesn't add a + # screen. Renders as a +/- stepper (the old 1-9 option grid capped too low); + # options are kept inert so the default resolves and text mode still parses. if spec.get("offer_quantity"): fields.append({"id": "quantity", "type": "single_select", - "label": "Quantity", "group": _GROUP, "compact": True, + "label": "Copies", "group": _GROUP, "options": [{"id": i, "label": l} for i, l in QUANTITY_OPTIONS], - "default": "1", "required": False}) + "default": "1", "required": False, + "stepper": _STEPPER["quantity"]}) profiles = spec.get("profiles", []) if profiles: _pfield = {"id": "profile", "type": "single_select", "label": "Print profile", @@ -1272,16 +1277,21 @@ def parse_answers_json(obj: dict[str, Any], spec: dict[str, Any]) -> dict[str, A errors.append(f"unknown action {obj['action']!r}") # quantity (v2.3) — honored only when the spec offered it (single-part - # jobs). Option ids are strings ("1".."9"); a widget can only send an - # offered id, so anything else fails loudly. + # jobs). The stepper sends an absolute count, so validate against the + # control's range rather than a fixed option list. if spec.get("offer_quantity") and obj.get("quantity") not in (None, ""): - q = str(obj["quantity"]).strip() - if q in _QUANTITY_IDS: - values["quantity"] = int(q) + _qcfg = _STEPPER["quantity"] + try: + q = int(round(float(str(obj["quantity"]).strip()))) + except (TypeError, ValueError): + errors.append(f"invalid quantity {obj['quantity']!r}") else: - errors.append( - f"quantity {obj['quantity']!r} not offered " - f"(have {', '.join(i for i, _ in QUANTITY_OPTIONS)})") + if _qcfg["min"] <= q <= _qcfg["max"]: + values["quantity"] = q + else: + errors.append( + f"quantity {q} out of range " + f"({_qcfg['min']}-{_qcfg['max']})") # Advanced overrides (v2.3) — honored only when the spec offered them. # "default" (or absent) = no override; anything else must be an offered diff --git a/tests/test_quantity.py b/tests/test_quantity.py index 4b6e450..e90790f 100644 --- a/tests/test_quantity.py +++ b/tests/test_quantity.py @@ -111,16 +111,14 @@ def fake_list_profiles(nozzle=None, history_print_settings_id=None): # ---------- schema ---------- -def test_schema_quantity_field_rides_setup_group(): +def test_schema_quantity_is_a_stepper_on_the_setup_group(): schema = u1_form.build_form_schema(_spec()) q = next(f for f in schema["fields"] if f["id"] == "quantity") - assert q["type"] == "single_select" and q["label"] == "Quantity" - assert q["group"] == "setup" and q["compact"] is True + assert q["label"] == "Copies" and q["group"] == "setup" assert q["default"] == "1" and q["required"] is False - assert [o["id"] for o in q["options"]] == ["1", "2", "3", "4", "6", "9"] - # self-describing labels — the shared setup screen shows bare buttons - assert q["options"][0]["label"] == "1 copy" - assert all("cop" in o["label"] for o in q["options"]) + # rendered as a +/- stepper (1..50), no longer a capped 1-9 option grid + assert q.get("stepper") == {"steps": (5, 1), "unit": "", "min": 1, "max": 50} + assert not q.get("advanced") # a plain top-level count, not a profile override # not offered -> absent entirely schema2 = u1_form.build_form_schema(_spec(offer=False)) assert "quantity" not in [f["id"] for f in schema2["fields"]] @@ -182,10 +180,17 @@ def test_text_repeated_same_quantity_is_harmless(): assert r["values"]["quantity"] == 3 -def test_text_unoffered_count_rejected(): +def test_text_in_range_count_now_accepted(): + # 5 was NOT in the old 1/2/3/4/6/9 grid; the stepper's 1..50 range accepts it r = u1_form.parse_answers("T0 | PLA | profile 1 | x5", _spec()) + assert r["ok"], r["errors"] + assert r["values"]["quantity"] == 5 + + +def test_text_out_of_range_count_rejected(): + r = u1_form.parse_answers("T0 | PLA | profile 1 | x99", _spec()) assert not r["ok"] - assert any("quantity 5 not offered" in e for e in r["errors"]) + assert any("out of range" in e for e in r["errors"]) def test_text_quantity_ignored_when_not_offered(): @@ -212,11 +217,18 @@ def test_json_quantity_defaults_to_one(): assert r["ok"] and r["values"]["quantity"] == 1 -def test_json_unoffered_count_fails_loudly(): +def test_json_count_past_nine_accepted(): + r = u1_form.parse_answers_json( + {"tool": "T0", "material": "PLA", "profile": 1, "quantity": "12"}, _spec()) + assert r["ok"], r["errors"] + assert r["values"]["quantity"] == 12 + + +def test_json_out_of_range_count_fails_loudly(): r = u1_form.parse_answers_json( - {"tool": "T0", "material": "PLA", "profile": 1, "quantity": "5"}, _spec()) + {"tool": "T0", "material": "PLA", "profile": 1, "quantity": "99"}, _spec()) assert not r["ok"] - assert any("quantity" in e for e in r["errors"]) + assert any("out of range" in e for e in r["errors"]) def test_json_quantity_ignored_when_not_offered(): @@ -246,24 +258,50 @@ def test_echo_parse_shows_quantity_only_when_plural(): # ---------- renderer ---------- -def test_renderer_quantity_shares_setup_screen_and_review_echoes(): +def test_renderer_quantity_is_a_stepper_that_dials_past_nine(): schema = u1_form.build_form_schema(_spec()) form = tg.new_form(schema) - # quantity renders on the SAME screen as orient + supports (no new step) + # quantity renders on the SAME screen as supports (no new step) screen_ids = [[f["id"] for f in sc] for sc in tg._screens(form)] setup = next(sc for sc in screen_ids if "supports" in sc) assert "quantity" in setup - # default review line reads "1 copy" - form["current"] = tg.REVIEW_FIELD - assert "1 copy" in tg.render_screen(form)["text"] - # pick 3 copies (grouped radio: marks, group Next advances) fi = tg._field_index(form, "quantity") - three = next(i for i, o in enumerate(tg._field(form, "quantity")["options"]) - if tg._opt_id(o) == "3") - tg.apply_callback(form, f"s:{fi}:{three}") + form["current"] = "quantity" + kb = tg.render_screen(form)["keyboard"] + # a header showing the current count (tap = reset) + one step row -5/-/+/+5 + assert any(b.get("callback_data") == f"T:{fi}:k" and "Copies: 1" in b["text"] + for row in kb for b in row) + step_cbs = [b["callback_data"] for row in kb for b in row + if b.get("callback_data", "").startswith(f"T:{fi}:") + and b["callback_data"] != f"T:{fi}:k"] + assert step_cbs == [f"T:{fi}:-5", f"T:{fi}:-1", f"T:{fi}:1", f"T:{fi}:5"] + # dial PAST the old 9 cap: +5 +5 +1 -> 12 (the whole point) + tg.apply_callback(form, f"T:{fi}:5") + tg.apply_callback(form, f"T:{fi}:5") + tg.apply_callback(form, f"T:{fi}:1") + assert form["steps"]["quantity"] == 12 + assert tg.answer_json(form)["quantity"] == "12" + # review echoes the dialed count form["current"] = tg.REVIEW_FIELD - assert "3 copies" in tg.render_screen(form)["text"] - assert tg.answer_json(form)["quantity"] == "3" + assert "12" in tg.render_screen(form)["text"] + + +def test_renderer_quantity_clamps_and_resets(): + schema = u1_form.build_form_schema(_spec()) + form = tg.new_form(schema) + fi = tg._field_index(form, "quantity") + # can't dial below the floor of 1 + for _ in range(5): + tg.apply_callback(form, f"T:{fi}:-5") + assert form["steps"]["quantity"] == 1 + # clamps at the 50 ceiling + for _ in range(20): + tg.apply_callback(form, f"T:{fi}:5") + assert form["steps"]["quantity"] == 50 + assert tg.answer_json(form)["quantity"] == "50" + # tap the header -> reset to default (omitted from answers -> workflow uses 1) + tg.apply_callback(form, f"T:{fi}:k") + assert "quantity" not in tg.answer_json(form) # ---------- commit path (workflow) ---------- From ca8ef5cddab166babc387d41d0936fa0292966e5 Mon Sep 17 00:00:00 2001 From: bbolinger <14969048+bbolinger@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:58:46 +0000 Subject: [PATCH 4/7] Bed capture: retry a truncated / transient camera frame The U1 MJPEG endpoint intermittently closes the connection a few hundred bytes short of the declared length (http.client.IncompleteRead) or returns a JPEG missing its EOI marker. fetch_monitor took the first frame unconditionally, so a single short read aborted _capture_bed_and_issue_token and the start gate refused a real print (live 2026-07-18: a 487-byte-short frame during a kit drill). Re-GET up to 3 times, accepting only a complete JPEG (SOI + EOI, over 1KB); persistent failure still raises so the caller stays fail-closed. Adds test_u1_camera_retry.py. Full suite green (1050 passed, 13 skipped). --- scripts/u1_camera.py | 40 +++++++++++++++++-- tests/test_u1_camera_retry.py | 75 +++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 tests/test_u1_camera_retry.py diff --git a/scripts/u1_camera.py b/scripts/u1_camera.py index 44d964a..eb9c058 100755 --- a/scripts/u1_camera.py +++ b/scripts/u1_camera.py @@ -146,12 +146,46 @@ def start_monitor(host: str, port: int, interval: float) -> list[dict]: ) -def fetch_monitor(host: str, port: int, output: str) -> dict: - image = http_get(f"{moonraker_base(host, port)}/server/files/camera/monitor.jpg", timeout=10) +def _looks_complete_jpeg(image: bytes) -> bool: + """A full JPEG starts with SOI (FFD8FF) and ends with EOI (FFD9). The U1 + camera occasionally returns a frame truncated a few hundred bytes short of + the declared length, which passes the SOI check but is missing the EOI.""" + return (len(image) > 1024 and image[:3] == b"\xff\xd8\xff" + and b"\xff\xd9" in image[-16:]) + + +def fetch_monitor(host: str, port: int, output: str, attempts: int = 3) -> dict: + """Fetch monitor.jpg, retrying on a truncated / transient read. + + The U1's MJPEG endpoint intermittently closes the connection a few hundred + bytes short of the declared Content-Length (http.client.IncompleteRead) or + hands back a JPEG missing its end-of-image marker. A single short frame must + NOT abort a gated start (live 2026-07-18: a 487-byte-short frame refused a + real print), so re-GET a few times before giving up. Persistent failure + still raises so the caller stays fail-closed.""" + url = f"{moonraker_base(host, port)}/server/files/camera/monitor.jpg" + last_exc: Exception | None = None + image = b"" + for attempt in range(1, attempts + 1): + try: + image = http_get(url, timeout=10) + except Exception as exc: # IncompleteRead, timeout, connection reset + last_exc, image = exc, b"" + if _looks_complete_jpeg(image): + break + if attempt < attempts: + time.sleep(0.5 * attempt) + else: + if last_exc is not None: + raise last_exc + raise ValueError( + f"camera returned {len(image)} bytes but never a complete JPEG " + f"in {attempts} attempts (truncated frame)") out = Path(output) out.parent.mkdir(parents=True, exist_ok=True) out.write_bytes(image) - return {"output": str(out), "bytes": len(image), "jpeg_magic": image[:3] == b"\xff\xd8\xff"} + return {"output": str(out), "bytes": len(image), + "jpeg_magic": image[:3] == b"\xff\xd8\xff"} def capture_photo(host: str, port: int, output: str, diff --git a/tests/test_u1_camera_retry.py b/tests/test_u1_camera_retry.py new file mode 100644 index 0000000..9fb10df --- /dev/null +++ b/tests/test_u1_camera_retry.py @@ -0,0 +1,75 @@ +"""fetch_monitor retries a truncated / transient camera read instead of +aborting a gated start. Live 2026-07-18: the U1 camera returned a frame 487 +bytes short of its declared length (http.client.IncompleteRead) during a kit +drill, so _capture_bed_and_issue_token failed and the start gate refused a real +print. A single short frame must not do that; persistent failure still raises so +the caller stays fail-closed. +""" +from __future__ import annotations + +from http.client import IncompleteRead + +import pytest + +import u1_camera + +_SOI = b"\xff\xd8\xff" +_EOI = b"\xff\xd9" +_COMPLETE = _SOI + b"\x00" * 4000 + _EOI # full JPEG: SOI .. EOI +_TRUNCATED = _SOI + b"\x00" * 4000 # missing the EOI marker + + +@pytest.fixture(autouse=True) +def _no_sleep(monkeypatch): + monkeypatch.setattr(u1_camera.time, "sleep", lambda *a, **k: None) + + +def _seq_http_get(monkeypatch, results): + """Stub http_get to return/raise `results` in order (last item repeats).""" + calls = {"n": 0} + + def fake(url, timeout=10.0): + r = results[min(calls["n"], len(results) - 1)] + calls["n"] += 1 + if isinstance(r, Exception): + raise r + return r + + monkeypatch.setattr(u1_camera, "http_get", fake) + return calls + + +def test_looks_complete_jpeg(): + assert u1_camera._looks_complete_jpeg(_COMPLETE) + assert not u1_camera._looks_complete_jpeg(_TRUNCATED) # no EOI + assert not u1_camera._looks_complete_jpeg(_SOI + _EOI) # too small + assert not u1_camera._looks_complete_jpeg(b"nope" * 500) # not a JPEG + + +def test_retries_after_incomplete_read(tmp_path, monkeypatch): + calls = _seq_http_get(monkeypatch, [IncompleteRead(b"x" * 94992), _COMPLETE]) + out = tmp_path / "bed.jpg" + res = u1_camera.fetch_monitor("h", 7125, str(out)) + assert calls["n"] == 2 # first raised, retry won + assert res["bytes"] == len(_COMPLETE) and res["jpeg_magic"] + assert out.read_bytes() == _COMPLETE + + +def test_retries_a_truncated_frame_without_exception(tmp_path, monkeypatch): + calls = _seq_http_get(monkeypatch, [_TRUNCATED, _TRUNCATED, _COMPLETE]) + out = tmp_path / "bed.jpg" + res = u1_camera.fetch_monitor("h", 7125, str(out)) + assert calls["n"] == 3 + assert out.read_bytes() == _COMPLETE + + +def test_raises_after_persistent_incomplete_read(tmp_path, monkeypatch): + _seq_http_get(monkeypatch, [IncompleteRead(b"x" * 10)]) # always raises + with pytest.raises(IncompleteRead): + u1_camera.fetch_monitor("h", 7125, str(tmp_path / "bed.jpg"), attempts=3) + + +def test_raises_on_persistent_truncation(tmp_path, monkeypatch): + _seq_http_get(monkeypatch, [_TRUNCATED]) # never a full frame + with pytest.raises(ValueError): + u1_camera.fetch_monitor("h", 7125, str(tmp_path / "bed.jpg"), attempts=2) From 98dcaf8242174310b2617573a56d52e32ce8c505 Mon Sep 17 00:00:00 2001 From: bbolinger <14969048+bbolinger@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:19:40 +0000 Subject: [PATCH 5/7] Kit start: a re-slice invalidates the prior slice's bed-clear prompt When a re-slice reused the same request, a bed-clear prompt armed for the earlier plan survived if the new slice's bed capture failed (the arm/overwrite only happened on capture success). The operator's YES then redeemed that stale prompt against the new plan and the Stage-2 gate refused (revision + gcode_hash mismatch, live 2026-07-18). Clear pending_bed_clear_start at plan-persist, before the capture, so a fresh plan never carries an old prompt regardless of capture outcome. Clearing only tightens the gate (a missing pending refuses), so it stays fail-closed. On capture success _action_start re-arms a fresh prompt as before. Adds a regression test. Full suite green (1051 passed, 13 skipped). --- scripts/u1_kit_workflow.py | 13 +++++++++++ tests/test_u1_kit_workflow.py | 41 +++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/scripts/u1_kit_workflow.py b/scripts/u1_kit_workflow.py index 02658e2..1c1e1ab 100644 --- a/scripts/u1_kit_workflow.py +++ b/scripts/u1_kit_workflow.py @@ -5754,10 +5754,23 @@ def _commit_kit_legacy(args, request_id, operator, out_dir, events_file, # Persist plate + plan state BEFORE any bed-clear routing so _action_start # (below) reads plate_filename / gcode_hash / tool / material from state. + # + # A fresh plan supersedes any bed-clear prompt armed for a PRIOR slice + # (request ids are content-derived, so a re-slice reuses the request). Drop + # that stale pending NOW, before the bed capture below: a capture failure + # must not leave an old prompt the operator could confirm against this new + # plan (live 2026-07-18: a short camera frame stranded a rev-4 prompt while + # the plan had advanced to rev 5, so the operator's YES redeemed the old + # one and the Stage-2 gate refused). On capture success _action_start + # re-arms a fresh one. Clearing only ever tightens the gate (a missing + # pending refuses), so it is safe. + _cleared_safety = dict((u1_request.read_request(request_id) or {}).get("safety") or {}) + _cleared_safety.pop("pending_bed_clear_start", None) persist_phase = "awaiting_confirm" if action == "start" else "complete" u1_request.write_request( request_id, phase=persist_phase, + safety=_cleared_safety, kit={"parts": kit["parts"], "part_count": kit["part_count"], "selected": [p["part_id"] for p in selected], "orient_mode": values.get("orient")}, plates=plates_state, diff --git a/tests/test_u1_kit_workflow.py b/tests/test_u1_kit_workflow.py index 0876341..13f378b 100644 --- a/tests/test_u1_kit_workflow.py +++ b/tests/test_u1_kit_workflow.py @@ -874,3 +874,44 @@ def test_configured_binding_does_not_warn(tmp_path, monkeypatch, capsys, fake_pr events = [json.loads(l) for l in out.splitlines() if l.strip().startswith("{")] assert not any(e.get("kind") == "operator_binding_unconfigured" for e in events) + + +def test_reslice_clears_stale_pending_when_bed_capture_fails( + tmp_path, monkeypatch, fake_profiles, fake_slice_upload): + """A re-slice must invalidate a bed-clear prompt armed for the PRIOR plan. + + Regression (live 2026-07-18): the printer camera returned a truncated frame + on a re-slice, so _capture_bed_and_issue_token failed and the start path + bailed to the upload-only fallback WITHOUT re-arming, stranding the previous + slice's pending. The operator's YES then redeemed that stale prompt against + the new plan and the Stage-2 gate refused. Clearing the pending at + plan-persist (before the capture) fixes it: after a failed re-slice there is + simply NO pending to confirm. + """ + import u1_request + zp = _kit_zip(tmp_path, 1) + rid = kw.run_kit_workflow(_args(zp))["request_id"] # create the request + # plant a bed-clear prompt as if a PRIOR slice had armed it + u1_request.write_request(rid, safety={"pending_bed_clear_start": { + "prompt_key": "bed_clear_start", "request_revision": 1, + "gcode_hash": "sha256:OLD_PLAN", "nonce": "stale-nonce", + "confirm_token": "stale-token"}}) + assert (u1_request.read_request(rid)["safety"] + .get("pending_bed_clear_start")), "planted pending should be present" + + # re-slice with action=start, but the bed camera fails (truncated frame) + monkeypatch.setattr(kw, "_capture_bed_and_issue_token", lambda out_dir: { + "ok": False, "snapshot_path": None, "token": None, + "approval_ttl_seconds": None, "approval_expires_at": None, + "captured_at_utc": None, + "reason": "IncompleteRead(94992 bytes read, 487 more expected)"}) + res = kw.run_kit_workflow(_args( + zp, request_id=rid, live_upload=True, + form_answers="all | T0 | PLA | profile 1 | no-supports | start")) + + # capture failed -> upload-only fallback, NOT a gated start + assert res["phase"] == "awaiting_confirm" + # ...and the stale pending is GONE, so a YES can't redeem the old plan + safety = u1_request.read_request(rid).get("safety") or {} + assert "pending_bed_clear_start" not in safety, ( + "a failed re-slice must not leave the prior slice's pending prompt") From 5b51a99bdf4198b2abe8f0be59937a54e9521ad5 Mon Sep 17 00:00:00 2001 From: bbolinger <14969048+bbolinger@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:14:04 +0000 Subject: [PATCH 6/7] Form: first-layer nozzle temp + full brim types Two operator-requested form additions: - First-layer nozzle temperature: a third Temperature stepper (Nozzle / First-layer nozzle / Bed), same per-material range as the main nozzle. The main nozzle still sets both layers; the first-layer control overrides only the initial layer (nozzle_temperature_initial_layer) when touched, for adhesion tuning without changing the rest of the print. - Brim: expanded from off/auto to the U1 Orca fork's full brim_type set: off (no_brim), outer (outer_only), auto (auto_brim), mouse ears (brim_ears). Full suite green (1058 passed, 13 skipped). --- scripts/u1_form.py | 26 ++++++++++++++++++-------- scripts/u1_kit_workflow.py | 3 +++ scripts/u1_slice_workflow.py | 13 +++++++++++-- tests/test_advanced_settings.py | 26 ++++++++++++++++++++++++-- tests/test_filament_temp_override.py | 28 ++++++++++++++++++++++++++++ tests/test_temp_form.py | 18 +++++++++++++++++- 6 files changed, 101 insertions(+), 13 deletions(-) diff --git a/scripts/u1_form.py b/scripts/u1_form.py index 1dbdd92..d98bc3a 100644 --- a/scripts/u1_form.py +++ b/scripts/u1_form.py @@ -114,8 +114,10 @@ "wall_loops", {"2": "2", "3": "3", "4": "4"}), ("brim", "Brim", [("default", "Brim: profile default"), ("off", "Brim: off"), - ("auto", "Brim: auto")], - "brim_type", {"off": "no_brim", "auto": "auto_brim"}), + ("outer", "Brim: outer"), ("auto", "Brim: auto"), + ("ears", "Brim: mouse ears")], + "brim_type", {"off": "no_brim", "outer": "outer_only", + "auto": "auto_brim", "ears": "brim_ears"}), ("fuzzy", "Fuzzy skin", [("default", "Fuzzy skin: profile default"), ("off", "Fuzzy skin: off"), ("on", "Fuzzy skin: on (outer walls)")], @@ -180,7 +182,7 @@ "infill_pattern": {"grid": "grid", "gyroid": "gyroid", "honeycomb": "honeycomb", "triangles": "triangles", "cubic": "cubic"}, "walls": {"2": "2", "3": "3", "4": "4"}, - "brim": {"off": "off", "auto": "auto"}, + "brim": {"off": "off", "outer": "outer", "auto": "auto", "ears": "mouse ears"}, "fuzzy": {"off": "off", "on": "on"}, "top_shell": {"3": "3", "4": "4", "5": "5"}, "bottom_shell": {"3": "3", "4": "4"}, @@ -198,7 +200,8 @@ def _display_resolved(fid: str, raw: Any) -> str: if fid in ("infill", "infill_pattern", "walls", "top_shell", "bottom_shell"): return s if fid == "brim": - return "off" if s in ("", "no_brim") else "on" + return {"no_brim": "off", "outer_only": "outer", "auto_brim": "auto", + "brim_ears": "mouse ears"}.get(s, "off" if s == "" else s) if fid == "fuzzy": return "off" if s in ("", "none") else "on" if fid == "one_wall_top": @@ -246,6 +249,9 @@ def resolve_advanced_from_profile(flat_process: dict[str, Any]) -> dict[str, str ("nozzle_temp", "Nozzle temperature", "nozzle_temperature", (("default", "Nozzle: profile default"),) + tuple((str(v), f"Nozzle {v}°C") for v in _NOZZLE_SPAN)), + ("nozzle_temp_first", "First-layer nozzle", "nozzle_temperature_initial_layer", + (("default", "First-layer nozzle: profile default"),) + + tuple((str(v), f"First layer {v}°C") for v in _NOZZLE_SPAN)), ("bed_temp", "Bed temperature", "hot_plate_temp", (("default", "Bed: profile default"), ("0", "Bed: off")) + tuple((str(v), f"Bed {v}°C") for v in _BED_SPAN)), @@ -262,6 +268,7 @@ def resolve_advanced_from_profile(flat_process: dict[str, Any]) -> dict[str, str # so it dials from the field default (1) with no "keep profile" language. _STEPPER = { "nozzle_temp": {"steps": (5, 1), "unit": "°C", "min": 0, "max": 300}, + "nozzle_temp_first": {"steps": (5, 1), "unit": "°C", "min": 0, "max": 300}, "bed_temp": {"steps": (5, 1), "unit": "°C", "min": 0, "max": 120}, "infill": {"steps": (5, 1), "unit": "%", "min": 5, "max": 100}, "walls": {"steps": (1,), "unit": "", "min": 1, "max": 8}, @@ -274,6 +281,7 @@ def resolve_advanced_from_profile(flat_process: dict[str, Any]) -> dict[str, str # _ADVANCED_SHORT for the process controls). _ADVANCED_SHORT.update({ "nozzle_temp": {str(v): f"{v}°C" for v in _NOZZLE_SPAN}, + "nozzle_temp_first": {str(v): f"{v}°C" for v in _NOZZLE_SPAN}, "bed_temp": {"0": "off", **{str(v): f"{v}°C" for v in _BED_SPAN}}, }) @@ -298,7 +306,7 @@ def resolve_advanced_from_profile(flat_process: dict[str, Any]) -> dict[str, str # collide with parts/profile numbers. _ADV_INFILL_RE = re.compile(r"^infill\s*(\d{1,3})\s*%?$", re.IGNORECASE) _ADV_WALLS_RE = re.compile(r"^walls?\s*(\d)$", re.IGNORECASE) -_ADV_BRIM_RE = re.compile(r"^brim\s*(off|auto|on)$", re.IGNORECASE) +_ADV_BRIM_RE = re.compile(r"^brim\s*(off|auto|on|outer|ears|mouse\s*ears?)$", re.IGNORECASE) _ADV_FUZZY_RE = re.compile(r"^fuzzy(?:[\s-]*skin)?(?:\s+(on|off))?$", re.IGNORECASE) # NOTE: bare "grid" stays an infill-pattern token (pre-existing grammar); # support style needs the word: "tree supports" / "grid supports" / "tree". @@ -503,8 +511,9 @@ def _adv_set(field_id: str, option_id: str) -> bool: continue m = _ADV_BRIM_RE.match(tok) if m: - _adv_set("brim", "auto" if m.group(1).lower() == "on" - else m.group(1).lower()) + _g = m.group(1).lower().replace(" ", "") + _adv_set("brim", {"on": "outer", "mouseears": "ears", + "mouseear": "ears"}.get(_g, _g)) continue m = _ADV_FUZZY_RE.match(tok) if m: @@ -627,7 +636,7 @@ def _finalize(values: dict[str, Any], spec: dict[str, Any], errors: list[str], except (TypeError, ValueError): errors.append(f"invalid {_tfid} value {_traw!r}") continue - if _tbase == "nozzle_temperature": + if _tbase in ("nozzle_temperature", "nozzle_temperature_initial_layer"): _tok, (_tlo, _thi) = u1_temps.nozzle_in_range(_t_material, _tiv), u1_temps.nozzle_range(_t_material) else: _tok, (_tlo, _thi) = u1_temps.bed_in_range(_t_material, _tiv), u1_temps.bed_range(_t_material) @@ -1180,6 +1189,7 @@ def build_form_schema(spec: dict[str, Any], *, submit: dict[str, str] | None = N # the renderer dependency-free — it just filters to these bounds. schema["temp_range_by_material"] = { str(m): {"nozzle_temp": list(u1_temps.nozzle_range(m)), + "nozzle_temp_first": list(u1_temps.nozzle_range(m)), "bed_temp": list(u1_temps.bed_range(m))} for m in _tcm } diff --git a/scripts/u1_kit_workflow.py b/scripts/u1_kit_workflow.py index 1c1e1ab..c8925d5 100644 --- a/scripts/u1_kit_workflow.py +++ b/scripts/u1_kit_workflow.py @@ -2145,9 +2145,12 @@ def _first_int(_d, _k): continue _entry = {} _n = _first_int(_flat, "nozzle_temperature") + _nf = _first_int(_flat, "nozzle_temperature_initial_layer") _b = _first_int(_flat, "hot_plate_temp") if _n is not None: _entry["nozzle_temp"] = _n + if _nf is not None: + _entry["nozzle_temp_first"] = _nf if _b is not None: _entry["bed_temp"] = _b if _entry: diff --git a/scripts/u1_slice_workflow.py b/scripts/u1_slice_workflow.py index bf7c463..bcce239 100755 --- a/scripts/u1_slice_workflow.py +++ b/scripts/u1_slice_workflow.py @@ -924,7 +924,8 @@ def apply_profile_overrides(process_path: Path, overrides: dict[str, str], # patch the FILAMENT profile — the U1 stores every temp as a single-element list # (nozzle_temperature=['220']), so each override is written as [''] across # the key's siblings to keep the filament self-consistent. -FILAMENT_OVERRIDE_KEYS = ('nozzle_temperature', 'hot_plate_temp') +FILAMENT_OVERRIDE_KEYS = ('nozzle_temperature', 'nozzle_temperature_initial_layer', + 'hot_plate_temp') # When bed temp is overridden, every plate variant must move together — Orca # stamps whichever the active bed_type selects, and _materialize_flat_filament @@ -963,13 +964,21 @@ def apply_filament_overrides(filament_path: Path, overrides: dict[str, Any], except (TypeError, ValueError): continue clean[k] = (u1_temps.clamp_nozzle(material, iv) - if k == 'nozzle_temperature' else u1_temps.clamp_bed(material, iv)) + if k in ('nozzle_temperature', 'nozzle_temperature_initial_layer') + else u1_temps.clamp_bed(material, iv)) if not clean: return filament_path data = _flatten_filament_profile(filament_path) if 'nozzle_temperature' in clean: for key in _NOZZLE_SIBLINGS: data[key] = [str(clean['nozzle_temperature'])] + if 'nozzle_temperature_initial_layer' in clean: + # First-layer nozzle overrides ONLY the initial layer. The main-nozzle + # write above set both siblings, so this lets the operator dial a hotter + # (or cooler) first layer for adhesion without changing the rest of the + # print. Applied after the sibling write so it wins. + data['nozzle_temperature_initial_layer'] = [ + str(clean['nozzle_temperature_initial_layer'])] if 'hot_plate_temp' in clean: for key in _BED_SIBLINGS: data[key] = [str(clean['hot_plate_temp'])] diff --git a/tests/test_advanced_settings.py b/tests/test_advanced_settings.py index 236289e..9ad5dc2 100644 --- a/tests/test_advanced_settings.py +++ b/tests/test_advanced_settings.py @@ -74,7 +74,8 @@ def test_schema_offers_advanced_fields_flagged(): adv = [f for f in schema["fields"] if f.get("advanced")] assert [f["id"] for f in adv] == ["infill", "infill_pattern", "walls", "brim", "fuzzy", "top_shell", "bottom_shell", "one_wall_top", "raft", - "support_style", "nozzle_temp", "bed_temp"] + "support_style", "nozzle_temp", "nozzle_temp_first", + "bed_temp"] assert all(f["group"] == "advanced" and f["default"] == "default" for f in adv) # not offered -> absent entirely schema2 = u1_form.build_form_schema(_spec(offer=False)) @@ -101,6 +102,26 @@ def test_json_answers_default_means_no_override(): assert res["ok"] and "overrides" not in res["values"] +def test_brim_types_map_to_all_orca_values(): + # the U1 Orca fork has exactly these four brim_type values + for pick, orca in (("off", "no_brim"), ("outer", "outer_only"), + ("auto", "auto_brim"), ("ears", "brim_ears")): + res = u1_form.parse_answers_json( + {"parts": "all", "tool": "T0", "material": "PLA", "profile": 1, + "brim": pick}, _spec()) + assert res["ok"], (pick, res["errors"]) + assert res["values"]["overrides"]["brim_type"] == orca, pick + + +def test_brim_text_tokens_cover_the_types(): + for tok, orca in (("brim off", "no_brim"), ("brim outer", "outer_only"), + ("brim on", "outer_only"), ("brim ears", "brim_ears"), + ("brim mouse ears", "brim_ears"), ("brim auto", "auto_brim")): + res = u1_form.parse_answers(f"all | T0 | PLA | profile 1 | {tok}", _spec()) + assert res["ok"], (tok, res["errors"]) + assert res["values"]["overrides"]["brim_type"] == orca, tok + + def test_json_answers_unknown_advanced_option_fails_loudly(): # a categorical control rejects an unknown option id... res = u1_form.parse_answers_json( @@ -215,7 +236,8 @@ def test_advanced_buttons_self_describing_and_review_not_duplicated(): "brim": "Brim", "fuzzy": "Fuzzy", "support_style": "Support", "top_shell": "Top", "bottom_shell": "Bottom", "one_wall_top": "One wall", "raft": "Raft", - "nozzle_temp": "Nozzle", "bed_temp": "Bed"}[f["id"]] + "nozzle_temp": "Nozzle", "nozzle_temp_first": "First", + "bed_temp": "Bed"}[f["id"]] assert all(key in tg._opt_label(o) for o in f["options"]), f["id"] # Review: exactly ONE advanced-related button (opens the tweak menu), and # NO per-advanced-field Edit rows. diff --git a/tests/test_filament_temp_override.py b/tests/test_filament_temp_override.py index 3e660d3..9c7917b 100644 --- a/tests/test_filament_temp_override.py +++ b/tests/test_filament_temp_override.py @@ -46,6 +46,34 @@ def test_override_patches_both_layers_and_all_bed_plates(tmp_path): assert out != src +def test_first_layer_nozzle_overrides_only_the_initial_layer(tmp_path): + src = _write_filament(tmp_path) # profile nozzle 245, initial 250 + # main nozzle sets both siblings, then the separate first-layer value wins + # on the initial-layer key + out = wf.apply_filament_overrides( + src, {"nozzle_temperature": 235, "nozzle_temperature_initial_layer": 245}, + tmp_path, material="PETG") + d = json.loads(out.read_text()) + assert d["nozzle_temperature"] == ["235"] # main layer + assert d["nozzle_temperature_initial_layer"] == ["245"] # first layer, independent + + +def test_first_layer_nozzle_alone_leaves_main_at_profile(tmp_path): + src = _write_filament(tmp_path) # profile nozzle 245, initial 250 + out = wf.apply_filament_overrides( + src, {"nozzle_temperature_initial_layer": 240}, tmp_path, material="PETG") + d = json.loads(out.read_text()) + assert d["nozzle_temperature"] == ["245"] # untouched profile value + assert d["nozzle_temperature_initial_layer"] == ["240"] # only the first layer moved + + +def test_first_layer_nozzle_clamps_to_material_envelope(tmp_path): + src = _write_filament(tmp_path) + out = wf.apply_filament_overrides( + src, {"nozzle_temperature_initial_layer": 999}, tmp_path, material="PETG") + assert json.loads(out.read_text())["nozzle_temperature_initial_layer"] == ["270"] # PETG max + + def test_override_clamps_out_of_range_to_material_envelope(tmp_path): src = _write_filament(tmp_path) # PETG nozzle max is 270, bed max 90 (u1_temps) diff --git a/tests/test_temp_form.py b/tests/test_temp_form.py index 2da636e..b459152 100644 --- a/tests/test_temp_form.py +++ b/tests/test_temp_form.py @@ -30,10 +30,11 @@ def _spec(): def test_schema_offers_temperature_fields_and_ranges(): sch = u1_form.build_form_schema(_spec()) temp = [f for f in sch["fields"] if f.get("category") == "temperature"] - assert [f["id"] for f in temp] == ["nozzle_temp", "bed_temp"] + assert [f["id"] for f in temp] == ["nozzle_temp", "nozzle_temp_first", "bed_temp"] assert all(f.get("material_dynamic") and f.get("advanced") for f in temp) assert {"key": "temperature", "label": "\U0001f525 Temperature"} in sch["advanced_categories"] assert sch["temp_range_by_material"]["PLA"]["nozzle_temp"] == [190, 240] + assert sch["temp_range_by_material"]["PLA"]["nozzle_temp_first"] == [190, 240] assert sch["temp_range_by_material"]["PLA"]["bed_temp"] == [0, 70] @@ -46,6 +47,21 @@ def test_parse_in_range_temp_becomes_filament_override(): assert res["values"]["filament_overrides"] == {"nozzle_temperature": 230, "hot_plate_temp": 0} +def test_parse_first_layer_nozzle_maps_to_initial_layer_key(): + res = u1_form.parse_answers_json( + {"tool": "T0", "profile": 1, "nozzle_temp": "230", "nozzle_temp_first": "235"}, _spec()) + assert res["ok"], res["errors"] + assert res["values"]["filament_overrides"] == { + "nozzle_temperature": 230, "nozzle_temperature_initial_layer": 235} + + +def test_parse_first_layer_nozzle_out_of_range_fails_for_material(): + res = u1_form.parse_answers_json( + {"tool": "T0", "profile": 1, "nozzle_temp_first": "280"}, _spec()) # PLA max 240 + assert not res["ok"] + assert any("out of range for PLA" in e for e in res["errors"]) + + def test_parse_out_of_range_temp_fails_loudly(): res = u1_form.parse_answers_json( {"tool": "T0", "profile": 1, "nozzle_temp": "280"}, _spec()) # PLA max 240 From f4f0c1c92c5e2f3050f15656367b0e0b58dfd0b6 Mon Sep 17 00:00:00 2001 From: bbolinger <14969048+bbolinger@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:10:33 +0000 Subject: [PATCH 7/7] Form callback: publish from the plugin so it survives Hermes upgrades A Hermes package upgrade replaces gateway/run.py and wipes the anchor patch that published the per-turn form callback, so every deployment silently loses the form flow on a Hermes upgrade (the cryptic "callback not registered" error operators hit after pip install -U). Publish the callback from the plugin's own pre_gateway_dispatch hook instead. That hook already holds the live adapter, the inbound chat_id, and the gateway loop, so it can build the same callback and register it on every inbound message. The plugin lives on the persistent volume, so this survives upgrades and self-heals on the next message. The run.py patch stays as a belt-and-suspenders fallback for older Hermes builds whose dispatch context is thinner; last-writer-wins per turn and both build an equivalent callback. install.py no longer aborts when it cannot patch run.py. This is form-render plumbing only; the model-free print-start boundary is untouched. --- adapters/hermes/install.py | 63 +++++---- adapters/hermes/plugin/__init__.py | 137 ++++++++++++++++++- tests/test_adapters.py | 209 +++++++++++++++++++++++++++-- 3 files changed, 363 insertions(+), 46 deletions(-) diff --git a/adapters/hermes/install.py b/adapters/hermes/install.py index b93b208..469a043 100644 --- a/adapters/hermes/install.py +++ b/adapters/hermes/install.py @@ -473,16 +473,18 @@ def main(argv=None) -> int: return 0 # Install. - # Pre-flight (read-only): verify the run.py anchor BEFORE copying any - # files. Hermes auto-imports tools/*, so copying first and then failing - # the anchor check would leave a partial install — the form tool would - # register while run.py stays unwired. + # Pre-flight (read-only): note whether the run.py FALLBACK anchor is + # present. It no longer gates the install. The u1-form plugin publishes + # the form callback itself from its pre_gateway_dispatch hook (the primary, + # upgrade-durable path), so a missing anchor only means the belt-and- + # suspenders run.py patch is skipped, not a broken form. run_txt = run_py.read_text() - if RUN_PY_MARKER not in run_txt and RUN_PY_ANCHOR not in run_txt: - print(f"ERROR: anchor {RUN_PY_ANCHOR!r} not found in gateway/run.py.") - print(" Hermes may have changed the clarify wiring layout. Aborting") - print(" before copying anything — no files were modified.") - return 2 + _run_anchor_present = (RUN_PY_MARKER in run_txt or RUN_PY_ANCHOR in run_txt) + if not _run_anchor_present: + print(f"note: run.py anchor {RUN_PY_ANCHOR!r} not found; skipping the") + print(" optional run.py fallback patch. The u1-form plugin publishes") + print(" the form callback itself, so the form still works.") + print() print("[1/6] copy tools/ (form_gateway) + remove pre-plugin layout files") for name, src in tools_src.items(): @@ -528,22 +530,25 @@ def main(argv=None) -> int: print(f" {_install_hook_plugin(venv_python, hook_plugin_src, dry_run=a.dry_run)}") print() - print("[5/6] patch gateway/run.py (anchor-based, marker-guarded)") - status = _patch_run_py(run_py, dry_run=a.dry_run) - print(f" {status}: {run_py}") - if status == "anchor-not-found": - # Unreachable in practice (pre-flight above checks the same strings), - # kept as a belt-and-braces guard against races. - print() - print(f" ERROR: anchor {RUN_PY_ANCHOR!r} not found in gateway/run.py.") - print(" Hermes may have changed the clarify wiring layout. Aborting.") - return 2 - if status == "malformed-block": - print() - print(" ERROR: found the u1 begin marker without its end marker in run.py.") - print(" Refusing to edit a half-present block — inspect run.py, then") - print(" restore the .u1-bak backup or remove the block manually.") - return 2 + print("[5/6] patch gateway/run.py (OPTIONAL fallback; anchor-based, marker-guarded)") + if not _run_anchor_present: + print(" skipped: no anchor in this Hermes build; the plugin publish covers it") + else: + status = _patch_run_py(run_py, dry_run=a.dry_run) + print(f" {status}: {run_py}") + if status == "anchor-not-found": + # Anchor vanished between pre-flight and here (a race). Non-fatal: + # the plugin's pre_gateway_dispatch publish carries the form. + print(" note: anchor vanished since pre-flight; skipping the fallback") + print(" patch. The u1-form plugin publish covers the form.") + elif status == "malformed-block": + # A half-present prior block (begin marker, no end). _patch_run_py + # refused to write it. Non-fatal now that the plugin carries the + # form; flag it so the operator can tidy run.py. + print(" WARNING: found the u1 begin marker without its end marker in") + print(" run.py; left it untouched. Inspect run.py and restore") + print(" the .u1-bak backup or remove the block manually. The") + print(" plugin publish still carries the form regardless.") if a.dry_run: print() @@ -556,9 +561,11 @@ def main(argv=None) -> int: print(" ", _verify_hook_plugin(venv_python)) print() - print("Done. Restart the Hermes gateway so both plugins load and the patched") - print("gateway/run.py takes effect. First inbound Telegram message installs") - print("send_form on the live adapter class (watch for the") + print("Done. Restart the Hermes gateway so the plugins load (and any run.py") + print("fallback patch takes effect). Each inbound Telegram message installs") + print("send_form on the live adapter class AND publishes the form callback") + print("from the plugin, so the form keeps working across Hermes upgrades even") + print("if the run.py fallback is absent (watch for the") print("'u1-form: TelegramAdapter.send_form installed' log line).") return 0 diff --git a/adapters/hermes/plugin/__init__.py b/adapters/hermes/plugin/__init__.py index 88671f7..0ca3577 100644 --- a/adapters/hermes/plugin/__init__.py +++ b/adapters/hermes/plugin/__init__.py @@ -17,17 +17,29 @@ hook that patches ``send_form`` onto the LIVE Telegram adapter class. * ``telegram_patch.py`` — the class-level patch (inline-keyboard renderer, callback router, answers-file writer). - * The gateway's ``run.py`` anchor patch (applied by ``install.py``) - publishes a per-turn form callback into ``tools.form_gateway`` keyed - by ``agent.session_id`` — the same value Hermes' registry dispatch - passes to tool handlers. That bridge exists because generic dispatch - hands handlers only (task_id, session_id, user_task): there is no - callback kwarg and no agent reference, so an ``agent.form_callback`` - attribute alone is unreachable from a registered tool. + * The per-turn form callback published into ``tools.form_gateway``. + A registered tool cannot reach it any other way: generic dispatch + hands handlers only (task_id, session_id, user_task), with no callback + kwarg and no agent reference, so an ``agent.form_callback`` attribute + alone is unreachable. Two publishers, primary + fallback: + 1. PRIMARY (upgrade-durable): ``_pre_gateway_dispatch`` builds the + callback from its own context (live adapter + inbound chat_id + + gateway loop) and publishes it every inbound message. The plugin + lives on the persistent volume, so this survives a Hermes package + upgrade that replaces ``gateway/run.py``. + 2. FALLBACK (older Hermes): the ``run.py`` anchor patch applied by + ``install.py`` publishes the same callback from the gateway's + per-turn locals. Kept for Hermes builds whose dispatch context is + thinner than the primary path needs. Last-writer-wins per turn; + both build an equivalent callback, so they coexist safely. + Publishing only renders the operator FORM; it is not the print-start + boundary (that stays in ``u1_print_start_gate.py``, untouched), so + where the callback is wired has no bearing on model-free confirm. """ from __future__ import annotations +import asyncio import json import logging from typing import Any, Callable, Dict, Optional @@ -196,6 +208,104 @@ def _form_handler(args: Dict[str, Any], **kwargs: Any) -> str: # fires before agent dispatch on every inbound message, so send_form exists # before any form callback can run. +def _acquire_loop(): + """The gateway event loop, captured while we are ON it. + + ``pre_gateway_dispatch`` runs synchronously inside the gateway's async + dispatch coroutine, so ``get_running_loop()`` returns the loop the + adapter's ``send_form`` coroutine must be scheduled on. Returns None when + no loop runs in this thread (e.g. unit tests, or a future Hermes that + dispatches hooks off-loop) so the caller skips the plugin publish and + lets the run.py fallback carry the turn.""" + try: + return asyncio.get_running_loop() + except RuntimeError: + return None + + +def _make_form_callback(adapter, chat_id, session_key, loop): + """Build the per-turn form callback the u1_kit / form tool invokes. + + Same contract as the gateway run.py patch's ``_form_callback_sync``, but + sourced from the plugin's own dispatch context so it survives a Hermes + upgrade that replaces run.py: render ``form_schema`` via the patched + adapter's ``send_form``, block on the form_gateway primitive, return the + answer dict. Fail-soft: every failure path returns an ``{"_error": ...}`` + dict (the driving tool surfaces it) and never raises.""" + def _callback(form_schema): + import uuid + from tools import form_gateway as _fmod + if not hasattr(adapter, "send_form"): + return {"_error": "active adapter has no send_form (plugin not loaded?)"} + form_id = uuid.uuid4().hex[:10] + _fmod.register(form_id, session_key or "", form_schema) + try: + adapter.pause_typing_for_chat(chat_id) + except Exception: + pass + try: + fut = asyncio.run_coroutine_threadsafe( + adapter.send_form( + chat_id=chat_id, form_schema=form_schema, + form_id=form_id, session_key=session_key or "", + metadata=None, + ), + loop, + ) + except Exception as exc: + _fmod.clear_session(session_key or "") + return {"_error": f"form prompt could not be scheduled: {exc}"} + try: + send_result = fut.result(timeout=15) + if not getattr(send_result, "success", False): + _fmod.clear_session(session_key or "") + return {"_error": "form prompt send failed"} + except Exception as exc: + logger.warning("u1-form: form send failed: %s", exc) + _fmod.clear_session(session_key or "") + return {"_error": f"form send exception: {exc}"} + response = _fmod.wait_for_response( + form_id, timeout=float(_fmod.get_form_timeout())) + if response is None: + return {"_timeout": True} + return response + return _callback + + +def _publish_form_callback(adapter, event, session_store) -> bool: + """Publish a per-turn form callback into tools.form_gateway from the + plugin's own dispatch context (the upgrade-durable primary path). + + Best-effort and fail-soft: any missing ingredient (no addressable chat, + not on the gateway loop, form_gateway absent) just returns False and + leaves the run.py fallback, where present, to carry the turn. Returns + True when a callback was published.""" + source = getattr(event, "source", None) + chat_id = getattr(source, "chat_id", None) + if source is None or chat_id is None: + return False # internal / None event; no chat to address + loop = _acquire_loop() + if loop is None: + return False # not on the gateway loop; run.py fallback carries it + try: + from tools import form_gateway + except ImportError: + return False + session_key = "" + try: + gen = getattr(session_store, "_generate_session_key", None) + if gen is not None: + session_key = gen(source) or "" + except Exception: + # A private-API drift just means we key on "" -> form_gateway's + # __default__ (latest registration) resolves it for a single + # operator, exactly as the run.py path already relies on. + session_key = "" + form_gateway.set_form_callback( + session_key, _make_form_callback(adapter, chat_id, session_key, loop)) + return True + + def _pre_gateway_dispatch(**kwargs: Any) -> None: try: from . import telegram_patch @@ -223,6 +333,19 @@ def _pre_gateway_dispatch(**kwargs: Any) -> None: except Exception: logger.warning("u1-form: proactive callback-handler " "registration failed", exc_info=True) + # PRIMARY form-callback publish (upgrade-durable). Builds + # the callback from this dispatch context and publishes it + # into form_gateway so the form works even when a Hermes + # upgrade has wiped the run.py fallback patch. Fail-soft: + # a False return (no chat / off-loop) just leaves the + # run.py fallback to carry the turn where it is present. + try: + _publish_form_callback( + adapter, kwargs.get("event"), + kwargs.get("session_store")) + except Exception: + logger.warning("u1-form: form-callback publish failed", + exc_info=True) except Exception: logger.warning("u1-form: pre_gateway_dispatch patch attempt failed", exc_info=True) diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 7d00064..55d62ad 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -564,6 +564,186 @@ class _Gw: assert mod._pre_gateway_dispatch(gateway=_Gw(), event=None) is None +# --------------------------------------------------------------------------- # +# PRIMARY (upgrade-durable) form-callback publish. The plugin publishes the +# form callback from pre_gateway_dispatch itself, from its own dispatch +# context (live adapter + inbound chat_id + gateway loop), so the form keeps +# working after a Hermes package upgrade wipes the run.py fallback patch. +# These prove the publish happens with NO run.py involvement. +# --------------------------------------------------------------------------- # + +class _Src: + def __init__(self, chat_id="c1"): + self.chat_id = chat_id + + +class _Event: + def __init__(self, chat_id="c1"): + self.source = _Src(chat_id) + + +class _SessionStore: + def _generate_session_key(self, source): + return "sess-" + str(getattr(source, "chat_id", "?")) + + +def _telegram_gw(adapter): + class _Gw: + adapters = {"telegram": adapter} + return _Gw() + + +def test_pre_dispatch_publishes_form_callback_without_run_py(monkeypatch, tmp_path): + """After an inbound message, form_gateway resolves a callback for the + session, wired by the plugin, not by any run.py patch.""" + mod = _load_plugin_pkg(monkeypatch, tmp_path) + _patch_ensure(monkeypatch) + fg = _fake_form_gateway(monkeypatch) + monkeypatch.setattr(mod, "_acquire_loop", lambda: object()) # sentinel loop + + class _Adapter: + def send_form(self, **kw): + return None + + def _u1_ensure_cb_handler(self): + pass + + mod._pre_gateway_dispatch(gateway=_telegram_gw(_Adapter()), + event=_Event("c1"), session_store=_SessionStore()) + assert fg.get_form_callback("sess-c1") is not None, ( + "pre_gateway_dispatch must publish the form callback keyed by session") + # __default__ also set, so the kit path (which resolves via the contextvar + # session key, not agent.session_id) keeps working on a mismatch. + assert fg.get_form_callback("some-other-session") is not None + + +def test_pre_dispatch_publish_noop_for_internal_event(monkeypatch, tmp_path): + """event=None (internal / non-addressable) publishes nothing and never + crashes; the existing cancel-handler path still runs.""" + mod = _load_plugin_pkg(monkeypatch, tmp_path) + _patch_ensure(monkeypatch) + fg = _fake_form_gateway(monkeypatch) + monkeypatch.setattr(mod, "_acquire_loop", lambda: object()) + + class _Adapter: + def send_form(self, **kw): + return None + + def _u1_ensure_cb_handler(self): + pass + + mod._pre_gateway_dispatch(gateway=_telegram_gw(_Adapter()), + event=None, session_store=_SessionStore()) + assert fg.get_form_callback("") is None # registry stays empty + + +def test_pre_dispatch_publish_skipped_off_loop(monkeypatch, tmp_path): + """With no running gateway loop (older Hermes / off-loop dispatch) the + plugin skips its publish and leaves the run.py fallback to carry it.""" + mod = _load_plugin_pkg(monkeypatch, tmp_path) + _patch_ensure(monkeypatch) + fg = _fake_form_gateway(monkeypatch) + monkeypatch.setattr(mod, "_acquire_loop", lambda: None) + + class _Adapter: + def send_form(self, **kw): + return None + + def _u1_ensure_cb_handler(self): + pass + + mod._pre_gateway_dispatch(gateway=_telegram_gw(_Adapter()), + event=_Event("c1"), session_store=_SessionStore()) + assert fg.get_form_callback("sess-c1") is None + + +def _rich_form_gateway(monkeypatch): + """tools.form_gateway fake with the full deterministic surface the + published callback drives (register/wait/clear/timeout).""" + fg = types.ModuleType("tools.form_gateway") + fg.events = [] + fg.register = lambda fid, sk, sch: fg.events.append(("register", fid, sk)) + fg.clear_session = lambda sk: fg.events.append(("clear", sk)) + fg.get_form_timeout = lambda: 1.0 + fg.wait_for_response = lambda fid, timeout: {"tool": "T0"} + tools_pkg = types.ModuleType("tools") + tools_pkg.form_gateway = fg + monkeypatch.setitem(sys.modules, "tools", tools_pkg) + monkeypatch.setitem(sys.modules, "tools.form_gateway", fg) + return fg + + +def test_make_form_callback_renders_and_returns_answer(monkeypatch, tmp_path): + """The published callback: register -> schedule send_form on the loop -> + confirm the send succeeded -> block for the operator's answer -> return.""" + mod = _load_plugin_pkg(monkeypatch, tmp_path) + fg = _rich_form_gateway(monkeypatch) + sent = {} + + class _Adapter: + def send_form(self, **kw): + sent.update(kw) + return "coro" # only handed to the (faked) scheduler; never awaited + + def pause_typing_for_chat(self, chat_id): + sent["paused"] = chat_id + + class _Fut: + def result(self, timeout): + return types.SimpleNamespace(success=True, message_id="m1") + + monkeypatch.setattr(mod.asyncio, "run_coroutine_threadsafe", + lambda coro, loop: _Fut()) + + cb = mod._make_form_callback(_Adapter(), "chatX", "sessX", object()) + ans = cb({"version": 1, "fields": [{"id": "tool"}]}) + assert ans == {"tool": "T0"} + assert sent["chat_id"] == "chatX" and sent["session_key"] == "sessX" + assert sent["metadata"] is None # single-operator DM; no topic + assert sent.get("paused") == "chatX" + assert any(e[0] == "register" for e in fg.events) + + +def test_make_form_callback_reports_send_failure(monkeypatch, tmp_path): + """A failed send returns an _error dict and clears the pending form; the + driving tool surfaces it, and nothing hangs.""" + mod = _load_plugin_pkg(monkeypatch, tmp_path) + fg = _rich_form_gateway(monkeypatch) + + class _Adapter: + def send_form(self, **kw): + return "coro" + + def pause_typing_for_chat(self, chat_id): + pass + + class _Fut: + def result(self, timeout): + return types.SimpleNamespace(success=False) + + monkeypatch.setattr(mod.asyncio, "run_coroutine_threadsafe", + lambda coro, loop: _Fut()) + + cb = mod._make_form_callback(_Adapter(), "chatX", "sessX", object()) + out = cb({"version": 1, "fields": [{"id": "tool"}]}) + assert "_error" in out + assert any(e[0] == "clear" for e in fg.events) + + +def test_make_form_callback_errors_without_send_form(monkeypatch, tmp_path): + """An adapter that never got send_form patched yields a clean error, not a + crash (plugin-not-loaded guard).""" + mod = _load_plugin_pkg(monkeypatch, tmp_path) + _rich_form_gateway(monkeypatch) + + class _Adapter: + pass # no send_form + + cb = mod._make_form_callback(_Adapter(), "chatX", "sessX", object()) + out = cb({"version": 1, "fields": [{"id": "tool"}]}) + assert "_error" in out and "send_form" in out["_error"] + + def _fake_form_gateway(monkeypatch): """Stand-in for tools.form_gateway with the callback registry surface.""" fg = types.ModuleType("tools.form_gateway") @@ -1042,28 +1222,35 @@ def test_install_removes_pre_plugin_layout_files(tmp_path, monkeypatch): assert not (sp / "tools" / "u1_form_telegram.py").exists() -def test_install_aborts_before_copying_when_anchor_missing(tmp_path, monkeypatch): - """Unrecognized Hermes: the (read-only) anchor check must run BEFORE any - file copy — otherwise Hermes auto-imports an orphaned half-install.""" +def test_install_continues_when_anchor_missing(tmp_path, monkeypatch): + """Unrecognized Hermes (no clarify anchor): the run.py FALLBACK patch is + skipped, but the install still SUCCEEDS and deploys the plugin. The plugin + publishes the form callback itself, so the form works without the run.py + edit (that is the whole upgrade-durability point).""" venv, sp, run_py = _fake_hermes( tmp_path, monkeypatch, run_py_text="def start():\n pass # layout changed upstream\n") monkeypatch.setattr(hermes_install.subprocess, "run", _stub_subprocess_run([])) rc = hermes_install.main(["--venv", str(venv)]) - assert rc == 2 - assert list((sp / "tools").iterdir()) == [] # nothing copied - assert not (tmp_path / "hermes-home").exists() # no plugin deployed - assert run_py.read_text().startswith("def start()") # untouched + assert rc == 0 # non-fatal now + assert (sp / "tools" / "form_gateway.py").exists() # tools copied + assert (tmp_path / "hermes-home" / "plugins" / "u1-form").exists() # plugin deployed + assert run_py.read_text().startswith("def start()") # untouched (no anchor) assert not run_py.with_suffix(run_py.suffix + ".u1-bak").exists() -def test_install_refuses_malformed_marker_block(tmp_path, monkeypatch): - """Begin marker without end marker: never edit blind.""" +def test_install_warns_but_continues_on_malformed_marker_block(tmp_path, monkeypatch): + """Begin marker without end marker: never edit blind, but no longer fatal. + The plugin publish carries the form, so the install completes and leaves + the malformed run.py untouched for the operator to tidy.""" venv, sp, run_py = _fake_hermes(tmp_path, monkeypatch) - run_py.write_text(_STOCK_RUN_PY + "\n" + hermes_install.RUN_PY_MARKER + "\n") + original = _STOCK_RUN_PY + "\n" + hermes_install.RUN_PY_MARKER + "\n" + run_py.write_text(original) monkeypatch.setattr(hermes_install.subprocess, "run", _stub_subprocess_run([])) rc = hermes_install.main(["--venv", str(venv)]) - assert rc == 2 + assert rc == 0 + assert run_py.read_text() == original # left untouched, never edited blind + assert (tmp_path / "hermes-home" / "plugins" / "u1-form").exists() # plugin still deployed def test_uninstall_restores_backup_removes_plugin_and_disables(tmp_path, monkeypatch):