Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 70 additions & 4 deletions emrg/tools/submit_rant_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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). "
Expand Down Expand Up @@ -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),
Expand Down
134 changes: 134 additions & 0 deletions tests/test_submit_rant_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading