diff --git a/emrg/tools/submit_rant_tool.py b/emrg/tools/submit_rant_tool.py index 887a5c49..0a23b2cc 100644 --- a/emrg/tools/submit_rant_tool.py +++ b/emrg/tools/submit_rant_tool.py @@ -29,6 +29,38 @@ _ACTIONS = ("submit", "list", "update", "cleanup") +def _indented(message: str, indent: str = " ") -> str: + """`message` with every line indented, so it reads as belonging to its header line. + + The line breaks are kept rather than flattened: a rant body is written as prose with + its own structure (the host's own words in quotes, then the demand), and a summary of + it is what this tool used to return — 100 characters of 3504 on the rant measured + 2026-09-17. An **empty line stays empty** (no indent, so no trailing whitespace): it + cannot run into the next rant's header, which starts at column 0 with a timestamp, and + a reader who treats a blank line as the end of a paragraph inside a block is reading it + the way the rant was written. + """ + return "\n".join(indent + line if line else indent.rstrip() for line in message.splitlines()) + + +#: How much of a one-line field the **header** may carry, in characters. The header is the +#: scan view — the row a caller looks down to find a timestamp — so it is bounded, and the +#: full text follows as a block below it. Measured 2026-09-17: with `progress` printed in +#: full the header of the one rant in flight was **1743** characters, i.e. the scan view was +#: the longest line in the output and the thing it was supposed to be a view of. +_HEADER_EXCERPT = 100 + + +def _excerpt(text: str, limit: int = _HEADER_EXCERPT) -> str: + """`text`, cut to `limit` characters and **marked** with `…` when anything was removed. + + The marker is the half that carries the information: a caller that cannot see the marker + cannot tell an excerpt from the whole field, which is the silent half of the defect this + action was fixed for. Both arms are asserted where this is used. + """ + return text[:limit] + ("…" if len(text) > limit else "") + + class SubmitRantTool(ToolExecutor): """Submit a user-confirmed rant / list / update / cleanup rants.jsonl. @@ -51,8 +83,11 @@ def definition(self) -> ToolDefinition: "clarify the target and polish the text, show the user the " "result, and only then call. " "**action=list**: list rants (optional status/project filters; " - "returns timestamp/project/status/progress/completed + message " - "summary). " + "each row carries timestamp/project/status/a progress excerpt/" + "completed and a message excerpt — the scan view — and the full " + "message and progress follow as indented blocks under it. It is " + "the read path the task templates point at, so it has to carry " + "the text they point it at for). " "**action=update**: update a rant by its timestamp (status " "follows the pending→in_progress→completed state machine, no " "skipping; completed timestamp auto-written). " @@ -250,14 +285,45 @@ def _execute_list(self, arguments: dict) -> ToolResult: + (f" (project={project})" if project else "") ), ) + # The message is the rant: `[…][:100]` used to be all of it that this action + # showed, and that was measured to be 100 of 3504 characters on the one rant in + # flight when it was looked at (2026-09-17) — 97% of the feedback dropped by the + # very path the task templates now route every read through (`paper_prompt.md` + # says to check the queue with `submit_rant(action="list")` and that there is no + # reason to open the file at all, "not even to read it"). A read path that cannot + # deliver the text is not a read path; the header line stays for scanning, and the + # message follows it whole — a cap here would be the same defect with a larger + # number in it, since nothing else can hand the caller the rest. + # + # Measured cost on the same queue (11 rants, cleanup caps it at 10 completed plus + # the pending/in-progress ones; messages 3504 / 4031 / 3594 … chars): the whole + # queue goes 15300 → 42725 characters, and `status="in_progress"` alone is 5544. + # The filters are the way to narrow it; the header line is still the scan view. + # + # `progress` is bounded in the header too, and for the same reason it is *not* + # dropped there: it is a one-line field of the row (the prompt curates with it), but + # printed whole it **was** the row — 1651 of that 1743-character header on the rant + # in flight. So the row keeps an excerpt, and the whole value follows in a `progress:` + # block after the message, exactly as the message does. Nothing is cut: an excerpt in + # the scan view plus the full text in a block, never one without the other. lines = [] for r in rants: - summary = (r.get("message") or "").replace("\n", " ").strip()[:100] + message = (r.get("message") or "").strip() + progress = (r.get("progress") or "").strip() + summary = _excerpt(" ".join(message.split())) + progress_row = _excerpt(" ".join(progress.split())) lines.append( f"{r.get('timestamp')} | {r.get('project')} | " - f"status={r.get('status')} | progress={r.get('progress')} | " + f"status={r.get('status')} | progress={progress_row} | " f"completed={r.get('completed')} | {summary}" ) + lines.append( + _indented(message) if message + else " (this rant has no message)" + ) + if progress: + lines.append(" progress:") + lines.append(_indented(progress, " ")) return ToolResult( name="submit_rant", content=f"{len(rants)} rant(s):\n" + "\n".join(lines), diff --git a/tests/test_submit_rant_tool.py b/tests/test_submit_rant_tool.py index 2e4bb628..a34e3cbf 100644 --- a/tests/test_submit_rant_tool.py +++ b/tests/test_submit_rant_tool.py @@ -336,6 +336,140 @@ def test_cleanup_rants_keeps_pending_plus_10_completed(tmp_path): assert timestamps == sorted(timestamps) +def test_the_list_action_returns_the_whole_message_not_a_summary(tmp_path, monkeypatch): + """The message IS the rant, so a 100-character excerpt is not a reading of it. + + Measured 2026-09-17: the only rant in flight had a **3504**-character message and + `action=list` showed its first 100 — 97% dropped by the very path the task templates + route every read through (`paper_prompt.md` says to check the queue with + `submit_rant(action="list")` and that there is no reason to open the file at all, + "not even to read it"; `promote_prompt.md` deduplicates against the same call). A read + path that cannot deliver the text is not a read path — and the failure is silent: the + caller sees a plausible sentence and never learns the rest existed. + + Asserted on the *tail* of the message and on an interior line, because those are what + a truncation at the front removes: a substring taken from the beginning passes under + `[:100]` and would have made this test green over the defect it exists for. + + The fixture is synthetic and deliberately shaped like the real one (multi-paragraph + body, long tail) — it quotes no host and names no real rant. + """ + monkeypatch.setattr("emrg.config.config_dir", lambda: tmp_path) + message = ( + "line one of the rant, the part a summary keeps\n\n" + " an indented detail line\n\n" + "a third paragraph, which a 100-character cut removes entirely along with" + " the rest of the message." + + " tail-marker-" + "z" * 300 + ) + _write_rant_lines(tmp_path, [ + {"timestamp": "2026-09-17T09:00:00+08:00", "project": "emrg", + "status": "pending", "progress": None, "completed": None, + "message": message}, + {"timestamp": "2026-09-17T09:01:00+08:00", "project": "emrg", + "status": "pending", "progress": None, "completed": None, + "message": "short rant"}, + ]) + tool = SubmitRantTool() + out = __import__("asyncio").run(tool.execute({"action": "list"})).content + + # Read the message back off the output instead of searching for the raw string: the + # block is indented under its header line, so the message is present line by line and + # never as one literal run of characters. Comparing the whole block is also what makes + # the assertion about *all* of the text — a `in out` check on the head of the message + # passes under the truncation this test exists for. + lines = out.splitlines() + start = next(i for i, l in enumerate(lines) if l.startswith("2026-09-17T09:00:00")) + block: list[str] = [] + for line in lines[start + 1:]: + if line.startswith("2026-09-17T09:01:00"): # the next header ends this rant's block + break + block.append(line[4:] if line.startswith(" ") else line) + assert "\n".join(block) == message, ( + "the whole message has to arrive, line for line; a caller that gets a " + "100-character excerpt cannot decide the rant's relevance" + ) + assert "tail-marker-" + "z" * 300 in out, "the tail of a long message is the part truncation eats" + assert "short rant" in out + # The header line is the scan view: it says it is an excerpt rather than pretending + # to be the text. Both arms, so the marker cannot be unconditional. + header = next(line for line in lines if line.startswith("2026-09-17T09:00:00")) + assert header.endswith("…"), f"a truncated header must say so: {header[-60:]!r}" + short_header = next(line for line in lines if line.startswith("2026-09-17T09:01:00")) + assert not short_header.endswith("…"), f"a message that fits needs no marker: {short_header!r}" + + +def test_the_list_header_is_bounded_and_the_full_progress_follows_it(tmp_path, monkeypatch): + """The row is a scan view, so it is bounded — and nothing is cut on the way out. + + Measured 2026-09-17 on the live queue: the one rant in flight carried a 1651-character + `progress` printed **in full** inside its header line, so the row a caller scans down was + 1743 characters — the scan view was itself the bulk of the output, and the field it was + supposed to frame was a tenth of it. Bounded here, with the whole value following in a + `progress:` block, on the same rule the message already follows: an excerpt in the scan + view *and* the full text, never one without the other. + + Both arms of the marker are asserted for `progress` as well as for the message. The + failure this guards is not only "the text was cut" — the block proves it was not — but + "the cut was not visible": a row silently showing a third of a field is indistinguishable + from a row showing the field, which is what made the original truncation survive as long + as it did. + """ + monkeypatch.setattr("emrg.config.config_dir", lambda: tmp_path) + progress = ( + "Stage 1 landed; the guard half is still open\n\n" + " a detail line inside the progress\n\n" + "and a closing line that a header excerpt removes." + + " progress-tail-" + "p" * 300 + ) + message = ("a short message, then enough filler that the marker below sits past the " + "excerpt's " + "f" * 80 + " message-tail-" + "m" * 300) + _write_rant_lines(tmp_path, [ + {"timestamp": "2026-09-17T09:00:00+08:00", "project": "emrg", + "status": "in_progress", "progress": progress, "completed": None, + "message": message}, + {"timestamp": "2026-09-17T09:01:00+08:00", "project": "emrg", + "status": "pending", "progress": "PR #1 submitted", "completed": None, + "message": "short rant"}, + ]) + out = __import__("asyncio").run(SubmitRantTool().execute({"action": "list"})).content + lines = out.splitlines() + + header = next(l for l in lines if l.startswith("2026-09-17T09:00:00")) + # (a) the row is an excerpt of both fields, not either field: neither tail is in it, and + # a row that carried one of them whole would be unbounded by construction. + assert "progress-tail-" not in header, f"the header carries the whole progress: {len(header)} chars" + assert "message-tail-" not in header, f"the header carries the whole message: {len(header)} chars" + assert len(header) < 400, ( + f"the header is a scan view, so it is bounded; this one is {len(header)} characters" + ) + # (b) both arms of the marker, on the progress field as well as on the message. + assert "progress=Stage 1 landed; the guard half is still open" in header + assert "progress=Stage 1 landed; the guard half is still open\n" not in header + "\n" + assert "… | completed=None" in header, f"a cut progress must say so: {header!r}" + short_header = next(l for l in lines if l.startswith("2026-09-17T09:01:00")) + assert "progress=PR #1 submitted |" in short_header, short_header + assert "…" not in short_header.split(" | ")[3], f"a progress that fits needs no marker: {short_header!r}" + + # (c) the full progress arrives, in its own block, line for line — read back off the + # output rather than searched for, because the block is indented and the value is + # therefore never one literal run of characters in the output. + start = lines.index(header) + assert lines[start + 1].startswith(" "), "the message block follows the header" + label = next(i for i in range(start + 1, len(lines)) if lines[i] == " progress:") + progress_block: list[str] = [] + for line in lines[label + 1:]: + if line and not line.startswith(" "): # the block's own indent; a header ends it + break + progress_block.append(line[6:] if line else "") + assert "\n".join(progress_block) == progress, ( + "the whole progress has to arrive, line for line; the row above shows an excerpt of it" + ) + assert "progress-tail-" + "p" * 300 in out, "the tail of the progress is what a header cut removes" + # (d) the message is not the casualty of the space the progress block takes. + assert "message-tail-" + "m" * 300 in out, "the message still arrives whole" + + def test_tool_list_action(tmp_path, monkeypatch): monkeypatch.setattr("emrg.config.config_dir", lambda: tmp_path) tool = SubmitRantTool()