diff --git a/experiments/core/text_selection.hpp b/experiments/core/text_selection.hpp index 36c72885..068c2cfa 100644 --- a/experiments/core/text_selection.hpp +++ b/experiments/core/text_selection.hpp @@ -21,6 +21,9 @@ class TextSelection { size_t row = 0; size_t saved_bytes = 0, current_bytes = 0; size_t saved_columns = 0, current_columns = 0; + // #194: the bytes that differed, so a partial repaint can be told apart + // from a real content change without re-running the scenario. + std::string saved_text, current_text; }; using DiscardObserver = void (*)(const DiscardReport&); static void set_discard_observer(DiscardObserver observer) { discard_observer_ = observer; } @@ -106,6 +109,8 @@ class TextSelection { if (!same_text(saved, current, compare_boundary)) { DiscardReport report; report.reason = "row-text-changed"; report.row = i; report.saved_bytes = saved.projection.text.size(); report.current_bytes = current.projection.text.size(); + report.saved_text = saved.projection.text.substr(0, 48); + report.current_text = current.projection.text.substr(0, 48); report.saved_columns = saved.columns; report.current_columns = current.columns; return invalidate(report); } diff --git a/experiments/shell/run.py b/experiments/shell/run.py index 90ac222f..70fe82b6 100644 --- a/experiments/shell/run.py +++ b/experiments/shell/run.py @@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from experiments.build_paths import binary +from experiments.shell import summarize_failure from experiments.conpty.run import OwnedJob from experiments.shell.history_checks import validate_history, validate_native_scroll from experiments.shell.paste_checks import validate_paste_workflow @@ -504,7 +505,15 @@ def execute(exe, mode, out, timeout=40, settings_source=None, *, resources=False if not record["desktop_gone"]: raise ValueError("owned desktop remained") if mode != "watchdog" and (record["timed_out"] or record["exit_code"]): - raise ValueError("shell probe failed: " + record["stderr"].strip()) + # Read the dump back with the shared parser so the raised message always + # carries the same verdict instead of a raw multi-line stderr (#73). + failures = summarize_failure.parse(record["stderr"]) + summary = "" + if failures: + verdicts = summarize_failure.describe(failures[0]) + if verdicts: + summary = " | " + "; ".join(verdicts) + raise ValueError("shell probe failed: " + record["stderr"].strip() + summary) result = json.loads(record["stdout"]) if mode == "watchdog": if not record["timed_out"] or result != {"watchdog_ready": True} or record["job_at_timeout"]["active"] < 2: diff --git a/experiments/shell/shell.cpp b/experiments/shell/shell.cpp index 90126d04..8b33b4c6 100644 --- a/experiments/shell/shell.cpp +++ b/experiments/shell/shell.cpp @@ -41,6 +41,17 @@ std::string env_path(const char* name) { #endif } std::string selection_diag_path() { return env_path("TERMFORGE_SELECTION_DIAG"); } +// JSON-safe copy of a short diagnostics string; control bytes become hex escapes. +std::string json_escape(const std::string& text) { + static constexpr char digits[] = "0123456789abcdef"; + std::string escaped; + for (const char ch : text) { + const auto byte = static_cast(ch); + if (byte >= 0x20 && byte < 0x7f && ch != '"' && ch != 0x5c) escaped.push_back(ch); + else { escaped += 0x5c; escaped += 'x'; escaped += digits[byte >> 4]; escaped += digits[byte & 15]; } + } + return escaped; +} void selection_discard_diagnostic(const termforge::TextSelection::DiscardReport& report) { static const std::string path = selection_diag_path(); if (path.empty()) return; @@ -49,7 +60,10 @@ void selection_discard_diagnostic(const termforge::TextSelection::DiscardReport& stream << "{\"schema\":\"termforge-selection-discard-v1\",\"reason\":\"" << report.reason << "\",\"row\":" << report.row << ",\"saved_bytes\":" << report.saved_bytes << ",\"current_bytes\":" << report.current_bytes << ",\"saved_columns\":" << report.saved_columns - << ",\"current_columns\":" << report.current_columns << ",\"t_ms\":" << GetTickCount64() << "}\n"; + << ",\"current_columns\":" << report.current_columns + << ",\"saved_text\":\"" << json_escape(report.saved_text) + << "\",\"current_text\":\"" << json_escape(report.current_text) + << "\",\"t_ms\":" << GetTickCount64() << "}\n"; } // Each window owns its trace stream; the hook resolves it by queue pointer so a // window that is being destroyed can never leave a dangling global stream.