GitHub Actions commited on
Commit
cbad453
·
1 Parent(s): 650a29f

fix: resolve cross-node chat [echo] bug

Browse files

Root causes:
1. Gradio race condition: peer_dropdown.change updates peer_id textbox
async, but clicking Send before update completes sends to empty recipient
which falls back to self → 'direct' → [echo].
Fix: pass both peer_dropdown and peer_id as inputs to send_msg;
use peer_box when non-empty, else peer_drop (no async dependency).

2. Node ID prefix collision: local app.py used hf-space-* prefix same as
HF Space nodes. _apply_roster skips own ID, so the live Space node
(hf-space-f28143c5) was silently skipped and only stale dead nodes
appeared in the dropdown.
Fix: use SPACE_HOST env var (set only on HF) to discriminate; local
nodes now get local-* prefix (local-f28143c5), so Space nodes appear
as genuine remote peers.

Also added:
- tests/test_chat_cross_node.py: 4 integration tests for cross-node
chat delivery, history, self-send, and unknown-node cases (all pass)

app.py CHANGED
@@ -175,11 +175,18 @@ def _build_node():
175
  from hearthnet.services.llm.service import LlmService
176
  from hearthnet.services.marketplace.service import MarketplaceService
177
 
178
- # Generate a stable node_id from the HF Space hostname (so it doesn't change on restart)
179
- _host = os.getenv("SPACE_HOST", socket.gethostname())
 
 
 
180
  _suffix = hashlib.sha256(_host.encode()).hexdigest()[:8]
181
- _node_id = f"hf-space-{_suffix}"
182
- _display = os.getenv("SPACE_TITLE", f"HearthNet Space ({_suffix})")
 
 
 
 
183
 
184
  node = HearthNode(
185
  node_id=_node_id,
 
175
  from hearthnet.services.llm.service import LlmService
176
  from hearthnet.services.marketplace.service import MarketplaceService
177
 
178
+ # Generate a stable node_id from the HF Space hostname (so it doesn't change on restart).
179
+ # Use SPACE_HOST env var (set only on HF Spaces) to differentiate: local nodes get
180
+ # "local-*" prefix so they never collide with live "hf-space-*" peers in the relay.
181
+ _space_host = os.getenv("SPACE_HOST", "")
182
+ _host = _space_host or socket.gethostname()
183
  _suffix = hashlib.sha256(_host.encode()).hexdigest()[:8]
184
+ if _space_host:
185
+ _node_id = f"hf-space-{_suffix}"
186
+ _display = os.getenv("SPACE_TITLE", f"HearthNet Space ({_suffix})")
187
+ else:
188
+ _node_id = f"local-{_suffix}"
189
+ _display = os.getenv("SPACE_TITLE", f"HearthNet Local ({_suffix})")
190
 
191
  node = HearthNode(
192
  node_id=_node_id,
hearthnet/ui/tabs/chat.py CHANGED
@@ -74,10 +74,12 @@ def build_chat_tab(bus=None):
74
  peers = _get_known_peers(bus)
75
  return gr.update(choices=peers, value=peers[0] if peers else None)
76
 
77
- async def load_history(peer):
 
 
78
  if bus is None:
79
  return [{"role": "assistant", "content": "⚠️ Bus not connected"}]
80
- target = peer.strip() if peer else None
81
  try:
82
  r = await bus.call("chat.history", (1, 0), {"input": {"peer": target}})
83
  msgs = r.get("output", {}).get("messages", [])
@@ -98,7 +100,10 @@ def build_chat_tab(bus=None):
98
  except Exception as e:
99
  return [{"role": "assistant", "content": f"Error loading history: {e}"}]
100
 
101
- async def send_msg(peer, msg, history):
 
 
 
102
  if not msg.strip():
103
  return history, "", gr.update(visible=False)
104
  history = history or []
@@ -113,7 +118,7 @@ def build_chat_tab(bus=None):
113
  gr.update(visible=False),
114
  )
115
 
116
- recipient = peer.strip() if peer else getattr(bus, "node_id_full", "")
117
  if recipient == "*":
118
  all_peers = _get_known_peers(bus)
119
  if not all_peers:
@@ -155,14 +160,16 @@ def build_chat_tab(bus=None):
155
  return history, "", gr.update(visible=False)
156
 
157
  refresh_peers_btn.click(refresh_peers, outputs=peer_dropdown)
158
- history_btn.click(load_history, inputs=peer_id, outputs=chat_out)
 
 
159
  send_btn.click(
160
  send_msg,
161
- inputs=[peer_id, msg_input, chat_out],
162
  outputs=[chat_out, msg_input, status_out],
163
  )
164
  msg_input.submit(
165
  send_msg,
166
- inputs=[peer_id, msg_input, chat_out],
167
  outputs=[chat_out, msg_input, status_out],
168
  )
 
74
  peers = _get_known_peers(bus)
75
  return gr.update(choices=peers, value=peers[0] if peers else None)
76
 
77
+ async def load_history(peer_drop, peer_box):
78
+ # peer_box wins if filled; fall back to dropdown selection
79
+ peer = (peer_box or peer_drop or "").strip()
80
  if bus is None:
81
  return [{"role": "assistant", "content": "⚠️ Bus not connected"}]
82
+ target = peer if peer else None
83
  try:
84
  r = await bus.call("chat.history", (1, 0), {"input": {"peer": target}})
85
  msgs = r.get("output", {}).get("messages", [])
 
100
  except Exception as e:
101
  return [{"role": "assistant", "content": f"Error loading history: {e}"}]
102
 
103
+ async def send_msg(peer_drop, peer_box, msg, history):
104
+ # peer_box wins when filled; fall back to dropdown (avoids Gradio race condition
105
+ # where dropdown.change hasn't updated peer_box before Send fires)
106
+ peer = (peer_box or peer_drop or "").strip()
107
  if not msg.strip():
108
  return history, "", gr.update(visible=False)
109
  history = history or []
 
118
  gr.update(visible=False),
119
  )
120
 
121
+ recipient = peer if peer else getattr(bus, "node_id_full", "")
122
  if recipient == "*":
123
  all_peers = _get_known_peers(bus)
124
  if not all_peers:
 
160
  return history, "", gr.update(visible=False)
161
 
162
  refresh_peers_btn.click(refresh_peers, outputs=peer_dropdown)
163
+ # Pass BOTH dropdown and textbox so send_msg can pick the authoritative value
164
+ # even if the dropdown.change callback hasn't propagated to peer_id yet.
165
+ history_btn.click(load_history, inputs=[peer_dropdown, peer_id], outputs=chat_out)
166
  send_btn.click(
167
  send_msg,
168
+ inputs=[peer_dropdown, peer_id, msg_input, chat_out],
169
  outputs=[chat_out, msg_input, status_out],
170
  )
171
  msg_input.submit(
172
  send_msg,
173
+ inputs=[peer_dropdown, peer_id, msg_input, chat_out],
174
  outputs=[chat_out, msg_input, status_out],
175
  )
tests/test_chat_cross_node.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integration test: cross-node chat delivery (M10).
2
+
3
+ Verifies that chat.send from node A to node B:
4
+ - calls chat.deliver on B's bus
5
+ - B stores the message
6
+ - chat.history on B shows the message
7
+ - delivery status is "delivered" (not "queued" or "direct")
8
+
9
+ Uses in-process InMemoryTransport so no relay/HTTP needed.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import pytest
14
+ from hearthnet.bus import InMemoryTransport
15
+
16
+
17
+ @pytest.fixture()
18
+ def two_nodes():
19
+ from hearthnet.node import HearthNode
20
+
21
+ net = InMemoryTransport()
22
+ alice = HearthNode("node-alice", "Alice", "ed25519:test", transport=net)
23
+ alice.install_demo_services()
24
+ net.register(alice.bus)
25
+
26
+ bob = HearthNode("node-bob", "Bob", "ed25519:test", transport=net)
27
+ bob.install_demo_services()
28
+ net.register(bob.bus)
29
+
30
+ return alice, bob
31
+
32
+
33
+ @pytest.mark.asyncio
34
+ async def test_chat_send_cross_node_delivers(two_nodes):
35
+ alice, bob = two_nodes
36
+
37
+ r = await alice.bus.call(
38
+ "chat.send", (1, 0), {"input": {"recipient": "node-bob", "body": "Hello Bob!"}}
39
+ )
40
+ status = r.get("output", {}).get("delivered")
41
+ assert status == "delivered", f"Expected 'delivered', got {status!r}"
42
+
43
+
44
+ @pytest.mark.asyncio
45
+ async def test_chat_history_shows_received_message(two_nodes):
46
+ alice, bob = two_nodes
47
+
48
+ await alice.bus.call(
49
+ "chat.send", (1, 0), {"input": {"recipient": "node-bob", "body": "Hi from Alice"}}
50
+ )
51
+
52
+ r = await bob.bus.call("chat.history", (1, 0), {"input": {"peer": "node-alice"}})
53
+ msgs = r.get("output", {}).get("messages", [])
54
+ assert len(msgs) == 1, f"Expected 1 message in Bob's history, got {len(msgs)}"
55
+ assert msgs[0]["body"] == "Hi from Alice"
56
+ assert msgs[0]["from"] == "node-alice"
57
+
58
+
59
+ @pytest.mark.asyncio
60
+ async def test_self_send_returns_direct(two_nodes):
61
+ alice, _ = two_nodes
62
+ r = await alice.bus.call(
63
+ "chat.send", (1, 0), {"input": {"recipient": "node-alice", "body": "hi me"}}
64
+ )
65
+ status = r.get("output", {}).get("delivered")
66
+ assert status == "direct", f"Expected 'direct' for self-send, got {status!r}"
67
+
68
+
69
+ @pytest.mark.asyncio
70
+ async def test_send_to_unknown_node_returns_queued(two_nodes):
71
+ alice, _ = two_nodes
72
+ r = await alice.bus.call(
73
+ "chat.send", (1, 0), {"input": {"recipient": "node-nobody", "body": "hello?"}}
74
+ )
75
+ status = r.get("output", {}).get("delivered")
76
+ assert status == "queued", f"Expected 'queued' for unknown node, got {status!r}"