Skip to content

fix(mobile): hydrate and refresh relay presence snapshots - #7382

Open
loganj wants to merge 1 commit into
mainfrom
fix/mobile-agent-presence-20260905
Open

fix(mobile): hydrate and refresh relay presence snapshots#7382
loganj wants to merge 1 commit into
mainfrom
fix/mobile-agent-presence-20260905

Conversation

@loganj

@loganj loganj commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

🤖

Summary

Mobile showed every person in a DM as Offline until they happened to publish a presence update while the app was connected. Presence was never loaded when the app opened, never refreshed, and a missing or failed read looked identical to actually being offline. This PR makes presence load and stay current: on connect or foreground, the app fetches the relay's presence snapshot for everyone visible, keeps live updates overlapping so nothing is missed in between, and re-checks every 60 seconds while the app is in the foreground.

  • Snapshot reads are authenticated like the rest of the session and batched by exact key (at most 100 per batch), instead of relying on live events only.
  • The live subscription and the snapshots overlap, so a gap between them can't silently drop an update; late results and same-status heartbeats are fenced so they can't overwrite newer state.
  • Refreshing every 60 foreground seconds observes relay lease expiry and retry failures; missing or failed evidence simply stays absent — it is never reported as Offline.
  • Disconnects, backgrounding, and account/community changes reset the cache cleanly. The app's existing subscription/recovery machinery is reused rather than duplicated.

Mirrors Desktop's presence hydration (desktop/src-tauri/src/commands/profile.rs::get_presence, desktop/src/features/presence/hooks.ts).

Related issue

Testing

  • Regressions cover cold-open hydration, live/snapshot races, lease expiry, failed reads and retries, disconnect/background/scope changes, and >100-key batching.
  • At the branch head: just mobile-check and the full mobile test suite pass independently, and every broader CI gate has successful evidence after isolated recovery runs (no monolithic local just ci run) — receipts in the exact-head evidence comment.
  • Verification is automated-test level; no native device or simulator run is claimed. No layout change in this slice.

Screenshots

No layout change in this slice — the renders below show the same existing profile sheet, with the presence status now following hydrated relay data. Flutter production-widget test renders — not native-device screenshots or acceptance captures.

Scenario Before After
Existing profile opened cold, with a signed relay snapshot saying Online Before: the profile stays Offline — the snapshot is never fetched After: the profile shows Online once the snapshot hydrates
Capture provenance

Rendered by the Flutter widget engine in a flutter test run (production widgets, production theme; no device or simulator). Before: this PR's declared base 3c7f288c60d67df78577b237e27c3dfc8831aaa1. After: its head a8e8028bfb9bb67de2db03ed448839e2f3ab2b0d.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

🔐 Codex Security Review

Status: review required for the current range.

The current range is 813bbd14121edacc6cb4733301a3af12131aa10e...3ff2f290bac41811fe2db0b141961148740ebce5.
A new review must complete for this exact range. When manual authorization
is required, a Block organization member must comment exactly
@buzz-security-review 3ff2f290bac41811fe2db0b141961148740ebce5 to authorize a new review.
Any previous review applies only to its recorded range.

@loganj

loganj commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

@buzz-security-review a8e8028

@loganj

loganj commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Independent presence readiness evidence — 2026-09-07

Verified current range: main at 3c7f288c60d67df78577b237e27c3dfc8831aaa1a8e8028bfb9bb67de2db03ed448839e2f3ab2b0d; +493/−54 = 547 lines. Still OPEN/draft. No source changes or pushes were needed.

  • Exact-head mobile-check passed; full mobile suite 2,083 passed. Completed mobile validation was preserved, not repeated during continuation.
  • Broader local validation: repository-wide check passed. Original just ci returned 1 in Rust unit fixtures, not a clean monolithic pass. In-checkout TMPDIR made five agent hints tests read the real repository AGENTS.md; ACP shell/timer tests and one process-global DB waiter gauge also failed under parallel execution. After moving TMPDIR outside any git root (still private), complete affected ACP/agent/DB lanes passed with --test-threads=1, without altering code/assertions: ACP 915 + 9, agent 525 (1 ignored), DB 122 (252 ignored).
  • All previously unreached local lanes passed independently on this head: Desktop 6,450 tests, Desktop build, Tauri check, Tauri 3,266 tests across targets (20 ignored), web build. Thus every local gate component has successful evidence after documented recovery; the original exit-1 logs remain preserved.
  • Remote CI attempt 2 completed successfully. Retried Desktop shard 4: 345 passed, 3 flaky, zero final failures. The original structured-condition case also passed its exact-head targeted local reproduction. Neither this PR nor its UI child changes Desktop source/tests. Final head rollup: 32 success / 31 intentionally skipped, nothing pending/failing; path-filtered skips are not execution claims.

Advisory security: unavailable, not clean

The authorized exact-range advisory finished cancelled at 15:54:56 UTC. Its annotation states: “The job has exceeded the maximum execution time of 40m0s.” Authorization/checkout/range pin passed, but review never completed; salvage/post did not run, zero artifacts were published, and the review-job log endpoint returned 404. No findings were produced for inspection. No manual cancellation or blind retry was performed.

Next actor/action: the maintainer of the trusted Codex Security Review workflow must resolve the runner/action timeout/output loss, then rerun the authorized current range. Resume advisory sign-off only when actual current-range output is posted and findings have been reviewed. This is an external advisory limitation, not a failed presence test or a claim that security review passed.

Technical review found no substantive mobile defect: snapshot/live overlap, per-key revisions, generation fences, invalidation on transport/account/community/lifecycle changes, and bounded 60-second polling were checked. Unknown rendering is deliberately the separate stacked UI PR #7383. Presence is conversational availability, not substrate health.

Native relay-backed foreground/background/reconnect validation remains unavailable on this host (no Xcode/simulator). Widget renders are not native-runtime evidence. Both PRs remain drafts; no merge/readiness-state change was made.

Durable evidence: WORK_LOGS/PRESENCE_REVIEW_3FE2D8A4/REVIEW_REPORT.md, original *-ci.log/exit records, recover-ci-lanes.sh, per-head recovery logs/results, and final GitHub API snapshots.

@loganj
loganj marked this pull request as ready for review September 8, 2026 13:54
@loganj
loganj requested a review from a team as a code owner September 8, 2026 13:54

@jedwards27 jedwards27 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing exact head a8e8028bfb9bb67de2db03ed448839e2f3ab2b0d against base 3c7f288c60d67df78577b237e27c3dfc8831aaa1, I found two user-visible correctness defects that need to be resolved before merge.

  1. Transient snapshot failures are rendered as authoritative Offline. PresenceCacheNotifier._refresh removes requested keys when the query fails (mobile/lib/features/profile/presence_cache_provider.dart:163-195), while each production consumer converts an absent key directly to offline (mobile/lib/features/profile/user_profile_sheet.dart:56-57, mobile/lib/features/channels/channel_detail_page/app_bar.dart:218-250, and mobile/lib/features/channels/channels_page/channel_tile.dart:177-182). A previously online/away person therefore flips visibly—and in profile accessibility semantics, audibly—to Offline during relay, Redis, auth, or network failure. The new test asserts that the failed refresh empties the helper cache, but does not exercise a widget consumer, so it codifies the state loss without protecting the user-facing contract.

    Required: preserve unknown/stale explicitly through the renderer (for example, neutral/hidden/loading, or retained last-known state marked stale), and add widget regression coverage for both no observation and known-online → failed refresh. Neither case may claim Offline without evidence.

  2. Snapshot freshness is not joined to live-event ordering. Snapshot settlement selects the latest result but does not write its createdAt into _timestamps (mobile/lib/features/profile/presence_cache_provider.dart:176-198). The next live event is consequently compared against zero in _handlePresenceEvent (:137-149). A production-notifier regression probe at this head demonstrated that snapshot online @ 20 followed by delayed live offline @ 10 yields offline (expected online). HTTP snapshot and WebSocket delivery are independent, so delayed pre-snapshot delivery can regress visible presence until another heartbeat or the next refresh.

    Required: use one monotonic freshness policy across snapshot and live settlement, define equal-second conflict behavior, account for relay-synthesized snapshot timestamps versus signer clock skew, and add the deterministic snapshot-20/live-10 regression.

Validation at this exact head: just mobile-check passed; the full just mobile-test passed 2,083 tests; focused shipped presence suites passed 17 tests; git diff --check passed. The adversarial stale-order probe failed as described and was then removed, leaving its review worktree clean. Identity/tenant boundaries, subscribe-before-snapshot fencing, scope reset, cancellation/disposal, retry, batching, and Redis tenant TTL storage otherwise held under source/test review. Green gates do not cover the two production seams above.

Confidence gaps, not additional author defects: no native iOS foreground/background/reconnect or accessibility journey was observed, and the mobile client relies on authenticated transport plus server validation/synthesis rather than locally verifying inbound Nostr signatures. Native relay-backed verification should follow the fixes when a simulator/device-capable owner is available.

@loganj loganj left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disposition for review 5142925666 at head a8e8028bf — both items are addressed in this stack; this branch itself is intentionally left unchanged so each stack delta stays small and reviewable.

  1. Transient snapshot failures rendered as authoritative Offline. The consumer-side fix is in #7383 (this PR's descendant): it removes the null→offline mapping at all three renderers, hides unknown dots/notches, and labels unknown presence explicitly. Landing #7382 together with #7383 ships the unknown semantics without the intermediate false-Offline UI, so I have not duplicated those renderer changes onto this branch.

  2. Snapshot freshness not joined to live-event ordering. Fixed in #7526, a focused follow-up based on #7383's branch rather than further growth here. It records each settled snapshot's observation second, never compares the relay-synthesized snapshot timestamp against signer event time directly (snapshot records carry the relay clock at synthesis; live kind:20001 events carry the subject's signer clock), and confirms a live event older than the observation second against a fresh snapshot — the relay ingests presence before fan-out, so that observation always reflects the event or a newer one. Equal-second conflicts settle last-arrival-wins, and a bounded event-id memory keeps redelivered ambiguous events from re-arming confirmations. The deterministic snapshot-20/live-10 regression is included, plus lagging-clock confirm, equal-second, and redelivery bounds; full mobile suite 2090/2090 at its head 08d20f9.

Happy to reshape the stack if maintainers prefer a different ordering.

loganj added a commit that referenced this pull request Sep 10, 2026
## Summary
- Reject kind:20001 presence events with `OK false` / `error: presence
storage unavailable` when Redis SET or DEL fails, before publishing,
local fan-out, or local-event marking.
- Preserve the producer contract needed by snapshot-confirming
consumers: delivered live presence must follow successful mutation of
the Redis state read by snapshots.
- Classify those backend rejections with the existing `IngestError`
taxonomy so a presence storage outage counts as
`buzz_events_rejected_total{transport="ws",reason="error"}`, not client
`reason="invalid"`; genuine client-input refusals (verification failure,
membership gates) stay `invalid`, and every wire message is an unchanged
fixed sanitized string (review follow-up, no protocol wording change).
- Add actual `handle_event` integration coverage for rejected
online/offline transitions, healthy online→offline
accepted/stored/fanned-out behavior, and the rejection-counter routing
on storage failure with an invalid-signature control.

This is standalone on main; it does not depend on the mobile
implementation. Deploy this relay prerequisite before relying on #7526's
snapshot-confirmation policy. Existing
pubsub-failure-after-successful-storage behavior and disconnect TTL
cleanup are deliberately unchanged. A storage error may be an ambiguous
write outcome, not a rollback guarantee; the rejected event is not
published by this handler. Clients may retry the generic `error:`
rejection. Desktop's 60s heartbeat retries non-offline presence, not
every explicit offline transition.

### Related issue
Addresses the relay prerequisite identified in [#7526 review
5157607827](#7526 (review)).
Searched open presence/storage PRs; no duplicate relay storage-error
rejection fix found. #7382/#7383/#7526 heads and bases are unchanged.

### Testing
Exact head: `389174df29cc02d0f885c03209eff661d8bb2ec0` (+380/-13; 393
total), one commit `389174df2` on top of the reviewed `c031d6eb1`
(DCO-signed; base `bfc384855889432df4a333a0edf3080f332ee169` unchanged).

- PASS: `cargo fmt --all -- --check`, `cargo clippy -p buzz-relay
--all-targets -- -D warnings`, `git diff --check`, `just
file-size-check`, PostgreSQL discovery validation — all run at the exact
final head with a clean tree before and after.
- PASS: documented native `scripts/postgres-test-run.sh -p buzz-relay
--lib --tests`: **89/89** actual integration tests, including the four
presence cases (online/offline storage rejection, healthy
online→offline, and the new rejection-classification case). Owned
PostgreSQL 17/Redis on isolated loopback ports, schema plus
reconciliation applied; no shared development database.
- PASS: explicit `cargo test -p buzz-relay presence_storage -- --ignored
--nocapture`: **4/4**, not skipped.
- Full isolated relay crate suite at the final head (`cargo nextest run
-p buzz-relay --lib --tests`): **1062 run: 1062 passed, 94 skipped**.
The previously failing
`api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo` passed
in this run (1.5s); it is a known timing-sensitive main baseline failure
tracked open in #7140 and untouched by this PR, so this single passing
run is reported as-is and does not claim environmental clearance or
close #7140. No full-suite-green claim is made beyond this run.
- Mobile is untouched; #7526's existing 2090-test/format/analyze
evidence remains scoped to its unchanged head. Its separate Desktop
Smoke E2E (2) failure remains red; no CI retries requested.

[Production-seam regression
coverage](https://github.com/block/buzz/blob/389174df29cc02d0f885c03209eff661d8bb2ec0/crates/buzz-relay/src/handlers/event.rs#L1491-L1803):
the metric case drives real `handle_event` traffic against a genuinely
dead Redis endpoint with a seeded active PostgreSQL community and a
registered presence watcher, asserts the storage rejection counts
`reason="error"` while a tampered-signature control through the same
dispatcher arm stays `reason="invalid"`, and re-asserts the rejected
ACK, no fan-out, and no local-event marker. Counter assertions use a
thread-local recorder guard held across `.await` points (the buzz-db
counter-test convention) inside the per-process nextest postgres-ci
lane, so no parallel test can race the counter snapshot.

No UI change or screenshot. Local logs and reproducible service/gate
scripts are retained under
`WORK_LOGS/MOBILE_FEEDBACK_PRESENCE_20260909/relay_prerequisite/metric_correction/`
in the engineering workspace. This PR is a review candidate, not merge
clearance.

Causal checks: restoring only the pre-fix production mutation block
makes both original rejection tests fail (`OK true` instead of `false`);
healthy success still passes. Reverting only the typed classification
(mapping the ephemeral `Internal` arm back to `invalid`) makes the new
metric regression fail with the outage counted as `[("ws","invalid",2)]`
instead of `[("ws","error",1),("ws","invalid",1)]`. The unchanged mesh
echo case also failed 504/200 with the main-production block restored in
the prior run, supporting its separation from this change without
claiming environmental clearance. Candidate source restored
byte-for-byte after each mutation. Repository-wide `just ci` was not
rerun; the scoped relay gates above are the new evidence.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
@loganj
loganj force-pushed the fix/mobile-agent-presence-20260905 branch from a8e8028 to 2e3eae6 Compare September 11, 2026 02:22
@loganj
loganj requested a review from jedwards27 September 11, 2026 02:22
@loganj

loganj commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

Both blocking items from review 5142925666 are now included directly in this PR at 2e3eae681, based on main 813bbd141. This supersedes my earlier stack-based disposition: this root no longer relies on #7383/#7526 to supply the fixes.

  • Failed refresh ≠ Offline: all three consumers preserve Unknown (hidden dot/notch, explicit Unknown label and profile semantics). Widget regressions cover no observation and Online → failed refresh, plus known states. See header, tile, profile, and renderer regressions.
  • Snapshot/live ordering: provider separates relay observation time from signer ordering, confirms ambiguous older live events, and fences older in-flight results by per-key revision. Equal-second nonduplicate live conflicts are last-arrival-wins; bounded event-ID memory prevents repeated confirmations. Deterministic tests cover snapshot Online@20/live Offline@10, lagging signer confirmation, equal-second conflict, redelivery and stale completion.

Complete main → head diff is 8 Mobile files, +802/−96 = 898 including four test files. Local evidence is reused by exact whole-Mobile-subtree identity with the independently reviewed/tested candidate: 2,113 passed / 0 failed, six targeted presence/geometry controls, analyzer clean, formatter 552 files unchanged. These were not rerun on this new commit; intervening main changes touch no Mobile validation inputs. This is not a new CI success claim. Native device/live-relay/accessibility journeys and actual relay rollout remain unverified.

Requested re-review from @jedwards27 through GitHub's review-request API (accepted and verified pending). Request delivery is not approval.

Signed-off-by: Logan Johnson <loganj@squareup.com>
@loganj
loganj force-pushed the fix/mobile-agent-presence-20260905 branch from 2e3eae6 to 3ff2f29 Compare September 11, 2026 03:52
@loganj

loganj commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

@jedwards27 — requesting exact-new-head re-review after independent review and parent-first publication.

Head: 3ff2f290bac41811fe2db0b141961148740ebce5
Tree: 3572f81790cad53dfce740efaabae92d6ffe61f1
Sole parent / actual target: 813bbd14121edacc6cb4733301a3af12131aa10e
Complete target-pair diff: 899 added+deleted lines (899 cap). Existing PR/base branch retained.

This governing root now directly owns both original findings from review 5142925666: failed/absent observations render Unknown (hidden unconfirmed dots, one truthful profile accessibility label), and snapshot/live ambiguity is confirmed rather than immediately regressing settled state. The equality defect identified in #7526 reviews 5174631081 / 5174636673 is repaired here with event.createdAt <= snapshotAt. No reliance on a descendant to repair root production remains.

Freshness/producer evidence: the root increments the per-key revision before queuing ambiguous evidence. A pre-event in-flight response cannot settle that key; the pending loop issues a distinct subsequent query. Idle coalescing groups keys, not response evidence. RelaySessionNotifier.queryRelay signs and sends a new HTTP POST, and RelayHttpQueryClient reuses connections, not cached responses/futures. Successful empty Offline snapshots retain the previous observed second. Failure remains Unknown. Root busy-query tests and child idle-query tests cover both directions; newer equal-second state is obtained from confirmation, not discarded. Merged #7532 squash 00209076c7a10d9e4a475466c313e8ebecf041f5 is structurally in this lineage; storage failures return before live fanout.

Local evidence (one composed leaf execution, not separate 2126-test runs per ancestor): immutable leaf 21818212e5b2b2e8cae55e07de0132ef2a74b8b3 / tree 6f3b392656e2647ede1e8656b9dcce41dee7ccef passed the full Mobile package 2126 tests, analyzer clean, formatter 555 files / 0 changed. Both child deltas are tests-only: all ancestor Mobile production/dependency/build inputs are preserved. Root consumer tests run within the composed child harness; #7383 existing Mobile tests are unchanged by #7526. Private offline lock-enforced 222 package roots, gpt_markdown 1.2.1, candidate source contents/modes and restored provider verified independently against Git objects. Evidence retained locally in WORK_LOGS/MOBILE_FEEDBACK_CLASSIFICATION_20260909/MAIN_ROOT_ENV_1089D92A/runs/equal22c6385e_v4/receipts/ and the independent review report.

Causal rollback: minimal <=< fails all six equality regressions: four child cases first fail at presence_ordering_test.dart:97, two root cases at presence_snapshot_test.dart:117, with wrong visible Online/Offline states before query-count assertions. Restoring exact provider bytes returns 22 focused passes and source/mode/binding verification succeeds. No author/test fixes were made by the independent reviewer.

Outstanding: initial exact-head CI is pending/in progress, not green; old-head CI is not evidence for these commits. Re-review and required new-head CI remain merge gates. No new Rust/Desktop runtime, simulator/native or live deployment acceptance is claimed. Structural #7532 ancestry is not a deployed relay receipt: release ownership must still establish the deployed producer guard and relay-before-client rollout/native acceptance. No merge or release clearance is requested by this evidence post.

@jedwards27 jedwards27 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES

Reviewed: 813bbd14121edacc6cb4733301a3af12131aa10e..3ff2f290bac41811fe2db0b141961148740ebce5 (exact head 3ff2f290bac41811fe2db0b141961148740ebce5)

Risk: high — authenticated relay snapshots, live-event ordering, lifecycle recovery, and visible availability state.

Behavior/contracts traced: mobile rendering/accessibility; Riverpod session/lifecycle/community scoping; NIP-98 /query; Redis presence synthesis; persistence-before-fanout ordering; retry, expiry, batching, and stale-completion fences.

Blocking finding

[P2] A successful all-offline snapshot has no observation watermark, so delayed pre-snapshot live evidence can resurrect stale Online/Away.

mobile/lib/features/profile/presence_cache_provider.dart:188-214 computes _snapshotAt only from returned events. For a successful empty response, observedAt remains 0, but the cache authoritatively writes offline. A subsequently delivered live event therefore bypasses the confirmation branch at :154-163 and immediately changes visible state.

That ordering is permitted by the production relay contract: presence ingestion mutates Redis before global fan-out (crates/buzz-relay/src/handlers/event.rs:851-895), while the HTTP snapshot independently reads Redis and returns an authoritative empty result after lease absence (crates/buzz-relay/src/api/bridge.rs:2285-2301). An ingest can persist, its fan-out can be delayed, the lease can expire, an empty snapshot can settle Offline, and then the old fan-out can arrive. Independent production-notifier probes reproduced [] → Offline; delayed online@10 → Online at this head. The client has no relay observation token that distinguishes this stale delivery from genuinely post-snapshot evidence.

Consequence: a person authoritatively observed Offline can be displayed Online/Away from stale transport delivery until the next 60-second poll.

Author action: give every successful snapshot—including an entirely empty batch—a relay-issued observation watermark or equivalent safe ordering token, route at/below-watermark live evidence through fresh confirmation, and add a deterministic empty-snapshot → delayed-live regression. Do not substitute client response time unless its cross-clock and ordering semantics are established.

Verification owner: author for the protocol/client fix and causal regression; reviewer for exact-new-head relay-ordering re-review.

Re-tested prior findings

  • Unknown/failure semantics are repaired. Failures remove evidence (presence_cache_provider.dart:206-208); the DM header renders Unknown without a badge, the tile hides its dot, and the profile owns one Presence: Unknown semantic label (app_bar.dart:247-293, channel_tile.dart:177-231, user_profile_sheet.dart:341-378). No further author action.
  • Non-empty and equal-second ordering are repaired. Live evidence revision-fences in-flight queries, snapshot synthesis time is retained, and <= sends equal-second evidence through confirmation (presence_cache_provider.dart:154-163,188-214). Changing <= to < caused both equal-second state regressions to fail at presence_snapshot_test.dart:117. No further action beyond the empty-snapshot hole.
  • No additional material identity/community, retry, lifecycle, or stale-completion defect was found in the traced paths.

Validation

At matching clean head 3ff2f290bac41811fe2db0b141961148740ebce5:

  • just mobile-check — PASS; formatter 552 files / 0 changed, analyzer clean.
  • Full just mobile-test — PASS independently; 2,116 tests.
  • Focused changed suites — PASS independently; provider suites 21/21 and three changed suites 280 tests.
  • Reviewer run: flutter test --no-pub test/features/profile/presence_snapshot_test.dart test/features/profile/presence_cache_provider_test.dart — PASS, 21 tests.
  • Causal equality mutation (<=<) — FAIL as intended in both equal-second cases; clean source restored.
  • git diff --check — PASS. Exact-head Mobile CI and required rollups — PASS.

Manual/native evidence: none.

Residual risk / external gates: native iOS foreground/background/reconnect and VoiceOver observation remain reviewer/release-owner confidence gaps; no author action solely for those gaps. The exact-range Codex advisory reports review required/skipped; that is an external workflow gate, not evidence of a code defect.

mfethe1 added a commit to mfethe1/buzz that referenced this pull request Sep 11, 2026
* fix(mobile): style inline code with the app mono face (#6631)

## Summary

Inline code on mobile renders as **bold body text on a faint background
wash** — no monospace face, no chip, and it cannot wrap. #5257 diagnosed
this as a missing `highlightBuilder`.

That is no longer the right fix. `gpt_markdown` 1.2.0 deprecates
`highlightBuilder` (removal in 2.0.0), renders inline code as a real
chip, and adds `InlineCodeStyle` for restyling it. The package author
confirmed this on the issue. So this PR is an upgrade — 1.1.6 → 1.2.1 —
plus one theme declaration, rather than the builder the issue originally
asked for.

**Where the style is declared.** `GptMarkdownThemeData` goes in
`AppTheme._buildTheme`, which both `light()` and `dark()` call. That
reaches all four `GptMarkdown` call sites — `message_content`,
`transcript_item_widget`, `token_pill`, `custom_emoji_render` — so the
style is stated once instead of per widget. A widget-level
`inlineCodeStyle` would have covered channel messages only, leaving the
other three on the package's defaults.

**What is declared.** Face, size, ink, chip fill and outline — not the
face alone. A face name on its own leaves the rest on the package's
defaults, which put inline code at 14.1sp beside a fenced block's 13, on
a neutral `onSurface` tint rather than the app's code surface. In dark
that tint is *lighter* than the surface, while every other code surface
in the app is recessed, so the chip read as a different kind of object.
All of it now comes from one `CodeStyle` declaration that the fenced
block reads from too, so the two cannot be edited apart.

**Three adaptations the upgrade requires.** Each was found by running
the gate, not by reading the changelog:

1. **`imageBuilder` widened** to `(context, url, width, height)`. This
is a hard compile error, and it is **not listed in the package's
migration guide**, which states "nothing here stops code compiling".
Worth reporting upstream.
2. **`autolink` now defaults to `true`.** `normalizeBareLinks()` already
rewrites bare URLs into Markdown links before rendering, so both would
run. `message_content` opts out with `autolink: false` to keep current
behaviour exactly. The migration guide argues for dropping the
pre-processor instead — a better fix, but a behavioural change that
belongs in its own PR.
3. **`gpt_markdown.dart` now re-exports `markdown_config.dart`**, making
two direct imports redundant. `flutter analyze` reports `No issues
found!` on 1.1.6 and flags both on 1.2.1, so these warnings are new, not
pre-existing.

**Deliberately out of scope.** The three non-message call sites now
autolink bare URLs, since only `message_content` has a pre-processor to
collide with. Custom inline components (`_MentionMd`, `CustomEmojiMd`,
`_ChannelLinkMd`) could additionally declare `allScopesExceptLinkLabel`
— 1.2.0 offers it as the fix for a `WidgetSpan` chip going blank inside
a link label on iOS — but current behaviour is unchanged without it, so
that stays a separate change.

### Related issue

Fixes #5257

Duplicate scan: searched `gpt_markdown`, `inline code mobile`,
`highlightBuilder` and `InlineCodeStyle` across both PRs and issues. No
open PR touches inline code styling. #6135 (link labels) and #6166 (text
selection) also touch mobile Markdown but address different defects.

### Testing

Full gate, `just ci` — exit 0:

| Stage | Result |
|---|---|
| Rust (33 suites) | 4768 passed, 0 failed |
| Desktop | 5799 passed, 0 failed |
| Mobile | **2011 passed**, 0 failed |
| `flutter analyze` | `No issues found!` |
| Desktop + web build | ok |

Run on the branch with `main` merged in, so these numbers match what CI
builds.

**New regression test** — `renders inline code in the app code style`.
It resolves the `CodeTextSpan` the package tags inline code with, which
carries both the resolved `TextStyle` and the colours the chip behind it
is painted with, so face, size, ink, fill and outline are all asserted
rather than a widget's presence. It is negative-controlled: reverting
only the theme declaration fails it with

```text
Expected: a numeric value within <0.001> of <13.0>
  Actual: <14.1>
```

and dropping the declaration entirely falls back to
`packages/gpt_markdown/JetBrainsMono` — so the test measures the real
thing, and it would catch a future regression that silently drops the
theme extension.

The test passes `baseStyle: messageBodyTextStyle`, the style the message
surfaces actually use; the widget's own fallback is the smaller
`bodyMedium`, which would move the expected size.

The test finds paragraphs with `find.byWidgetPredicate((widget) =>
widget is RichText)`, not `find.byType(RichText)`: inline code renders
through `BidiRichText`, a `RichText` subclass, and `byType` matches
exact runtime types.

That is a hazard for any test that reads text back out of a paragraph,
and one landed after this branch was cut:
`message_content_custom_emoji_test.dart` arrived with #6996 and its
`code keeps literal emoji while adjacent known tokens render` case reads
a code span through `find.byType(RichText)`. It passes on `main` and
fails on the merge result, which is what CI builds, so it went red only
once CI was authorized. It now uses the same predicate. The two other
`byType(RichText)` call sites — the rest of that file and
`message_author_meta_test.dart` — were re-run and pass: their content
carries no code span, so the exact type still matches. They were left
alone.

### Screenshots

Rendered through the real `MessageContent` widget with the app's own
fonts loaded, at 390pt wide, 3x DPR. Sample text: ``Set `BUZZ_RELAY_URL`
before launch, then run `just mobile-test` to verify.``

| | Before (1.1.6) | After (1.2.1) |
|---|---|---|
| Light |
![before-inline-code-light](https://raw.githubusercontent.com/TolgaCinisli/buzz/2d2d846291416d9b32d3fb9cfead950bcc4fe123/pr-6631--before-inline-code-light.png)
|
![after-inline-code-light](https://raw.githubusercontent.com/TolgaCinisli/buzz/f230b95c7260a32bd5d76b1ac42130720a168521/pr-6631--after-inline-code-light.png)
|
| Dark |
![before-inline-code-dark](https://raw.githubusercontent.com/TolgaCinisli/buzz/2d2d846291416d9b32d3fb9cfead950bcc4fe123/pr-6631--before-inline-code-dark.png)
|
![after-inline-code-dark](https://raw.githubusercontent.com/TolgaCinisli/buzz/f230b95c7260a32bd5d76b1ac42130720a168521/pr-6631--after-inline-code-dark.png)
|

Before: bold Inter on a flat wash, no chip edge, and `just mobile-test`
breaks across the line with the wash simply ending. After: Geist Mono in
a bordered, rounded chip, and the wrapped fragment gets its own chip on
each line.

---------

Signed-off-by: Tolga Cinisli <tolgacinisli@gmail.com>
Co-authored-by: Tolga Cinisli <tolgacinisli@gmail.com>

* fix(buzz-acp): wake held ACP threads and fence forked sessions (#7340)

## Summary

Adds an independent deadline wakeup so held thread work dispatches after
its 10-second bound even when the relay loop is otherwise quiet. Fences
session ownership by generation so a worker returning after a fork
cannot make an older provider session claimable again.

This follows up on the two post-merge findings from
[#7337](https://github.com/block/buzz/pull/7337#pullrequestreview-5116329341).

### Related issue

Follow-up to #7337.

### Testing

- `cargo test -p buzz-acp`
- `cargo clippy -p buzz-acp --all-targets -- -D warnings`
- Pre-push file-size, differential Rust test, and desktop Tauri gates

No UI changes.

---
**Update Sep 4, 15:35:** Addressed both Codex review findings.
- Queue-cap eviction now prunes orphaned hold deadlines.
- An expired hold stays expired until a worker is successfully claimed.
- Hold timers remain disabled while every worker is busy; worker return
wakes dispatch directly.
- Added regressions for queue eviction and pool exhaustion.

Generated with Codex

---------

Signed-off-by: Salman Mohammed <smohammed@squareup.com>

* fix(agent): route GPT-5+ model-service FQNs to Responses (#7358)

## Summary
Route Databricks Unity Catalog model services to OpenAI Responses when
their service name matches GPT-5 or newer. These models can reject tools
plus reasoning on Chat Completions.

Match only the service component, using the existing family-token
boundaries and a numeric major version. Catalog and schema names cannot
select the protocol. Keep neutral effort capabilities and the full model
ID unchanged; other services still use MLflow Chat Completions.

Keep the Rust and desktop resolvers in sync, add shared boundary cases
and a captured-HTTP regression for completion and summarization, and
update the documented FQN rule.

### Related issue
No duplicate found in searches for “FQN responses” PRs or “astra”
issues. Related: #6918 introduced Unity Catalog discovery.

Originating conversation:
buzz://message?channel=0b881928-a3a6-4c01-b981-8e64268f01ce&id=770949343bc96a9ed88acd90a1b37d358a0efc52c79237d0fdb491ce02b8d4ed

### Testing
No live Databricks inference test. The gateway must accept the full
model-service ID on its OpenAI Responses route; this remains the
integration risk.

The local `just ci` attempt exceeded its five-minute deadline during
`mobile-check`, so the full repository gate was not completed. All
push-hook checks passed.

Generated with Codex

Signed-off-by: Fizz <400e8babadcee6a7f420103f10a2849d84c4a9c71d5bd04f3948c814216648a3@buzz.block.builderlab.xyz>
Co-authored-by: Fizz <400e8babadcee6a7f420103f10a2849d84c4a9c71d5bd04f3948c814216648a3@buzz.block.builderlab.xyz>

* feat(mesh): upgrade to mesh-llm 0.76.0-rc8 and recommend Qwen3.8 27B (#6189)

Upgrades Buzz's mesh-llm dependencies through the released `v0.76.0-rc8`
tag (`2040765d`), including the Qwen3.8 curated recommendation and rc8's
scheduler/runtime improvements.

**Scope note:** the earlier open-relay/unenforced-admission mode has
been removed from this PR at Mic's direction — it is not a product mode
we want. Mesh admission remains roster/allowlist driven, exactly as on
`main`: on a relay with no NIP-43 membership snapshot the mesh runs
self-only. No NIP-11 mode probing, no mode-transition restarts. A future
perimeter/admission strategy for open relays will be designed
separately.

This PR also:
- seeds `BUZZ_AGENT_LLM_TIMEOUT_SECS=660` for mesh agents, above
MeshLLM's 600-second backend timeout;
- makes `desktop-tauri-clippy` lint both default and `mesh-llm` cfg
graphs;
- runs the feature-enabled desktop test suite in CI;
- recommends Qwen3.8 27B Q4_K_M for 64 GB-and-larger machines, then
ladders down through Gemma 4 E4B and Qwen 9B for smaller machines;
- keeps stored shared-compute `auto` translated to MeshLLM's supported
wire model `mesh`.

RC8 verification:
- `just ci` passed locally at
`92ecc7ec933bdd4df804cc9f28a2b51efa5313c5`.
- Pre-push differential gates passed, including both desktop Tauri cfg
graphs and package tests.
- A prior isolated runtime smoke used the RC8 binary's OpenAI endpoint
for a Buzz-shaped system/user/tool/tool-result/final-response loop; all
assertions passed and the isolated process was shut down.

Perf previously measured on M5 Metal, Qwen3.8-27B-Q4_K_M: TTFT 0.22–0.32
s, ~25 tok/s streaming; agent-shaped turns ~1 s to first token after the
first (prefix cache).

---------

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
Signed-off-by: Alessandro Joabar <sandro@squareup.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
Co-authored-by: Mic Neale <305999590+micspiral@users.noreply.github.com>
Co-authored-by: Alessandro Joabar <sandro@squareup.com>

* fix(link-preview): keep composer fetches user-paced (#7211)

**Category:** fix
**User Impact:** Link previews can keep loading while a message is being
composed, while sending still has a finite escape hatch and stalled
network transports cannot occupy preview slots forever.

**Problem:** Native metadata and image deadlines could collapse slow
previews into fallback cards while the user was still composing, and a
shared image-host cooldown made pasted batches fail inconsistently after
one rate limit. **Solution:** Keep preview resolution user-paced with no
aggregate request deadline, bound transport inactivity (15s DNS/connect,
30s idle read), serialize image requests by host, and allow at most one
server-directed cooldown wait of up to 30s across an image fetch and its
redirects. The existing bounded post-Send preparation and immediate Skip
paths remain unchanged.

<details>
<summary>File changes</summary>

**desktop/src-tauri/src/commands/link_preview.rs**
Removes aggregate native deadlines so composer metadata work can
complete at the user's pace, while retaining DNS/connect/idle-read
liveness bounds. Adds bounded host-paced image request coordination that
releases its gate during cooldown, waits inline at most once for at most
30 seconds, and cannot renew that wait through redirects or the outer
transient retry. Same-host image and favicon requests remain
deliberately serialized to align with host rate limits.

**desktop/src-tauri/src/commands/link_preview_rate_limit.rs**
Adds a fixed-size striped host gate so concurrent image requests are
serialized without retaining an unbounded attacker-controlled hostname
map.

**desktop/src-tauri/src/commands/link_preview_tests.rs**
Moves native link-preview tests into a dedicated module and covers the
user-paced metadata contract, bounded one-shot cooldown behavior, and
gate release while a rate-limited request sleeps—including a different
host sharing the same bounded gate stripe.

**desktop/src-tauri/src/commands/link_preview_youtube.rs**
Removes the thumbnail fetch deadline so YouTube previews follow the same
composer lifecycle contract while using the shared bounded transport.

**desktop/src/shared/lib/useResolvedLinkPreviews.ts**
Adds development-only metadata outcome diagnostics with elapsed time and
image/fallback state, without logging encoded image payloads.

</details>

### Reproduction steps

1. Open the desktop composer and paste several GitHub pull request links
whose OpenGraph images share a host.
2. Observe that image requests are paced by host instead of racing, and
slow-but-progressing preview work remains pending rather than
immediately becoming a completed favicon fallback.
3. Send while preview work is still pending and confirm **Preparing link
preview** remains bounded by the existing post-Send budget.
4. Use **Skip** during preparation and confirm the message proceeds
immediately.
5. In a development build, inspect the console for `[link-preview]
metadata fetch completed` diagnostics containing elapsed time and image
state without base64 payloads.

### Related issue

N/A — scoped from the linked Buzz implementation room.

### Testing

At current head `dfb394aafbee537e9ffb04ad3732d08f65f30b8e`:

- Production-bound paused-time metadata regression passed through
`fetch_link_preview_metadata`; restoring the former 10-second aggregate
wrapper makes it fail at the pending assertion.
- Native link-preview module: 19/19 passed.
- `cargo check --manifest-path desktop/src-tauri/Cargo.toml` passed.
- Rust formatting and `git diff --check` passed.
- Pre-push `push-head-scope`, org safety, differential file-size,
branch-skew, and `desktop-tauri-checks` hooks passed.

At prior head `59e2dcf167b15c7a3e637ad2608008b7f9cef5f3`:

- Full Tauri Rust suite: 3,056 passed, 19 ignored; integration crates 7
+ 3 passed.
- Focused native link-preview suite: 26/26 passed.
- The pasted multi-preview workflow was exercised in the desktop app and
confirmed improved before draft publication.

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@users.noreply.github.com>

* fix(acp): pace targeted overflow recovery on consumer capacity (#7325)

🤖
## Summary

When a Buzz agent falls behind on incoming messages, its connection can
make the backlog worse while trying to recover. The connection buffers
messages from the relay server until the agent is ready to process them;
if that buffer overflows, recovery previously requested history for
**every subscribed channel** and paused socket reads while sending those
requests. That adds traffic to an already overloaded connection. This
change requests history only for affected subscriptions, once the code
consuming those messages has room, with at least five seconds between
attempts.

The recovery path now:

- Combines repeated losses into one pending recovery per affected
subscription, keeping the oldest dropped timestamp so replay starts
early enough.
- Waits until at least half the consumer queue is free and the relay's
existing rate-limit delay has expired. The queue wakes recovery when
space becomes available; recovery does not periodically sample capacity
or hold queue space away from live messages.
- Attempts one subscription at a time, choosing the least recently
attempted so a busy channel cannot crowd out other channels or
membership notifications. The five-second delay starts when an attempt
finishes, including a failed write; failed writes leave recovery
pending.

Recovery is paced by available capacity, not by how often messages are
lost. This is not a larger buffer or a cutoff that abandons recovery.
Subscription identifiers, message filters, replay timestamp overlap and
duplicate filtering are unchanged; no downstream agent changes are
required.

This targets a reproducible overload **amplifier**, not every cause of
overload or every catch-up limitation. The initial live overload's cause
has not been established. Recovery remains best effort: a successful
request write is not proof of delivery, and existing history/retention
limits, bounded duplicate tracking and replay limitations still apply.
There is no exactly-once or complete catch-up guarantee. A stalled write
can still pause socket reads for the existing ten-second timeout; the
pacing bound does not cover initial subscriptions, reconnects or other
retry paths.

### Related issue

Closest related: #5014 (channel re-subscription); also #6661 (membership
reconciliation) and #6090 (relay backpressure gap signaling). This
addresses local overflow recovery scheduling, not those separate
mechanisms.

### Testing

Recorded offline comparisons against the previous behavior, with the
final implementation at `8000636f3073167c5a5107bb179c7d91160f1729`:

| Same fixture: 18 subscriptions, three overload rounds | Before | After
|
| --- | --- | --- |
| Recovery history requests | 108 | 3 |
| Ping-response delay | About 4.6 seconds | Below the measurement's 1 ms
resolution |

A separate bounded-history fixture delivered all 320 events plus
subsequent live traffic in **both** versions. Regression coverage
exercises the real socket-handling task, including intermittent consumer
capacity, fairness, failed writes and cancellation of capacity waits
before live delivery. These are synthetic results, not production
throughput measurements or evidence of a deployed cure.

The full local `RUST_TEST_THREADS=4 just ci` run passed on September 4,
2026. Earlier unsuccessful local runs remain part of the validation
history. The [recorded validation evidence and separate desktop
follow-up](https://github.com/block/buzz/pull/7325#issuecomment-5540592398)
preserve the original desktop mock-history scroll failure, its passing
rerun and the remaining investigation. That desktop path does not run
the agent connection code; neither this repair nor the passing rerun
fixes the observed scroll problem.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>

* fix(mobile): render push notification sender identity as npub (#7494)

🤖

## Summary

When an iOS push notification comes from someone the app has no cached
name for, the notification title showed the first characters of the
sender's raw public key — for example `aa4fc866…`. That fragment is
unreadable and doesn't match how the same person appears anywhere else
in Buzz. This PR changes that title to the compact form of the sender's
npub (npub is the human-readable encoding of a Nostr public key): first
8 and last 4 characters — for example `npub14f8…9nsy`, the same identity
shape used across the desktop and mobile apps.

- Unnamed senders: raw hex fragment → compact npub.
- Named senders: unchanged — a sender the app has a display name for
still titles the notification with that name.
- Unverifiable sender identities (malformed keys, or lookalike strings
that are not literal 64-hex-digit keys) now render a neutral "Someone"
instead of partial raw key material.
- Everything else about the notification is unchanged: body text,
subtitle, thread matching and grouping, deep-link navigation, thread
identifiers, and the internal hex public key the resolver matches on.

The native iOS notification-service package (`BuzzPushKit`) gains a
minimal in-house bech32 codec (bech32 is the checksummed string encoding
npubs use) — checksum-validated, 32-byte keys only, and no new external
dependency. The hex input branch accepts exactly a 64 ASCII hex digit
key before any parsing, so strings that merely parse like hex (for
example a run of `+a` pairs) cannot become a displayed identity; this is
input validation for presentation. Event signature verification is
untouched.

### Related issue

Fixes: N/A. Searched existing issues/PRs for push-notification npub
identity — closest related: none found.

### Testing

At head `3e3f2813b8864b76257ccb50dea3a4b31fa4de0d` (base
`44316ff72f5f7de014c66b01cbf534298a70c249`; 4 files, +321/−4):

- CI `Mobile Swift` lane, at this exact head — all passed: `swift test`
(73 tests, 0 failures), the SwiftPM debug and release builds of
`mobile/ios/BuzzPushKit`, and the unsigned iOS release build.
- Test coverage: npub encoding cross-checked against independent
nostr-rs/NIP-19 vectors; rejection of bad checksums, mixed case, wrong
lengths, invalid alphabet, padding, and non-32-byte payloads; resolver
boundary matrix — hex/npub/invalid sender keys render compact npub or
"Someone" while body, subtitle, sender key, and thread identifier pass
through; named senders keep cached display names.

### Task provenance

Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9`

Task:
buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>

* fix(desktop): shared npub identity foundation (canonicalNpub, PubKey gate, strict parser) (#7488)

🤖
## Summary

Identity keys in the desktop app are displayed as raw 64-character hex.
A person's key shows up as something like `953d3363…` — unreadable,
impossible to recognize as the same identity on another screen, and a
hazard when copied by hand. Nostr (the protocol Buzz runs on) has a
human-readable spelling for identity keys — the `npub1…` form — but the
desktop app did not use it consistently.

This is the foundation of the desktop npub changes: it adds the shared
pieces every identity surface builds on, and two follow-up slices stack
directly on this branch — #7489 converts the identity controls (profile,
settings, allowlist, workflow key fields) and #7495 converts the
everyday display surfaces (mentions, member lists, sidebar, and other
name fallbacks).

After this change:

- The shared identity widget shows the compact npub form —
`npub1j57...fjmv` — instead of a hex prefix, everywhere it renders (for
example the owned-agent public-key row on a profile). Copying it puts
the full npub on the clipboard.
- Copy is a real interaction, verified end-to-end: both popover variants
put the exact canonical npub on the actual clipboard — never the raw hex
the popover also lists, never a truncation — and a portaled popover's
clicks no longer steal focus from the new-DM To-field mid-copy. Pointer
copy, a natural Space-then-Enter path, and inner/outer Escape are
covered.
- Anything that isn't a valid identity key fails neutrally: short or
corrupt values — including degenerate values that technically encode to
a checksum-valid npub but aren't real identity keys — show "Unavailable"
with no copy button, instead of a misleading value.
- Both valid npub spellings display: all-lowercase `npub1…` and
all-uppercase `NPUB1…` (Bech32, npub's encoding, permits either casing)
both render the same canonical lowercase npub. Mixed case is rejected by
the display path as written — `canonicalNpub` and the widget don't
case-normalize input — while input parsing (`parsePubkeyInput`) keeps
its trim-and-lowercase normalization and accepts mixed-case npubs; both
paths require the decoded payload to be exactly a 64-character identity
key.
- Identity-key input is strict on payload: an npub whose decoded payload
isn't exactly a 64-character identity key is rejected, matching the
validation the app's Rust side already applies to agent allowlists.

Intentional scope boundary: only surfaces that render through the shared
widget change here. Outer profile copy, settings identity cards, the
respond-to allowlist, and workflow key fields still show hex — they move
to npub in the controls follow-up (#7489). Nothing else changes identity
representation: display names, private keys, event IDs, and the hex the
app stores, sends, and matches internally are untouched; only the
user-facing spelling of an identity key changes.

## Details

- `desktop/src/shared/lib/pubkey.ts` — `canonicalNpub()`: strict
canonical full-npub helper (64-char hex in any case, or a
checksum-validated npub, returns the canonical npub; anything else
returns `null`); `truncateNpub()`: the compact display form; existing
exports unchanged.
- `desktop/src/shared/ui/PubKey.tsx` — the shared widget's identity gate
validates through `canonicalNpub`; the popover copies the npub only.
- `desktop/src/shared/lib/nostrUtils.ts` — `parsePubkeyInput` rejects
npubs whose payload is not exactly a 64-character identity key.
- `desktop/src/features/messages/ui/NewMessageScreen.tsx` — the To-field
focuses its search input only for clicks that land inside the field
itself, so portaled recipient popovers keep their focus while open (a
popover click previously dismissed it mid-copy).
- Unit suites cover the helper, widget, and parser (including the
degenerate-encode and uppercase regressions); the e2e specs that render
these rows assert the npub display.

### Related issue

- Fixes: N/A. Searched existing issues/PRs for npub identity display —
no existing match.
- Stack: #7489 is based on this branch and builds on these primitives;
it does not stand alone on main.

### Testing

At head `b3310c248` (base: main `44316ff72`; 12 files, +440/−39):

- Focused unit suites (pubkey, PubKey, parsePubkeyInput): 20/20 green;
mutation-checked — removing the decoded-length predicate fails the
short/empty checksum-valid-npub assertions in `canonicalNpub` and the
widget, and a wrong-identity clipboard value fails the new copy
assertions.
- `pnpm typecheck` and `pnpm check`: pass; full desktop unit suite
6459/6459 at this exact head.
- Targeted e2e at this exact head: 8/8 across the two specs that own the
clipboard flows — `agent-access-warning.spec.ts` (compact variant,
agent-access owner hint) and `pubkey-display-screenshots.spec.ts` (full
variant, new-DM recipient verification: pointer copy, popover surviving
the copy, inner/outer Escape, Space-then-Enter).
- No Rust-side or build files change in this PR, so those results are
unaffected.

### Task provenance

Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9`

Task:
buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>

* fix(desktop): npub identity displays for mention, member, and workflow surfaces (#7495)

🤖
## Summary

Every Buzz account is identified by a long public key. Before this
change, when someone had no display name, surfaces fell back to
inconsistent labels — mostly raw hex fragments like `abcd1234…wxyz`,
sometimes a generic role label with no key — so the same person looked
different from surface to surface, and nothing looked like an npub
address. This PR applies the npub identity foundation from #7488 to the
everyday surfaces: a person without a display name now falls back to the
same compact npub everywhere — `npub1xxxx…yyyy`, the human-readable
spelling of their public key (first 8 + last 4 characters of the full
npub) — across messages and mentions, reactions, huddles, member and
participant lists, the sidebar and channel activity, search, projects,
tray, notifications, and workflow surfaces.

- **Mentions and messages**: key-only mention chips render the compact
npub. Pasting a copied mention back still re-binds it byte-exactly to
the identity it declares, for both the new npub chips and legacy
hex-truncated chips copied by older clients — wrong, missing, or
tampered key qualification is rejected instead of silently degrading to
plain text.
- **Reactions and huddles**: huddle reaction events and the huddle
roster/participants render the compact npub for unnamed participants;
workflow reaction triggers describe authors with the same form.
- **Members and sidebar**: channel and community member lists,
add-member results and invites, the members sidebar, the
channel-activity popover, search, projects (assignees/reviewers/PR
panels), the tray menu, and desktop notifications all fall back to the
compact npub; titles and aria labels keep the machine-readable full
labels.
- **Profile labels**: panel/popover display names and owner handles fall
back to the compact npub (never raw hex) when there is no name;
linked-event (nevent) message metadata shows the npub-shaped author
fallback while the event lookup and event IDs are unchanged.
- **Workflows**: author-picker secondary labels, step destination keys,
and trigger-author references render compact npubs; event and blob IDs
keep their existing hex compacts (they are not identities).
- **Avatars stay distinct**: fallback avatars for key-only identities
derive initials from the key's tail, so prefixed role labels like
"Participant npub1…" no longer collapse every unnamed participant onto
the same initials; people with names keep their name initials.

Preserved exactly: display names and distinct avatars, internal hex keys
(storage/API forms unchanged), clipboard identity roundtrips, event/blob
ID compaction, private keys (no nsec path is touched), and nevent link
handling.

Scope: this PR changes what identity labels **display**, not identity
controls — profile/settings copy controls, the respond-to allowlist,
workflow key fields, and agent dialogs are the sibling slice #7489, and
the shared primitives (`canonicalNpub`, `truncateNpub`, the `<PubKey>`
gate, strict input parsing) come from the foundation #7488.

### Related issue

- Fixes: N/A. Searched existing issues/PRs for duplicates — none found;
the related work is the npub identity stack this slice belongs to.
- Base/dependency: stacks on #7488 (foundation) — this PR does not stand
alone on main.
- #7489 is a sibling slice on the same #7488 base
(profile/agent/workflow controls), not a dependency: this PR does not
require #7489, and #7489 does not require this PR — both only require
#7488.

### Testing

At exact head `4763cbeae1dd521309755e6d61f657324cb98667` (base:
`fix/desktop-npub-identity-d1a` @
`5f3a4a8111998c8aa41ad77cf66992bd1c85343c`; 71 files, +656/−189 —
production +277/−136, test support +379/−53):

- At this head: targeted `mentions.spec.ts` (1/1), the e2e build,
typecheck, and biome — green.
- 9 changed/related unit files: 100/100 green; typecheck, e2e build,
biome, and px text/truncation checks clean; huddle-roster focused run
green; channel-activity e2e 11/11; mutation checks confirm the fallback
wiring (removing it collapses shared initials and drops fallback rows).
- Known pre-existing local e2e failures, unchanged by this PR and
reproduced identically at the upstream merge-base: huddle-transcription
voice-menu attribution (25 pass / 1 fail) and the
`workflow-local-controls` 438px caret drift. Not claimed green locally.
- Update at head `236af9e6137386737e84d3a474d6bc808a704c50` (test-only
follow-ups `1143af345` + `236af9e6`): the `workflow-local-controls`
races were fixed in the test drivers, and the 438px diff was shown to be
a stale Darwin snapshot baseline (name-row enable switch already absent
and `message_posted` already MessageSquare at recording commit
`9390e11c9`) and refreshed — the focused screenshot test, including
keyboard/caret assertions, now passes locally (twice). The full spec was
not rerun after the snapshot refresh; the huddle-transcription item
above is unchanged.

Label/copy text changes are asserted by the e2e specs (`mentions`,
`mention-recipients`, `pubkey-display-screenshots`,
`huddle-transcription`, `channel-activity-popover`,
`workflow-local-controls`) rather than new screenshots; the screenshot
spec pins the compact npub text forms.

### Task provenance

Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9`

Task:
buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>

* fix(desktop): npub identity controls across profile, agents, and workflows (#7489)

🤖
## Summary

Building on #7488's npub foundation, this PR finishes the identity
display change for the controls where you actually manage people and
keys: profile, settings, agent access, and workflows. Everywhere in
these surfaces, an identity key shows — and copies — as its canonical
npub (npub is the human-readable encoding of a Nostr public key: the
compact `npub1j57...fjmv` form where space is tight, the full npub where
the whole key matters), and accepts npub as input.

After this change:

- Profile panel: the public-key row and the managed-by / declared-owner
copies show the full npub. If a key can't be encoded, you see
"Unavailable" with no copy button — never a raw or partial key.
- Settings: the identity card shows and copies the npub. The
hosted-communities account identity derives from the bound key
(`pubkey_hex`) — the same authority as the mismatch gate and hosted
operations — so the display can never disagree with what the app acts
on; an unusable hex falls back to a neutral label instead of rendering
the unverified server npub. The connected claim and a community's
Connect action require that same usable bound key to match the local one
— with no usable binding the card cannot claim connected or start
Connect, while the community list, linking, and delete/rebind recovery
stay available.
- Hosted create/onboarding: the account and device identity rows in the
create flow and owner onboarding derive from the same authoritative
fields (bound key / local key), with the same neutral fallback;
readiness requires a usable bound key that matches the local one.
- Respond-to allowlist (controls who may respond to an agent): entries
can be typed or pasted as hex or npub; both spellings of the same key
are recognized as one entry and dedupe. Search results, chips, and
remove buttons use the compact npub.
- Workflow key fields: to/from keys display as npubs in the form and
save back as canonical hex. Templates like `{{trigger.author}}`, roles,
and free text pass through untouched; placeholders accept both
spellings.
- Recipient and agent dialogs: the verify popover is npub-only (the
raw-hex line is gone); denied-membership screens never show a raw key.
- The Rust-side truncated display name (used for native surfaces) shows
the same compact npub, so those surfaces match the web UI.

Internal representation is unchanged: keys are still stored, sent, and
matched as canonical 64-character hex — npub is a display and input
spelling, normalized to hex at the boundary, so existing data and
integrations keep working. Bound-key usability and comparison use one
normalized form (trimmed, lowercased, 64 hex characters; npub rejected),
so padded or mixed-case spellings of the same key match. Display names,
private keys, and event IDs are untouched.

## Details

- `respondToAllowlist` / `RespondToField`: npub entries normalize to
canonical hex; cross-form dedupe; compact npub in rows and chips;
direct-add accepts npub and stores canonical hex.
- `workflowFormTypes` / `WorkflowStepCard`: hex → npub for display, npub
→ canonical hex on save; templates, roles, and free text pass through in
both directions (roundtrip-tested).
- `UserProfilePanelFields`, `ProfileSettingsCard`,
`HostedCommunitiesSettingsCard`, `MembershipDenied`,
`SelectedRecipientChip`, `AddAgentToChannelDialog`: npub display and
copy; invalid keys → "Unavailable" with no copy; hosted identity rows
derive from the bound `pubkey_hex` (create/onboarding rows from the
bound and local keys), never the unverified server npub;
connected/readiness/Connect gates use the same usable-bound-key
predicate, and the settings Connect invocation callback re-checks it
before starting.
- `src-tauri/src/commands/identity.rs`: `truncated_display_name`
compacts to the first 8 + last 4 characters of the npub (above a 12-char
threshold), mirroring `truncateNpub`.
- e2e: profile key rows and clipboard polls assert npub forms and
raw-hex suppression; the display-screenshots spec pins the npub-only
popover; hosted specs drive the real settings card, create flow, and
onboarding rows through their real providers, and the unlinked/npub-only
identity cases assert no connected claim and no Connect action.

### Related issue

- Fixes: N/A. No separate issue; the related work is the stack below.
- Stack: builds on #7488 (shared npub foundation), now merged; this PR
is rebased onto main and stands on its own.

### Testing

At head `303c90ffa` (base: main `bfc38485`; 24 files, +1125/−146):

- Focused unit suites (respondToAllowlist, workflowFormTypes,
hostedCommunityApi bound-key helpers) green; mutation-checked — dropping
allowlist canonicalization fails the dedupe case, and dropping bound-key
normalization fails the npub-in-hex and padded same-key cases.
- Full desktop unit suite 6,477/6,477, `desktop-typecheck`,
`desktop-check` (formatting fixed narrowly with `biome check --write` on
the touched files only), and a fresh E2E build at the current head; the
add-community + hosted-communities-settings specs 18/18 and onboarding
integration 69/69 on a fresh dedicated port, with focused new-case runs
4+4 covering padded same-key (ready, Connect kept — no false rebind) and
npub-in-hex (neutral label, recovery, no Connect) across the settings
card, create flow, and first-community onboarding, plus the
unlinked-account settings regression asserting Connect cannot occur.
- `cargo fmt`/clippy (both feature sets) and `cargo test identity` (71
pass) passed at the earlier full-change head; since then, the only
production changes in this PR's delta are the hosted identity display
authority and its fail-closed bound-key gating/normalization above
(base-side fixes carry #7488's receipts) — every other change is
test-only.

### Task provenance

Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9`

Task:
buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>

* fix(mobile): standardize public-key identity display on npub (#7493)

🤖

## Summary

In the mobile app, anyone who hasn't set a display name shows up as a
raw 64-character hex key (e.g. `3a5d4f9c…`) — unreadable, and
unrecognizable as the same identity across screens. Profile and Settings
also let you copy that raw hex. Nostr public keys have a standard
readable form — `npub1…`, the same encoding other Nostr apps and our
desktop app already display. This PR makes every mobile identity surface
render npub instead:

- **Unnamed people everywhere** — message and thread authors, reactions,
typing indicators, member lists, channel details, DM headers and tiles,
inbox, search, forum cards, Pulse notes and reply context, mention
suggestions, and invite rows — now show a compact npub label: first 8 +
last 4 characters of the full npub joined by an ellipsis
(`npub1abcd…wxyz`), the same truncation desktop uses. Previously these
showed truncated raw hex.
- **DM fallback avatars and blank names** — 1:1 DM tiles and headers key
their fallback avatar to the same non-self counterpart the label names,
including self-first participant order; a self-DM keeps its
hex-key-derived initial. Blank or whitespace-only display names fall
back to the compact npub instead of rendering empty, while nonblank
authored names render verbatim (padding included).
- **Profile sheet → "Copy public key"** now copies the full canonical
npub — never raw hex. When the identity string isn't a valid public key,
the copy tile is disabled, so a malformed key never reaches the
clipboard.
- **Settings → Identity (pubkey)** displays and copies the full npub; an
invalid identity reads "Identity unavailable" with copy disabled.
- **Invalid identities never leak truncated raw hex** into the UI
anywhere — they render a neutral "Unknown identity" label.
- **Unchanged on purpose:** display names and verified handles (NIP-05 —
the `name@domain` badge) still render as before. Unnamed avatars keep
distinct per-key initials, derived from the underlying hex key rather
than the npub — otherwise every unnamed key would render the same "N"
initial. Event IDs are not public keys, so they keep their hex
truncation (in Pulse's "Replying to", the parent author shows npub while
an event-id fallback still shows hex). The nevent share link, private
keys, and internal hex storage are untouched. Inputs that accept a key
(invite/member entry) accept both hex and npub and keep working in hex
internally.

### Related issue

N/A. Searched open issues/PRs for npub identity display on mobile —
closest related: none found. Desktop's parallel npub standardization
lives in the stacked desktop PRs (#7488 foundation, #7489 controls,
#7495 display surfaces); this is the independent mobile slice (based
directly on `main`, not on those branches).

### Testing

At exact head `5a620e420a1fd57d9d8011ac26434eed32fcf765` (base: `main`
`44316ff72`; 40 files, +1,345/−154):

- Full mobile suite: 2,098 tests passing (`cd mobile && flutter test`);
`flutter analyze` clean; `dart format --set-exit-if-changed .` clean —
the same checks CI runs.
- Widget/unit coverage at production seams: compact labels and hex-keyed
avatar initials for DM headers/tiles, member rows, mention suggestions,
and Pulse reply context; DM fallback avatars keyed to the labeled
counterpart (self-first order and self-DMs); blank/whitespace
display-name npub fallback with nonblank authored labels verbatim,
including the Activity inbox sender and profile-sheet heading (each with
its own empty/whitespace production-seam regression); full-npub copy and
disabled-copy semantics in profile and settings; invalid-key
suppression; and hex↔npub input round-trips.

Verified via unit and widget tests — no device/simulator validation is
claimed.

### Task provenance

Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9`

Task:
buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>

* fix(desktop): order unnamed roster members by full canonical npub (#7503)

🤖

## Summary

- Channel members appear in the Members sidebar. A member who has never
set a display name is listed under an abbreviated form of their public
key (npub), and the sidebar previously sorted those unnamed members by
that short label. Short labels are not unique — different keys can share
one — so the order of unnamed members could look arbitrary or unstable.
Unnamed members now sort by their full public key, so the order is
deterministic.
- When two members display the same name, the previous tiebreak was
membership order (who joined first), which is not visible to a reader
and can shift as roster data loads in. The tiebreak is now the full
public key, so identical display names always land in the same order.
- Nothing gets noisier on screen: the full key is used only for sorting,
and the sidebar still shows the compact abbreviated form. Priorities are
unchanged — authored (custom) names still outrank fallback labels, and
role/current-user grouping still applies.
- Scope is the desktop app's Members sidebar and member management: the
two existing sort comparators. Mobile and other lists in the app are
untouched.

### Related issue

Based on #7495 (introduced the abbreviated npub labels this follows up
on). The original five presentation PRs remain independently reviewable.
No closer duplicate found.

### Testing

- 6469 desktop unit tests, typecheck, and check pass.
- The 3 existing consumer-seam E2E tests still pass; a new E2E test
asserts the sidebar lists unnamed members in full-key order, with
fixture members deliberately inserted in the opposite order so incoming
membership order cannot mask the sort.
- Negative check: reverting only this change makes the new ordering
assertion fail, so it genuinely binds the new sort.
- CI has not run on this PR yet.

Buzz provenance: channel 1f0e4a3d-7e01-4efe-bb16-843b357f85c9 / task
340c3de9b27dbedb8453c0c7652220f9080d30fcc70a7c4f6e27fdd4fa378056

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>

* fix(desktop): require a Codex adapter with Astra support (#7427)

## Summary

Buzz considers codex-acp 1.6.2 current because the supported adapter
floor is still 1.1.7. That adapter bundles Codex 0.148.0, so updating a
separate Codex CLI to 0.153.4 leaves managed agents on the older runtime
and unable to use GPT-6 Astra.

Raise the supported adapter floor to the published 1.10.0 release, which
depends on `@openai/codex ^0.153.3`. Existing discovery and installation
code then classifies older adapters as outdated and offers the managed
reinstall path. Update the availability and install-plan regressions to
cover the observed 1.6.2 installation and the new minimum.

This follows the existing version-floor policy. It does not
automatically update a running installation: the user must complete
Buzz’s offered adapter upgrade. Future upstream compatibility changes
may require another floor update.

### Related issue

No exact duplicate found in searches for Astra, CODEX_PATH, bundled
Codex, outdated runtime, and codex-acp 1.10. Related: #3097 raised the
older floor to 1.1.7 (already present on main); #2422 covers lost error
details for runtime mismatches. Neither resolves this version gap.

Originating conversation:
buzz://message?channel=3286cd76-f83e-4c7d-8317-10a16580744d&id=8b79a73078217222b870fff144c27e7d27bcd5a67c966869c18fe726db716898

### Testing

- Isolated npm install of codex-acp 1.10.0 resolved bundled Codex
0.153.4, with no CODEX_PATH override.
- Live macOS ACP probe: initialize protocol v1 → session/new → select
gpt-6-astra[medium] → prompt. Received `OK` and `stopReason: end_turn`;
usage metadata confirms gpt-6-astra.
- Existing adapter 1.6.2 initialized but advertised no Astra model in
the same probe.
- Desktop Rust formatting and `git diff --check` pass.
- `just desktop-tauri-test`: 3,266 passed, 20 ignored, zero failures
across the Desktop workspace and integration tests.
- Workspace and Desktop Clippy, frontend static checks, and `just
file-size-check` pass.
- Repository `just ci`: still running the remaining
mobile/build/workspace-test stages.

The installed Buzz app and managed adapter were not replaced or
restarted. The live check validates the new adapter/runtime path; a
complete packaged Desktop upgrade workflow remains untested.

Signed-off-by: Stephen DeLorme <stephen@d.elor.me>

* fix(buzz-acp): report missing models without retrying (#7538)

## Summary

When an agent reports model-not-found, Buzz retries the unavailable
model and delays the failure reply until retries are exhausted. Stop
retrying this error and immediately post a threaded recovery notice. The
notice tells users to select a different model in agent settings, save,
restart the agent to apply the configuration, and re-send their request.

This adds one error-handling branch and regression coverage in
`buzz-acp`. It matches `-32002` errors containing `model not found`.
Other resource-not-found errors, such as stale sessions, retain the
existing retry behavior. Detailed error events remain available for
diagnosis. The existing restart policy is unchanged.

### Related issue

None found in existing issue/PR searches for model-not-found recovery.

### Testing

Playwright captured and visually checked the thread UI with seeded
conversation data and the exact recovery text. The check opens the
request's thread, confirms no reply before the failure, injects the
notice, and verifies the full text is visible. [Before/after
screenshots](https://github.com/block/buzz/pull/7538#issuecomment-5608196506)
show the corrected save-and-restart instructions. These are local test
captures, not a deployed provider recovery flow.

Generated with Codex

---------

Signed-off-by: Diem Nguyen <diem@squareup.com>

* fix(desktop): let inbox title and message author names truncate under narrow panes (#7550)

## Summary

Fixes two instances of the same dead-truncate pattern in the desktop
app, where a flex item's implicit `min-width: auto` prevented `truncate`
from engaging, so long text painted over adjacent controls instead of
ellipsizing:

- **Inbox detail title** (`InboxDetailPane.tsx`): the clickable
context-title button sized to its text instead of shrinking with the
pane, overlapping the header controls (open-in-channel, members, huddle,
more menu). Fixed by adding `max-w-full`.
- **Message author names** (`MessageHeader.tsx` /
`UserProfilePopover.tsx`): the `UserProfilePopover` inline-flex trigger
wrapper refused to shrink below the name's nowrap width, running long
author names under the hover action bar and off the pane edge. Fixed by
adding a `triggerClassName` prop to `UserProfilePopover` and passing
`min-w-0 max-w-full` at the author call site.

Two other suspected instances (project file breadcrumb, drafts pane
title) were stress-tested and already truncate correctly — no change.

### Related issue

N/A — none found.

### Testing

- New Playwright regression tests for both fixes
(`inbox-title-overlap.spec.ts`, `message-author-overlap.spec.ts`,
registered in the smoke project), each proven to discriminate: they fail
with the fix reverted (real measured overlap) and assert the ellipsis
actually engages with non-zero title width, so they can't pass
vacuously.
- Typecheck, lint, and full desktop unit suite green (pre-push hooks);
full desktop e2e smoke suite run earlier: 1402 passed, 3 pre-existing
unrelated failures (each fails identically with the fix reverted).

**Inbox title — before** (long title paints under the header controls):

![Inbox title before: title text overlaps the header control
icons](https://github.com/user-attachments/assets/d5a10f98-114f-4466-8012-468616b82807)

**Inbox title — after** (truncates with ellipsis, controls stay clear):

![Inbox title after: title truncates with an ellipsis before the
controls](https://github.com/user-attachments/assets/80520f7c-1120-4dbc-929d-1ef5eceb874b)

**Author name — before** (long name runs past the header row edge):

![Author name before: name glyphs bleed past the action
bar](https://github.com/user-attachments/assets/be9743ae-ef51-4db0-8c46-a0649e98f0d5)

**Author name — after** (clean cutoff):

![Author name after: name truncates cleanly inside the header
row](https://github.com/user-attachments/assets/24cc0412-ca99-40c4-add6-13380b3ae065)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: cynfria <yescynthia@gmail.com>
Signed-off-by: Tree Trunks <6ba22921d9dc2ad0aa6ecdf63787ddd24726e266d866da31af69f2e4e146ace5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Tree Trunks <6ba22921d9dc2ad0aa6ecdf63787ddd24726e266d866da31af69f2e4e146ace5@buzz.block.builderlab.xyz>

* fix(relay): reject presence updates when Redis storage fails (#7532)

## Summary
- Reject kind:20001 presence events with `OK false` / `error: presence
storage unavailable` when Redis SET or DEL fails, before publishing,
local fan-out, or local-event marking.
- Preserve the producer contract needed by snapshot-confirming
consumers: delivered live presence must follow successful mutation of
the Redis state read by snapshots.
- Classify those backend rejections with the existing `IngestError`
taxonomy so a presence storage outage counts as
`buzz_events_rejected_total{transport="ws",reason="error"}`, not client
`reason="invalid"`; genuine client-input refusals (verification failure,
membership gates) stay `invalid`, and every wire message is an unchanged
fixed sanitized string (review follow-up, no protocol wording change).
- Add actual `handle_event` integration coverage for rejected
online/offline transitions, healthy online→offline
accepted/stored/fanned-out behavior, and the rejection-counter routing
on storage failure with an invalid-signature control.

This is standalone on main; it does not depend on the mobile
implementation. Deploy this relay prerequisite before relying on #7526's
snapshot-confirmation policy. Existing
pubsub-failure-after-successful-storage behavior and disconnect TTL
cleanup are deliberately unchanged. A storage error may be an ambiguous
write outcome, not a rollback guarantee; the rejected event is not
published by this handler. Clients may retry the generic `error:`
rejection. Desktop's 60s heartbeat retries non-offline presence, not
every explicit offline transition.

### Related issue
Addresses the relay prerequisite identified in [#7526 review
5157607827](https://github.com/block/buzz/pull/7526#pullrequestreview-5157607827).
Searched open presence/storage PRs; no duplicate relay storage-error
rejection fix found. #7382/#7383/#7526 heads and bases are unchanged.

### Testing
Exact head: `389174df29cc02d0f885c03209eff661d8bb2ec0` (+380/-13; 393
total), one commit `389174df2` on top of the reviewed `c031d6eb1`
(DCO-signed; base `bfc384855889432df4a333a0edf3080f332ee169` unchanged).

- PASS: `cargo fmt --all -- --check`, `cargo clippy -p buzz-relay
--all-targets -- -D warnings`, `git diff --check`, `just
file-size-check`, PostgreSQL discovery validation — all run at the exact
final head with a clean tree before and after.
- PASS: documented native `scripts/postgres-test-run.sh -p buzz-relay
--lib --tests`: **89/89** actual integration tests, including the four
presence cases (online/offline storage rejection, healthy
online→offline, and the new rejection-classification case). Owned
PostgreSQL 17/Redis on isolated loopback ports, schema plus
reconciliation applied; no shared development database.
- PASS: explicit `cargo test -p buzz-relay presence_storage -- --ignored
--nocapture`: **4/4**, not skipped.
- Full isolated relay crate suite at the final head (`cargo nextest run
-p buzz-relay --lib --tests`): **1062 run: 1062 passed, 94 skipped**.
The previously failing
`api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo` passed
in this run (1.5s); it is a known timing-sensitive main baseline failure
tracked open in #7140 and untouched by this PR, so this single passing
run is reported as-is and does not claim environmental clearance or
close #7140. No full-suite-green claim is made beyond this run.
- Mobile is untouched; #7526's existing 2090-test/format/analyze
evidence remains scoped to its unchanged head. Its separate Desktop
Smoke E2E (2) failure remains red; no CI retries requested.

[Production-seam regression
coverage](https://github.com/block/buzz/blob/389174df29cc02d0f885c03209eff661d8bb2ec0/crates/buzz-relay/src/handlers/event.rs#L1491-L1803):
the metric case drives real `handle_event` traffic against a genuinely
dead Redis endpoint with a seeded active PostgreSQL community and a
registered presence watcher, asserts the storage rejection counts
`reason="error"` while a tampered-signature control through the same
dispatcher arm stays `reason="invalid"`, and re-asserts the rejected
ACK, no fan-out, and no local-event marker. Counter assertions use a
thread-local recorder guard held across `.await` points (the buzz-db
counter-test convention) inside the per-process nextest postgres-ci
lane, so no parallel test can race the counter snapshot.

No UI change or screenshot. Local logs and reproducible service/gate
scripts are retained under
`WORK_LOGS/MOBILE_FEEDBACK_PRESENCE_20260909/relay_prerequisite/metric_correction/`
in the engineering workspace. This PR is a review candidate, not merge
clearance.

Causal checks: restoring only the pre-fix production mutation block
makes both original rejection tests fail (`OK true` instead of `false`);
healthy success still passes. Reverting only the typed classification
(mapping the ephemeral `Internal` arm back to `invalid`) makes the new
metric regression fail with the outage counted as `[("ws","invalid",2)]`
instead of `[("ws","error",1),("ws","invalid",1)]`. The unchanged mesh
echo case also failed 504/200 with the main-production block restored in
the prior run, supporting its separation from this change without
claiming environmental clearance. Candidate source restored
byte-for-byte after each mutation. Repository-wide `just ci` was not
rerun; the scoped relay gates above are the new evidence.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>

* fix(markdown): align mention chip wrapping (#7501)

**Category:** fix
**User Impact:** Human and agent mentions now break across lines with
the same cloned chip treatment as repository and permalink chips while
preserving the conversation text rhythm.

**Problem:** Profile-backed rendered mentions sat inside an
`inline-flex` popover trigger, unlike entity chips, so the wrapper
interfered with true inline fragmentation. The browser-layout test
measured text-range rows rather than the painted chip rectangles,
allowing touching decorations to pass as “separate” fragments.

**Solution:** Keep the profile trigger interactive but override its
layout to true `inline`, then give mention fragments 18px computed
leading inside the message’s 20px prose rhythm. Chromium paints each
fragment at 17px and advances it by 20px, leaving a visible gap between
cloned rounded rectangles. The browser test now measures the chip’s own
`getClientRects()` and asserts fragment count, height, gap, and step;
entity links retain their existing 22px leading.

<details>
<summary>File changes</summary>

**desktop/src/features/profile/ui/UserProfilePopover.tsx**
Allows inline consumers to override the trigger wrapper’s layout without
changing other profile-popover call sites.

**desktop/src/shared/styles/globals/markdown.css**
Keeps one shared wrapping-chip mechanic and gives mention decorations
enough room to separate visibly within 20px prose.

**desktop/src/shared/ui/markdown.test.mjs**
Pins both rendered mentions and entity links to the shared wrapping-chip
contract.

**desktop/src/shared/ui/markdown/MarkdownMention.tsx**
Makes the profile-popover trigger truly inline so the nested mention
chip can fragment with surrounding prose.

**desktop/src/shared/ui/mentionChip.ts**
Keeps `wrapping-inline-chip` as the single contract for fragmenting
decorated chips.

**desktop/tests/e2e/mentions.spec.ts**
Measures the painted chip rectangles, requires a positive fragment gap,
and verifies the inline trigger remains mouse- and keyboard-operable.

**desktop/tests/e2e/navigation.spec.ts**
Keeps a wrapped repository chip as the control, asserting its existing
22px line height and fragment advance.

</details>

## Reproduction steps

1. Open a channel in Buzz Desktop using dark theme.
2. Send a message containing a human mention and another containing an
agent mention; both chips should remain aligned with adjacent text on a
20px line.
3. Render a collision-qualified mention in a narrow message width; it
should break into separately decorated fragments exactly like another
wrapping chip, while each fragment follows the 20px prose rhythm.
4. Render a long repository or permalink chip in the same constrained
width; it should retain its roomier 22px fragment spacing.

## Screenshot

The dark-theme production renderer shows the real qualified label (`bob
(npub1hv3…tpuc)`) at an 8rem width. The two lines now paint as visibly
separate rounded fragments rather than one continuous rectangle.

![Qualified human mention wrapping into two visibly separate rounded
fragments in the dark-theme Buzz
timeline](https://github.com/user-attachments/assets/ee6e7be5-937e-4022-9c82-cf71f8203470)

## Validation

At commit `2b063e1b4ade30e11f1616269ad4ba4190366885`:

- Pre-push desktop gates — file-size check, Biome/checks, typecheck, and
6,483 unit tests passed
- `pnpm --dir desktop build` — passed
- Focused Playwright coverage for single-line agent mention, single-line
human mention, wrapped qualified mention including keyboard profile
activation, and timeline mention click — 4 passed
- `git diff --check` — passed

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Rizz <rizz@agents.buzz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>

* fix(acp): integrate the Buzz Pi adapter fork (#7552)

## Summary

PR #7335 worked around missing Pi adapter support by generating a
private Pi launcher and injecting Buzz's standing prompt and skills at
process launch. The Buzz Pi fork now carries the required adapter
extensions, so this removes that launcher and returns prompt
construction to the normal ACP session path while retaining the
base-prompt composition introduced by #7335.

The Pi preset now installs `salman1993/pi-acp` and launches its renamed
`buzz-pi-acp` binary. Buzz adds `-- --skill
<harness-cwd>/.agents/skills` only when launching that binary, sends the
complete composed prompt as the `_meta.systemPrompt` replacement string
on `session/new` only when `initialize.agentInfo.name` is `buzz-pi-acp`,
and sends the scoped title alongside it as `_meta.sessionTitle`. The
fork identity is treated as system-prompt capable regardless of its
reported ACP protocol version, which prevents duplicate legacy
user-message framing. Upstream `pi-acp` does not receive either
fork-specific behavior. Observer transcript projection accepts the
string, `{ replace }`, and `{ append }` metadata forms.

The fork now stores restore metadata in one atomic file per session
under `~/.pi/buzz-pi-acp/sessions/`. This prevents concurrent Buzz
workers from overwriting another session's prompt or title. The fix
landed in
[salman1993/pi-acp#9](https://github.com/salman1993/pi-acp/pull/9).

This supersedes the closed #7508. No agent-configuration rules changed;
this changes the Buzz Pi adapter contract and launch arguments.

### Related issue

#7329

### Testing

Installed fork commit `09cf07e436b8f18e52401558f988f31a15702313` through
the documented Git URL. The installed bundle matched the committed
`dist/index.js` byte for byte and contained the `~/.pi/buzz-pi-acp`
metadata path. The fork's 106 non-skipped tests, typecheck, and lint
pass.

Ran the ignored real-Pi integration test through Buzz's production
session composer. The test exercised the renamed package,
`agentInfo.name`, and the new per-session metadata store. Base, persona,
team, core-memory, huddle, canvas, and skill markers each appeared once
after switching sessions and again after restarting the adapter, while
the other session and Pi's native default prompt were absent.

Added regression coverage proving `buzz-pi-acp` receives fork-specific
system-prompt metadata and managed skills while upstream `pi-acp` does
not. `just ci` passes.

Generated with Codex

---------

Signed-off-by: Salman Mohammed <smohammed@squareup.com>

* feat(git): add default-branch management to relay and CLI (#7562)

Authored by Brain and opened on behalf of Wes (`wesbillman`).

## Summary

Add `buzz repos default-branch get…
@loganj

loganj commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

Clarification on review 5175843472's snapshot-ordering scope — no fix attached, no re-review requested

@jedwards27 — we agree with the blocking finding and are not asking to waive it: a successful all-empty snapshot settles confirmed Offline with no observation watermark, and delayed pre-snapshot live evidence then bypasses the confirmation branch and can resurrect Online/Away until the next 60-second poll. It also breaks this PR's own written promise that late results are fenced "so they can't overwrite newer state." Before implementing, we need one contract decision from you, because the baseline comparison changes what is PR-introduced, and the current wire may not be able to carry the strict reading of the finding.

Inherited behavior vs the new snapshot contradiction. The PR base 813bbd14121edacc6cb4733301a3af12131aa10e and current main 44c1cc7df8e0067c9d578a93ad8e4393304422b3 are byte-identical across the presence cache, the three presence-rendering UI files, and the relay/presence producers (presence_cache_provider.dart blob f735c376 on both sides; three-way pins re-derivable from both refs). On that baseline the cache never issues an HTTP presence query (track only registers keys; queryRelay exists but this cache never calls it), live kind 20001 online/away/offline is accepted for tracked keys purely by arrival order with no timestamp ordering, no expiry polling and no confirmation, and the UI renders missing entries as Offline. So delayed Online already visibly resurrects Offline on main, and a stale Online persists with no poll to correct it. What is genuinely new here is that a successful [] now settles an authoritative confirmed Offline, so the exact [] → Offline → delayed online@10 → Online reversal belongs to this PR's new snapshot contract — that is why the finding is in scope, not waived. Because the baseline has no snapshot consumer, no baseline HTTP journey exists or was executed; the comparison above is source-derived, and the red empty-snapshot regression stands as your probes showed it.

What the current wire cannot establish. The head documents its domain as "live timestamps order live events only; at/below the relay snapshot second, persistence-before-fanout makes a fresh snapshot confirmation necessary" (mobile/lib/features/profile/presence_cache_provider.dart:9-11), which your re-test accepted as repaired for non-empty batches, with <= covering the equal second. For the empty batch, three wire facts constrain the remedy:

  • bridge.rs returns [] for an empty presence map before sampling wall-clock seconds, so no relay-issued observation token for empty batches exists on the current protocol (crates/buzz-relay/src/api/bridge.rs:2253-2330).
  • Live events carry signer-created timestamps while synthesized snapshot records carry the relay's synthesis clock, so a snapshot-only watermark does not order a skewed signer clock: an event signed at t=1000 accepted while relay time is 10 (the presence ingest path applies no ±5-minute timestamp bound) still bypasses a <=200 fence after an empty snapshot at 200 — and the same skew exists against the non-empty synthesis seconds your re-test accepted as repaired. Client response time is a third clock domain, which your action already excludes unless cross-clock/ordering semantics are established.
  • Redis presence values are status-only (SET EX 180 / DEL; bulk MGET omits absent keys — crates/buzz-pubsub/src/presence.rs), so nothing on the wire distinguishes stale delayed live delivery from genuinely post-snapshot delivery carrying the same signer timestamp.

Strict rejection of all pre-snapshot delayed live evidence therefore requires comparable ordering metadata on both paths (a relay protocol change), or a policy that freshly confirms unversioned live evidence — which changes direct-live latency and makes visibility depend on HTTP success even for apparently-newer timestamps. We do not want to pick between those unilaterally inside this mobile data/lifecycle slice.

Concrete question. Does the requested contract require strict rejection of all pre-snapshot delayed live evidence — including initial empty batches and signer clock skew — which implies a relay-issued ordering token on both snapshot and live paths (or approved per-live fresh confirmation); or can this PR preserve the baseline's immediate best-effort application of live evidence above the observation second (the domain your re-test accepted for non-empty batches), keep truthful Unknown-on-failure, confirmed-empty Offline and periodic 60-second recovery, fence the empty batch within that domain, and document the ordering limitation explicitly — with the comparable cross-path token designed as a separate protocol change? If the strict guarantee is required, please also say whether the relay-side change belongs here or in a follow-up.

Head 3ff2f290bac41811fe2db0b141961148740ebce5 and review 5175843472 unchanged as of posting. No code change, waiver claim, or re-review request is attached to this comment.

@jedwards27 jedwards27 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: COMMENT — product/protocol contract decision required before the prior ordering request can be resolved

Reviewed: 813bbd14121edacc6cb4733301a3af12131aa10e..3ff2f290bac41811fe2db0b141961148740ebce5 (unchanged exact head 3ff2f290bac41811fe2db0b141961148740ebce5), reconsidering the author clarification in issue comment 5638588362.

The author’s clarification is technically supported. The reproduced [] → Offline → delayed Online reversal is PR-introduced as an internal snapshot contradiction, but the visible stale resurrection class is baseline-equivalent: the base cache never queries snapshots, applies tracked live presence by arrival order, maps absence to Offline in all three consumers, and has no periodic correction. This PR adds authoritative hydration, truthful Unknown-on-failure, and foreground correction on the next 60-second poll.

The current wire cannot prove the stronger cross-path guarantee requested in review 5175843472:

  • Empty snapshot responses return before the relay samples a synthesis timestamp (crates/buzz-relay/src/api/bridge.rs:2288-2301), so they carry no observation watermark.
  • Live created_at is signer-controlled, while non-empty snapshots use relay wall time; the ephemeral presence path verifies signatures but applies no comparable timestamp bound (crates/buzz-relay/src/handlers/event.rs:698-764,814-895).
  • Redis stores status only, expires Online/Away after 180 seconds, deletes Offline, and omits absent MGET values (crates/buzz-pubsub/src/presence.rs:27-58,73-93). Persistence-before-fanout permits fresh confirmation, but does not itself identify whether a later live delivery predates the query.

Accordingly, a relay-issued token is not the only possible remedy. The client could freshly confirm every post-snapshot live event, but that intentionally exchanges immediate presence for HTTP-dependent latency and availability. Choosing that policy—or requiring a comparable relay ordering token on both paths—is a product/protocol decision, not a safe implementation detail to invent inside this Mobile slice. Client response time must not be substituted as a fourth pseudo-ordering domain.

Decision owner: Mobile presence product owner with relay protocol owner.

Decision required: Is presence a bounded best-effort estimate between foreground polls, permitting immediate live application with documented cross-clock ambiguity and correction within the next 60-second cycle, or must a successful snapshot remain authoritative until later evidence is relay-ordered or freshly confirmed?

Author action: no code change until that contract is selected. If bounded best-effort is accepted, amend the PR description and provider comments: per-key revisions fence stale query completions; snapshot/live ordering across signer and relay clocks is best-effort; delayed live evidence can temporarily supersede Offline; foreground polling corrects it. The current claims that overlap means “nothing is missed” and late results “can’t overwrite newer state” are broader than the implementation. If strict authority is selected, implement either fresh confirmation with empty/non-empty/skew regressions or comparable relay ordering metadata on both snapshot and live paths.

Verification owner: product/protocol owner for the contract; reviewer for prose or exact-new-head code recheck. The prior exact-head Mobile/full-suite, focused, mutation, and CI evidence remains applicable because the head did not change. Independent source tracing and git diff --check also passed at this exact clean head; a separate focused Flutter attempt was blocked before discovery by an unhydrated reviewer worktree, which is a tooling confidence gap, not an author failure. Native lifecycle/VoiceOver remains an external verification gap.

This is an interim contract hold, not a new code verdict. The previous request should not be interpreted as mandating a relay change until the owner answers the decision above.

@jedwards27 jedwards27 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:bot: Jude’s code review agent — REQUEST CHANGES

Same-head reconsideration of review 5175843472 after the author’s substantive response in #7382 (comment). Reviewed exact head 3ff2f290bac41811fe2db0b141961148740ebce5 on base 813bbd14121edacc6cb4733301a3af12131aa10e.

The response correctly narrows the earlier finding. Strict cross-path monotonic ordering is not implementable merely by attaching a snapshot watermark: live events use signer-controlled time, non-empty snapshots use relay synthesis time, empty snapshots carry no token, and those clocks are not comparable. A safe client-only alternative—freshly confirming live events after an authoritative snapshot—would intentionally trade immediate presence for HTTP-dependent latency and availability. Choosing that behavior or introducing relay-owned ordering metadata is a product/protocol decision, not reviewer-invented author work.

However, the current PR description still materially overstates what this head guarantees. It says overlapping live/snapshot reads mean “nothing is missed” and that “late results ... can’t overwrite newer state.” The accepted and reproduced empty-snapshot interleaving disproves that broad statement: Redis persistence can precede delayed fan-out, the lease can expire, an empty read can establish Offline, and the delayed event can then restore stale Online/Away until the next foreground poll. The provider’s snapshot-second heuristic also cannot establish causal order against arbitrarily skewed signer timestamps.

Author action: choose and state the intended contract before merge:

  1. If presence is a bounded best-effort estimate, amend the PR description and relevant provider comment to say that revision fencing prevents stale query completions, while snapshot/live ordering across signer and relay clocks remains best-effort; delayed live evidence can temporarily supersede a successful Offline read and foreground polling repairs it on the next 60-second cycle. Explicitly place strict causal ordering outside this PR and track it separately if required.
  2. If a successful Offline snapshot must remain authoritative until causally newer evidence is proven, obtain the product/protocol decision and implement either fresh confirmation for subsequent live events or comparable relay-owned ordering metadata on both snapshot and live paths, with empty/non-empty/skew regressions. Do not use client response time as an ordering clock.

This is now a concrete scope/truthfulness blocker, not a demand for an unjustified token design. Approval with the current governing prose would certify a guarantee the implementation does not provide.

Verification owner: author/maintainer for accurate scope text and the contract choice; relay protocol owner if strict ordering is selected; reviewer for exact-head or description-only reconsideration.

Prior exact-head validation remains applicable: Mobile checks and full tests passed, focused provider coverage passed, the equal-second mutation failed as intended, and required CI is green. Unknown/failure rendering and non-empty equal-second behavior remain repaired. Native lifecycle and VoiceOver observation are confidence gaps only, with no additional author action.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants