Membership/metadata reads used limit:1 + first(), picking an arbitrary
kind:39002/39000 copy when relays disagreed. A stale copy on one relay
(e.g. a dropped write) could be read and then clobber members on the next
read-modify-write. Add latest_event/latest_by_d_tag (max created_at, id
tie-break) and apply at all four read sites; drop the limit truncation in
get_channels.
Agents only published presence (kind:20001), never a kind:0 profile, so
clients showed a raw hex pubkey. Add publish_profile (name/display_name/
about/picture) at startup, sourced from the persona display_name with a
title-cased fallback from the agent command (goose -> Goose).
Tests: membership latest-wins resolution + profile metadata/name fallback.
- Replace relay.nostr.band (indexer relay that never completes a plain
client WS handshake — observed 0/10 connects, only ever cost connect
timeouts) with relay.primal.net (fast, free, anon read/write).
- query_relay_ws no longer uses join_all (which waited for the slowest
relay). It now returns on the first relay's response plus an 800ms
grace window for stragglers, so a slow-but-alive relay can't drag every
read out to the full 6s per-relay QUERY_TIMEOUT. Dead relays were
already skipped via the pool's failure cooldown; this fixes the
slow-but-connected case.
Post-merge fixes for desktop-tauri compile (clippy compiles tests):
- build_message gained custom_emoji_tags param from main; pass &[] in the
three serverless test/helper call sites (channels_tests, encrypted)
- remove duplicate #[allow(too_many_arguments)] left by the merge
The desktop encrypted_recipients() lookup used .first() on a multi-relay
merge of the addressable kind:39002 membership event and silently fell
back to encrypting to only self when the lookup came back empty. With the
membership event present on only some relays (split-brain / propagation
lag), a private-channel message could be gift-wrapped to just the sender —
so the agent (and other members) never received it, with no visible error.
Now: pick the NEWEST 39002 by created_at, and if an encrypted channel
resolves to no other members, return an error so the send fails loudly and
can be retried, instead of silently dropping every other recipient.
Adds agent_responds_in_dm_e2e — completes the functional trio (public
channel, private channel, DM). A DM in serverless is a private channel
with a deterministic UUIDv5 id derived from the participants (matching
desktop/src-tauri/src/commands/dms.rs). The message is gift-wrapped to
the agent; the test asserts the agent decrypts it and replies.
All three pass against live public relays (3 passed; 0 failed).
Adds an e2e test that sends a NIP-59 gift-wrap (kind 1059) to the agent —
exactly what the desktop publishes for an encrypted channel — and asserts the
agent decrypts it, extracts the channel from the inner rumor's h tag, passes the
gate, and replies. Proves the private-channel path end-to-end against live
relays; the earlier 'goose didn't reply in private' was the agent being offline,
not a code bug.
Replies in encrypted serverless channels (DM/private) were falling through
to the plaintext path — a privacy leak: the reply content went to public
relays as a cleartext kind-9. The encrypt branch was gated on
parent_event_id.is_none(), so only top-level messages were gift-wrapped.
Fix: encrypt replies too. The NIP-10 thread tags now live INSIDE the rumor
(the encrypted inner event), so threading is preserved without leaking the
reply. The thread root is resolved locally from decrypted messages (the
parent rumor isn't queryable in plaintext on the relay) and passed via a new
root_event_id command arg.
Proven by encrypted_threaded_reply_is_wrapped_and_preserves_thread: asserts
the wire event is kind-1059 (not plaintext kind-9), content never leaks, and
the decrypted rumor carries the NIP-10 root + reply e-tags.
Extend e2e_agent_responds to exercise the paths the live GUI hits:
- second @mention proves the OwnerInterrupt cancel/redispatch path (a
follow-up message gets a fresh reply) — previously untested
- live listener asserts the typing indicator (kind 20002) fires during a
turn so the 'agent is typing…' cue reaches relays in serverless
- stub now replies per-prompt (-1, -2) instead of once
Fixes found by the test:
- reaction working-cue timeout 500ms -> 4s: the pool does a real
WebSocket publish + OK round-trip across public relays, too slow for
500ms, so the 💬 cue never landed
- typing listener now watches ALL relays (agent publishes to whichever
accepts; one relay may be 503-ing)
The serverless agent path hand-rolled multi-relay subscribe/reconnect/
resubscribe/dedup in relay.rs — reimplementing what nostr-relay-pool
(rust-nostr) already does, with gaps that caused the agent to miss
messages when a single relay 502'd mid-session.
Replace the serverless backend with nostr-relay-pool's RelayPool, which
provides the standard Nostr client model used by damus/0xchat/nostr-tools:
- persistent REQ per relay, fanned out across all relays
- auto-reconnect (default) with jittered backoff
- auto-resubscribe on reconnect (relays replay stored events on REQ)
- merge + dedup across relays (Event notification = first-seen only)
HarnessRelay now dispatches to ServerlessRelay when serverless; server
mode (NIP-42 AUTH + HTTP bridge) is unchanged. RestClient and the typing/
presence publisher share the pool. Subscription IDs match the server-mode
scheme so event routing is identical.
Proven: e2e_agent_responds passes 3x consecutively (~3s each) against live
public relays, surviving damus 503s via nos.lol.
- 272 unit tests pass, clippy clean
- Route agent typing indicators (kind 20002) through the serverless pool
subscription so the 'agent is typing…' signal shows in serverless mode
(previously dropped — CHANNEL_EVENT_KINDS excluded 20002). useChannelTyping
now listens on serverless-event:<channel> when serverless; appendMessage
skips ephemeral typing events so they never enter the timeline.
- Retry serverless history fetch with short backoff on cold start: public
relays return 0/partial while pooled connections warm up, causing the
'blank for a minute after launch' feeling. Retry until events arrive.
Agent restore at startup raced ahead of the frontend applying the active
workspace, so serverless agents launched with the stale relay list frozen
into their record at creation time (including paid relays that 403) and one
even ran in server mode. When the only healthy relay rate-limited a reply,
there was no failover and the reply silently vanished.
- AppState.workspace_applied: agent restore now waits (bounded 15s) for the
frontend to call apply_workspace before launching, so agents inherit the
CURRENT workspace relays + serverless mode instead of record defaults.
- apply_workspace sets the flag once config is live.
- justfile _ensure-sidecar-stubs builds REAL sprout-acp + sprout sidecars
(serverless agents need them) instead of 0-byte stubs that silently fail
to launch/reply under just dev.