From 1f8fb941d71988b62646fb44feeeda69d5e89636 Mon Sep 17 00:00:00 2001 From: GeneAI Date: Fri, 28 Aug 2026 23:21:05 -0400 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20round=20table=20ruling=20on=20a=20n?= =?UTF-8?q?inth=20construct=20=E2=80=94=20build=20nothing=20(q-forms-hunk-?= =?UTF-8?q?review-001)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3/3 for (C): `hunk_review` stays consumer-gated. No seat argued for building it; none could name a construct-defining validator rule that `triage` does not already cover. Two corrections to the chair's held lean, reached independently: - The reuse target is `triage`, not `assumption_review`. Its vocabulary is fixed BECAUSE the vocabulary is the construct (D2-a), so accept/edit/reject over hunks matches the answer shape and lies about the speech act. - The interim costs zero files, not three: a verdict-only hunk board is expressible in shipped `triage` slots (id / detail / tag / dispositions / suggested) with no library change. Curated stub carries the ruling, the zero-change encoding table, the needs classified as rendering-vs-answer-shape, and three pre-recorded flip conditions so the gate stays a decision rather than an indefinite deferral. Full transcript is machine-local and untracked. Co-Authored-By: Claude Opus 5 --- .../roundtable/q-forms-hunk-review-001.md | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 docs/reports/roundtable/q-forms-hunk-review-001.md diff --git a/docs/reports/roundtable/q-forms-hunk-review-001.md b/docs/reports/roundtable/q-forms-hunk-review-001.md new file mode 100644 index 0000000..d561863 --- /dev/null +++ b/docs/reports/roundtable/q-forms-hunk-review-001.md @@ -0,0 +1,93 @@ +# Round table — a ninth construct? (q-forms-hunk-review-001) + +Curated stub — chair-promoted sections only. Full transcript: +`~/.attune/reports/roundtable/q-forms-hunk-review-001.md` +(machine-local, TTL-exempt). Board thread: `q-forms-hunk-review-001` +(2026-08-28, 1 round, 3/3 seats: Claude, Antigravity, Codex). + +**Question.** Should attune-forms build a ninth construct +`hunk_review`, or is a per-hunk diff review just `triage` / +`assumption_review` with code-shaped items plus a presentation flag? +Measured cost of a construct: ~19 files, +865 (ranking, PR #24) to ++1219 (assumption_review, PR #25). + +## Ruling: build nothing (3/3, board msgs 3/4/5) + +No seat argued for building the construct. The candidate fails +admission test #4 (no committed consumer) and no seat could state a +construct-defining validator rule under test #2 that `triage` does not +already cover. + +Two corrections the independent round made to the chair's held lean +(msg 2, deliberately not shown to the seats): + +1. **The reuse target is `triage`, not `assumption_review`.** All + three seats rejected `assumption_review` independently. Its + vocabulary is fixed *because* the vocabulary is the construct + (D2-a), so `accept` / `edit` / `reject` over hunks is semantically + wrong even though the answer shape matches. `triage`'s dispositions + are author-named; that is the correct member. +2. **The interim costs zero files, not three.** A verdict-only hunk + board is expressible in shipped `triage` slots with no library + change at all. + +## The zero-change encoding (msg 3, chair-promoted) + +| hunk review needs | existing `triage` slot | +|---|---| +| stable hunk key | `triage_items[].id` = `path@base_sha:start-end` | +| what changed | `triage_items[].detail` = the unified diff, fenced ` ```diff ` | +| file / severity | `triage_items[].tag` | +| verdict vocabulary | `dispositions` = `["apply","revise","drop"]` (author-named) | +| agent's proposal | `suggested` (already rendered as a proposal) | +| answer | `{hunk id: verdict}` — already the shape | + +Validated, on all four surfaces, for the price of a helper function in +the *consumer*. What is lost versus a purpose-built construct is +red/green diff rendering — a rendering gap, and rendering gaps do not +buy `QuestionType` members here (`list_style`, `progress_style` are the +standing precedent). + +## Needs, classified (msgs 3/4/5, unanimous) + +| Need | Kind | Verdict | +|---|---|---| +| file/line anchors | rendering + key stability | `id` already carries the key | +| `+`/`−` line semantics | rendering | flag | +| syntax highlighting | rendering (evaporates on 3 of 4 surfaces) | flag | +| hunk ordering | presentation; `ranking` is a different speech act | not a need | +| per-hunk replacement **code** | **answer-shape** | one field on `triage`, not a construct | +| partial-hunk / line-subset selection | **answer-shape**, genuinely new | **out of scope** — see below | + +**Line-level selection is the boundary.** It turns the answer from +`{key: scalar}` into `{key: subset-of-a-variable-list}`, and there is +no honest `AskUserQuestion` control for "lines 3, 4, 7 of 12" short of +an option-per-line explosion — it fails admission test #3 on the +flattest surface. `git add -p` line staging belongs to a widget-only +staging UI, not to a four-surface grammar. + +## Flip conditions (pre-recorded so the gate is a decision, not a drift) + +The backlog item stays gated. It flips to **build** only when all three +hold: + +1. A committed consumer is **already shipping** the `triage` encoding + above and can point at a wall. +2. The wall is **answer-shape, not paint**. If the wall is "I need to + hand back replacement code," the answer is an opt-in + `edit_dispositions: ["revise"]` on `triage` — lifting the + `assumption_review` edit lane (`{"edit": text}` + paired + `"..text"` + the text-iff-edit fold, all already written + and paid for). That is one field, **not** a construct. +3. Someone can state, in one sentence, the validator rule that *is* + `hunk_review`. The table named exactly one candidate — anchor + integrity — and the chair did **not** promote it; it is recorded in + the machine-local transcript (msg 3) as the seat's own stated risk + of waiting, not as project doctrine. + +## Open chair fact + +All three seats converged on the same follow-up (msgs 6/7/8): does the +prospective consumer need the human to hand back **code**, or only a +**verdict**? Verdict-only is free today; code-back is one field. The +answer decides which of the flip conditions is even reachable. From 6dfd3f81868e16ff25fddb83778ca3443ff0f57b Mon Sep 17 00:00:00 2001 From: GeneAI Date: Fri, 28 Aug 2026 23:21:19 -0400 Subject: [PATCH 2/3] fix: keep a multi-line item detail's shape on the widget and markdown surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `detail` carrying more than one line — a diff hunk, a log excerpt — was corrupted on the way out of both rendering surfaces. Found by probing the zero-change `triage` encoding for hunk review (round table q-forms-hunk-review-001): the deliberation asserted the encoding works, running it showed what it costs. Widget: the detail rendered as an inline inside a flex row with no `white-space` rule, so HTML folded its newlines and leading indentation into single spaces and a diff arrived as one run-on line. A multi-line detail now renders as an `ae-detail-block` — full-width, white-space:pre-wrap, monospace — via one shared `_detail_html` helper and one CSS_BASE rule, fixing `triage`, `assumption_review`, `progress` and `confirm` together. A single-line detail still renders as the same inline span it always did, so the common case is byte-identical. Markdown: the detail was interpolated into the item's bullet line, so every line it started with `-` — every removed line of a diff — parsed as a NEW bullet, and a `suggested` suffix landed on the detail's last line instead of the bullet. A multi-line detail now renders as an indented code block under the bullet. The block is INDENTED rather than fenced because a fence cannot survive this surface: `_defuse_fences` breaks every three-backtick run in author text so the trailing reply skeleton keeps its boundaries (confirmation pass 2). For the same reason an author's own ```lang wrapper is stripped rather than kept — defused, it would render as visible backtick noise. The answers skeleton is verified to still round-trip through `markdown_to_answers` -> `collect_form_response`. Answer layer untouched: no validator, fold, or answer shape changes. FORM_THEME_CSS budget raised 10 KB -> 12 KB (chair-ruled 2026-08-28) for the shared rule; a trim to fit under 10 KB was offered and declined. Now 10,263 B. The ratification is recorded in the theme docstring alongside the previous three. 11 regression tests added, each naming the defect and its thread. 824 passing. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 32 ++++++++ src/attune_forms/markdown_surface.py | 64 ++++++++++++--- src/attune_forms/theme.py | 18 +++-- src/attune_forms/widget.py | 47 +++++------ tests/test_form_theme.py | 2 +- tests/test_markdown_surface.py | 93 ++++++++++++++++++++++ tests/test_widget.py | 114 +++++++++++++++++++++++++++ 7 files changed, 329 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74188cb..80c9e8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,38 @@ follow [SemVer](https://semver.org/). ## [Unreleased] +### Fixed +- **Multi-line item `detail` kept its shape on both rendering surfaces** + (round table `q-forms-hunk-review-001`, 2026-08-28). A `detail` + carrying more than one line — a diff hunk, a log excerpt — was + corrupted on the way out: + - **Widget**: it rendered as an inline `` inside a flex row with + no `white-space` rule, so HTML folded its newlines and leading + indentation into single spaces and a diff arrived as one run-on + line. A multi-line detail now renders as an `ae-detail-block` — + full-width, `white-space:pre-wrap`, monospace. Fixed across + `triage`, `assumption_review`, `progress` and `confirm` through one + shared `widget._detail_html` helper and one `CSS_BASE` rule. A + single-line detail still renders as the same inline span it always + did (output byte-identical). + - **Markdown**: it was interpolated into the item's bullet line, so + every line the detail started with `-` — every removed line of a + diff — parsed as a NEW bullet, and a `suggested` suffix landed on + the detail's last line. A multi-line detail now renders as an + indented code block under the bullet, with the suffix left on the + bullet. The block is INDENTED rather than fenced because + `_defuse_fences` breaks every three-backtick run in author text to + keep the reply skeleton's boundaries; for the same reason an + author's own ` ```lang ` wrapper is stripped rather than rendered + as defused noise. Fixed for `triage`, `assumption_review` and + `confirm` consequences. + +### Changed +- `FORM_THEME_CSS` budget raised 10 KB → 12 KB (chair-ruled + 2026-08-28) for the shared `ae-detail-block` rule in `CSS_BASE`; a + trim to fit under 10 KB was offered and declined. Current size + 10,263 B. The cap remains a design decision, not a ratchet. + ## [0.8.0] — 2026-08-24 Per-stage form telemetry: the lifecycle is now measurable end to end diff --git a/src/attune_forms/markdown_surface.py b/src/attune_forms/markdown_surface.py index aa7b86e..f8bf5be 100644 --- a/src/attune_forms/markdown_surface.py +++ b/src/attune_forms/markdown_surface.py @@ -136,18 +136,62 @@ def _progress_lines(q: FormQuestion) -> list[str]: return lines +#: Indent that makes a block inside a ``- `` bullet an INDENTED code +#: block: 2 columns of list continuation + the 4 CommonMark requires. +_DETAIL_INDENT = " " * 6 + + +def _detail_block(detail: str) -> list[str]: + """A MULTI-LINE item ``detail`` as an indented code block under its bullet. + + Interpolating a multi-line detail into the bullet itself breaks out + of the list item: every line the detail starts with ``-`` — every + removed line of a diff — parses as a NEW bullet, and a trailing + ``suggested`` suffix lands on the detail's last line. + + The block is INDENTED rather than fenced because a fence cannot + survive this surface: :func:`_defuse_fences` breaks every run of + three backticks in author text so the trailing reply skeleton keeps + its boundaries. An indented block needs no backticks at all, so it + passes through untouched and its content stays literal. + + For the same reason an author's own `````lang`` wrapper is + STRIPPED rather than kept: defused, it would render as visible + backtick-plus-zero-width noise around the block. + """ + lines = detail.strip("\n").splitlines() + if lines and lines[0].lstrip().startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip().startswith("```"): + lines = lines[:-1] + return [f"{_DETAIL_INDENT}{line}".rstrip() for line in lines] + + +def _item_row(head: str, detail: str | None, suffix: str = "") -> list[str]: + """One bullet row plus, for a multi-line ``detail``, its indented block. + + A single-line detail stays inline after an em dash (unchanged + rendering); a multi-line one moves below the bullet so ``suffix`` + (e.g. "→ suggested: `apply`") stays on the bullet instead of being + glued onto the detail's closing fence. + """ + multiline = bool(detail) and "\n" in (detail or "") + inline = f" — {detail}" if detail and not multiline else "" + rows = [f"{head}{inline}{suffix}"] + if multiline: + rows.extend(_detail_block(detail or "")) + return rows + + def _triage_lines(q: FormQuestion) -> list[str]: """Item rows + the shared ruling vocabulary for a TRIAGE question.""" vocabulary = " / ".join(f"`{d}`" for d in q.dispositions or []) lines = [f"Rule each item as one of: {vocabulary}"] for key, item in expansion_items(q): tag = f" `{item['tag']}`" if item.get("tag") else "" - detail = f" — {item['detail']}" if item.get("detail") else "" - line = f"- **{item.get('label', '')}**{tag}{detail}" pick = suggested_pick(q, key) - if pick is not None: - line += f" → suggested: `{pick}`" - lines.append(line) + suffix = f" → suggested: `{pick}`" if pick is not None else "" + lines.extend(_item_row(f"- **{item.get('label', '')}**{tag}", item.get("detail"), suffix)) return lines @@ -167,13 +211,10 @@ def _assumption_lines(q: FormQuestion) -> list[str]: accept, edit, reject = ASSUMPTION_RULINGS lines = [f"Rule each assumption: `{accept}` / `{edit}: ` / `{reject}`"] for key, item in expansion_items(q): - detail = f" — {item['detail']}" if item.get("detail") else "" source = f" *(from {item['source']})*" if item.get("source") else "" - line = f"- **{item.get('label', '')}**{detail}{source}" pick = suggested_pick(q, key) - if pick is not None: - line += f" → suggested: `{pick}`" - lines.append(line) + suffix = source + (f" → suggested: `{pick}`" if pick is not None else "") + lines.extend(_item_row(f"- **{item.get('label', '')}**", item.get("detail"), suffix)) return lines @@ -225,8 +266,7 @@ def _control_lines(q: FormQuestion) -> list[str]: lines = ["If approved:"] for item in confirm_consequences(q): tag = f" `{item['severity']}`" if item.get("severity") else "" - detail = f" — {item['detail']}" if item.get("detail") else "" - lines.append(f"- {item.get('label', '')}{tag}{detail}") + lines.extend(_item_row(f"- {item.get('label', '')}{tag}", item.get("detail"))) lines.append("") lines.append("Answer one of: " + " / ".join(f"**{opt}**" for opt in q.options)) return lines diff --git a/src/attune_forms/theme.py b/src/attune_forms/theme.py index fb97516..ade384d 100644 --- a/src/attune_forms/theme.py +++ b/src/attune_forms/theme.py @@ -15,15 +15,20 @@ stylesheet renders native on claude.ai (host tokens win, light/dark follows the host) and standalone (fallbacks win). -Budget: ``FORM_THEME_CSS`` is capped at 10 KB raw by +Budget: ``FORM_THEME_CSS`` is capped at 12 KB raw by ``test_form_theme_budget`` (4 KB → 6 KB by chair ruling 2026-07-31; 6 KB → 8 KB ratified with the grammar-expansion merge 2026-08-14 — TRIAGE + CONFIRM families + deliberation seat chips, 8,158 B; 8 KB → 10 KB ratified 2026-08-15 for the 0.6.0 constructs, ranking-construct spec D2-a — the RANK family did not fit under 8 KB; a CSS consolidation pass was offered and not chosen, so the cap is NOT a -ratchet: growth past it is a design decision, not a drift) — no fonts, -no icon fonts, no images, no @import. +ratchet: growth past it is a design decision, not a drift; 10 KB → +12 KB chair-ruled 2026-08-28 for the shared ``ae-detail-block`` rule +in CSS_BASE, which lets a MULTI-LINE item detail — a diff hunk, a log +excerpt — keep its newlines instead of collapsing inside a flex row, +across triage / assumption_review / progress / confirm at once; a +trim to fit under 10 KB was offered and declined) — no fonts, no icon +fonts, no images, no @import. Copyright 2026 Smart-AI-Memory Licensed under Apache 2.0 @@ -66,6 +71,9 @@ color:var(--text-accent,#a1571c); } #attune-elicit-form .ae-field-missing { border-left:3px solid var(--text-accent,#a1571c); padding-left:.6rem; } +#attune-elicit-form .ae-detail-block { flex-basis:100%; white-space:pre-wrap; + font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; + overflow-x:auto; } """ #: INPUT — text_input, textarea, number, date, boolean, non-list single_select. @@ -123,7 +131,7 @@ CSS_PROGRESS = """#attune-elicit-form .ae-progress { display:flex; flex-direction:column; gap:.5rem; } #attune-elicit-form .ae-prog-rows { display:flex; flex-direction:column; gap:.25rem; } #attune-elicit-form .ae-prog-row { display:flex; align-items:baseline; gap:.5rem; - font-size:14px; color:var(--text-secondary,#5f5e59); } + flex-wrap:wrap; font-size:14px; color:var(--text-secondary,#5f5e59); } #attune-elicit-form .ae-prog-icon { flex:none; font-weight:700; width:1.1em; text-align:center; } #attune-elicit-form .ae-prog-done .ae-prog-icon { color:var(--text-success,#3fb950); } @@ -168,7 +176,7 @@ #attune-elicit-form .ae-gate-h { font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.03em; color:var(--text-accent,#a1571c); } #attune-elicit-form .ae-gate-row { display:flex; align-items:baseline; gap:.5rem; - font-size:14px; color:var(--text-secondary,#5f5e59); } + flex-wrap:wrap; font-size:14px; color:var(--text-secondary,#5f5e59); } #attune-elicit-form .ae-gate-label { color:var(--text-primary,#2c2c2a); } #attune-elicit-form .ae-gate-detail { font-size:13px; color:var(--text-muted,#8a887f); } #attune-elicit-form .ae-gate-tag { flex:none; font-size:10px; font-weight:600; diff --git a/src/attune_forms/widget.py b/src/attune_forms/widget.py index 47e0da9..0d0f74c 100644 --- a/src/attune_forms/widget.py +++ b/src/attune_forms/widget.py @@ -151,9 +151,7 @@ def _control_progress_report_html(q: FormQuestion) -> str: if it.get("label") in q.options: continue tag = f'{_esc(it.get("status", ""))}' - detail = ( - f'{_esc(it["detail"])}' if it.get("detail") else "" - ) + detail = _detail_html(it["detail"], "ae-prog-detail") if it.get("detail") else "" rows += ( f'
' f'{tag}{_esc(it.get("label", ""))}' @@ -207,11 +205,7 @@ def _control_progress_html(q: FormQuestion) -> str: ) for status_key, icon, sr in status_rows: for it in by_status[status_key]: - detail = ( - f'{_esc(it["detail"])}' - if it.get("detail") - else "" - ) + detail = _detail_html(it["detail"], "ae-prog-detail") if it.get("detail") else "" rows += ( f'
' f'' @@ -276,6 +270,25 @@ def _control_deliberation_html(q: FormQuestion) -> str: return f'
{cards}
' +def _detail_html(text: str, cls: str) -> str: + """An item ``detail`` as an inline span, or a BLOCK when multi-line. + + A multi-line detail — a diff hunk, a log excerpt — rendered as an + inline span inside a flex row collapses: HTML folds its newlines and + leading indentation into single spaces, so a diff arrives as one + run-on line (found probing the triage encoding for hunk review, + round table q-forms-hunk-review-001). As an ``ae-detail-block`` it + takes its own full-width line with ``white-space:pre-wrap`` and + keeps the shape the author wrote. + + Single-line details stay inline spans — the common case renders + exactly as before. + """ + if "\n" in text: + return f'
{_esc(text)}
' + return f'{_esc(text)}' + + def _control_triage_html(q: FormQuestion) -> str: """Render a TRIAGE control: one row per item, each row its own disposition radiogroup. @@ -290,11 +303,7 @@ def _control_triage_html(q: FormQuestion) -> str: for idx, (key, item) in enumerate(expansion_items(q)): label = item.get("label", "") tag = f'{_esc(item["tag"])}' if item.get("tag") else "" - detail = ( - f'{_esc(item["detail"])}' - if item.get("detail") - else "" - ) + detail = _detail_html(item["detail"], "ae-triage-detail") if item.get("detail") else "" pick = suggested_pick(q, key) opts = "" for disposition in q.dispositions or []: @@ -330,11 +339,7 @@ def _control_confirm_html(q: FormQuestion) -> str: if item.get("severity") else "" ) - detail = ( - f'{_esc(item["detail"])}' - if item.get("detail") - else "" - ) + detail = _detail_html(item["detail"], "ae-gate-detail") if item.get("detail") else "" rows += ( f'
{tag}' f'{_esc(item.get("label", ""))}' @@ -430,11 +435,7 @@ def _control_assumption_review_html(q: FormQuestion) -> str: if item.get("source") else "" ) - detail = ( - f'{_esc(item["detail"])}' - if item.get("detail") - else "" - ) + detail = _detail_html(item["detail"], "ae-triage-detail") if item.get("detail") else "" pick = suggested_pick(q, key) opts = "" for ruling in ASSUMPTION_RULINGS: diff --git a/tests/test_form_theme.py b/tests/test_form_theme.py index 04be648..18facba 100644 --- a/tests/test_form_theme.py +++ b/tests/test_form_theme.py @@ -13,7 +13,7 @@ #: merge, 8,158 B; 8 KB -> 10 KB ratified 2026-08-15, ranking-construct #: decisions.md D2-a — a consolidation pass was offered and NOT chosen, #: so the cap is not a ratchet: the next raise needs its own ruling). -_BUDGET_BYTES = 10240 +_BUDGET_BYTES = 12288 #: ``var(--name)`` with NO fallback value — the pattern the theme #: must never contain (host-token fallbacks are the design). diff --git a/tests/test_markdown_surface.py b/tests/test_markdown_surface.py index a977ab0..714ebae 100644 --- a/tests/test_markdown_surface.py +++ b/tests/test_markdown_surface.py @@ -195,3 +195,96 @@ def test_falsy_zero_default_survives_in_skeleton(self) -> None: } ) assert _skeleton(form_to_markdown(form))["answers"]["retries"] == 0 + + +# --- multi-line item detail (regression, 2026-08-28) ----------------- +# +# A multi-line ``detail`` — a diff hunk, a log excerpt — used to be +# interpolated straight into the bullet: the opening fence landed +# mid-line so it never opened a code block, every removed line of a +# diff parsed as a NEW bullet, and a "suggested" suffix was glued onto +# the detail's closing fence. Found by probing the zero-change triage +# encoding for hunk review (round table q-forms-hunk-review-001). + +_DIFF = "```diff\n@@ -88,7 +88,9 @@\n- while True:\n+ for _ in range(3):\n```" + + +def _hunk_board(**extra) -> dict: + item = {"id": "src/bridge.py@a1b2c3d:88-96", "label": "bound the retry loop", "detail": _DIFF} + return { + "title": "t", + "fields": [ + { + "id": "hunks", + "type": "triage", + "text": "Rule each hunk.", + "triage_items": [item], + "dispositions": ["apply", "revise", "drop"], + **extra, + } + ], + } + + +def test_multiline_detail_moves_below_the_bullet() -> None: + """The detail is an indented block, not part of the bullet line.""" + md = form_to_markdown(form_from_dict(_hunk_board())) + bullet = next(ln for ln in md.splitlines() if ln.startswith("- **bound the retry loop**")) + assert "```" not in bullet, "fence must not open mid-bullet" + # Indented, not fenced: _defuse_fences would break any fence we + # emitted, so the block relies on indentation alone. + assert " @@ -88,7 +88,9 @@" in md + + +def test_multiline_detail_removed_lines_stay_inside_the_fence() -> None: + """A diff's ``-`` lines must not parse as new bullets.""" + md = form_to_markdown(form_from_dict(_hunk_board())) + assert " - while True:" in md + assert not any(ln.startswith("- while True:") for ln in md.splitlines()), ( + "removed line escaped the fence and became a bullet" + ) + + +def test_multiline_detail_keeps_suggested_on_the_bullet() -> None: + """``suggested`` rides the bullet, never the detail's closing fence.""" + board = _hunk_board(suggested={"src/bridge.py@a1b2c3d:88-96": "apply"}) + md = form_to_markdown(form_from_dict(board)) + bullet = next(ln for ln in md.splitlines() if ln.startswith("- **bound the retry loop**")) + assert bullet.endswith("→ suggested: `apply`") + + +def test_single_line_detail_still_renders_inline() -> None: + """The common case is unchanged — no gratuitous block.""" + board = _hunk_board() + board["fields"][0]["triage_items"][0]["detail"] = "worker.py:88" + md = form_to_markdown(form_from_dict(board)) + assert "- **bound the retry loop** — worker.py:88" in md + + +def test_unfenced_multiline_detail_is_still_indented() -> None: + """An author who did not fence the block still gets literal lines.""" + board = _hunk_board() + board["fields"][0]["triage_items"][0]["detail"] = "line one\n- line two\nline three" + md = form_to_markdown(form_from_dict(board)) + assert " line one" in md + assert " - line two" in md + + +def test_author_fence_is_stripped_not_defused() -> None: + """A ```lang wrapper cannot survive this surface — drop it. + + ``_defuse_fences`` breaks every three-backtick run so the reply + skeleton keeps its boundaries; a kept wrapper would therefore render + as visible backtick-plus-zero-width noise around the block. + """ + md = form_to_markdown(form_from_dict(_hunk_board())) + detail = [ln for ln in md.splitlines() if ln.startswith(" ")] + assert detail, "detail block missing" + assert not any("`" in ln for ln in detail), "author fence survived into the block" + + +def test_multiline_detail_keeps_the_skeleton_round_tripping() -> None: + """The block must not desync the trailing answers skeleton.""" + form = form_from_dict(_hunk_board()) + skeleton = _skeleton(form_to_markdown(form)) + assert skeleton["answers"] == {"hunks": {"src/bridge.py@a1b2c3d:88-96": None}} diff --git a/tests/test_widget.py b/tests/test_widget.py index f53a562..dd2ad15 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -272,3 +272,117 @@ def test_quote_in_id_does_not_break_attribute(self): html = _render([{"id": 'a"x', "text": "A?", "type": "text_input"}]) assert 'data-fid="a"x"' not in html assert """ in html + + +class TestMultilineItemDetail: + """A multi-line ``detail`` — a diff hunk, a log excerpt — must keep + its shape (regression, 2026-08-28). + + It used to render as a ```` INSIDE ``.ae-triage-head``, a flex + row with no ``white-space`` rule: newlines and leading indentation + collapsed to single spaces, so a diff arrived as one run-on line. + Found by probing the zero-change triage encoding for hunk review + (round table q-forms-hunk-review-001). + """ + + DIFF = "@@ -88,7 +88,9 @@\n- while True:\n+ for _ in range(3):" + + def _triage(self) -> str: + return _render( + [ + { + "id": "hunks", + "type": "triage", + "text": "Rule each hunk.", + "triage_items": [ + { + "id": "h1", + "label": "bound the retry loop", + "tag": "high", + "detail": self.DIFF, + } + ], + "dispositions": ["apply", "revise", "drop"], + } + ] + ) + + def _assumption(self) -> str: + return _render( + [ + { + "id": "assumed", + "type": "assumption_review", + "text": "Rule each.", + "assumptions": [ + {"id": "a1", "label": "3.10 is the floor", "detail": self.DIFF} + ], + } + ] + ) + + def test_triage_detail_becomes_a_block(self): + assert '
' in self._triage() + + def test_assumption_detail_becomes_a_block(self): + assert '
' in self._assumption() + + def test_progress_detail_becomes_a_block(self): + html = _render( + [ + { + "id": "p", + "type": "progress", + "text": "Where are we?", + # Blocked items become picker CARDS; the detail rows + # this fix touches come from done / in_flight. + "progress_items": [ + {"label": "migrate the schema", "status": "done", "detail": self.DIFF}, + {"label": "ship it", "status": "blocked"}, + ], + "options": ["ship it"], + } + ] + ) + assert '
' in html + + def test_confirm_detail_becomes_a_block(self): + html = _render( + [ + { + "id": "c", + "type": "confirm", + "text": "Go?", + "consequences": [ + {"label": "flag flips", "severity": "high", "detail": self.DIFF} + ], + } + ] + ) + assert '
' in html + + def test_single_line_detail_stays_an_inline_span(self): + """The common case is byte-identical to before the fix.""" + html = _render( + [ + { + "id": "hunks", + "type": "triage", + "text": "Rule each hunk.", + "triage_items": [{"id": "h1", "label": "L", "detail": "worker.py:88"}], + "dispositions": ["apply", "drop"], + } + ] + ) + assert 'worker.py:88' in html + assert 'ae-detail-block">' not in html, "no block markup for a single-line detail" + + def test_newlines_and_indentation_survive(self): + assert "\n- while True:" in self._triage() + + def test_css_preserves_whitespace(self): + html = self._triage() + rule = html[html.index(".ae-detail-block {") :] + rule = rule[: rule.index("}")] + assert "white-space:pre-wrap" in rule + assert "monospace" in rule From 9c05039aa77be49db067593effae32179280e95d Mon Sep 17 00:00:00 2001 From: GeneAI Date: Fri, 28 Aug 2026 23:24:15 -0400 Subject: [PATCH 3/3] style: black formatting on the new markdown regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project formats with black (pinned 24.10.0 in .pre-commit-config), not `ruff format` — the two disagree on these call wrappings, so CI's lint job failed on an otherwise-green branch. Co-Authored-By: Claude Opus 5 --- tests/test_markdown_surface.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_markdown_surface.py b/tests/test_markdown_surface.py index 714ebae..bb51d5e 100644 --- a/tests/test_markdown_surface.py +++ b/tests/test_markdown_surface.py @@ -240,9 +240,9 @@ def test_multiline_detail_removed_lines_stay_inside_the_fence() -> None: """A diff's ``-`` lines must not parse as new bullets.""" md = form_to_markdown(form_from_dict(_hunk_board())) assert " - while True:" in md - assert not any(ln.startswith("- while True:") for ln in md.splitlines()), ( - "removed line escaped the fence and became a bullet" - ) + assert not any( + ln.startswith("- while True:") for ln in md.splitlines() + ), "removed line escaped the fence and became a bullet" def test_multiline_detail_keeps_suggested_on_the_bullet() -> None: