Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 41 additions & 13 deletions ts/packages/agents/visualStudio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,51 @@ for build and install instructions.
+-------------------------------+ +--------------------+
| Visual Studio (VSIX) | | TypeAgent |
| | | agent-server |
| +------------------------+ | WS | |
| | ChatToolWindow |<--|------->| +--------------+ |
| +------------------------+ | | |
| | ChatToolWindow |<--|--WS--->| +--------------+ |
| | WebView2 | | 8999 | | dispatcher | |
| | chat-ui | | | +------+-------+ |
| +------------------------+ | | | |
| | | +------v-------+ |
| +------------------------+ | | | visualstudio | |
| | AgentBridgeClient.cs |<--|---WS---|->| agent | |
| | DTEActionExecutor.cs | | 5680 | +--------------+ |
| | DTEActionExecutor.cs | | ephem. | +--------------+ |
| +-----------+------------+ | +--------------------+
| | |
| v EnvDTE |
| (Solution, Build, |
| Debugger, Editor) |
| | ^
| | + port discovered via
| | discovery channel on 8999
| v EnvDTE
| (Solution, Build,
| Debugger, Editor)
+-------------------------------+
```

Two WebSocket channels:

- **Chat channel** (port 8999) — WebView2 inside the VSIX talks to the dispatcher.
- **Action bridge** (port 5680) — this agent owns a `WebSocketServer`; the C#
host connects as a client and dispatches incoming actions through EnvDTE.
- **Chat channel** (port 8999) — WebView2 inside the VSIX talks to the
dispatcher. The same port also hosts the dispatcher's read-only
**discovery channel** that the bridge uses to find the action port
(see below).
- **Action bridge** (OS-assigned ephemeral port) — this agent owns a
`WebSocketServer`; the C# host connects as a client and dispatches
incoming actions through EnvDTE. The actual port is published to the
agent-server's `PortRegistrar` under `(visualStudio, default)` and
discovered by `AgentBridgeClient` on every connect attempt.

### Port discovery

`AgentBridgeClient` resolves the bridge port on each connect attempt by
calling `lookupPort("visualStudio", "default")` against the
agent-server's discovery channel (`ws://localhost:<AGENT_SERVER_PORT>/`,
default 8999). If discovery is unreachable or the agent isn't yet
registered, the reconnect loop simply retries — there is no silent
fallback to a well-known port. Knobs:

| Env var | Default | Purpose |
| ----------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------ |
| `AGENT_SERVER_PORT` | `8999` | Where the dispatcher's discovery channel is hosted. |
| `TYPEAGENT_VS_BRIDGE_PORT` _(C# host)_ | _(unset → use discovery)_ | Pin the C# host to dial a specific bridge port; bypasses discovery. Useful for manual debugging. |
| `VISUALSTUDIO_BRIDGE_PORT` _(agent-side)_ | _(unset → OS-assigned)_ | Pin the agent's bridge to a specific port when debugging. |

## Action Categories

Expand Down Expand Up @@ -77,7 +100,8 @@ The VSIX host has its own two-stage build (WebView2 bundle + MSBuild). See
4. **View → Other Windows → TypeAgent Chat**.

The chat panel connects to `ws://localhost:8999`. Once connected, the C# bridge
auto-connects to `ws://localhost:5680`.
discovers the agent's action port via that same WS (via the discovery channel)
and auto-connects.

## Project Structure

Expand Down Expand Up @@ -126,8 +150,12 @@ Any use of third-party trademarks or logos are subject to those third-party's po
- **Chat panel never connects.** The agent-server must be running before VS
opens the tool window. Check that port 8999 is reachable.
- **Actions hang or error with "Host plugin not connected".** The C# bridge
could not reach port 5680 — confirm the `visualstudio-agent` is enabled in
the dispatcher and that no other process is bound to 5680.
could not reach the agent's action port. Confirm `visualstudio-agent` is
enabled in the dispatcher. Check the VS **Output → Debug** pane for
`[TypeAgent] Bridge…` lines indicating whether discovery succeeded and
which port was used. To bypass discovery for diagnostics (e.g. dial a
manually-launched bridge), set `TYPEAGENT_VS_BRIDGE_PORT` to the port
you want to dial.
- **Action ran but did nothing visible.** Check the Visual Studio **Output**
window and the agent-server logs; EnvDTE silently no-ops on some commands
when the relevant context (e.g. an active document, an active debug session)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,29 @@
namespace Microsoft.TypeAgent.VisualStudio.Bridge
{
/// <summary>
/// WebSocket client that connects to the visualstudio-agent's bridge
/// (default ws://localhost:5680). Receives BridgeRequest messages,
/// dispatches them through DTEActionExecutor, and sends BridgeResponse
/// messages back.
/// WebSocket client that connects to the visualstudio-agent's bridge.
/// Receives BridgeRequest messages, dispatches them through
/// DTEActionExecutor, and sends BridgeResponse messages back.
///
/// Port discovery:
/// The bridge port is no longer hardcoded. On each connect attempt
/// we ask the agent-server's discovery channel where the
/// `(visualStudio, default)` allocation lives. If discovery is
/// unreachable or the agent isn't yet registered, the reconnect
/// loop simply retries — there is no silent fallback to a
/// well-known port. To pin a specific port (e.g. when running the
/// bridge against a manually-launched agent), set
/// `TYPEAGENT_VS_BRIDGE_PORT`; that bypasses discovery entirely.
/// See <see cref="BridgeDiscovery"/> for the wire protocol and the
/// `AGENT_SERVER_PORT` env-var knob.
///
/// Wire format (matches packages/agents/visualStudio/src/visualStudioActionHandler.ts):
/// request: { id, actionName, parameters }
/// response: { id, success, result?, error? }
/// </summary>
internal sealed class AgentBridgeClient : IDisposable
{
// Port 5678 + 5679 are taken by the Excel agent. Keep this in sync
// with BRIDGE_PORT in packages/agents/visualStudio/src/visualStudioActionHandler.ts.
private static readonly Uri DefaultUri = new Uri("ws://localhost:5680");
private const string BridgePortOverrideEnv = "TYPEAGENT_VS_BRIDGE_PORT";
private static readonly TimeSpan ReconnectDelay = TimeSpan.FromSeconds(3);

private readonly AsyncPackage _package;
Expand All @@ -46,21 +55,38 @@ public async Task StartAsync(CancellationToken cancellation)
using var linked = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, cancellation);
while (!linked.IsCancellationRequested)
{
int port = 0;
try
{
await ConnectAndReceiveAsync(linked.Token);
// Resolve the port fresh on every attempt: the agent may
// have restarted on a different ephemeral port since the
// last loop iteration, and the standalone shell may have
// come up while we were retrying.
int? resolved = ResolvePortOverride()
?? await BridgeDiscovery.ResolveBridgePortAsync(linked.Token).ConfigureAwait(false);
if (resolved is null)
{
// Discovery succeeded but the agent isn't registered
// yet — wait one reconnect cycle and try again.
Debug.WriteLine("[TypeAgent] visualStudio agent not yet registered; will retry");
}
else
{
port = resolved.Value;
await ConnectAndReceiveAsync(port, linked.Token).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex)
{
Debug.WriteLine($"[TypeAgent] Bridge error: {ex.Message}");
Debug.WriteLine($"[TypeAgent] Bridge error (port {port}): {ex.Message}");
}
try
{
await Task.Delay(ReconnectDelay, linked.Token);
await Task.Delay(ReconnectDelay, linked.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
Expand All @@ -69,11 +95,28 @@ public async Task StartAsync(CancellationToken cancellation)
}
}

private async Task ConnectAndReceiveAsync(CancellationToken cancellation)
// Returns an explicit port override from `TYPEAGENT_VS_BRIDGE_PORT`,
// or null when the env var is unset/malformed (caller falls through
// to discovery). Mirrors `CODE_WEBSOCKET_HOST` from coda.
private static int? ResolvePortOverride()
{
string? raw = Environment.GetEnvironmentVariable(BridgePortOverrideEnv);
if (string.IsNullOrEmpty(raw)) return null;
if (int.TryParse(raw, out int p) && p > 0 && p <= 65535)
{
Debug.WriteLine($"[TypeAgent] {BridgePortOverrideEnv} override active: {p}");
return p;
}
Debug.WriteLine($"[TypeAgent] Ignoring malformed {BridgePortOverrideEnv}={raw}");
return null;
}

private async Task ConnectAndReceiveAsync(int port, CancellationToken cancellation)
{
var uri = new Uri($"ws://localhost:{port}");
_ws = new ClientWebSocket();
await _ws.ConnectAsync(DefaultUri, cancellation);
Debug.WriteLine($"[TypeAgent] Bridge connected to {DefaultUri}");
await _ws.ConnectAsync(uri, cancellation).ConfigureAwait(false);
Debug.WriteLine($"[TypeAgent] Bridge connected to {uri}");

var buffer = new ArraySegment<byte>(new byte[16 * 1024]);
var assembly = new StringBuilder();
Expand All @@ -84,10 +127,10 @@ private async Task ConnectAndReceiveAsync(CancellationToken cancellation)
WebSocketReceiveResult result;
do
{
result = await _ws.ReceiveAsync(buffer, cancellation);
result = await _ws.ReceiveAsync(buffer, cancellation).ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close)
{
await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, cancellation);
await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, cancellation).ConfigureAwait(false);
return;
}
assembly.Append(Encoding.UTF8.GetString(buffer.Array!, 0, result.Count));
Expand Down
170 changes: 170 additions & 0 deletions ts/packages/agents/visualStudio/host/csharp/Bridge/BridgeDiscovery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System;
using System.Diagnostics;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

namespace Microsoft.TypeAgent.VisualStudio.Bridge
{
/// <summary>
/// Looks up the visualStudio agent's bridge port via the dispatcher's
/// discovery channel.
///
/// Wire protocol (matches packages/agentServer/protocol/src/protocol.ts):
/// client → server:
/// { "name": "discovery",
/// "message": { "type": "invoke", "callId": N, "name": "lookupPort",
/// "args": [{ "agentName": "visualStudio",
/// "role": "default" }] } }
/// server → client:
/// { "name": "discovery",
/// "message": { "type": "invokeResult", "callId": N,
/// "result": { "port": &lt;int|null&gt; } } }
///
/// Returns the discovered port, or null when the agent isn't yet
/// registered with the agent-server (transient — caller should retry).
/// Throws on transport failure so the outer reconnect loop can apply
/// its own retry/backoff. There is intentionally no hardcoded fallback
/// port — the migrated TS clients (browser, code, coda) all return
/// "undefined" on discovery failure and rely on the reconnect loop;
/// dialing a stale well-known port would just connect to nothing.
Comment thread
TalZaccai marked this conversation as resolved.
/// </summary>
internal static class BridgeDiscovery
{
// Read on every resolve so users can flip behavior without
// restarting the IDE between debugging sessions.
private const string AgentServerPortEnv = "AGENT_SERVER_PORT";

// Must match AGENT_SERVER_DEFAULT_PORT in agentServer/protocol.
private const int DefaultAgentServerPort = 8999;

// Names this client uses to look itself up. Must match the role
// registered by visualStudioActionHandler.ts.
private const string AgentName = "visualStudio";
private const string Role = "default";

/// <summary>
/// Resolve the bridge port via discovery. Returns the discovered
/// port, or null when the agent has not yet registered (transient
/// — caller should retry on its reconnect loop).
/// Throws on transport failure (agent-server unreachable, timeout,
/// malformed response) so the caller can log and retry.
/// </summary>
public static async Task<int?> ResolveBridgePortAsync(CancellationToken cancellation)
{
int agentServerPort = GetAgentServerPort();
int? discovered = await LookupPortAsync(agentServerPort, cancellation).ConfigureAwait(false);
if (discovered is int p)
{
Debug.WriteLine($"[TypeAgent] Discovery resolved bridge port {p}");
}
else
{
Debug.WriteLine($"[TypeAgent] Discovery returned null for ({AgentName}, {Role}); agent not yet registered");
}
return discovered;
}

private static int GetAgentServerPort()
{
string? raw = Environment.GetEnvironmentVariable(AgentServerPortEnv);
if (int.TryParse(raw, out int p) && p > 0 && p <= 65535)
{
return p;
Comment thread
TalZaccai marked this conversation as resolved.
}
return DefaultAgentServerPort;
}

private static async Task<int?> LookupPortAsync(int agentServerPort, CancellationToken cancellation)
{
var uri = new Uri($"ws://localhost:{agentServerPort}/");
using var ws = new ClientWebSocket();
// Cap the discovery call so a hung agent-server doesn't stall
// the whole reconnect loop. The outer AgentBridgeClient loop
// already retries on a separate cadence.
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellation, timeout.Token);

await ws.ConnectAsync(uri, linked.Token).ConfigureAwait(false);

// callId is arbitrary — the server echoes it back verbatim,
// and we only have one outstanding request per socket.
const int callId = 1;
var request = new JObject
{
["name"] = "discovery",
["message"] = new JObject
{
["type"] = "invoke",
["callId"] = callId,
["name"] = "lookupPort",
["args"] = new JArray
{
new JObject
{
["agentName"] = AgentName,
["role"] = Role,
},
},
},
};
byte[] requestBytes = Encoding.UTF8.GetBytes(request.ToString(Formatting.None));
await ws.SendAsync(
new ArraySegment<byte>(requestBytes),
WebSocketMessageType.Text,
endOfMessage: true,
linked.Token).ConfigureAwait(false);

string responseText = await ReceiveFullMessageAsync(ws, linked.Token).ConfigureAwait(false);
try
{
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None).ConfigureAwait(false);
}
catch
{
// Best-effort close — the response is already in hand.
}

var root = JObject.Parse(responseText);
string? name = root.Value<string>("name");
if (name != "discovery") return null;
var inner = root["message"] as JObject;
if (inner == null) return null;
string? type = inner.Value<string>("type");
if (type == "invokeError")
{
throw new InvalidOperationException(
inner.Value<string>("error") ?? "Discovery returned invokeError");
}
if (type != "invokeResult") return null;
if (inner.Value<int?>("callId") != callId) return null;
var result = inner["result"] as JObject;
if (result == null) return null;
// `port` is `int|null`; JObject returns null cleanly for both.
return result.Value<int?>("port");
}

private static async Task<string> ReceiveFullMessageAsync(ClientWebSocket ws, CancellationToken cancellation)
{
var buffer = new ArraySegment<byte>(new byte[16 * 1024]);
var sb = new StringBuilder();
WebSocketReceiveResult result;
do
{
result = await ws.ReceiveAsync(buffer, cancellation).ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close)
{
throw new InvalidOperationException("Discovery WS closed before response");
}
sb.Append(Encoding.UTF8.GetString(buffer.Array!, 0, result.Count));
} while (!result.EndOfMessage);
return sb.ToString();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
<DependentUpon>ChatToolWindowControl.xaml</DependentUpon>
</Compile>
<Compile Include="Bridge\AgentBridgeClient.cs" />
<Compile Include="Bridge\BridgeDiscovery.cs" />
<Compile Include="Bridge\DTEActionExecutor.cs" />
</ItemGroup>

Expand Down
Loading
Loading