Files
f5e46c974b feat: add Octop↔Octop cloud collab for remote experts (#1281)
* feat: add Octop↔Octop Bridge for remote agent access

Enable admin-managed peer connections with WS tunnel, path allowlist,
hello_ack handshake, SSRF guards, and Chat shadow-agent hydrate so
operators can probe and use remote agents from the local dashboard.

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

* fix: serialize bridge turn chunks and flag remote experts

LangChain HumanMessage objects in peer turn.chunk frames crashed
json.dumps; add a bridge JSON default. Show a 远端/Remote badge on
shadow experts in the picker and sidebar lists.

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

* fix: bridge avatars, named remote badges, and auto-reconnect

Keep bundled / CDN icon URLs for shadow experts and only proxy uploaded
avatars. Show the bridge display name on remote badges. Add a default-on
auto-reconnect switch that backs off on disconnect and disables itself
after repeated failures, recording the reason in last_error.

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

* feat: add card/table views for bridge connections

Match the storage page card layout and add a Segmented toggle so
admins can switch between a card grid and a table of remote links.

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

* fix: render bridge expert icons correctly on the chat page

Encode bridge agent ids in avatar URLs the same way as chat WS paths,
fall back to Lucide when the image fails, size portraits to fill the
sidebar avatar, and refresh AgentContext when icon_url changes.

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

* feat: tunnel peer models and knowledge bases for bridge chat

Allow read-only GET of peer providers/resolved, active-model, and
knowledge-bases (plus capability) through the Bridge tunnel, expose
local bridge connection routes, and point the chat composer at those
sources when talking to a shadow expert. Hide local connectors for
remote sessions. Also allow agent subagents paths on the tunnel.

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

* fix: scope bridge chat expert picker to peer agents

When the active expert is a Bridge shadow, the composer expert picker
and @-mentions only list peers on that connection (not local experts).
Encode bridge agent ids for skills and subagents list/install paths.

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

* fix: hint remote chat pickers to manage settings on the peer

In Bridge sessions, replace local “manage …” footers for models,
skills, knowledge bases, experts, and subagents with a muted
“edit on remote” tip and a toast instead of navigating to local admin.

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

* feat: polish bridge remote UX and tunnel agent status

Allowlist GET …/status so hubs can read real peer harness state (peers need
this build). Keep history-migration hub-local, skip noisy bridge polls in the
UI, move Bridge under Settings, and mark remote experts with cable + name.

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

* feat: tunnel remote expert catalogs and polish bridge connection UX

Hub can edit peer subagents, memory, channels, tools, plugins, and MBTI
through the tunnel; surfaces that cannot hop show an in-drawer hint. Peer
must run this build so the new allowlist takes effect.

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

* feat: group cloud-collab experts and unify header-tunneled composer APIs

Keep the selected remote expert on disconnect, gate peer-only surfaces in the UI, and stop forking composer/task requests through dedicated bridge endpoints.

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

* feat: add search to the Experts toolbar

Filter my experts, teams, and the library from the existing button row so long catalogs stay scannable.

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

* fix: keep inbound cloud-collab cards peer-owned

Inbound links can only edit name/icon/notes; disconnect, delete, and auto-reconnect stay disabled. Peer offline shows as 离线, peer delete removes the card, and the default inbound name is the short connection id.

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

* fix: polish inbound cloud-collab UX and unify 对端 copy

Peer-initiated links can only edit display info and cannot redial;
offline cards may be deleted. Local-only pages hint when a peer
expert is selected.

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

* fix: map peer team speakers onto Bridge shadows

Peer team chats stamped local member ids, so the hub fell back to the host
avatar. Rewrite roster and stream speakers onto bridge:{cid}:{id} shadows,
and keep the local team picker from mixing in peer experts.

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

* fix: stop rewriting team thread ids in the Bridge tunnel

Member job threads are {room}~{agent}. A blanket JSON replace of the peer
agent id turned those into hub shadows, so history 404ed on the peer.

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

* fix: rewrite peer media URLs and keep bridge turn composer fields

Live send_file_to_user frames still pointed at peer-local /api/agents/{id}.
Map every teammate id in tunneled JSON, and copy the full dashboard turn
body so knowledge bases and HITL policy are not dropped on the peer.

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

* fix: rename bridge probe tests to avoid pytest module clash

tests/unit/bridge/test_probe.py collided with tests/unit/mobile/test_probe.py
during collection (same basename).

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

* feat: collapse extra experts on Bridge cards and fill avatars

Show at most four experts on a connection card, with the rest behind
expand. Portraits fill the 40px ring without a tinted background.

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

---------

Co-authored-by: jubaoliang <jubaoliang@tencent.com>
Co-authored-by: lulinzhi37-alt <lulinzhi37@gmail.com>
2026-09-29 16:49:37 +08:00

135 lines
4.8 KiB
Python

"""Unit tests for bridge agent id helpers."""
from __future__ import annotations
import pytest
from octop.infra.bridge.ids import (
format_bridge_agent_id,
is_bridge_agent_id,
parse_bridge_agent_id,
restore_peer_path_ids,
rewrite_peer_agent_id,
rewrite_peer_agent_ids,
rewrite_peer_payload_agent_id,
rewrite_peer_stream_frame,
rewrite_tunneled_json,
)
def test_format_and_parse_roundtrip() -> None:
agent_id = format_bridge_agent_id("01ABCDEF", "agent-1")
assert agent_id == "bridge:01ABCDEF:agent-1"
ref = parse_bridge_agent_id(agent_id)
assert ref is not None
assert ref.connection_id == "01ABCDEF"
assert ref.remote_agent_id == "agent-1"
assert is_bridge_agent_id(agent_id)
def test_parse_rejects_local_ids() -> None:
assert parse_bridge_agent_id("01LOCALAGENT") is None
assert not is_bridge_agent_id("01LOCALAGENT")
def test_format_rejects_colon_in_connection_id() -> None:
with pytest.raises(ValueError):
format_bridge_agent_id("bad:id", "agent")
def test_rewrite_peer_agent_ids_maps_roster() -> None:
assert rewrite_peer_agent_id("CID", "mem-1") == "bridge:CID:mem-1"
assert rewrite_peer_agent_id("CID", "bridge:CID:mem-1") == "bridge:CID:mem-1"
assert rewrite_peer_agent_ids("CID", ["a", "a", "", "b"]) == [
"bridge:CID:a",
"bridge:CID:b",
]
def test_rewrite_peer_payload_agent_id_maps_speaker() -> None:
frame = {"type": "token", "agent_id": "doctor", "content": "ok"}
out = rewrite_peer_payload_agent_id("CID", frame)
assert out["agent_id"] == "bridge:CID:doctor"
unlabeled = {"type": "token", "content": "host"}
assert rewrite_peer_payload_agent_id("CID", unlabeled) is unlabeled
def test_rewrite_tunneled_json_keeps_peer_thread_and_session_ids() -> None:
payload = {
"thread_id": "01ROOM~doctor",
"session_key": "doctor:dashboard:1:team:01ROOM",
"agent_id": "doctor",
"content": "ask doctor",
"icon_url": "/api/agents/doctor/avatar",
"member_ids": ["doctor", "nurse"],
}
out = rewrite_tunneled_json(
payload,
remote_agent_id="doctor",
bridge_agent_id="bridge:cid:doctor",
)
assert out["thread_id"] == "01ROOM~doctor"
assert out["session_key"] == "doctor:dashboard:1:team:01ROOM"
assert out["agent_id"] == "bridge:cid:doctor"
assert out["content"] == "ask doctor"
assert out["icon_url"] == "/api/agents/bridge:cid:doctor/avatar"
assert out["member_ids"] == ["bridge:cid:doctor", "bridge:cid:nurse"]
def test_restore_peer_path_ids_unwraps_stale_shadow_thread() -> None:
rest = "/threads/01ROOM~bridge:cid:doctor/history"
assert (
restore_peer_path_ids(
rest,
bridge_agent_id="bridge:cid:doctor",
remote_agent_id="doctor",
)
== "/threads/01ROOM~doctor/history"
)
def test_rewrite_tunneled_json_rewrites_member_media_urls() -> None:
payload = {
"thread_id": "01ROOM~doctor",
"session_key": "main:dashboard:1:dm:01ROOM",
"agent_id": "main",
"url": "/api/agents/doctor/media/preview?source=outbound%2Fnote.pdf",
"preview_url": "/api/agents/main/workspace/download?path=outbound/note.pdf",
"output": '{"preview_url":"/api/agents/doctor/media/preview?source=outbound/a.png"}',
}
out = rewrite_tunneled_json(
payload,
remote_agent_id="main",
bridge_agent_id="bridge:cid:main",
)
assert out["thread_id"] == "01ROOM~doctor"
assert out["session_key"] == "main:dashboard:1:dm:01ROOM"
assert out["agent_id"] == "bridge:cid:main"
assert out["url"] == "/api/agents/bridge:cid:doctor/media/preview?source=outbound%2Fnote.pdf"
assert out["preview_url"] == (
"/api/agents/bridge:cid:main/workspace/download?path=outbound/note.pdf"
)
assert "/api/agents/bridge:cid:doctor/media/preview" in out["output"]
assert "/api/agents/doctor/" not in out["output"]
def test_rewrite_peer_stream_frame_rewrites_attachment_urls() -> None:
frame = {
"type": "attachment",
"agent_id": "doctor",
"kind": "file",
"url": "/api/agents/doctor/workspace/download?path=outbound/note.pdf",
"preview_url": "/api/agents/doctor/workspace/download?path=outbound/note.pdf",
"filename": "note.pdf",
}
out = rewrite_peer_stream_frame("cid", frame, remote_agent_id="main")
assert out["agent_id"] == "bridge:cid:doctor"
assert out["url"] == "/api/agents/bridge:cid:doctor/workspace/download?path=outbound/note.pdf"
assert out["preview_url"] == out["url"]
already = {
"type": "attachment",
"url": "/api/agents/bridge:cid:main/media/preview?source=outbound/a.png",
}
unchanged = rewrite_peer_stream_frame("cid", already, remote_agent_id="main")
assert unchanged["url"] == already["url"]