fix(threads): bound a thread title at 50 characters, regenerating rather than cutting #822

Merged
coilysiren merged 1 commit from aos/claude/wy58-thread-title-length into main 2026-08-15 17:07:56 +00:00
Member

Closes #753, implementing the resolved design-pass spec rather than the original report.

What changed

The bound is 50. threadTitleRunes = 50. Discord's own cap is 100 and threadNameRunes still holds it, but 100 does not display whole in a thread list.

Over-length regenerates, it does not truncate. The second request states the limit, and threadTitleRetryPrompt builds that sentence from the constant so the number in the prose cannot drift from the number in the check.

Exactly one regeneration. A second over-length answer is hard-trimmed and recorded as thread.title.trimmed. A title generator must not be able to spend a turn's budget on itself.

The trim is a plain cut, not truncateRunes. That helper spends a rune on an ellipsis saying it truncated, which is the thing this bound exists to avoid.

One reading of the spec worth confirming

The acceptance says to assert the bound on the creation path, not only in the generator. I took that at its word, so threadCreationName bounds both names a thread can get: the generated title, and the name derived mechanically from the member's own message.

The derived name never went through the generator and was previously free to reach 100 runes with an ellipsis, which is the same unreadable channel-list row this issue was filed about. Bounding only the generated title would have left that standing while making the creation-path assert conditional and odd. The mutation below shows it firing on exactly that case.

If you meant the bound to apply only to model-authored titles, say so and I will narrow it.

Mutation

Trim instead of regenerate - both title tests fail, and the result is market price comparison market price comparison ma. That is the precise shape the spec rejects, reproduced by removing the fix.

Drop the creation-path bound - TestTheCreationPathBoundsEveryThreadName fails twice: 279 runes for the generated title, and 100 for the derived name.

The retry bound is pinned by asserting the call count is exactly 2, so a loop fails as loudly as no retry does.

Acceptance

  • No generated title exceeds 50, asserted on the creation path - threadCreationName, mutation-checked.
  • Over-length triggers exactly one regeneration and the second request states the limit - asserted on the recorded prompts, including that the first did not already state it, so the test cannot pass vacuously.
  • Two failures hard-trim and log - thread.title.trimmed.
  • Existing threads untouched - this binds creation only, nothing renames.

Note on failure versus over-length

A failed titling call returns empty and the caller keeps the derived name, as before. Empty is inside the bound, so a failure triggers no regeneration - only an over-length answer does. That keeps the degrade-rather-than-fail property docs/sirens-echo-threads.md promises and avoids spending a second call on a backend that just errored.

Verification

ward gate green: build, policy-check, vet, test, test-skips, pre-commit. Four new tests, each run individually and each confirmed to fail under the mutations above.

Reasoning: docs/sirens-echo-thread-title-length.md.

🤖 Generated with Claude Code

Closes #753, implementing the resolved design-pass spec rather than the original report. ## What changed **The bound is 50.** `threadTitleRunes = 50`. Discord's own cap is 100 and `threadNameRunes` still holds it, but 100 does not display whole in a thread list. **Over-length regenerates, it does not truncate.** The second request states the limit, and `threadTitleRetryPrompt` builds that sentence from the constant so the number in the prose cannot drift from the number in the check. **Exactly one regeneration.** A second over-length answer is hard-trimmed and recorded as `thread.title.trimmed`. A title generator must not be able to spend a turn's budget on itself. **The trim is a plain cut**, not `truncateRunes`. That helper spends a rune on an ellipsis saying it truncated, which is the thing this bound exists to avoid. ## One reading of the spec worth confirming The acceptance says to assert the bound *on the creation path, not only in the generator*. I took that at its word, so `threadCreationName` bounds **both** names a thread can get: the generated title, and the name derived mechanically from the member's own message. The derived name never went through the generator and was previously free to reach 100 runes with an ellipsis, which is the same unreadable channel-list row this issue was filed about. Bounding only the generated title would have left that standing while making the creation-path assert conditional and odd. The mutation below shows it firing on exactly that case. If you meant the bound to apply only to model-authored titles, say so and I will narrow it. ## Mutation **Trim instead of regenerate** - both title tests fail, and the result is `market price comparison market price comparison ma`. That is the precise shape the spec rejects, reproduced by removing the fix. **Drop the creation-path bound** - `TestTheCreationPathBoundsEveryThreadName` fails twice: 279 runes for the generated title, and 100 for the derived name. The retry bound is pinned by asserting the call count is exactly 2, so a loop fails as loudly as no retry does. ## Acceptance - No generated title exceeds 50, asserted on the creation path - `threadCreationName`, mutation-checked. - Over-length triggers exactly one regeneration and the second request states the limit - asserted on the recorded prompts, including that the *first* did not already state it, so the test cannot pass vacuously. - Two failures hard-trim and log - `thread.title.trimmed`. - Existing threads untouched - this binds creation only, nothing renames. ## Note on failure versus over-length A failed titling call returns empty and the caller keeps the derived name, as before. Empty is inside the bound, so a failure triggers no regeneration - only an over-length answer does. That keeps the degrade-rather-than-fail property `docs/sirens-echo-threads.md` promises and avoids spending a second call on a backend that just errored. ## Verification `ward gate` green: build, policy-check, vet, test, test-skips, pre-commit. Four new tests, each run individually and each confirmed to fail under the mutations above. Reasoning: `docs/sirens-echo-thread-title-length.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(threads): bound a thread title at 50 characters, regenerating rather than cutting
All checks were successful
ci / image-build (pull_request) Successful in 24s
ci / test (pull_request) Successful in 41s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
398bea8274
A generated thread title could reach Discord's 100-character cap, which does
not display whole in a thread list or in the surfaces that truncate hardest.

`threadTitleRunes` is 50. An over-length title goes back to the model rather
than being cut, because a trimmed title loses its subject and that is the one
thing a title carries. `Comparing current market prices for wood, stone, an...`
reads worse in a channel list than a shorter title that names the topic. The
second request states the limit, built from the constant so the sentence cannot
drift from the number.

Exactly one regeneration. A second over-length answer is hard-trimmed and
recorded as `thread.title.trimmed`, so a generator that keeps overrunning is
visible rather than silent. A title generator must not be able to spend a
turn's budget on itself.

The trim is a plain cut rather than `truncateRunes`, which spends a rune on an
ellipsis saying it truncated.

The bound holds at `threadCreationName`, not only in the generator. The other
name a thread can get is derived from the member's own message and never went
through the generator, and at 100 runes it is the same unreadable row the issue
was filed about.

Existing threads are untouched. A failed titling call still returns empty and
the caller keeps the derived name, because failure is not over-length and
triggers no regeneration.

Mutation: trimming instead of regenerating fails both title tests and produces
`market price comparison market price comparison ma`, the exact shape the issue
rejects. Dropping the creation-path bound fails on 279 runes for the generated
title and 100 for the derived name.

Closes #753

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo!822
No description provided.