constitution · epochs · watch · epoch 3

comparison

c_25172cf8caa0 (tommy-mor) vs c_ca9169f732b8 (tommy-mor)

download prompt · raw event · cmp_a1764b7ab5cc02

council reasoning

~anthropic/claude-sonnet-latest · winner A · 6:4 · permalink

Commit A fixes a real correctness bug (shared sentinel delegate blocking multi-user voting), removes dead/misleading abstractions cleanly, and adds a solid integration test proving two distinct users can vote without collision. Commit B is also a legitimate bugfix (avoiding blocked public Reddit API, forcing OAuth refresh) with good error-detail handling and a unit test, but it's a narrower operational fix versus A's core multi-user correctness fix affecting fundamental voting functionality.

~x-ai/grok-latest · winner A · 2:1 · permalink

Commit A removes the shared WEB_BROWSER_AGENT/invite sentinel design that bound AgentBound on the first browser vote and blocked later humans, making delegate Optional end-to-end (auth, PendingSession, UI posts, authorship, poll types) with a multi-user vote integration test—lasting core product correctness. Commit B is a solid production fix (mandatory OAuth when creds exist, 401/403 refresh, no public www fallback, fly.toml base URL) but is narrower operational resilience for the Reddit worker rather than a foundational multi-user model fix.

openai/gpt-chat-latest · winner A · 3:2 · permalink

Side A removes the shared browser sentinel delegate by changing pending sessions and browser posts to use `Option<String>` delegates, eliminating a design flaw where one shared delegate caused later human votes to fail (`delegate already bound`). It also updates attribution logic and adds a comprehensive integration test verifying multiple browser users can vote independently, whereas Side B improves Reddit OAuth robustness with required OAuth, token refresh on 401/403, and clearer error handling but is confined to a specific integration path.

sides

A — c_25172cf8caa0 (tommy-mor)

message

[b9476669] Remove browser sentinel delegates so multi-user votes work.

Shared WEB_BROWSER_AGENT bound on first vote and blocked every later human; browser posts now use no delegate, matching forum UI.

Co-authored-by: Cursor <cursoragent@cursor.com>

diff preview

diff --git a/cli/src/main.rs b/cli/src/main.rs
index c4f1494df3aedbd8b883aea6249579aa8336abfe..af3e4fee876910a44f6f872b24821bc541a23e20 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -1741,8 +1741,8 @@ async fn run() -> Result<()> {
                     tokio::time::sleep(std::time::Duration::from_millis(poll_interval_ms)).await;
                     let poll: PendingSessionPollResponse =
                         expect_json(client.get(&poll_url).send().await?).await?;
-                    if !poll.agent.trim().is_empty() {
-                        agent_out = Some(poll.agent.clone());
+                    if let Some(a) = poll.agent.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
+                        agent_out = Some(a.to_string());
                     }
                     if poll.complete {
                         token_out = poll.token;
diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index 50dc6af908412b16343829eae25154ebf4e19fca..01c02f50c19bcd62c7f1717f9b927f203c3164d0 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -29,18 +29,6 @@ use crate::{
     write_cmd::WriteCmd,
 };
 
-/// Delegate id for browser users who land via `/join/inv_…` (no CLI agent).
-const INVITE_BROWSER_AGENT: &str = "00000000-0000-0000-0000-000000000000:invite:web/join";
-
-/// Agent id for `/login` browser OAuth (no CLI); must pass [`parse_agent`].
-pub const WEB_BROWSER_AGENT: &str = "00000000-0000-0000-0000-000000000001:social:web/browser";
-
-/// True for well-known browser / human-form sentinel delegates (not real AI agents).
-/// HTML attribution should show the human username for these, not `@@uuid:rig:…`.
-pub fn is_browser_sentinel_delegate(agent: &str) -> bool {
-    agent == WEB_BROWSER_AGENT || agent == INVITE_BROWSER_AGENT
-}
-
 /// HttpOnly cookie storing the same `slug_*` bearer string the CLI uses.
 pub const SLUG_SESSION_COOKIE: &str = "slug_session";
 
@@ -288,7 +276,7 @@ pub async fn get_join_invite(
     let session = format!("p_{}", uuid::Uuid::new_v4().simple());
     let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref()));
     let s = PendingSession {
-        agent: INVITE_BROWSER_AGENT.to_string(),
+        agent: None,
         created_ts: now_ms(),
         provider: None,
         provider_id: None,
@@ -555,7 +543,7 @@ pub async fn post_choose_username(
     };
 
     let sessions = pending_sessions(&state);
-    let (provider, provider_id, agent) = {
+    let (provider, provider_id) = {
         let sessions_read = sessions.read().await;
         let Some(s) = sessions_read.get(&form.session) else {
             return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response();
@@ -566,14 +554,9 @@ pub async fn post_choose_username(
         let Some(provider_id) = s.provider_id.clone() else {
             return js_form_error_fragment(&form.session, "oauth not completed").into_response();
         };
-        (provider, provider_id, s.agent.clone())
+        (provider, provider_id)
     };
 
-    if let Err(msg) = parse_agent(&agent) {
-        return js_form_error_fragment(&form.session, &format!("invalid agent format — {msg}"))
-            .into_response();
-    }
-
     let redeem_invite = {
         let sessions_read = sessions.read().await;
         sessions_read
@@ -643,7 +626,8 @@ pub async fn get_web_login(
     let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref()))
         .or_else(|| Some("/".to_string()));
     let s = PendingSession {
-        agent: WEB_BROWSER_AGENT.to_string(),
+        // Humans sign in via the website with no AI delegate.
+        agent: None,
         created_ts: now_ms(),
         provider: None,
         provider_id: None,
@@ -704,7 +688,7 @@ pub async fn post_pending_session(
     );
     let poll_url = format!("/api/v0/pending-session/{session}");
     let s = PendingSession {
-        agent: agent_naked,
+        agent: Some(agent_naked),
         created_ts: now_ms(),
         provider: None,
         provider_id: None,
diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index fdff6db0472b36cd7870a4be68574023e0daf120..920e967b47852ea82fa61b84c457ae3582dd9800 100644
--- a/server/src/api/mod.rs
+++ b/server/src/api/mod.rs
@@ -18,13 +18,11 @@ pub use auth::{
     get_choose_username,
     get_web_login,
     get_logout,
-    is_browser_sentinel_delegate,
     optional_principal,
     resolve_web_session,
     session_cookie_header_value,
     WebSession,
     SLUG_SESSION_COOKIE,
-    WEB_BROWSER_AGENT,
 };
 
 pub use helpers::{
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 611956d0a46062b49ce350be176e4be139312ff0..6e56c039a184953cd1d0593c34cf4d5abe41f2a3 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -277,7 +277,7 @@ async fn dispatch_ui_action(
                 &session.bearer,
                 room.clone(),
                 thread_tag.clone(),
-                Some(crate::api::auth::WEB_BROWSER_AGENT.to_string()),
+                None,
                 text,
             )
             .await
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 645d54f2e117ba901e02ed958ada6ff69a78ae34..03ad762e88709c77b9d5dd130c48bc96226616a1 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -560,27 +560,28 @@ fn identity_color_css(seed: &str) -> String {
     format!("hsl({hue}, 62%, 66%)")
 }
 
-/// Seed for author color: real AI → delegate uuid; human/sentinel → principal username.
+/// Seed for author color: AI → delegate uuid; human (no delegate) → principal username.
 fn authorship_color_seed<'a>(principal: &'a str, delegate: &'a Option<String>) -> &'a str {
     match delegate {
-        Some(d) if !crate::api::is_browser_sentinel_delegate(d) => {
-            d.split(':').next().filter(|s| !s.is_empty()).unwrap_or(d.as_str())
-        }
-        _ => principal,
+        Some(d) => d
+            .split(':')
+            .next()
+            .filter(|s| !s.is_empty())
+            .unwrap_or(d.as_str()),
+        None => principal,
     }
 }
 
-/// Prefer the AI delegate in attribution; fall back to the human username when there is no
-/// delegate or the delegate is a browser/human-form sentinel.
+/// Prefer the AI delegate in attribution; humans post with no delegate and show `@username`.
 pub(crate) fn authorship_attr(principal: &str, delegate: &Option<String>) -> AuthorshipAttr {
     let color = identity_color_css(authorship_color_seed(principal, delegate));
     match delegate {
-        Some(d) if !crate::api::is_browser_sentinel_delegate(d) => AuthorshipAttr {
+        Some(d) => AuthorshipAttr {
             label: format!("@@{}", actor_label(d)),
             author_title: Some(format!("@{principal}")),
             color,
         },
-        _ => AuthorshipAttr {
+        None => AuthorshipAttr {
             label: format!("@{principal}"),
             author_title: None,
             color,
@@ -933,7 +934,6 @@ pub(super) fn recency_class(now_ms: i64, ts_ms: i64) -> &'static str {
 #[cfg(test)]
 mod authorship_tests {
     use super::*;
-    use crate::api::WEB_BROWSER_AGENT;
 
     #[test]
     fn human_or_missing_delegate_shows_username() {
@@ -943,15 +943,6 @@ mod authorship_tests {
         assert!(a.color.starts_with("hsl("));
     }
 
-    #[test]
-    fn browser_sentinel_delegate_shows_username() {
-        let d = Some(WEB_BROWSER_AGENT.to_string());
-        let a = authorship_attr("alice", &d);
-        assert_eq!(a.label, "@alice");
-        assert_eq!(a.author_title, None);
-        assert_eq!(authorship_address("alice", &d), "@alice");
-    }
-
     #[test]
     fn real_ai_delegate_shows_short_agent_with_username_hover() {
         let d = Some(
diff --git a/server/src/state.rs b/server/src/state.rs
index 648ab5304764a329fcabbbbcd3782b94e3e005a8..8ea84dcf9b1df8f8037e913cdd94e5908e6d5d55 100644
--- a/server/src/state.rs
+++ b/server/src/state.rs
@@ -21,7 +21,8 @@ pub struct InviteState {
 
 #[derive(Debug, Clone)]
 pub struct PendingSession {
-    pub agent: String,
+    /// CLI `identity start` delegate (`uuid:rig:model`). `None` for browser `/login` and `/join`.
+    pub agent: Option<String>,
     pub created_ts: i64,
     pub provider: Option<String>,
     pub provider_id: Option<String>,
diff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs
index 6b1475b773106a2dd3f326475c9fb4cc727f6b4b..714563a8b330e3917d95a54ae29b4de143e3b8a4 100644
--- a/server/tests/integration_ui.rs
+++ b/server/tests/integration_ui.rs
@@ -418,6 +418,99 @@ async fn test_web_login_carries_vote_pair_next_into_pending_session() {
     let sessions = state.pending_sessions.read().await;
     let pending = sessions.get(&session).expect("pending session");
     assert_eq!(pending.redirect_next.as_deref(), Some(next));
+    assert_eq!(
+        pending.agent, None,
+        "browser /login must not invent a sentinel delegate"
+    );
+}
+
+#[tokio::test]
+async fn test_vote_compare_two_users_both_succeed_without_delegate() {
+    let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;
+    let client = reqwest::Client::new();
+    let alice = test_bearer();
+    let bob = seed_test_identity(&state, "bob", "bobtok", "bobsecret").await;
+
+    // Define items first (votes require existing item bodies).
+    let seed = ui_post_ingest_rpc(
+        "public",
+        "multi-vote",
+        "~/multi-a {alpha}\n~/multi-b {beta}\n",
+    );
+    let seed_resp = client
+        .post(format!("http://{addr}/ui"))
+        .header("Authorization", format!("Bearer {alice}"))
+        .form(&[("__rpc__", seed.as_str())])
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(seed_resp.status(), reqwest::StatusCode::OK);
+    let seed_js = seed_resp.text().await.unwrap();
+    assert!(
+        !seed_js.contains("auth-error"),
+        "item seed must succeed, got: {seed_js}"
+    );
+
+    for (bearer, left, right, explanation) in [
+        (&alice, "3", "1", "alice prefers a"),
+        (&bob, "1", "3", "bob prefers b"),
+    ] {
+        let rpc = ui_vote_compare_post_rpc(
+            "public",
+            "multi-vote",
+            "~/multi-a",
+            "~/multi-b",
+            left,
+            right,
+            explanation,
+        );
+        let resp = client
+            .post(format!("http://{addr}/ui"))
+            .header("Authorization", format!("Bearer {bearer}"))
+            .form(&[("__rpc__", rpc.as_str())])
+            .send()
+            .await
+            .unwrap();
+        assert_eq!(resp.status(), reqwest::StatusCode::OK);
+        let js = resp.text().await.unwrap();
+        assert!(
+            !js.contains("delegate already bound"),
+            "human vote must not hit shared-sentinel AgentBound ({explanation}), got: {js}"
+        );
+        assert!(
+            !js.contains("auth-error"),
+            "human vote must succeed ({explanation}), got: {js}"
+        );
+        assert!(
+            js.contains("vote-edge-history-region"),
+            "vote should morph edge history ({explanation}), got: {js}"
+        );
+    }
+
+    let reduced = state.reduced.read().await;
+    let human_votes: Vec<_> = reduced
+        .ingests_ordered
+        .iter()
+        .filter_map(|id| reduced.ingests_by_id.get(id))
+        .filter(|ing| ing.raw.contains("prefers"))
+        .collect();
+    assert_eq!(human_votes.len(), 2, "expected two vote ingests");
+    let mut principals: Vec<&str> = human_votes.iter().map(|i| i.principal.as_str()).collect();
+    principals.sort();
+    assert_eq!(principals, ["bob", "testuser"]);
+    for ing in &human_votes {
+        assert!(
+            ing.delegate.is_none(),
+            "browser votes must have no delegate, principal={} delegate={:?}",
+            ing.principal,
+            ing.delegate
+        );
+    }
+    assert!(
+        reduced.agent_bindings.is_empty(),
+        "human

… preview truncated; 3,004 characters omitted

download full diff A

B — c_ca9169f732b8 (tommy-mor)

message

[8f69c309] Require Reddit OAuth when credentials are set and refresh on 401/403.

Avoid falling back to the public www.reddit.com API from cloud IPs, which
returns Reddit's network-security block page. Also pin SORTER2_BASE_URL in fly.toml.

Co-authored-by: Cursor <cursoragent@cursor.com>

diff preview

diff --git a/fly.toml b/fly.toml
index f0c6a39f643c204987debc177234d95a8ef44b65..ca7e0088a7efd7d58d29d808f8d89080b2bff233 100644
--- a/fly.toml
+++ b/fly.toml
@@ -5,6 +5,7 @@ primary_region = "iad"
   dockerfile = "Dockerfile"
 
 [env]
+  SORTER2_BASE_URL = "https://reddit.sorter.social"
   SORTER2_DATA_DIR = "/data"
   SORTER2_EVENT_LOG = "/data/events.jsonl"
   PORT = "8080"
diff --git a/server/src/reddit.rs b/server/src/reddit.rs
index a874814f8927192ee62cab2d0db1efd27dcd57b7..f409764c1e1f36216f1b08107043c2eab905694c 100644
--- a/server/src/reddit.rs
+++ b/server/src/reddit.rs
@@ -283,26 +283,35 @@ async fn reddit_worker(
         );
         tokio::time::sleep(current_delay).await;
 
-        if let Some(c) = &creds {
-            oauth = ensure_oauth_token(&client, &oauth_token_base, c, oauth.take()).await;
-        }
-
-        let token = oauth.as_ref().map(|t| t.access_token.as_str());
-        let fetch_base = if token.is_some() {
-            tracing::debug!(
-                item = %fetch_id,
-                base = %oauth_api_base,
-                "reddit fetch using OAuth bearer"
-            );
-            &oauth_api_base
-        } else {
-            &api_base
-        };
-        let url = match kind {
-            FetchKind::SelfEntity => map_item_to_reddit_api(&fetch_id, fetch_base),
-            FetchKind::Children => map_children_url(&fetch_id, fetch_base),
+        let outcome = match &creds {
+            Some(c) => {
+                // OAuth is required when credentials are configured — never fall
+                // back to the public www.reddit.com JSON endpoints (cloud IPs
+                // get blocked with a 403 HTML interstitial).
+                fetch_with_oauth(
+                    &client,
+                    &oauth_token_base,
+                    &oauth_api_base,
+                    c,
+                    &mut oauth,
+                    &fetch_id,
+                    kind,
+                )
+                .await
+            }
+            None => {
+                let url = match kind {
+                    FetchKind::SelfEntity => map_item_to_reddit_api(&fetch_id, &api_base),
+                    FetchKind::Children => map_children_url(&fetch_id, &api_base),
+                };
+                match do_fetch(&client, &url, &fetch_id, None).await {
+                    Ok(FetchOutcome::AuthRejected { status, detail }) => {
+                        Err(format!("Reddit API {status}: {detail}"))
+                    }
+                    other => other,
+                }
+            }
         };
-        let outcome = do_fetch(&client, &url, &fetch_id, token).await;
 
         match outcome {
             Ok(FetchOutcome::Payload(payload)) => {
@@ -342,6 +351,12 @@ async fn reddit_worker(
                 current_delay = (current_delay * 2).min(Duration::from_secs(60));
                 notify(done, FetchJobResult::RateLimited { reset_secs });
             }
+            Ok(FetchOutcome::AuthRejected { status, detail }) => {
+                let e = format!("Reddit API {status}: {detail}");
+                tracing::warn!(item = %fetch_id, err = %e, "reddit fetch auth rejected");
+                current_delay = (current_delay * 2).min(Duration::from_secs(60));
+                notify(done, FetchJobResult::Failed(e));
+            }
             Err(e) => {
                 tracing::warn!(item = %fetch_id, err = %e, "reddit fetch failed");
                 current_delay = (current_delay * 2).min(Duration::from_secs(60));
@@ -357,6 +372,60 @@ enum FetchOutcome {
     Payload(Value),
     NotFound,
     RateLimited { reset_secs: u64 },
+    /// Bearer rejected — caller should drop the cached token and retry once.
+    AuthRejected { status: StatusCode, detail: String },
+}
+
+async fn fetch_with_oauth(
+    client: &Client,
+    oauth_token_base: &str,
+    oauth_api_base: &str,
+    creds: &RedditCredentials,
+    oauth: &mut Option<OAuthToken>,
+    fetch_id: &ItemId,
+    kind: FetchKind,
+) -> Result<FetchOutcome, String> {
+    for attempt in 0..2 {
+        let force_refresh = attempt > 0;
+        *oauth = Some(
+            ensure_oauth_token(client, oauth_token_base, creds, oauth.take(), force_refresh)
+                .await?,
+        );
+        let token = oauth
+            .as_ref()
+            .expect("token set above")
+            .access_token
+            .clone();
+
+        tracing::debug!(
+            item = %fetch_id,
+            base = %oauth_api_base,
+            attempt,
+            "reddit fetch using OAuth bearer"
+        );
+
+        let url = match kind {
+            FetchKind::SelfEntity => map_item_to_reddit_api(fetch_id, oauth_api_base),
+            FetchKind::Children => map_children_url(fetch_id, oauth_api_base),
+        };
+        match do_fetch(client, &url, fetch_id, Some(&token)).await? {
+            FetchOutcome::AuthRejected { status, detail } if attempt == 0 => {
+                tracing::warn!(
+                    item = %fetch_id,
+                    %status,
+                    %detail,
+                    "reddit OAuth rejected; refreshing token and retrying"
+                );
+                *oauth = None;
+                continue;
+            }
+            FetchOutcome::AuthRejected { status, detail } => {
+                return Err(format!("Reddit API {status}: {detail}"));
+            }
+            other => return Ok(other),
+        }
+    }
+    unreachable!("loop always returns")
 }
 
 async fn ensure_oauth_token(
@@ -364,35 +433,35 @@ async fn ensure_oauth_token(
     oauth_base: &str,
     creds: &RedditCredentials,
     existing: Option<OAuthToken>,
-) -> Option<OAuthToken> {
-    if let Some(t) = existing {
-        if Instant::now() < t.expires_at - Duration::from_secs(60) {
-            tracing::debug!("reddit OAuth token still valid");
-            return Some(t);
+    force_refresh: bool,
+) -> Result<OAuthToken, String> {
+    if !force_refresh {
+        if let Some(t) = existing {
+            if Instant::now() < t.expires_at - Duration::from_secs(60) {
+                tracing::debug!("reddit OAuth token still valid");
+                return Ok(t);
+            }
         }
     }
 
     let url = format!("{}/api/v1/access_token", oauth_base.trim_end_matches('/'));
-    tracing::debug!(%url, "reddit OAuth token request");
+    tracing::debug!(%url, force_refresh, "reddit OAuth token request");
 
     let resp = client
         .post(&url)
         .basic_auth(&creds.client_id, Some(&creds.client_secret))
         .form(&[("grant_type", "client_credentials")])
         .send()
-        .await;
-
-    let resp = match resp {
-        Ok(r) => r,
-        Err(e) => {
-            tracing::warn!("reddit OAuth token request failed: {e}");
-            return None;
-        }
-    };
+        .await
+        .map_err(|e| format!("Reddit OAuth token request failed: {e}"))?;
 
     if !resp.status().is_success() {
-        tracing::warn!("reddit OAuth token HTTP {}", resp.status());
-        return None;
+        let status = resp.status();
+        let body = resp.text().await.unwrap_or_default();
+        return Err(format!(
+            "Reddit OAuth token HTTP {status}: {}",
+            truncate_for_error(&body)
+        ));
     }
 
     #[derive(Deserialize)]
@@ -401,21 +470,40 @@ async fn ensure_oauth_token(
         expires_in: u64,
     }
 
-    let body: TokenResponse = match resp.json().await {
-        Ok(b) => b,
-        Err(e) => {
-            tracing::warn!("reddit OAuth token parse failed: {e}");
-            return None;
-        }
-    };
+    let body: TokenResponse = resp
+        .json()
+        .await
+        .map_err(|e| format!("Reddit OAuth token parse failed: {e}"))?;
 
-    tracing::debug!(expires_in = body.expires_in, "reddit OAuth token acquired");
-    Some(OAuthToken {
+    tracing::info!(expires_in = body.expires_in, "reddit OAuth token acquired");
+    Ok(OAuthToken {
         access_token: body.access_token,
         expires_at: Instant::now() + Duration::from_secs(body.expires_in),
     })
 }
 
+fn truncate_for_error(body: &str) -> String {
+    let compact: String = body.split_whitespace().collect::<Vec<_>>().join(" ");
+    if compact.is_empty() {
+        return "(empty body)".into();
+    }
+    // Prefer the human-readable block message over dumping Reddit's CSS.
+    if let Some(idx) = compact.find("You've been blocked") {
+        let slice: String = compact.chars().skip(idx).take(160).collect();
+        return if compact.chars().count() > idx + 160 {
+            format!("{slice}…")
+        } else {
+            slice
+        };
+    }
+    let chars: String = compact.chars().take(200).collect();
+    if compact.chars().count() > 200 {
+        format!("{chars}…")
+    } else {
+        chars
+    }
+}
+
 async fn do_fetch(
     client: &Client,
     url: &str,
@@ -460,15 +548,16 @@ async fn do_fetch(
 
     if !status.is_success() {
         let body = resp.text().await.unwrap_or_default();
+        let detail = truncate_for_error(&body);
         tracing::debug!(
             item = %id,
             %status,
             body_len = body.len(),
-            body_prefix = %body.chars().take(240).collect::<String>(),
+            %detail,
             "reddit non-success body"
         );
         if status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED {
-            return Err(format!("Reddit API {status}: {body}"));
+            return Ok(FetchOutcome::AuthRejected { status, detail });
         }
         return Ok(FetchOutcome::NotFound);
     }
@@ -716,6 +805,15 @@ fn reddit_direct_image_url(url: &str) -> bool {
 mod tests {
     use super::*;
 
+    #[test]
+    fn truncate_error_prefers_block_message() {
+        let html = r#"<style>.x{color:red}</style><div>You've been blocked by network security. To continue, log in</div>"#;
+        let msg = truncate_for_error(html);
+        assert!(msg.starts_with("You've been blocked"));
+        assert!(msg.len() < 200);
+        assert!(!msg.contains(".x{color"));
+    }
+
     #[test]
     fn map_subreddit_about_url() {
         let id = ItemId::from_url("https://reddit.com/r/rust").unwrap();

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.