Files
boxmaker/docs/plans/M4b/17-gatewayd-asked-texts.md
kyleandClaude Opus 5.5 f6dd7f1741 M4b review, and follow-up tasks 16 to 18
The review of tasks 01 to 15 goes in the implementer log, with the
device check against the owner's Mattermost. Three follow-ups:

- 16: the refusal without a runner says why.
- 17: the texts that say who asked.
- 18: approvals from non-approvers in direct or group messages go to
  an approver.

Task 14's given serve_approvals.rs becomes serve_approvals.rs-task14,
since task 18 hands over a new version. Tip T31 is added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 11:35:24 -07:00

116 lines
4.6 KiB
Markdown

# M4b task 17: the texts of a redirected approval
**Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `gatewayd: the texts that say who asked for a redirected approval`
## Goal
Texts only; task 18 posts them. When someone who may talk to the bot but not approve asks for
something in a direct or group message, no approver can answer the approval there, so it goes to
the first approver's direct messages (M4b spec, section 6, as changed after the review of
2026-09-24). The approver must see who asked, where, and what they asked. Three texts:
1. `asked_post`: the first post of a redirected approval. The asker's message is escaped with
`proto::escape::escape_model_text` and fenced (`fence`, already in this file). It is context,
not what is approved, so only its first `PROMPT_SHOWN` (2,000) characters are shown, then a
line saying how many more there were. For example:
````text
**Approval 3** was asked by @alex in a direct message with the bot. Their message:
```
Read the file /srv/notes/q3.txt and summarise it.
```
````
2. `unfollowed_post`: the first post of an approval whose session `gatewayd` does not follow
(`bxctl chat`, or a turn from before a restart).
3. `sent_to_approver`: the line left in the asking thread.
## Files
- Copy: `crates/gatewayd/tests/approvals_asked.rs`
- Modify: `crates/gatewayd/src/approvals/text.rs` (append only), `docs/implementer-log.md`
## The change, exactly
Append this to the end of `crates/gatewayd/src/approvals/text.rs`, after `with_ending`, exactly
as it is. Nothing else in the file changes, and there are no new imports: `proto` is already a
dependency, and `code` and `fence` are in this file.
```rust
/// The most characters of an asker's message shown to the approver: it is context, not what is
/// approved, so it may be cut.
pub const PROMPT_SHOWN: usize = 2_000;
/// Who asked for an approval that is shown elsewhere (M4b spec, section 6).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Asked {
/// `@username`, or the user id when the name could not be found.
pub by: String,
/// The channel's type: `D` a direct message, `G` a group message.
pub channel_type: String,
/// The text of the turn that asked.
pub prompt: String,
}
/// The first post of a redirected approval: who asked, where, and their message.
pub fn asked_post(id: u64, asked: &Asked) -> String {
let place = if asked.channel_type == "G" {
"a group message"
} else {
"a direct message"
};
let escaped = proto::escape::escape_model_text(&asked.prompt);
let total = escaped.chars().count();
let shown: String = escaped.chars().take(PROMPT_SHOWN).collect();
let mut post = format!(
"**Approval {id}** was asked by {} in {place} with the bot. Their message:\n{}",
asked.by,
fence(&shown)
);
if total > PROMPT_SHOWN {
post.push_str(&format!("\n(… {} more characters)", total - PROMPT_SHOWN));
}
post
}
/// The first post of an approval whose session `gatewayd` does not follow.
pub fn unfollowed_post(id: u64, session: &str) -> String {
format!(
"**Approval {id}** was asked in session {}, which gatewayd does not follow now (a restart, or a session from outside Mattermost).",
code(session)
)
}
/// The line left in the asking thread when its approval goes to an approver.
pub fn sent_to_approver(id: u64) -> String {
format!("waiting for approval {id}: it was sent to an approver")
}
```
Characters are counted with `chars()`, never `len()`: `len()` counts bytes, and a message in any
language but English would be cut short.
## Steps
- [ ] **1. Copy.** `git switch m4b`, then
`cp docs/plans/M4b/files/crates/gatewayd/tests/approvals_asked.rs crates/gatewayd/tests/`
- [ ] **2. See it fail.** `cargo test -p gatewayd --test approvals_asked`. Expected: it does not
compile (`Asked` and the three functions do not exist yet).
- [ ] **3. Make the change.** Run `cargo fmt --all`.
- [ ] **4. See it pass.** `cargo test -p gatewayd --test approvals_asked`: 4 passed. Then
`cargo test -p gatewayd`: every suite passes.
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`, with 4 more tests than
before this task (about 850).
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
## Done when
- `cargo test -p gatewayd` passes; `make gate` prints `gate: ok`.
## Stop and report if
- A test in `approvals_asked.rs` still fails with the code above appended exactly. Do not change
the test or the texts to make it pass.